Skip to content

Commit 2aa777e

Browse files
committed
avoid duplicated TypedDict assignment diagnostics
1 parent 71c14c9 commit 2aa777e

File tree

5 files changed

+84
-72
lines changed

5 files changed

+84
-72
lines changed

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

Lines changed: 28 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -40,19 +40,18 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/typed_dict.md
4040
26 |
4141
27 | def create_with_invalid_string_key():
4242
28 | # error: [invalid-key]
43-
29 | # error: [invalid-assignment]
44-
30 | alice: Person = {"name": "Alice", "age": 30, "unknown": "Foo"}
45-
31 |
46-
32 | # error: [invalid-key]
47-
33 | bob = Person(name="Bob", age=25, unknown="Bar")
48-
34 | from typing_extensions import ReadOnly
49-
35 |
50-
36 | class Employee(TypedDict):
51-
37 | id: ReadOnly[int]
52-
38 | name: str
53-
39 |
54-
40 | def write_to_readonly_key(employee: Employee):
55-
41 | employee["id"] = 42 # error: [invalid-assignment]
43+
29 | alice: Person = {"name": "Alice", "age": 30, "unknown": "Foo"}
44+
30 |
45+
31 | # error: [invalid-key]
46+
32 | bob = Person(name="Bob", age=25, unknown="Bar")
47+
33 | from typing_extensions import ReadOnly
48+
34 |
49+
35 | class Employee(TypedDict):
50+
36 | id: ReadOnly[int]
51+
37 | name: str
52+
38 |
53+
39 | def write_to_readonly_key(employee: Employee):
54+
40 | employee["id"] = 42 # error: [invalid-assignment]
5655
```
5756

5857
# Diagnostics
@@ -160,69 +159,54 @@ info: rule `invalid-key` is enabled by default
160159
161160
```
162161

163-
```
164-
error[invalid-assignment]: Object of type `dict[Unknown | str, Unknown | str | int]` is not assignable to `Person`
165-
--> src/mdtest_snippet.py:30:5
166-
|
167-
28 | # error: [invalid-key]
168-
29 | # error: [invalid-assignment]
169-
30 | alice: Person = {"name": "Alice", "age": 30, "unknown": "Foo"}
170-
| ^^^^^
171-
31 |
172-
32 | # error: [invalid-key]
173-
|
174-
info: rule `invalid-assignment` is enabled by default
175-
176-
```
177-
178162
```
179163
error[invalid-key]: Invalid key for TypedDict `Person`
180-
--> src/mdtest_snippet.py:30:21
164+
--> src/mdtest_snippet.py:29:21
181165
|
166+
27 | def create_with_invalid_string_key():
182167
28 | # error: [invalid-key]
183-
29 | # error: [invalid-assignment]
184-
30 | alice: Person = {"name": "Alice", "age": 30, "unknown": "Foo"}
168+
29 | alice: Person = {"name": "Alice", "age": 30, "unknown": "Foo"}
185169
| -----------------------------^^^^^^^^^--------
186170
| | |
187171
| | Unknown key "unknown"
188172
| TypedDict `Person`
189-
31 |
190-
32 | # error: [invalid-key]
173+
30 |
174+
31 | # error: [invalid-key]
191175
|
192176
info: rule `invalid-key` is enabled by default
193177
194178
```
195179

196180
```
197181
error[invalid-key]: Invalid key for TypedDict `Person`
198-
--> src/mdtest_snippet.py:33:11
182+
--> src/mdtest_snippet.py:32:11
199183
|
200-
32 | # error: [invalid-key]
201-
33 | bob = Person(name="Bob", age=25, unknown="Bar")
184+
31 | # error: [invalid-key]
185+
32 | bob = Person(name="Bob", age=25, unknown="Bar")
202186
| ------ TypedDict `Person` ^^^^^^^^^^^^^ Unknown key "unknown"
203-
34 | from typing_extensions import ReadOnly
187+
33 | from typing_extensions import ReadOnly
204188
|
205189
info: rule `invalid-key` is enabled by default
206190
207191
```
208192

