Skip to content

Commit 8bb7069

Browse files
committed
[ty] Validate writes to TypedDict keys
1 parent 4887bdf commit 8bb7069

File tree

6 files changed

+348
-140
lines changed

6 files changed

+348
-140
lines changed

crates/ty/docs/rules.md

Lines changed: 60 additions & 60 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/ty_python_semantic/resources/mdtest/snapshots/typed_dict.md_-_`TypedDict`_-_Diagnostics_(e5289abf5c570c29).snap

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,15 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/typed_dict.md
2828
14 |
2929
15 | def access_with_str_key(person: Person, str_key: str):
3030
16 | person[str_key] # error: [invalid-key]
31+
17 |
32+
18 | def write_to_key_with_wrong_type(person: Person):
33+
19 | person["age"] = "42" # error: [invalid-assignment]
34+
20 |
35+
21 | def write_to_non_existing_key(person: Person):
36+
22 | person["naem"] = "Alice" # error: [invalid-key]
37+
23 |
38+
24 | def write_to_non_literal_string_key(person: Person, str_key: str):
39+
25 | person[str_key] = "Alice" # error: [invalid-assignment]
3140
```
3241

3342
# Diagnostics
@@ -71,7 +80,54 @@ error[invalid-key]: TypedDict `Person` cannot be indexed with a key of type `str
7180
15 | def access_with_str_key(person: Person, str_key: str):
7281
16 | person[str_key] # error: [invalid-key]
7382
| ^^^^^^^
83+
17 |
84+
18 | def write_to_key_with_wrong_type(person: Person):
7485
|
7586
info: rule `invalid-key` is enabled by default
7687
7788
```
89+
90+
```
91+
error[invalid-assignment]: Invalid assignment to key "age" with declared type `int | None` on TypedDict `Person`
92+
--> src/mdtest_snippet.py:19:5
93+
|
94+
18 | def write_to_key_with_wrong_type(person: Person):
95+
19 | person["age"] = "42" # error: [invalid-assignment]
96+
| ------ ----- ^^^^ value of type `Literal["42"]`
97+
| | |
98+
| | key has declared type `int | None`
99+
| TypedDict `Person`
100+
20 |
101+
21 | def write_to_non_existing_key(person: Person):
102+
|
103+
info: rule `invalid-assignment` is enabled by default
104+
105+
```
106+
107+
```
108+
error[invalid-key]: Invalid key access on TypedDict `Person`
109+
--> src/mdtest_snippet.py:22:5
110+
|
111+
21 | def write_to_non_existing_key(person: Person):
112+
22 | person["naem"] = "Alice" # error: [invalid-key]
113+
| ------ ^^^^^^ Unknown key "naem" - did you mean "name"?
114+
| |
115+
| TypedDict `Person`
116+
23 |
117+
24 | def write_to_non_literal_string_key(person: Person, str_key: str):
118+
|
119+
info: rule `invalid-key` is enabled by default
120+
121+
```
122+
123+
```
124+
error[invalid-assignment]: Can not assign value of type `Literal["Alice"]` to key of type `str` on TypedDict `Person`
125+
--> src/mdtest_snippet.py:25:12
126+
|
127+
24 | def write_to_non_literal_string_key(person: Person, str_key: str):
128+
25 | person[str_key] = "Alice" # error: [invalid-assignment]
129+
| ^^^^^^^
130+
|
131+
info: rule `invalid-assignment` is enabled by default
132+
133+
```

crates/ty_python_semantic/resources/mdtest/typed_dict.md

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,8 @@ Assignments to keys are also validated:
6969
```py
7070
# TODO: this should be an error
7171
alice["name"] = None
72-
# TODO: this should be an error
72+
73+
# error: [invalid-assignment] "Invalid assignment to key "name" with declared type `str` on TypedDict `Person`: value of type `None`"
7374
bob["name"] = None
7475
```
7576

@@ -78,7 +79,8 @@ Assignments to non-existing keys are disallowed:
7879
```py
7980
# TODO: this should be an error
8081
alice["extra"] = True
81-
# TODO: this should be an error
82+
83+
# error: [invalid-key] "Invalid key access on TypedDict `Person`: Unknown key "extra""
8284
bob["extra"] = True
8385
```
8486

@@ -138,6 +140,8 @@ reveal_type(alice["name"]) # revealed: Unknown
138140

139141
## Key-based access
140142

143+
### Reading
144+
141145
```py
142146
from typing import TypedDict, Final, Literal, Any
143147

