diff --git a/crates/ty_python_semantic/resources/mdtest/typed_dict.md b/crates/ty_python_semantic/resources/mdtest/typed_dict.md index f0ab061ec9daa..1170cfe66e186 100644 --- a/crates/ty_python_semantic/resources/mdtest/typed_dict.md +++ b/crates/ty_python_semantic/resources/mdtest/typed_dict.md @@ -1635,6 +1635,47 @@ config["host"] = "127.0.0.1" config["port"] = 80 ``` +## `update()` with `ReadOnly` items + +`update()` also cannot write to `ReadOnly` items, unless the source key is bottom-typed and +therefore cannot be present: + +```py +from typing_extensions import Never, NotRequired, ReadOnly, TypedDict + +class ReadOnlyPerson(TypedDict): + id: ReadOnly[int] + age: int + +class AgePatch(TypedDict, total=False): + age: int + +class IdPatch(TypedDict, total=False): + id: int + +class ImpossibleIdPatch(TypedDict, total=False): + id: NotRequired[Never] + +person: ReadOnlyPerson = {"id": 1, "age": 30} +age_patch: AgePatch = {"age": 31} +id_patch: IdPatch = {"id": 2} +impossible_id_patch: ImpossibleIdPatch = {} + +person.update(age_patch) + +# error: [invalid-argument-type] +person.update(id_patch) + +# error: [invalid-argument-type] +# error: [invalid-argument-type] +person.update({"id": 2}) + +# error: [invalid-argument-type] +person.update(id=2) + +person.update(impossible_id_patch) +``` + ## Methods on `TypedDict` ```py diff --git a/crates/ty_python_semantic/src/types/class.rs b/crates/ty_python_semantic/src/types/class.rs index 00a4ed4bf4b2f..8aea10b56aac4 100644 --- a/crates/ty_python_semantic/src/types/class.rs +++ b/crates/ty_python_semantic/src/types/class.rs @@ -2009,14 +2009,14 @@ pub(super) fn synthesize_typed_dict_update_member<'db>( instance_ty: Type<'db>, keyword_parameters: &[Parameter<'db>], ) -> Type<'db> { - let partial_ty = if let Type::TypedDict(typed_dict) = instance_ty { - Type::TypedDict(typed_dict.to_partial(db)) + let update_patch_ty = if let Type::TypedDict(typed_dict) = instance_ty { + Type::TypedDict(typed_dict.to_update_patch(db)) } else { instance_ty }; let value_ty = UnionBuilder::new(db) - .add(partial_ty) + .add(update_patch_ty) .add(KnownClass::Iterable.to_specialized_instance( db, &[Type::heterogeneous_tuple( diff --git a/crates/ty_python_semantic/src/types/class/static_literal.rs b/crates/ty_python_semantic/src/types/class/static_literal.rs index fc9b526808c18..e2b02bf204464 100644 --- a/crates/ty_python_semantic/src/types/class/static_literal.rs +++ b/crates/ty_python_semantic/src/types/class/static_literal.rs @@ -1978,15 +1978,20 @@ impl<'db> StaticClassLiteral<'db> { ))) } (CodeGeneratorKind::TypedDict, "update") => { - let keyword_parameters: Vec<_> = self - .fields(db, specialization, field_policy) - .iter() - .map(|(name, field)| { - Parameter::keyword_only(name.clone()) - .with_annotated_type(field.declared_ty) - .with_default_type(field.declared_ty) - }) - .collect(); + let keyword_parameters: Vec<_> = if let Type::TypedDict(typed_dict) = instance_ty { + typed_dict + .to_update_patch(db) + .items(db) + .iter() + .map(|(name, field)| { + Parameter::keyword_only(name.clone()) + .with_annotated_type(field.declared_ty) + .with_default_type(field.declared_ty) + }) + .collect() + } else { + Vec::new() + }; Some(synthesize_typed_dict_update_member( db, diff --git a/crates/ty_python_semantic/src/types/typed_dict.rs b/crates/ty_python_semantic/src/types/typed_dict.rs index 865f51c9000b5..9d78910f6b9ae 100644 --- a/crates/ty_python_semantic/src/types/typed_dict.rs +++ b/crates/ty_python_semantic/src/types/typed_dict.rs @@ -151,6 +151,27 @@ impl<'db> TypedDictType<'db> { Self::from_patch_items(db, items) } + /// Returns a patch version of this `TypedDict` for `TypedDict.update()`. + /// + /// All fields become optional, and read-only fields become bottom-typed. This preserves the + /// PEP 705 rule that `update()` must reject any source that can write a read-only key, while + /// still accepting `NotRequired[Never]` placeholders for keys that cannot be present. + pub(crate) fn to_update_patch(self, db: &'db dyn Db) -> Self { + let items: TypedDictSchema<'db> = self + .items(db) + .iter() + .map(|(name, field)| { + let mut field = field.clone().with_required(false); + if field.is_read_only() { + field.declared_ty = Type::Never; + } + (name.clone(), field) + }) + .collect(); + + Self::from_patch_items(db, items) + } + pub fn definition(self, db: &'db dyn Db) -> Option> { match self { TypedDictType::Class(defining_class) => defining_class.definition(db),