mirror of
https://github.com/astral-sh/ruff
synced 2026-01-21 13:30:49 -05:00
[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:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
```
|
||||
@@ -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>,
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user