@@ -169,6 +173,54 @@ def _(person: Person, literal_key: Literal["age"], union_of_keys: Literal["age",
169173
reveal_type(person[unknown_key]) # revealed: Unknown
170174
```
171175

176+
### Writing
177+
178+
```py
179+
from typing import TypedDict, Final, Literal, Any
180+
181+
class Person(TypedDict):
182+
name: str
183+
surname: str
184+
age: int | None
185+
186+
NAME_FINAL: Final = "name"
187+
AGE_FINAL: Final[Literal["age"]] = "age"
188+
189+
def _(person: Person):
190+
person["name"] = "Alice"
191+
person["age"] = 30
192+
193+
# error: [invalid-key] "Invalid key access on TypedDict `Person`: Unknown key "naem" - did you mean "name"?"
194+
person["naem"] = "Alice"
195+
196+
def _(person: Person):
197+
person[NAME_FINAL] = "Alice"
198+
person[AGE_FINAL] = 30
199+
200+
def _(person: Person, literal_key: Literal["age"]):
201+
person[literal_key] = 22
202+
203+
def _(person: Person, union_of_keys: Literal["name", "surname"]):
204+
person[union_of_keys] = "unknown"
205+
206+
# error: [invalid-assignment] "Can not assign value of type `Literal[1]` to key of type `Literal["name", "surname"]` on TypedDict `Person`"
207+
person[union_of_keys] = 1
208+
209+
def _(person: Person, union_of_keys: Literal["name", "age"], unknown_value: Any):
210+
person[union_of_keys] = unknown_value
211+
212+
# error: [invalid-assignment] "Can not assign value of type `None` to key of type `Literal["name", "age"]` on TypedDict `Person`"
213+
person[union_of_keys] = None
214+
215+
def _(person: Person, str_key: str):
216+
# error: [invalid-assignment] "Can not assign value of type `None` to key of type `str` on TypedDict `Person`"
217+
person[str_key] = None
218+
219+
def _(person: Person, unknown_key: Any):
220+
# No error here:
221+
person[unknown_key] = "Eve"
222+
```
223+
172224
## Methods on `TypedDict`
173225

174226
```py
@@ -398,6 +450,15 @@ def access_invalid_key(person: Person):
398450

399451
def access_with_str_key(person: Person, str_key: str):
400452
person[str_key] # error: [invalid-key]
453+
454+
def write_to_key_with_wrong_type(person: Person):
455+
person["age"] = "42" # error: [invalid-assignment]
456+
457+
def write_to_non_existing_key(person: Person):
458+
person["naem"] = "Alice" # error: [invalid-key]
459+
460+
def write_to_non_literal_string_key(person: Person, str_key: str):
461+
person[str_key] = "Alice" # error: [invalid-assignment]
401462
```
402463

403464
[`typeddict`]: https://typing.python.org/en/latest/spec/typeddict.html

crates/ty_python_semantic/src/types/class.rs

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2022,18 +2022,30 @@ impl<'db> ClassLiteral<'db> {
20222022
None
20232023
}
20242024
(CodeGeneratorKind::TypedDict, "__setitem__") => {
2025-
// TODO: synthesize a set of overloads with precise types
2026-
let signature = Signature::new(
2027-
Parameters::new([
2028-
Parameter::positional_only(Some(Name::new_static("self")))
2029-
.with_annotated_type(instance_ty),
2030-
Parameter::positional_only(Some(Name::new_static("key"))),
2031-
Parameter::positional_only(Some(Name::new_static("value"))),
2032-
]),
2033-
Some(Type::none(db)),
2034-
);
2025+
let fields = self.fields(db, specialization, field_policy);
20352026

2036-
Some(CallableType::function_like(db, signature))
2027+
// Add (key type, value type) overloads for all TypedDict items ("fields"):
2028+
let overloads = fields.iter().map(|(name, field)| {
2029+
let key_type = Type::StringLiteral(StringLiteralType::new(db, name.as_str()));
2030+
2031+
Signature::new(
2032+
Parameters::new([
2033+
Parameter::positional_only(Some(Name::new_static("self")))
2034+
.with_annotated_type(instance_ty),
2035+
Parameter::positional_only(Some(Name::new_static("key")))
2036+
.with_annotated_type(key_type),
2037+
Parameter::positional_only(Some(Name::new_static("value")))
2038+
.with_annotated_type(field.declared_ty),
2039+
]),
2040+
Some(Type::none(db)),
2041+
)
2042+
});
2043+
2044+
Some(Type::Callable(CallableType::new(
2045+
db,
2046+
CallableSignature::from_overloads(overloads),
2047+
true,
2048+
)))
20372049
}
20382050
(CodeGeneratorKind::TypedDict, "__getitem__") => {
20392051
let fields = self.fields(db, specialization, field_policy);

crates/ty_python_semantic/src/types/diagnostic.rs

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ use super::{
88
use crate::lint::{Level, LintRegistryBuilder, LintStatus};
99
use crate::suppression::FileSuppressionId;
1010
use crate::types::LintDiagnosticGuard;
11-
use crate::types::class::{SolidBase, SolidBaseKind};
11+
use crate::types::class::{Field, SolidBase, SolidBaseKind};
1212
use crate::types::function::KnownFunction;
1313
use crate::types::string_annotation::{
1414
BYTE_STRING_TYPE_ANNOTATION, ESCAPE_CHARACTER_IN_FORWARD_ANNOTATION, FSTRING_TYPE_ANNOTATION,
@@ -17,9 +17,10 @@ use crate::types::string_annotation::{
1717
};
1818
use crate::types::{SpecialFormType, Type, protocol_class::ProtocolClassLiteral};
1919
use crate::util::diagnostics::format_enumeration;
20-
use crate::{Db, FxIndexMap, Module, ModuleName, Program, declare_lint};
20+
use crate::{Db, FxIndexMap, FxOrderMap, Module, ModuleName, Program, declare_lint};
2121
use itertools::Itertools;
2222
use ruff_db::diagnostic::{Annotation, Diagnostic, SubDiagnostic, SubDiagnosticSeverity};
23+
use ruff_python_ast::name::Name;
2324
use ruff_python_ast::{self as ast, AnyNodeRef};
2425
use ruff_text_size::{Ranged, TextRange};
2526
use rustc_hash::FxHashSet;
@@ -2570,6 +2571,53 @@ fn report_invalid_base<'ctx, 'db>(
25702571
Some(diagnostic)
25712572
}
25722573

2574+
pub(crate) fn report_invalid_key_on_typed_dict<'db>(
2575+
context: &InferContext<'db, '_>,
2576+
value_node: AnyNodeRef,
2577+
slice_node: AnyNodeRef,
2578+
value_ty: Type<'db>,
2579+
slice_ty: Type<'db>,
2580+
items: &FxOrderMap<Name, Field<'db>>,
2581+
) {
2582+
let db = context.db();
2583+
if let Some(builder) = context.report_lint(&INVALID_KEY, slice_node) {
2584+
match slice_ty {
2585+
Type::StringLiteral(key) => {
2586+
let key = key.value(db);
2587+
let typed_dict_name = value_ty.display(db);
2588+
2589+
let mut diagnostic = builder.into_diagnostic(format_args!(
2590+
"Invalid key access on TypedDict `{typed_dict_name}`",
2591+
));
2592+
2593+
diagnostic.annotate(
2594+
context
2595+
.secondary(value_node)
2596+
.message(format_args!("TypedDict `{typed_dict_name}`")),
2597+
);
2598+
2599+
let existing_keys = items.iter().map(|(name, _)| name.as_str());
2600+
2601+
diagnostic.set_primary_message(format!(
2602+
"Unknown key \"{key}\"{hint}",
2603+
hint = if let Some(suggestion) = did_you_mean(existing_keys, key) {
2604+
format!(" - did you mean \"{suggestion}\"?")
2605+
} else {
2606+
String::new()
2607+
}
2608+
));
2609+
2610+
diagnostic
2611+
}
2612+
_ => builder.into_diagnostic(format_args!(
2613+
"TypedDict `{}` cannot be indexed with a key of type `{}`",
2614+
value_ty.display(db),
2615+
slice_ty.display(db),
2616+
)),
2617+
};
2618+
}
2619+
}
2620+
25732621
/// This function receives an unresolved `from foo import bar` import,
25742622
/// where `foo` can be resolved to a module but that module does not
25752623
/// have a `bar` member or submodule.
@@ -2619,7 +2667,7 @@ pub(super) fn hint_if_stdlib_submodule_exists_on_other_versions(
26192667
}
26202668

26212669
/// Suggest a name from `existing_names` that is similar to `wrong_name`.
2622-
pub(super) fn did_you_mean<S: AsRef<str>, T: AsRef<str>>(
2670+
fn did_you_mean<S: AsRef<str>, T: AsRef<str>>(
26232671
existing_names: impl Iterator<Item = S>,
26242672
wrong_name: T,
26252673
) -> Option<String> {

0 commit comments

Comments
 (0)