diff --git a/crates/ty_python_semantic/resources/mdtest/del.md b/crates/ty_python_semantic/resources/mdtest/del.md index 6b0676ad00..fdbac44d7c 100644 --- a/crates/ty_python_semantic/resources/mdtest/del.md +++ b/crates/ty_python_semantic/resources/mdtest/del.md @@ -239,21 +239,43 @@ del g[0] ### TypedDict deletion Deleting a required key from a TypedDict is a type error because it would make the object no longer -a valid instance of that TypedDict type. +a valid instance of that TypedDict type. However, deleting `NotRequired` keys (or keys in +`total=False` TypedDicts) is allowed. ```py -from typing_extensions import TypedDict +from typing_extensions import TypedDict, NotRequired class Movie(TypedDict): name: str year: int -m: Movie = {"name": "Blade Runner", "year": 1982} +class PartialMovie(TypedDict, total=False): + name: str + year: int +class MixedMovie(TypedDict): + name: str + year: NotRequired[int] + +m: Movie = {"name": "Blade Runner", "year": 1982} +p: PartialMovie = {"name": "Test"} +mixed: MixedMovie = {"name": "Test"} + +# Required keys cannot be deleted. # error: [invalid-argument-type] del m["name"] -``` -TODO: Deletion of `NotRequired` keys (or keys in `total=False` TypedDicts) should be allowed, but ty -currently uses the typeshed stub `__delitem__(k: Never)` which rejects all deletions. See -mypy/pyright behavior for reference. +# In a partial TypedDict (`total=False`), all keys can be deleted. +del p["name"] + +# `NotRequired` keys can always be deleted. +del mixed["year"] + +# But required keys in mixed `TypedDict` still cannot be deleted. +# error: [invalid-argument-type] +del mixed["name"] + +# And keys that don't exist cannot be deleted. +# error: [invalid-argument-type] +del mixed["non_existent"] +``` diff --git a/crates/ty_python_semantic/src/types/class.rs b/crates/ty_python_semantic/src/types/class.rs index 18a6d01996..8af71b3eed 100644 --- a/crates/ty_python_semantic/src/types/class.rs +++ b/crates/ty_python_semantic/src/types/class.rs @@ -2818,6 +2818,62 @@ impl<'db> ClassLiteral<'db> { CallableTypeKind::FunctionLike, ))) } + (CodeGeneratorKind::TypedDict, "__delitem__") => { + let fields = self.fields(db, specialization, field_policy); + + // Only non-required fields can be deleted. Required fields cannot be deleted + // because that would violate the TypedDict's structural type. + let mut deletable_fields = fields + .iter() + .filter(|(_, field)| !field.is_required()) + .peekable(); + + if deletable_fields.peek().is_none() { + // If there are no deletable fields (all fields are required), synthesize a + // `__delitem__` that takes a `key` of type `Never` to signal that no keys + // can be deleted. + return Some(Type::Callable(CallableType::new( + db, + CallableSignature::single(Signature::new( + Parameters::new( + db, + [ + Parameter::positional_only(Some(Name::new_static("self"))) + .with_annotated_type(instance_ty), + Parameter::positional_only(Some(Name::new_static("key"))) + .with_annotated_type(Type::Never), + ], + ), + Some(Type::none(db)), + )), + CallableTypeKind::FunctionLike, + ))); + } + + // Otherwise, add overloads for all deletable fields. + let overloads = deletable_fields.map(|(name, _field)| { + let key_type = Type::StringLiteral(StringLiteralType::new(db, name.as_str())); + + Signature::new( + Parameters::new( + db, + [ + Parameter::positional_only(Some(Name::new_static("self"))) + .with_annotated_type(instance_ty), + Parameter::positional_only(Some(Name::new_static("key"))) + .with_annotated_type(key_type), + ], + ), + Some(Type::none(db)), + ) + }); + + Some(Type::Callable(CallableType::new( + db, + CallableSignature::from_overloads(overloads), + CallableTypeKind::FunctionLike, + ))) + } (CodeGeneratorKind::TypedDict, "get") => { let overloads = self .fields(db, specialization, field_policy)