209193
```
210194
error[invalid-assignment]: Cannot assign to key "id" on TypedDict `Employee`
211-
--> src/mdtest_snippet.py:41:5
195+
--> src/mdtest_snippet.py:40:5
212196
|
213-
40 | def write_to_readonly_key(employee: Employee):
214-
41 | employee["id"] = 42 # error: [invalid-assignment]
197+
39 | def write_to_readonly_key(employee: Employee):
198+
40 | employee["id"] = 42 # error: [invalid-assignment]
215199
| -------- ^^^^ key is marked read-only
216200
| |
217201
| TypedDict `Employee`
218202
|
219203
info: Item declaration
220-
--> src/mdtest_snippet.py:37:5
204+
--> src/mdtest_snippet.py:36:5
221205
|
222-
36 | class Employee(TypedDict):
223-
37 | id: ReadOnly[int]
206+
35 | class Employee(TypedDict):
207+
36 | id: ReadOnly[int]
224208
| ----------------- Read-only item declared here
225-
38 | name: str
209+
37 | name: str
226210
|
227211
info: rule `invalid-assignment` is enabled by default
228212

crates/ty_python_semantic/resources/mdtest/typed_dict.md

Lines changed: 9 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,6 @@ CAPITALIZED_NAME = "Name"
8383

8484
# error: [invalid-key] "Invalid key for TypedDict `Person`: Unknown key "Name" - did you mean "name"?"
8585
# error: [missing-typed-dict-key] "Missing required key 'name' in TypedDict `Person` constructor"
86-
# error: [invalid-assignment]
8786
dave: Person = {CAPITALIZED_NAME: "Dave", "age": 20}
8887

8988
def age() -> Literal["age"] | None:
@@ -96,7 +95,6 @@ The construction of a `TypedDict` is checked for type correctness:
9695

9796
```py
9897
# error: [invalid-argument-type] "Invalid argument to key "name" with declared type `str` on TypedDict `Person`"
99-
# error: [invalid-assignment]
10098
eve1a: Person = {"name": b"Eve", "age": None}
10199

102100
# error: [invalid-argument-type] "Invalid argument to key "name" with declared type `str` on TypedDict `Person`"
@@ -106,7 +104,6 @@ reveal_type(eve1a) # revealed: Person
106104
reveal_type(eve1b) # revealed: Person
107105

108106
# error: [missing-typed-dict-key] "Missing required key 'name' in TypedDict `Person` constructor"
109-
# error: [invalid-assignment]
110107
eve2a: Person = {"age": 22}
111108

112109
# error: [missing-typed-dict-key] "Missing required key 'name' in TypedDict `Person` constructor"
@@ -116,7 +113,6 @@ reveal_type(eve2a) # revealed: Person
116113
reveal_type(eve2b) # revealed: Person
117114

118115
# error: [invalid-key] "Invalid key for TypedDict `Person`: Unknown key "extra""
119-
# error: [invalid-assignment]
120116
eve3a: Person = {"name": "Eve", "age": 25, "extra": True}
121117

122118
# error: [invalid-key] "Invalid key for TypedDict `Person`: Unknown key "extra""
@@ -205,8 +201,6 @@ reveal_type(alice["inner"]["age"]) # revealed: int | None
205201
reveal_type(alice["inner"]["non_existing"]) # revealed: Unknown
206202

207203
# error: [invalid-key] "Invalid key for TypedDict `Inner`: Unknown key "extra""
208-
# error: [invalid-argument-type]
209-
# error: [invalid-assignment]
210204
alice: Person = {"inner": {"name": "Alice", "age": 30, "extra": 1}}
211205
```
212206

@@ -243,7 +237,6 @@ All of these are missing the required `age` field:
243237

244238
```py
245239
# error: [missing-typed-dict-key] "Missing required key 'age' in TypedDict `Person` constructor"
246-
# error: [invalid-assignment]
247240
alice2: Person = {"name": "Alice"}
248241

249242
# error: [missing-typed-dict-key] "Missing required key 'age' in TypedDict `Person` constructor"
@@ -262,15 +255,13 @@ house.owner = {"name": "Alice"}
262255

263256
a_person: Person
264257
# error: [missing-typed-dict-key] "Missing required key 'age' in TypedDict `Person` constructor"
265-
# error: [invalid-assignment]
266258
a_person = {"name": "Alice"}
267259
```
268260

269261
All of these have an invalid type for the `name` field:
270262

271263
```py
272264
# error: [invalid-argument-type] "Invalid argument to key "name" with declared type `str` on TypedDict `Person`: value of type `None`"
273-
# error: [invalid-assignment]
274265
alice3: Person = {"name": None, "age": 30}
275266

276267
# error: [invalid-argument-type] "Invalid argument to key "name" with declared type `str` on TypedDict `Person`: value of type `None`"
@@ -289,19 +280,16 @@ house.owner = {"name": None, "age": 30}
289280

290281
a_person: Person
291282
# error: [invalid-argument-type] "Invalid argument to key "name" with declared type `str` on TypedDict `Person`: value of type `None`"
292-
# error: [invalid-assignment]
293283
a_person = {"name": None, "age": 30}
294284

