Skip to content

Commit e7cc762

Browse files
authored
[ty] Add error context for TypedDict assignments (#24790)
## Summary Add error context for `TypedDict` to `TypedDict` assignments ## Test Plan Adapted and new Markdown tests.
1 parent df3988d commit e7cc762

3 files changed

Lines changed: 199 additions & 13 deletions

File tree

crates/ty_python_semantic/resources/mdtest/diagnostics/error_context.md

Lines changed: 70 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
```toml
44
[environment]
5-
python-version = "3.12"
5+
python-version = "3.13"
66
```
77

88
A lot of ty's diagnostics are emitted as a direct result of a type-to-type assignability check
@@ -409,7 +409,7 @@ info: This violates the Liskov Substitution Principle
409409
Incompatible field types:
410410

411411
```py
412-
from typing import Any, TypedDict
412+
from typing import Any, TypedDict, NotRequired, ReadOnly
413413

414414
class Person(TypedDict):
415415
name: str
@@ -430,6 +430,7 @@ error[invalid-assignment]: Object of type `Person` is not assignable to `Other`
430430
| |
431431
| Declared type
432432
|
433+
info: field "name" on TypedDict `Person` has type `str` which is not assignable to type `bytes` expected by TypedDict `Other`
433434
```
434435

435436
Missing required fields:
@@ -452,23 +453,86 @@ error[invalid-assignment]: Object of type `Person` is not assignable to `PersonW
452453
| |
453454
| Declared type
454455
|
456+
info: required field "age" is not present in source TypedDict `Person`
455457
```
456458

457-
Assigning a `TypedDict` to a `dict`
459+
Non-required fields that are required in the target:
458460

459461
```py
460-
class Person(TypedDict):
462+
class PersonWithOptionalAge(TypedDict):
461463
name: str
464+
age: NotRequired[int]
465+
466+
def _(source: PersonWithOptionalAge):
467+
target: PersonWithAge = source # snapshot
468+
```
469+
470+
```snapshot
471+
error[invalid-assignment]: Object of type `PersonWithOptionalAge` is not assignable to `PersonWithAge`
472+
--> src/mdtest_snippet.py:22:13
473+
|
474+
22 | target: PersonWithAge = source # snapshot
475+
| ------------- ^^^^^^ Incompatible value of type `PersonWithOptionalAge`
476+
| |
477+
| Declared type
478+
|
479+
info: field "age" is required in TypedDict `PersonWithAge` but not required in TypedDict `PersonWithOptionalAge`
480+
```
481+
482+
Read-only fields that are mutable in the target:
483+
484+
```py
485+
class PersonWithReadOnlyName(TypedDict):
486+
name: ReadOnly[str]
487+
488+
def _(source: PersonWithReadOnlyName):
489+
target: Person = source # snapshot
490+
```
491+
492+
```snapshot
493+
error[invalid-assignment]: Object of type `PersonWithReadOnlyName` is not assignable to `Person`
494+
--> src/mdtest_snippet.py:27:13
495+
|
496+
27 | target: Person = source # snapshot
497+
| ------ ^^^^^^ Incompatible value of type `PersonWithReadOnlyName`
498+
| |
499+
| Declared type
500+
|
501+
info: field "name" is read-only in TypedDict `PersonWithReadOnlyName` but mutable in TypedDict `Person`
502+
```
503+
504+
Required fields that are not required and mutable in the target:
505+
506+
```py
507+
def _(source: PersonWithAge):
508+
target: PersonWithOptionalAge = source # snapshot
509+
```
510+
511+
```snapshot
512+
error[invalid-assignment]: Object of type `PersonWithAge` is not assignable to `PersonWithOptionalAge`
513+
--> src/mdtest_snippet.py:29:13
514+
|
515+
29 | target: PersonWithOptionalAge = source # snapshot
516+
| --------------------- ^^^^^^ Incompatible value of type `PersonWithAge`
517+
| |
518+
| Declared type
519+
|
520+
info: field "age" is required in TypedDict `PersonWithAge` but not required and mutable in TypedDict `PersonWithOptionalAge`
521+
help: The required field could be removed through a destructive operation like `del` on the target.
522+
```
523+
524+
Assigning a `TypedDict` to a `dict`
462525

526+
```py
463527
def _(source: Person):
464528
target: dict[str, Any] = source # snapshot
465529
```
466530

467531
```snapshot
468532
error[invalid-assignment]: Object of type `Person` is not assignable to `dict[str, Any]`
469-
--> src/mdtest_snippet.py:21:13
533+
--> src/mdtest_snippet.py:31:13
470534
|
471-
21 | target: dict[str, Any] = source # snapshot
535+
31 | target: dict[str, Any] = source # snapshot
472536
| -------------- ^^^^^^ Incompatible value of type `Person`
473537
| |
474538
| Declared type

crates/ty_python_semantic/src/types/relation_error.rs

Lines changed: 92 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,32 @@ pub(crate) enum ErrorContext<'db> {
5555
NotAssignableToNOtherUnionElements {
5656
n: usize,
5757
},
58+
TypedDictFieldMissing {
59+
field_name: Name,
60+
source: TypedDictType<'db>,
61+
},
62+
TypedDictFieldNotRequiredInSource {
63+
source: TypedDictType<'db>,
64+
target: TypedDictType<'db>,
65+
field_name: Name,
66+
},
67+
TypedDictFieldNotRequiredAndMutableInTarget {
68+
source: TypedDictType<'db>,
69+
target: TypedDictType<'db>,
70+
field_name: Name,
71+
},
72+
TypedDictFieldReadOnlyInSource {
73+
field_name: Name,
74+
source: TypedDictType<'db>,
75+
target: TypedDictType<'db>,
76+
},
77+
TypedDictFieldIncompatible {
78+
field_name: Name,
79+
source: TypedDictType<'db>,
80+
target: TypedDictType<'db>,
81+
source_field: Type<'db>,
82+
target_field: Type<'db>,
83+
},
5884
TypedDictNotAssignableToDict(TypedDictType<'db>),
5985
IncompatibleReturnTypes {
6086
source: Type<'db>,
@@ -105,6 +131,11 @@ impl<'db> ErrorContext<'db> {
105131
db: &'db dyn Db,
106132
help_messages: &mut FxOrderSet<HelpMessages>,
107133
) -> Option<String> {
134+
let typed_dict_name = |typed_dict: &TypedDictType<'db>| match typed_dict {
135+
TypedDictType::Class(class) => format!("TypedDict `{}`", class.name(db)),
136+
TypedDictType::Synthesized(_) => Type::TypedDict(*typed_dict).display(db).to_string(),
137+
};
138+
108139
Some(match self {
109140
Self::Empty => {
110141
return None;
@@ -128,15 +159,67 @@ impl<'db> ErrorContext<'db> {
128159
"... omitted {n} union element{} without additional context",
129160
if *n == 1 { "" } else { "s" }
130161
),
162+
Self::TypedDictFieldMissing { field_name, source } => {
163+
format!(
164+
"required field \"{field_name}\" is not present in source {source}",
165+
source = typed_dict_name(source)
166+
)
167+
}
168+
Self::TypedDictFieldNotRequiredInSource {
169+
field_name,
170+
source,
171+
target,
172+
} => {
173+
format!(
174+
"field \"{field_name}\" is required in {target} but not required in {source}",
175+
source = typed_dict_name(source),
176+
target = typed_dict_name(target)
177+
)
178+
}
179+
Self::TypedDictFieldNotRequiredAndMutableInTarget {
180+
field_name,
181+
source,
182+
target,
183+
} => {
184+
help_messages.insert(HelpMessages::RequiredFieldCouldBeRemoved);
185+
format!(
186+
"field \"{field_name}\" is required in {source} but not required and mutable in {target}",
187+
source = typed_dict_name(source),
188+
target = typed_dict_name(target)
189+
)
190+
}
191+
Self::TypedDictFieldReadOnlyInSource {
192+
field_name,
193+
source,
194+
target,
195+
} => {
196+
format!(
197+
"field \"{field_name}\" is read-only in {source} but mutable in {target}",
198+
source = typed_dict_name(source),
199+
target = typed_dict_name(target)
200+
)
201+
}
202+
Self::TypedDictFieldIncompatible {
203+
field_name,
204+
source,
205+
target,
206+
source_field,
207+
target_field,
208+
} => format!(
209+
"field \"{field_name}\" on {source} has type `{source_field}` which is not assignable to type `{target_field}` expected by {target}",
210+
source = typed_dict_name(source),
211+
target = typed_dict_name(target),
212+
source_field = source_field.display(db),
213+
target_field = target_field.display(db),
214+
),
131215
Self::TypedDictNotAssignableToDict(typed_dict) => {
132216
help_messages.insert(HelpMessages::TypedDictNotAssignableToDict);
133217
help_messages.insert(HelpMessages::ConsiderUsingMappingInsteadOfDict);
134218

135-
let name = match typed_dict {
136-
TypedDictType::Class(class) => format!("TypedDict `{}`", class.name(db)),
137-
TypedDictType::Synthesized(_) => "TypedDict".to_string(),
138-
};
139-
format!("{name} is not assignable to `dict`")
219+
format!(
220+
"{source} is not assignable to `dict`",
221+
source = typed_dict_name(typed_dict)
222+
)
140223
}
141224
Self::IncompatibleReturnTypes { source, target } => format!(
142225
"incompatible return types: `{source}` is not assignable to `{target}`",
@@ -230,13 +313,17 @@ impl<'db> ErrorContext<'db> {
230313

231314
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
232315
enum HelpMessages {
316+
RequiredFieldCouldBeRemoved,
233317
TypedDictNotAssignableToDict,
234318
ConsiderUsingMappingInsteadOfDict,
235319
}
236320

237321
impl std::fmt::Display for HelpMessages {
238322
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
239323
match self {
324+
HelpMessages::RequiredFieldCouldBeRemoved => {
325+
f.write_str("The required field could be removed through a destructive operation like `del` on the target.")
326+
}
240327
HelpMessages::TypedDictNotAssignableToDict => {
241328
f.write_str("A TypedDict is not usually assignable to any `dict[..]` type; `dict` types allow destructive operations like `clear()`.")
242329
}

crates/ty_python_semantic/src/types/typed_dict.rs

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ use super::diagnostic::{
1818
};
1919
use super::infer::infer_deferred_types;
2020
use super::{
21-
ApplyTypeMappingVisitor, IntersectionType, Type, TypeMapping, TypeQualifiers, UnionBuilder,
22-
definition_expression_type, visitor,
21+
ApplyTypeMappingVisitor, ErrorContext, IntersectionType, Type, TypeMapping, TypeQualifiers,
22+
UnionBuilder, definition_expression_type, visitor,
2323
};
2424
use crate::Db;
2525
use crate::types::TypeContext;
@@ -275,10 +275,19 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> {
275275
// required target fields
276276
let Some(source_item_field) = source_items.get(target_item_name) else {
277277
// Self is missing a required field.
278+
self.provide_context(|| ErrorContext::TypedDictFieldMissing {
279+
field_name: target_item_name.clone(),
280+
source,
281+
});
278282
return self.never();
279283
};
280284
if !source_item_field.is_required() {
281285
// A required field is not required in self.
286+
self.provide_context(|| ErrorContext::TypedDictFieldNotRequiredInSource {
287+
field_name: target_item_name.clone(),
288+
source,
289+
target,
290+
});
282291
return self.never();
283292
}
284293
if target_item_field.is_read_only() {
@@ -294,6 +303,11 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> {
294303
} else {
295304
if source_item_field.is_read_only() {
296305
// A read-only field can't be assigned to a mutable target.
306+
self.provide_context(|| ErrorContext::TypedDictFieldReadOnlyInSource {
307+
field_name: target_item_name.clone(),
308+
source,
309+
target,
310+
});
297311
return self.never();
298312
}
299313
// For mutable fields in the target, the relation needs to apply both
@@ -350,11 +364,23 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> {
350364
if let Some(source_item_field) = source_items.get(target_item_name) {
351365
if source_item_field.is_read_only() {
352366
// A read-only field can't be assigned to a mutable target.
367+
self.provide_context(|| ErrorContext::TypedDictFieldReadOnlyInSource {
368+
field_name: target_item_name.clone(),
369+
source,
370+
target,
371+
});
353372
return self.never();
354373
}
355374
if source_item_field.is_required() {
356375
// A required field can't be assigned to a not-required, mutable field
357376
// in the target, because `del` is allowed on the target field.
377+
self.provide_context(|| {
378+
ErrorContext::TypedDictFieldNotRequiredAndMutableInTarget {
379+
field_name: target_item_name.clone(),
380+
source,
381+
target,
382+
}
383+
});
358384
return self.never();
359385
}
360386

@@ -386,6 +412,15 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> {
386412
};
387413
result.intersect(db, self.constraints, field_constraints);
388414
if result.is_never_satisfied(db) {
415+
if let Some(source_item_field) = source_items.get(target_item_name) {
416+
self.provide_context(|| ErrorContext::TypedDictFieldIncompatible {
417+
field_name: target_item_name.clone(),
418+
source,
419+
target,
420+
source_field: source_item_field.declared_ty,
421+
target_field: target_item_field.declared_ty,
422+
});
423+
}
389424
return result;
390425
}
391426
}

0 commit comments

Comments
 (0)