Skip to content

Commit 1b44d7e

Browse files
authored
[ty] add SyntheticTypedDictType and implement normalized and is_equivalent_to (#21784)
1 parent a2fb2ee commit 1b44d7e

File tree

9 files changed

+676
-127
lines changed

9 files changed

+676
-127
lines changed

crates/ty/docs/rules.md

Lines changed: 74 additions & 74 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
---
2+
source: crates/ty_test/src/lib.rs
3+
expression: snapshot
4+
---
5+
---
6+
mdtest name: typed_dict.md - `TypedDict` - Redundant cast warnings
7+
mdtest path: crates/ty_python_semantic/resources/mdtest/typed_dict.md
8+
---
9+
10+
# Python source files
11+
12+
## mdtest_snippet.py
13+
14+
```
15+
1 | from typing import TypedDict, cast
16+
2 |
17+
3 | class Foo2(TypedDict):
18+
4 | x: int
19+
5 |
20+
6 | class Bar2(TypedDict):
21+
7 | x: int
22+
8 |
23+
9 | foo: Foo2 = {"x": 1}
24+
10 | _ = cast(Foo2, foo) # error: [redundant-cast]
25+
11 | _ = cast(Bar2, foo) # error: [redundant-cast]
26+
```
27+
28+
# Diagnostics
29+
30+
```
31+
warning[redundant-cast]: Value is already of type `Foo2`
32+
--> src/mdtest_snippet.py:10:5
33+
|
34+
9 | foo: Foo2 = {"x": 1}
35+
10 | _ = cast(Foo2, foo) # error: [redundant-cast]
36+
| ^^^^^^^^^^^^^^^
37+
11 | _ = cast(Bar2, foo) # error: [redundant-cast]
38+
|
39+
info: rule `redundant-cast` is enabled by default
40+
41+
```
42+
43+
```
44+
warning[redundant-cast]: Value is already of type `Bar2`
45+
--> src/mdtest_snippet.py:11:5
46+
|
47+
9 | foo: Foo2 = {"x": 1}
48+
10 | _ = cast(Foo2, foo) # error: [redundant-cast]
49+
11 | _ = cast(Bar2, foo) # error: [redundant-cast]
50+
| ^^^^^^^^^^^^^^^
51+
|
52+
info: `Bar2` is equivalent to `Foo2`
53+
info: rule `redundant-cast` is enabled by default
54+
55+
```

crates/ty_python_semantic/resources/mdtest/typed_dict.md

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -868,6 +868,172 @@ def _(o1: Outer1, o2: Outer2, o3: Outer3, o4: Outer4):
868868
static_assert(is_subtype_of(Outer4, Outer4))
869869
```
870870

871+
## Structural equivalence
872+
873+
Two `TypedDict`s with equivalent fields are equivalent types. This includes fields with gradual
874+
types:
875+
876+
```py
877+
from typing_extensions import Any, TypedDict, ReadOnly, assert_type
878+
from ty_extensions import is_assignable_to, is_equivalent_to, static_assert
879+
880+
class Foo(TypedDict):
881+
x: int
882+
y: Any
883+
884+
# exactly the same fields
885+
class Bar(TypedDict):
886+
x: int
887+
y: Any
888+
889+
# the same fields but in a different order
890+
class Baz(TypedDict):
891+
y: Any
892+
x: int
893+
894+
static_assert(is_assignable_to(Foo, Bar))
895+
static_assert(is_equivalent_to(Foo, Bar))
896+
static_assert(is_assignable_to(Foo, Baz))
897+
static_assert(is_equivalent_to(Foo, Baz))
898+
899+
foo: Foo = {"x": 1, "y": "hello"}
900+
assert_type(foo, Foo)
901+
assert_type(foo, Bar)
902+
assert_type(foo, Baz)
903+
```
904+
905+
Equivalent `TypedDict`s within unions can also produce equivalent unions, which currently relies on
906+
"normalization" machinery:
907+
908+
```py
909+
def f(var: Foo | int):
910+
assert_type(var, Foo | int)
911+
assert_type(var, Bar | int)
912+
assert_type(var, Baz | int)
913+
# TODO: Union simplification compares `TypedDict`s by name/identity to avoid cycles. This assert
914+
# should also pass once that's fixed.
915+
assert_type(var, Foo | Bar | Baz | int) # error: [type-assertion-failure]
916+
```
917+
918+
Here are several cases that are not equivalent. In particular, assignability does not imply
919+
equivalence:
920+
921+
```py
922+
class FewerFields(TypedDict):
923+
x: int
924+
925+
static_assert(is_assignable_to(Foo, FewerFields))
926+
static_assert(not is_equivalent_to(Foo, FewerFields))
927+
928+
class DifferentMutability(TypedDict):
929+
x: int
930+
y: ReadOnly[Any]
931+
932+
static_assert(is_assignable_to(Foo, DifferentMutability))
933+
static_assert(not is_equivalent_to(Foo, DifferentMutability))
934+
935+
class MoreFields(TypedDict):
936+
x: int
937+
y: Any
938+
z: str
939+
940+
static_assert(not is_assignable_to(Foo, MoreFields))
941+
static_assert(not is_equivalent_to(Foo, MoreFields))
942+
943+
class DifferentFieldStaticType(TypedDict):
944+
x: str
945+
y: Any
946+
947+
static_assert(not is_assignable_to(Foo, DifferentFieldStaticType))
948+
static_assert(not is_equivalent_to(Foo, DifferentFieldStaticType))
949+
950+
class DifferentFieldGradualType(TypedDict):
951+
x: int
952+
y: Any | str
953+
954+
static_assert(is_assignable_to(Foo, DifferentFieldGradualType))
955+
static_assert(not is_equivalent_to(Foo, DifferentFieldGradualType))
956+
```
957+
958+
## Structural equivalence understands the interaction between `Required`/`NotRequired` and `total`
959+
960+
```py
961+
from ty_extensions import static_assert, is_equivalent_to
962+
from typing_extensions import TypedDict, Required, NotRequired
963+
964+
class Foo1(TypedDict, total=False):
965+
x: int
966+
y: str
967+
968+
class Foo2(TypedDict):
969+
y: NotRequired[str]
970+
x: NotRequired[int]
971+
972+
static_assert(is_equivalent_to(Foo1, Foo2))
973+
static_assert(is_equivalent_to(Foo1 | int, int | Foo2))
974+
975+
class Bar1(TypedDict, total=False):
976+
x: int
977+
y: Required[str]
978+
979+
class Bar2(TypedDict):
980+
y: str
981+
x: NotRequired[int]
982+
983+
static_assert(is_equivalent_to(Bar1, Bar2))
984+
static_assert(is_equivalent_to(Bar1 | int, int | Bar2))
985+
```
986+
987+
## Assignability and equivalence work with recursive `TypedDict`s
988+
989+
```py
990+
from typing_extensions import TypedDict
991+
from ty_extensions import static_assert, is_assignable_to, is_equivalent_to
992+
993+
class Node1(TypedDict):
994+
value: int
995+
next: "Node1" | None
996+
997+
class Node2(TypedDict):
998+
value: int
999+
next: "Node2" | None
1000+
1001+
static_assert(is_assignable_to(Node1, Node2))
1002+
static_assert(is_equivalent_to(Node1, Node2))
1003+
1004+
class Person1(TypedDict):
1005+
name: str
1006+
friends: list["Person1"]
1007+
1008+
class Person2(TypedDict):
1009+
name: str
1010+
friends: list["Person2"]
1011+
1012+
static_assert(is_assignable_to(Person1, Person2))
1013+
static_assert(is_equivalent_to(Person1, Person2))
1014+
```
1015+
1016+
## Redundant cast warnings
1017+
1018+
<!-- snapshot-diagnostics -->
1019+
1020+
Casting between equivalent types produces a redundant cast warning. When the types have different
1021+
names, the warning makes that clear:
1022+
1023+
```py
1024+
from typing import TypedDict, cast
1025+
1026+
class Foo2(TypedDict):
1027+
x: int
1028+
1029+
class Bar2(TypedDict):
1030+
x: int
1031+
1032+
foo: Foo2 = {"x": 1}
1033+
_ = cast(Foo2, foo) # error: [redundant-cast]
1034+
_ = cast(Bar2, foo) # error: [redundant-cast]
1035+
```
1036+
8711037
## Key-based access
8721038

8731039
### Reading

crates/ty_python_semantic/src/types.rs

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1471,6 +1471,7 @@ impl<'db> Type<'db> {
14711471
/// - Strips the types of default values from parameters in `Callable` types: only whether a parameter
14721472
/// *has* or *does not have* a default value is relevant to whether two `Callable` types are equivalent.
14731473
/// - Converts class-based protocols into synthesized protocols
1474+
/// - Converts class-based typeddicts into synthesized typeddicts
14741475
#[must_use]
14751476
pub(crate) fn normalized(self, db: &'db dyn Db) -> Self {
14761477
self.normalized_impl(db, &NormalizedVisitor::default())
@@ -1529,10 +1530,9 @@ impl<'db> Type<'db> {
15291530
// Always normalize single-member enums to their class instance (`Literal[Single.VALUE]` => `Single`)
15301531
enum_literal.enum_class_instance(db)
15311532
}
1532-
Type::TypedDict(_) => {
1533-
// TODO: Normalize TypedDicts
1534-
self
1535-
}
1533+
Type::TypedDict(typed_dict) => visitor.visit(self, || {
1534+
Type::TypedDict(typed_dict.normalized_impl(db, visitor))
1535+
}),
15361536
Type::TypeAlias(alias) => alias.value_type(db).normalized_impl(db, visitor),
15371537
Type::NewTypeInstance(newtype) => {
15381538
visitor.visit(self, || {
@@ -3053,6 +3053,10 @@ impl<'db> Type<'db> {
30533053
left.is_equivalent_to_impl(db, right, inferable, visitor)
30543054
}
30553055

3056+
(Type::TypedDict(left), Type::TypedDict(right)) => visitor.visit((self, other), || {
3057+
left.is_equivalent_to_impl(db, right, inferable, visitor)
3058+
}),
3059+
30563060
_ => ConstraintSet::from(false),
30573061
}
30583062
}
@@ -7582,7 +7586,13 @@ impl<'db> Type<'db> {
75827586
Type::ProtocolInstance(protocol) => protocol.to_meta_type(db),
75837587
// `TypedDict` instances are instances of `dict` at runtime, but its important that we
75847588
// understand a more specific meta type in order to correctly handle `__getitem__`.
7585-
Type::TypedDict(typed_dict) => SubclassOfType::from(db, typed_dict.defining_class()),
7589+
Type::TypedDict(typed_dict) => match typed_dict {
7590+
TypedDictType::Class(class) => SubclassOfType::from(db, class),
7591+
TypedDictType::Synthesized(_) => SubclassOfType::from(
7592+
db,
7593+
todo_type!("TypedDict synthesized meta-type").expect_dynamic(),
7594+
),
7595+
},
75867596
Type::TypeAlias(alias) => alias.value_type(db).to_meta_type(db),
75877597
Type::NewTypeInstance(newtype) => Type::from(newtype.base_class_type(db)),
75887598
}
@@ -8291,7 +8301,7 @@ impl<'db> Type<'db> {
82918301
},
82928302

82938303
Self::TypedDict(typed_dict) => {
8294-
Some(TypeDefinition::Class(typed_dict.defining_class().definition(db)))
8304+
typed_dict.definition(db).map(TypeDefinition::Class)
82958305
}
82968306

82978307
Self::Union(_) | Self::Intersection(_) => None,

crates/ty_python_semantic/src/types/diagnostic.rs

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,7 @@ use crate::semantic_index::place::{PlaceTable, ScopedPlaceId};
1414
use crate::semantic_index::{global_scope, place_table, use_def_map};
1515
use crate::suppression::FileSuppressionId;
1616
use crate::types::call::CallError;
17-
use crate::types::class::{
18-
CodeGeneratorKind, DisjointBase, DisjointBaseKind, Field, MethodDecorator,
19-
};
17+
use crate::types::class::{CodeGeneratorKind, DisjointBase, DisjointBaseKind, MethodDecorator};
2018
use crate::types::function::{FunctionDecorators, FunctionType, KnownFunction, OverloadLiteral};
2119
use crate::types::infer::UnsupportedComparisonError;
2220
use crate::types::overrides::MethodKind;
@@ -26,6 +24,7 @@ use crate::types::string_annotation::{
2624
RAW_STRING_TYPE_ANNOTATION,
2725
};
2826
use crate::types::tuple::TupleSpec;
27+
use crate::types::typed_dict::TypedDictSchema;
2928
use crate::types::{
3029
BoundTypeVarInstance, ClassType, DynamicType, LintDiagnosticGuard, Protocol,
3130
ProtocolInstanceType, SpecialFormType, SubclassOfInner, Type, TypeContext, binding_type,
@@ -3471,7 +3470,7 @@ pub(crate) fn report_invalid_key_on_typed_dict<'db>(
34713470
typed_dict_ty: Type<'db>,
34723471
full_object_ty: Option<Type<'db>>,
34733472
key_ty: Type<'db>,
3474-
items: &FxIndexMap<Name, Field<'db>>,
3473+
items: &TypedDictSchema<'db>,
34753474
) {
34763475
let db = context.db();
34773476
if let Some(builder) = context.report_lint(&INVALID_KEY, key_node) {

0 commit comments

Comments
 (0)