[ty] Add a dedicated diagnostic for TypedDict deletions (#22123)

## Summary

Provides a message like:

```
  error[invalid-argument-type]: Cannot delete required key "name" from TypedDict `Movie`
    --> test.py:15:7
     |
  15 | del m["name"]
     |       ^^^^^^
     |
  info: Field defined here
   --> test.py:4:5
    |
  4 |     name: str
    |     --------- `name` declared as required here; consider making it `NotRequired`
    |
  info: Only keys marked as `NotRequired` (or in a TypedDict with `total=False`) can be deleted
```
This commit is contained in:
Charlie Marsh
2025-12-23 22:49:42 -05:00
committed by GitHub
parent 969c8a547e
commit 184f487c84
5 changed files with 251 additions and 24 deletions

View File

@@ -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.
<!-- snapshot-diagnostics -->
```py
from typing_extensions import TypedDict, NotRequired

View File

@@ -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
```

View File

@@ -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>,

View File

@@ -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 => {

View File

@@ -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<Definition<'db>> {
self.first_declaration
}
pub(crate) fn apply_type_mapping_impl<'a>(
self,
db: &'db dyn Db,