295285
# error: [invalid-argument-type] "Invalid argument to key "name" with declared type `str` on TypedDict `Person`: value of type `None`"
296-
# error: [invalid-assignment]
297286
(a_person := {"name": None, "age": 30})
298287
```
299288

300289
All of these have an extra field that is not defined in the `TypedDict`:
301290

302291
```py
303292
# error: [invalid-key] "Invalid key for TypedDict `Person`: Unknown key "extra""
304-
# error: [invalid-assignment]
305293
alice4: Person = {"name": "Alice", "age": 30, "extra": True}
306294

307295
# error: [invalid-key] "Invalid key for TypedDict `Person`: Unknown key "extra""
@@ -320,11 +308,9 @@ house.owner = {"name": "Alice", "age": 30, "extra": True}
320308

321309
a_person: Person
322310
# error: [invalid-key] "Invalid key for TypedDict `Person`: Unknown key "extra""
323-
# error: [invalid-assignment]
324311
a_person = {"name": "Alice", "age": 30, "extra": True}
325312

326313
# error: [invalid-key] "Invalid key for TypedDict `Person`: Unknown key "extra""
327-
# error: [invalid-assignment]
328314
(a_person := {"name": "Alice", "age": 30, "extra": True})
329315
```
330316

@@ -520,6 +506,15 @@ dangerous(alice)
520506
reveal_type(alice["name"]) # revealed: str
521507
```
522508

509+
Likewise, `dict`s are not assignable to typed dictionaries:
510+
511+
```py
512+
alice: dict[str, str] = {"name": "Alice"}
513+
514+
# error: [invalid-assignment] "Object of type `dict[str, str]` is not assignable to `Person`"
515+
alice: Person = alice
516+
```
517+
523518
## Key-based access
524519

525520
### Reading
@@ -790,7 +785,6 @@ class Employee(Person):
790785
alice: Employee = {"name": "Alice", "employee_id": 1}
791786

792787
# error: [missing-typed-dict-key] "Missing required key 'employee_id' in TypedDict `Employee` constructor"
793-
# error: [invalid-assignment]
794788
eve: Employee = {"name": "Eve"}
795789

796790
def combine(p: Person, e: Employee):
@@ -892,7 +886,6 @@ p1: TaggedData[int] = {"data": 42, "tag": "number"}
892886
p2: TaggedData[str] = {"data": "Hello", "tag": "text"}
893887

894888
# error: [invalid-argument-type] "Invalid argument to key "data" with declared type `int` on TypedDict `TaggedData`: value of type `Literal["not a number"]`"
895-
# error: [invalid-assignment]
896889
p3: TaggedData[int] = {"data": "not a number", "tag": "number"}
897890

898891
class Items(TypedDict, Generic[T]):
@@ -926,7 +919,6 @@ p1: TaggedData[int] = {"data": 42, "tag": "number"}
926919
p2: TaggedData[str] = {"data": "Hello", "tag": "text"}
927920

928921
# error: [invalid-argument-type] "Invalid argument to key "data" with declared type `int` on TypedDict `TaggedData`: value of type `Literal["not a number"]`"
929-
# error: [invalid-assignment]
930922
p3: TaggedData[int] = {"data": "not a number", "tag": "number"}
931923

932924
class Items[T](TypedDict):
@@ -961,9 +953,6 @@ grandchild: Node = {"name": "grandchild", "parent": child}
961953
nested: Node = {"name": "n1", "parent": {"name": "n2", "parent": {"name": "n3", "parent": None}}}
962954

