diff --git a/crates/ty_python_semantic/resources/mdtest/del.md b/crates/ty_python_semantic/resources/mdtest/del.md index fdbac44d7c..5703b26c35 100644 --- a/crates/ty_python_semantic/resources/mdtest/del.md +++ b/crates/ty_python_semantic/resources/mdtest/del.md @@ -242,6 +242,8 @@ Deleting a required key from a TypedDict is a type error because it would make t 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, NotRequired diff --git a/crates/ty_python_semantic/resources/mdtest/snapshots/del.md_-_`del`_statement_-_Delete_items_-_TypedDict_deletion_(1168a65357694229).snap b/crates/ty_python_semantic/resources/mdtest/snapshots/del.md_-_`del`_statement_-_Delete_items_-_TypedDict_deletion_(1168a65357694229).snap new file mode 100644 index 0000000000..7442526c0d --- /dev/null +++ b/crates/ty_python_semantic/resources/mdtest/snapshots/del.md_-_`del`_statement_-_Delete_items_-_TypedDict_deletion_(1168a65357694229).snap @@ -0,0 +1,113 @@ +--- +source: crates/ty_test/src/lib.rs +expression: snapshot +--- +--- +mdtest name: del.md - `del` statement - Delete items - TypedDict deletion +mdtest path: crates/ty_python_semantic/resources/mdtest/del.md +--- + +# Python source files + +## mdtest_snippet.py + +``` + 1 | from typing_extensions import TypedDict, NotRequired + 2 | + 3 | class Movie(TypedDict): + 4 | name: str + 5 | year: int + 6 | + 7 | class PartialMovie(TypedDict, total=False): + 8 | name: str + 9 | year: int +10 | +11 | class MixedMovie(TypedDict): +12 | name: str +13 | year: NotRequired[int] +14 | +15 | m: Movie = {"name": "Blade Runner", "year": 1982} +16 | p: PartialMovie = {"name": "Test"} +17 | mixed: MixedMovie = {"name": "Test"} +18 | +19 | # Required keys cannot be deleted. +20 | # error: [invalid-argument-type] +21 | del m["name"] +22 | +23 | # In a partial TypedDict (`total=False`), all keys can be deleted. +24 | del p["name"] +25 | +26 | # `NotRequired` keys can always be deleted. +27 | del mixed["year"] +28 | +29 | # But required keys in mixed `TypedDict` still cannot be deleted. +30 | # error: [invalid-argument-type] +31 | del mixed["name"] +32 | +33 | # And keys that don't exist cannot be deleted. +34 | # error: [invalid-argument-type] +35 | del mixed["non_existent"] +``` + +# Diagnostics + +``` +error[invalid-argument-type]: Cannot delete required key "name" from TypedDict `Movie` + --> src/mdtest_snippet.py:21:7 + | +19 | # Required keys cannot be deleted. +20 | # error: [invalid-argument-type] +21 | del m["name"] + | ^^^^^^ +22 | +23 | # In a partial TypedDict (`total=False`), all keys can be deleted. + | +info: Field defined here + --> src/mdtest_snippet.py:4:5 + | +3 | class Movie(TypedDict): +4 | name: str + | --------- `name` declared as required here; consider making it `NotRequired` +5 | year: int + | +info: Only keys marked as `NotRequired` (or in a TypedDict with `total=False`) can be deleted +info: rule `invalid-argument-type` is enabled by default + +``` + +``` +error[invalid-argument-type]: Cannot delete required key "name" from TypedDict `MixedMovie` + --> src/mdtest_snippet.py:31:11 + | +29 | # But required keys in mixed `TypedDict` still cannot be deleted. +30 | # error: [invalid-argument-type] +31 | del mixed["name"] + | ^^^^^^ +32 | +33 | # And keys that don't exist cannot be deleted. + | +info: Field defined here + --> src/mdtest_snippet.py:12:5 + | +11 | class MixedMovie(TypedDict): +12 | name: str + | --------- `name` declared as required here; consider making it `NotRequired` +13 | year: NotRequired[int] + | +info: Only keys marked as `NotRequired` (or in a TypedDict with `total=False`) can be deleted +info: rule `invalid-argument-type` is enabled by default + +``` + +``` +error[invalid-argument-type]: Cannot delete unknown key "non_existent" from TypedDict `MixedMovie` + --> src/mdtest_snippet.py:35:11 + | +33 | # And keys that don't exist cannot be deleted. +34 | # error: [invalid-argument-type] +35 | del mixed["non_existent"] + | ^^^^^^^^^^^^^^ + | +info: rule `invalid-argument-type` is enabled by default + +``` diff --git a/crates/ty_python_semantic/src/types/diagnostic.rs b/crates/ty_python_semantic/src/types/diagnostic.rs index 5ec7a9efc0..c092975acb 100644 --- a/crates/ty_python_semantic/src/types/diagnostic.rs +++ b/crates/ty_python_semantic/src/types/diagnostic.rs @@ -3723,6 +3723,66 @@ pub(crate) fn report_cannot_pop_required_field_on_typed_dict<'db>( } } +/// Enum representing the reason why a key cannot be deleted from a `TypedDict`. +#[derive(Copy, Clone)] +pub(crate) enum TypedDictDeleteErrorKind { + /// The key exists but is required (not `NotRequired`) + RequiredKey, + /// The key does not exist in the `TypedDict` + UnknownKey, +} + +pub(crate) fn report_cannot_delete_typed_dict_key<'db>( + context: &InferContext<'db, '_>, + key_node: AnyNodeRef, + typed_dict_ty: Type<'db>, + field_name: &str, + field: Option<&crate::types::typed_dict::TypedDictField<'db>>, + error_kind: TypedDictDeleteErrorKind, +) { + let db = context.db(); + let Some(builder) = context.report_lint(&INVALID_ARGUMENT_TYPE, key_node) else { + return; + }; + + let typed_dict_name = typed_dict_ty.display(db); + + let mut diagnostic = match error_kind { + TypedDictDeleteErrorKind::RequiredKey => builder.into_diagnostic(format_args!( + "Cannot delete required key \"{field_name}\" from TypedDict `{typed_dict_name}`" + )), + TypedDictDeleteErrorKind::UnknownKey => builder.into_diagnostic(format_args!( + "Cannot delete unknown key \"{field_name}\" from TypedDict `{typed_dict_name}`" + )), + }; + + // Add sub-diagnostic pointing to where the field is defined (if available) + if let Some(field) = field + && let Some(declaration) = field.first_declaration() + { + let file = declaration.file(db); + let module = parsed_module(db, file).load(db); + + let mut sub = SubDiagnostic::new(SubDiagnosticSeverity::Info, "Field defined here"); + sub.annotate( + Annotation::secondary( + Span::from(file).with_range(declaration.full_range(db, &module).range()), + ) + .message(format_args!( + "`{field_name}` declared as required here; consider making it `NotRequired`" + )), + ); + diagnostic.sub(sub); + } + + // Add hint about how to allow deletion + if matches!(error_kind, TypedDictDeleteErrorKind::RequiredKey) { + diagnostic.info( + "Only keys marked as `NotRequired` (or in a TypedDict with `total=False`) can be deleted", + ); + } +} + pub(crate) fn report_invalid_type_param_order<'db>( context: &InferContext<'db, '_>, class: ClassLiteral<'db>, diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index 4a5a855a97..e90944310b 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -65,20 +65,21 @@ use crate::types::diagnostic::{ INVALID_TYPE_ARGUMENTS, INVALID_TYPE_FORM, INVALID_TYPE_GUARD_CALL, INVALID_TYPE_VARIABLE_CONSTRAINTS, IncompatibleBases, NON_SUBSCRIPTABLE, POSSIBLY_MISSING_ATTRIBUTE, POSSIBLY_MISSING_IMPLICIT_CALL, POSSIBLY_MISSING_IMPORT, - SUBCLASS_OF_FINAL_CLASS, UNDEFINED_REVEAL, UNRESOLVED_ATTRIBUTE, UNRESOLVED_GLOBAL, - UNRESOLVED_IMPORT, UNRESOLVED_REFERENCE, UNSUPPORTED_OPERATOR, USELESS_OVERLOAD_BODY, - hint_if_stdlib_attribute_exists_on_other_versions, + SUBCLASS_OF_FINAL_CLASS, TypedDictDeleteErrorKind, UNDEFINED_REVEAL, UNRESOLVED_ATTRIBUTE, + UNRESOLVED_GLOBAL, UNRESOLVED_IMPORT, UNRESOLVED_REFERENCE, UNSUPPORTED_OPERATOR, + USELESS_OVERLOAD_BODY, hint_if_stdlib_attribute_exists_on_other_versions, hint_if_stdlib_submodule_exists_on_other_versions, report_attempted_protocol_instantiation, report_bad_dunder_set_call, report_bad_frozen_dataclass_inheritance, - report_cannot_pop_required_field_on_typed_dict, report_duplicate_bases, - report_implicit_return_type, report_index_out_of_bounds, report_instance_layout_conflict, - report_invalid_arguments_to_annotated, report_invalid_assignment, - report_invalid_attribute_assignment, report_invalid_exception_caught, - report_invalid_exception_cause, report_invalid_exception_raised, - report_invalid_exception_tuple_caught, report_invalid_generator_function_return_type, - report_invalid_key_on_typed_dict, report_invalid_or_unsupported_base, - report_invalid_return_type, report_invalid_type_checking_constant, - report_invalid_type_param_order, report_named_tuple_field_with_leading_underscore, + report_cannot_delete_typed_dict_key, report_cannot_pop_required_field_on_typed_dict, + report_duplicate_bases, report_implicit_return_type, report_index_out_of_bounds, + report_instance_layout_conflict, report_invalid_arguments_to_annotated, + report_invalid_assignment, report_invalid_attribute_assignment, + report_invalid_exception_caught, report_invalid_exception_cause, + report_invalid_exception_raised, report_invalid_exception_tuple_caught, + report_invalid_generator_function_return_type, report_invalid_key_on_typed_dict, + report_invalid_or_unsupported_base, report_invalid_return_type, + report_invalid_type_checking_constant, report_invalid_type_param_order, + report_named_tuple_field_with_leading_underscore, report_namedtuple_field_without_default_after_field_with_default, report_non_subscriptable, report_possibly_missing_attribute, report_possibly_unresolved_reference, report_rebound_typevar, report_slice_step_size_zero, report_unsupported_augmented_assignment, @@ -4405,17 +4406,64 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } } CallErrorKind::BindingError => { - if let Some(builder) = - self.context.report_lint(&INVALID_ARGUMENT_TYPE, target) - { - let mut diagnostic = builder.into_diagnostic(format_args!( - "Method `__delitem__` of type `{}` cannot be called \ - with key of type `{}` on object of type `{}`", - bindings.callable_type().display(db), - slice_ty.display(db), - object_ty.display(db), - )); - attach_original_type_info(&mut diagnostic); + // For deletions of string literal keys on `TypedDict`, provide + // a more detailed diagnostic. + if let Some(typed_dict) = object_ty.as_typed_dict() { + if let Type::StringLiteral(string_literal) = slice_ty { + let key = string_literal.value(db); + let items = typed_dict.items(db); + + if let Some(field) = items.get(key) { + // Key exists but is required (i.e., can't be deleted). + report_cannot_delete_typed_dict_key( + &self.context, + (&*target.slice).into(), + object_ty, + key, + Some(field), + TypedDictDeleteErrorKind::RequiredKey, + ); + } else { + // Key doesn't exist. + report_cannot_delete_typed_dict_key( + &self.context, + (&*target.slice).into(), + object_ty, + key, + None, + TypedDictDeleteErrorKind::UnknownKey, + ); + } + } else { + // Non-string-literal key on `TypedDict`. + if let Some(builder) = self + .context + .report_lint(&INVALID_ARGUMENT_TYPE, target) + { + let mut diagnostic = builder.into_diagnostic(format_args!( + "Method `__delitem__` of type `{}` cannot be called \ + with key of type `{}` on object of type `{}`", + bindings.callable_type().display(db), + slice_ty.display(db), + object_ty.display(db), + )); + attach_original_type_info(&mut diagnostic); + } + } + } else { + // Non-`TypedDict` object + if let Some(builder) = + self.context.report_lint(&INVALID_ARGUMENT_TYPE, target) + { + let mut diagnostic = builder.into_diagnostic(format_args!( + "Method `__delitem__` of type `{}` cannot be called \ + with key of type `{}` on object of type `{}`", + bindings.callable_type().display(db), + slice_ty.display(db), + object_ty.display(db), + )); + attach_original_type_info(&mut diagnostic); + } } } CallErrorKind::PossiblyNotCallable => { diff --git a/crates/ty_python_semantic/src/types/typed_dict.rs b/crates/ty_python_semantic/src/types/typed_dict.rs index 410c923d64..577e146dfa 100644 --- a/crates/ty_python_semantic/src/types/typed_dict.rs +++ b/crates/ty_python_semantic/src/types/typed_dict.rs @@ -612,7 +612,7 @@ pub(super) fn validate_typed_dict_key_assignment<'db, 'ast>( }; let add_item_definition_subdiagnostic = |diagnostic: &mut Diagnostic, message| { - if let Some(declaration) = item.first_declaration { + if let Some(declaration) = item.first_declaration() { let file = declaration.file(db); let module = parsed_module(db, file).load(db); @@ -972,6 +972,10 @@ impl<'db> TypedDictField<'db> { self.flags.contains(TypedDictFieldFlags::READ_ONLY) } + pub(crate) const fn first_declaration(&self) -> Option> { + self.first_declaration + } + pub(crate) fn apply_type_mapping_impl<'a>( self, db: &'db dyn Db,