963955
# error: [invalid-argument-type] "Invalid argument to key "name" with declared type `str` on TypedDict `Node`: value of type `Literal[3]`"
964-
# error: [invalid-assignment]
965-
# error: [invalid-argument-type]
966-
# error: [invalid-argument-type]
967956
nested_invalid: Node = {"name": "n1", "parent": {"name": "n2", "parent": {"name": 3, "parent": None}}}
968957
```
969958

@@ -1066,7 +1055,6 @@ def write_to_non_literal_string_key(person: Person, str_key: str):
10661055

10671056
def create_with_invalid_string_key():
10681057
# error: [invalid-key]
1069-
# error: [invalid-assignment]
10701058
alice: Person = {"name": "Alice", "age": 30, "unknown": "Foo"}
10711059

10721060
# error: [invalid-key]

crates/ty_python_semantic/src/types/call/bind.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3582,6 +3582,11 @@ impl<'db> BindingError<'db> {
35823582
expected_ty,
35833583
provided_ty,
35843584
} => {
3585+
// TODO: Ideally we would not emit diagnostics for `TypedDict` literal arguments
3586+
// here (see `diagnostic::is_invalid_typed_dict_literal`). However, we may have
3587+
// silenced diagnostics during overload evaluation, and rely on the assignability
3588+
// diagnostic being emitted here.
3589+
35853590
let range = Self::get_node(node, *argument_index);
35863591
let Some(builder) = context.report_lint(&INVALID_ARGUMENT_TYPE, range) else {
35873592
return;

crates/ty_python_semantic/src/types/diagnostic.rs

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2003,6 +2003,20 @@ pub(super) fn report_slice_step_size_zero(context: &InferContext, node: AnyNodeR
20032003
builder.into_diagnostic("Slice step size can not be zero");
20042004
}
20052005

2006+
// We avoid emitting invalid assignment diagnostic for literal assignments to a `TypedDict`, as
2007+
// they can only occur if we already failed to validate the dict (and emitted some diagnostic).
2008+
pub(crate) fn is_invalid_typed_dict_literal(
2009+
db: &dyn Db,
2010+
target_ty: Type,
2011+
source: AnyNodeRef<'_>,
2012+
) -> bool {
2013+
target_ty
2014+
.filter_union(db, Type::is_typed_dict)
2015+
.as_typed_dict()
2016+
.is_some()
2017+
&& matches!(source, AnyNodeRef::ExprDict(_))
2018+
}
2019+
20062020
fn report_invalid_assignment_with_message(
20072021
context: &InferContext,
20082022
node: AnyNodeRef,
@@ -2040,15 +2054,27 @@ pub(super) fn report_invalid_assignment<'db>(
20402054
target_ty: Type,
20412055
mut source_ty: Type<'db>,
20422056
) {
2057+
let value_expr = match definition.kind(context.db()) {
2058+
DefinitionKind::Assignment(def) => Some(def.value(context.module())),
2059+
DefinitionKind::AnnotatedAssignment(def) => def.value(context.module()),
2060+
DefinitionKind::NamedExpression(def) => Some(&*def.node(context.module()).value),
2061+
_ => None,
2062+
};
2063+
2064+
if let Some(value_expr) = value_expr
2065+
&& is_invalid_typed_dict_literal(context.db(), target_ty, value_expr.into())
2066+
{
2067+
return;
2068+
}
2069+
20432070
let settings =
20442071
DisplaySettings::from_possibly_ambiguous_type_pair(context.db(), target_ty, source_ty);
20452072

2046-
if let DefinitionKind::AnnotatedAssignment(annotated_assignment) = definition.kind(context.db())
2047-
&& let Some(value) = annotated_assignment.value(context.module())
2048-
{
2073+
if let Some(value_expr) = value_expr {
20492074
// Re-infer the RHS of the annotated assignment, ignoring the type context for more precise
20502075
// error messages.
2051-
source_ty = infer_isolated_expression(context.db(), definition.scope(context.db()), value);
2076+
source_ty =
2077+
infer_isolated_expression(context.db(), definition.scope(context.db()), value_expr);
20522078
}
20532079

20542080
report_invalid_assignment_with_message(
@@ -2070,6 +2096,11 @@ pub(super) fn report_invalid_attribute_assignment(
20702096
source_ty: Type,
20712097
attribute_name: &'_ str,
20722098
) {
2099+
// TODO: Ideally we would not emit diagnostics for `TypedDict` literal arguments
2100+
// here (see `diagnostic::is_invalid_typed_dict_literal`). However, we may have
2101+
// silenced diagnostics during attribute resolution, and rely on the assignability
2102+
// diagnostic being emitted here.
2103+
20732104
report_invalid_assignment_with_message(
20742105
context,
20752106
node,

crates/ty_python_semantic/src/types/typed_dict.rs

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ use ruff_text_size::Ranged;
88
use super::class::{ClassType, CodeGeneratorKind, Field};
99
use super::context::InferContext;
1010
use super::diagnostic::{
11-
INVALID_ARGUMENT_TYPE, INVALID_ASSIGNMENT, report_invalid_key_on_typed_dict,
11+
self, INVALID_ARGUMENT_TYPE, INVALID_ASSIGNMENT, report_invalid_key_on_typed_dict,
1212
report_missing_typed_dict_key,
1313
};
1414
use super::{ApplyTypeMappingVisitor, Type, TypeMapping, visitor};
@@ -213,9 +213,13 @@ pub(super) fn validate_typed_dict_key_assignment<'db, 'ast>(
213213
return true;
214214
}
215215

216+
let value_node = value_node.into();
217+
if diagnostic::is_invalid_typed_dict_literal(context.db(), item.declared_ty, value_node) {
218+
return false;
219+
}
220+
216221
// Invalid assignment - emit diagnostic
217-
if let Some(builder) = context.report_lint(assignment_kind.diagnostic_type(), value_node.into())
218-
{
222+
if let Some(builder) = context.report_lint(assignment_kind.diagnostic_type(), value_node) {
219223
let typed_dict_ty = Type::TypedDict(typed_dict);
220224
let typed_dict_d = typed_dict_ty.display(db);
221225
let value_d = value_ty.display(db);

0 commit comments

Comments
 (0)