From 8b0a217911f4ca0f46fdc5d2a34f1db535fe5b3b Mon Sep 17 00:00:00 2001 From: Jack O'Connor Date: Wed, 12 Nov 2025 13:20:21 -0800 Subject: [PATCH 1/2] [ty] implement `TypedDict` structural assignment --- .../mdtest/assignment/annotations.md | 7 +- .../mdtest/diagnostics/same_names.md | 2 +- ...…_-_Unknown_key_for_all_…_(8a0f0e8ceccc51b2).snap} | 31 +- ..._Unknown_key_for_one_…_(b515711c0a451a86).snap | 21 +- .../subscript/assignment_diagnostics.md | 4 +- .../resources/mdtest/typed_dict.md | 272 +++++++++++++++++- crates/ty_python_semantic/src/types.rs | 13 +- crates/ty_python_semantic/src/types/class.rs | 15 +- .../src/types/typed_dict.rs | 243 +++++++++++++++- 9 files changed, 558 insertions(+), 50 deletions(-) rename crates/ty_python_semantic/resources/mdtest/snapshots/{assignment_diagnosti…_-_Subscript_assignment…_-_Unknown_key_for_all_…_(1c685d9d10678263).snap => assignment_diagnosti…_-_Subscript_assignment…_-_Unknown_key_for_all_…_(8a0f0e8ceccc51b2).snap} (67%) diff --git a/crates/ty_python_semantic/resources/mdtest/assignment/annotations.md b/crates/ty_python_semantic/resources/mdtest/assignment/annotations.md index efdbbab36b..44b0324340 100644 --- a/crates/ty_python_semantic/resources/mdtest/assignment/annotations.md +++ b/crates/ty_python_semantic/resources/mdtest/assignment/annotations.md @@ -482,17 +482,14 @@ class TD2(TypedDict): x: str def f(self, dt: dict[str, Any], key: str): - # TODO: This should not error once typed dict assignability is implemented. - # error: [invalid-assignment] x1: TD = dt.get(key, {}) - reveal_type(x1) # revealed: TD + reveal_type(x1) # revealed: Any x2: TD = dt.get(key, {"x": 0}) reveal_type(x2) # revealed: Any x3: TD | None = dt.get(key, {}) - # TODO: This should reveal `Any` once typed dict assignability is implemented. - reveal_type(x3) # revealed: Any | None + reveal_type(x3) # revealed: Any x4: TD | None = dt.get(key, {"x": 0}) reveal_type(x4) # revealed: Any diff --git a/crates/ty_python_semantic/resources/mdtest/diagnostics/same_names.md b/crates/ty_python_semantic/resources/mdtest/diagnostics/same_names.md index c0578469d6..7711a7c3db 100644 --- a/crates/ty_python_semantic/resources/mdtest/diagnostics/same_names.md +++ b/crates/ty_python_semantic/resources/mdtest/diagnostics/same_names.md @@ -265,7 +265,7 @@ import dict_a import dict_b def _(b_person: dict_b.Person): - # TODO should be error: [invalid-assignment] "Object of type `dict_b.Person` is not assignable to `dict_a.Person`" + # error: [invalid-assignment] "Object of type `dict_b.Person` is not assignable to `dict_a.Person`" person_var: dict_a.Person = b_person ``` diff --git a/crates/ty_python_semantic/resources/mdtest/snapshots/assignment_diagnosti…_-_Subscript_assignment…_-_Unknown_key_for_all_…_(1c685d9d10678263).snap b/crates/ty_python_semantic/resources/mdtest/snapshots/assignment_diagnosti…_-_Subscript_assignment…_-_Unknown_key_for_all_…_(8a0f0e8ceccc51b2).snap similarity index 67% rename from crates/ty_python_semantic/resources/mdtest/snapshots/assignment_diagnosti…_-_Subscript_assignment…_-_Unknown_key_for_all_…_(1c685d9d10678263).snap rename to crates/ty_python_semantic/resources/mdtest/snapshots/assignment_diagnosti…_-_Subscript_assignment…_-_Unknown_key_for_all_…_(8a0f0e8ceccc51b2).snap index 2e7bbcfe4d..bc3070f556 100644 --- a/crates/ty_python_semantic/resources/mdtest/snapshots/assignment_diagnosti…_-_Subscript_assignment…_-_Unknown_key_for_all_…_(1c685d9d10678263).snap +++ b/crates/ty_python_semantic/resources/mdtest/snapshots/assignment_diagnosti…_-_Subscript_assignment…_-_Unknown_key_for_all_…_(8a0f0e8ceccc51b2).snap @@ -3,7 +3,7 @@ source: crates/ty_test/src/lib.rs expression: snapshot --- --- -mdtest name: assignment_diagnostics.md - Subscript assignment diagnostics - Unknown key for all elemens of a union +mdtest name: assignment_diagnostics.md - Subscript assignment diagnostics - Unknown key for all elements of a union mdtest path: crates/ty_python_semantic/resources/mdtest/subscript/assignment_diagnostics.md --- @@ -16,26 +16,27 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/subscript/assignment_dia 2 | 3 | class Person(TypedDict): 4 | name: str - 5 | - 6 | class Animal(TypedDict): - 7 | name: str - 8 | legs: int - 9 | -10 | def _(being: Person | Animal) -> None: -11 | # error: [invalid-key] + 5 | phone_number: str + 6 | + 7 | class Animal(TypedDict): + 8 | name: str + 9 | legs: int +10 | +11 | def _(being: Person | Animal) -> None: 12 | # error: [invalid-key] -13 | being["surname"] = "unknown" +13 | # error: [invalid-key] +14 | being["surname"] = "unknown" ``` # Diagnostics ``` error[invalid-key]: Invalid key for TypedDict `Person` - --> src/mdtest_snippet.py:13:5 + --> src/mdtest_snippet.py:14:5 | -11 | # error: [invalid-key] 12 | # error: [invalid-key] -13 | being["surname"] = "unknown" +13 | # error: [invalid-key] +14 | being["surname"] = "unknown" | ----- ^^^^^^^^^ Unknown key "surname" - did you mean "name"? | | | TypedDict `Person` in union type `Person | Animal` @@ -46,11 +47,11 @@ info: rule `invalid-key` is enabled by default ``` error[invalid-key]: Invalid key for TypedDict `Animal` - --> src/mdtest_snippet.py:13:5 + --> src/mdtest_snippet.py:14:5 | -11 | # error: [invalid-key] 12 | # error: [invalid-key] -13 | being["surname"] = "unknown" +13 | # error: [invalid-key] +14 | being["surname"] = "unknown" | ----- ^^^^^^^^^ Unknown key "surname" - did you mean "name"? | | | TypedDict `Animal` in union type `Person | Animal` diff --git a/crates/ty_python_semantic/resources/mdtest/snapshots/assignment_diagnosti…_-_Subscript_assignment…_-_Unknown_key_for_one_…_(b515711c0a451a86).snap b/crates/ty_python_semantic/resources/mdtest/snapshots/assignment_diagnosti…_-_Subscript_assignment…_-_Unknown_key_for_one_…_(b515711c0a451a86).snap index 6c919e6937..7718d164c4 100644 --- a/crates/ty_python_semantic/resources/mdtest/snapshots/assignment_diagnosti…_-_Subscript_assignment…_-_Unknown_key_for_one_…_(b515711c0a451a86).snap +++ b/crates/ty_python_semantic/resources/mdtest/snapshots/assignment_diagnosti…_-_Subscript_assignment…_-_Unknown_key_for_one_…_(b515711c0a451a86).snap @@ -16,23 +16,24 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/subscript/assignment_dia 2 | 3 | class Person(TypedDict): 4 | name: str - 5 | - 6 | class Animal(TypedDict): - 7 | name: str - 8 | legs: int - 9 | -10 | def _(being: Person | Animal) -> None: -11 | being["legs"] = 4 # error: [invalid-key] + 5 | phone_number: str + 6 | + 7 | class Animal(TypedDict): + 8 | name: str + 9 | legs: int +10 | +11 | def _(being: Person | Animal) -> None: +12 | being["legs"] = 4 # error: [invalid-key] ``` # Diagnostics ``` error[invalid-key]: Invalid key for TypedDict `Person` - --> src/mdtest_snippet.py:11:5 + --> src/mdtest_snippet.py:12:5 | -10 | def _(being: Person | Animal) -> None: -11 | being["legs"] = 4 # error: [invalid-key] +11 | def _(being: Person | Animal) -> None: +12 | being["legs"] = 4 # error: [invalid-key] | ----- ^^^^^^ Unknown key "legs" | | | TypedDict `Person` in union type `Person | Animal` diff --git a/crates/ty_python_semantic/resources/mdtest/subscript/assignment_diagnostics.md b/crates/ty_python_semantic/resources/mdtest/subscript/assignment_diagnostics.md index e4959e3627..ee0b6a34da 100644 --- a/crates/ty_python_semantic/resources/mdtest/subscript/assignment_diagnostics.md +++ b/crates/ty_python_semantic/resources/mdtest/subscript/assignment_diagnostics.md @@ -77,6 +77,7 @@ from typing import TypedDict class Person(TypedDict): name: str + phone_number: str class Animal(TypedDict): name: str @@ -86,13 +87,14 @@ def _(being: Person | Animal) -> None: being["legs"] = 4 # error: [invalid-key] ``` -## Unknown key for all elemens of a union +## Unknown key for all elements of a union ```py from typing import TypedDict class Person(TypedDict): name: str + phone_number: str class Animal(TypedDict): name: str diff --git a/crates/ty_python_semantic/resources/mdtest/typed_dict.md b/crates/ty_python_semantic/resources/mdtest/typed_dict.md index 314af332b4..1a2bc65794 100644 --- a/crates/ty_python_semantic/resources/mdtest/typed_dict.md +++ b/crates/ty_python_semantic/resources/mdtest/typed_dict.md @@ -460,6 +460,7 @@ and their types, rather than the class hierarchy: ```py from typing import TypedDict +from typing_extensions import ReadOnly class Person(TypedDict): name: str @@ -468,10 +469,117 @@ class Employee(TypedDict): name: str employee_id: int -p1: Person = Employee(name="Alice", employee_id=1) +class Robot(TypedDict): + name: int -# TODO: this should be an error -e1: Employee = Person(name="Eve") +_: Person = Employee(name="Alice", employee_id=1) + +# error: [invalid-assignment] "Object of type `Person` is not assignable to `Employee`" +_: Employee = Person(name="Eve") + +# error: [invalid-assignment] "Object of type `Robot` is not assignable to `Person`" +_: Person = Robot(name=0xDEADC0DE) +# error: [invalid-assignment] "Object of type `Person` is not assignable to `Robot`" +_: Robot = Person(name="Del Spooner") +``` + +Required keys in the target must also be required in the source. This includes explicitly +`NotRequired` keys and also all the keys in a `TypedDict` with `total=False`. If a key is +`NotRequired` and also mutable in the target, then it must be `NotRequired` in the source too +(because `del` is allowed on that key the target): + +```py +from typing_extensions import NotRequired + +class Spy(TypedDict): + name: NotRequired[str] + +# invalid because `Spy` might be missing `name` +# error: [invalid-assignment] "Object of type `Spy` is not assignable to `Person`" +_: Person = Spy(name="Powers, Austin Powers") + +# invalid because `Spy` is allowed to delete `name`, while `Person` is not +# error: [invalid-assignment] "Object of type `Person` is not assignable to `Spy`" +_: Spy = Person(name="Bond, James Bond") + +class Amnesiac(TypedDict, total=False): + name: ReadOnly[str] + +# invalid because `Amnesiac` might be missing `name` +# error: [invalid-assignment] "Object of type `Amnesiac` is not assignable to `Person`" +_: Person = Amnesiac() + +# Allowed. `Amnesiac` can't delete `name`, because it's read-only. +_: Amnesiac = Person(name="Jason Bourne") +``` + +If the target item is read-only, then the source item can have any assignable type. But if the +target is mutable, the source type must match exactly. The required and not-required cases are +different codepaths, so we need test all the permutations: + +```py +from typing import Any +from typing_extensions import ReadOnly + +class RequiredMutableInt(TypedDict): + x: int + +class RequiredReadOnlyInt(TypedDict): + x: ReadOnly[int] + +class NotRequiredMutableInt(TypedDict): + x: NotRequired[int] + +class NotRequiredReadOnlyInt(TypedDict): + x: NotRequired[ReadOnly[int]] + +class RequiredMutableBool(TypedDict): + x: bool + +class RequiredReadOnlyBool(TypedDict): + x: ReadOnly[bool] + +class NotRequiredMutableBool(TypedDict): + x: NotRequired[bool] + +class NotRequiredReadOnlyBool(TypedDict): + x: NotRequired[ReadOnly[bool]] + +_: RequiredMutableInt = RequiredMutableInt(x=1) +_: RequiredMutableInt = RequiredReadOnlyInt(x=1) # error: [invalid-assignment] +_: RequiredMutableInt = NotRequiredMutableInt(x=1) # error: [invalid-assignment] +_: RequiredMutableInt = NotRequiredReadOnlyInt(x=1) # error: [invalid-assignment] +_: RequiredMutableInt = RequiredMutableBool(x=True) # error: [invalid-assignment] +_: RequiredMutableInt = RequiredReadOnlyBool(x=True) # error: [invalid-assignment] +_: RequiredMutableInt = NotRequiredMutableBool(x=True) # error: [invalid-assignment] +_: RequiredMutableInt = NotRequiredReadOnlyBool(x=True) # error: [invalid-assignment] + +_: RequiredReadOnlyInt = RequiredMutableInt(x=1) +_: RequiredReadOnlyInt = RequiredReadOnlyInt(x=1) +_: RequiredReadOnlyInt = NotRequiredMutableInt(x=1) # error: [invalid-assignment] +_: RequiredReadOnlyInt = NotRequiredReadOnlyInt(x=1) # error: [invalid-assignment] +_: RequiredReadOnlyInt = RequiredMutableBool(x=True) +_: RequiredReadOnlyInt = RequiredReadOnlyBool(x=True) +_: RequiredReadOnlyInt = NotRequiredMutableBool(x=True) # error: [invalid-assignment] +_: RequiredReadOnlyInt = NotRequiredReadOnlyBool(x=True) # error: [invalid-assignment] + +_: NotRequiredMutableInt = RequiredMutableInt(x=1) # error: [invalid-assignment] +_: NotRequiredMutableInt = RequiredReadOnlyInt(x=1) # error: [invalid-assignment] +_: NotRequiredMutableInt = NotRequiredMutableInt(x=1) +_: NotRequiredMutableInt = NotRequiredReadOnlyInt(x=1) # error: [invalid-assignment] +_: NotRequiredMutableInt = RequiredMutableBool(x=True) # error: [invalid-assignment] +_: NotRequiredMutableInt = RequiredReadOnlyBool(x=True) # error: [invalid-assignment] +_: NotRequiredMutableInt = NotRequiredMutableBool(x=True) # error: [invalid-assignment] +_: NotRequiredMutableInt = NotRequiredReadOnlyBool(x=True) # error: [invalid-assignment] + +_: NotRequiredReadOnlyInt = RequiredMutableInt(x=1) +_: NotRequiredReadOnlyInt = RequiredReadOnlyInt(x=1) +_: NotRequiredReadOnlyInt = NotRequiredMutableInt(x=1) +_: NotRequiredReadOnlyInt = NotRequiredReadOnlyInt(x=1) +_: NotRequiredReadOnlyInt = RequiredMutableBool(x=True) +_: NotRequiredReadOnlyInt = RequiredReadOnlyBool(x=True) +_: NotRequiredReadOnlyInt = NotRequiredMutableBool(x=True) +_: NotRequiredReadOnlyInt = NotRequiredReadOnlyBool(x=True) ``` All typed dictionaries can be assigned to `Mapping[str, object]`: @@ -483,10 +591,20 @@ class Person(TypedDict): name: str age: int | None -m: Mapping[str, object] = Person(name="Alice", age=30) +alice = Person(name="Alice", age=30) +# Always assignable. +_: Mapping[str, object] = alice +# Follows from above. +_: Mapping[str, Any] = alice +# Not assignable. +# error: [invalid-assignment] "Object of type `Person` is not assignable to `Mapping[str, int]`" +_: Mapping[str, int] = alice +# TODO: Could be assignable once we support `closed=True` and/or `extra_items`. +# error: [invalid-assignment] +_: Mapping[str, str | int | None] = alice ``` -They can *not* be assigned to `dict[str, object]`, as that would allow them to be mutated in unsafe +They *cannot* be assigned to `dict[str, object]`, as that would allow them to be mutated in unsafe ways: ```py @@ -500,7 +618,7 @@ class Person(TypedDict): alice: Person = {"name": "Alice"} -# TODO: this should be an invalid-assignment error +# error: [invalid-argument-type] "Argument to function `dangerous` is incorrect: Expected `dict[str, object]`, found `Person`" dangerous(alice) reveal_type(alice["name"]) # revealed: str @@ -515,6 +633,138 @@ alice: dict[str, str] = {"name": "Alice"} alice: Person = alice ``` +## A subtle interaction between two structural assignability rules prevents unsoundness + +> For the purposes of these conditions, an open `TypedDict` is treated as if it had **read-only** +> extra items of type `object`. + +That language is at the top of [subtyping section of the `TypedDict` spec][subtyping section]. It +sounds like an obscure technicality, especially since `extra_items` is still TODO, but it has an +important interaction with another rule: + +> For each item in [the destination type]...If it is non-required...If it is mutable...If \[the +> source type does not have an item with the same key and also\] has extra items, the extra items +> type **must not be read-only**... + +In other words, by default (`closed=False`) a `TypedDict` cannot be assigned to a different +`TypedDict` that has an additional, optional, mutable item. That implicit rule turns out to be the +only thing standing in the way of this unsound example: + +```py +from typing_extensions import TypedDict, NotRequired + +class C(TypedDict): + x: int + y: str + +class B(TypedDict): + x: int + +class A(TypedDict): + x: int + y: NotRequired[object] # incompatible with both C and (surprisingly!) B + +def b_from_c(c: C) -> B: + return c # allowed + +def a_from_b(b: B) -> A: + # error: [invalid-return-type] "Return type does not match returned value: expected `A`, found `B`" + return b + +# The [invalid-return-type] error above is the only thing that keeps us from corrupting the type of c['y']. +c: C = {"x": 1, "y": "hello"} +a: A = a_from_b(b_from_c(c)) +a["y"] = 42 +``` + +If the additional, optional item in the target is read-only, the requirements are *somewhat* +relaxed. In this case, because the source might contain have undeclared extra items of any type, the +target item must be assignable from `object`: + +```py +from typing_extensions import ReadOnly + +class A2(TypedDict): + x: int + y: NotRequired[ReadOnly[object]] + +def a2_from_b(b: B) -> A2: + return b # allowed + +class A3(TypedDict): + x: int + y: NotRequired[ReadOnly[int]] # not assignable from `object` + +def a3_from_b(b: B) -> A3: + return b # error: [invalid-return-type] +``` + +## Structural assignability supports `TypedDict`s that contain other `TypedDict`s + +```py +from typing_extensions import TypedDict, ReadOnly, NotRequired + +class Inner1(TypedDict): + name: str + +class Inner2(TypedDict): + name: str + +class Outer1(TypedDict): + a: Inner1 + b: ReadOnly[Inner1] + c: NotRequired[Inner1] + d: ReadOnly[NotRequired[Inner1]] + +class Outer2(TypedDict): + a: Inner2 + b: ReadOnly[Inner2] + c: NotRequired[Inner2] + d: ReadOnly[NotRequired[Inner2]] + +def _(o1: Outer1, o2: Outer2): + _: Outer1 = o2 + _: Outer2 = o1 +``` + +This also extends to gradual types: + +```py +from typing import Any + +class Inner3(TypedDict): + name: Any + +class Outer3(TypedDict): + a: Inner3 + b: ReadOnly[Inner3] + c: NotRequired[Inner3] + d: ReadOnly[NotRequired[Inner3]] + +class Outer4(TypedDict): + a: Any + b: ReadOnly[Any] + c: NotRequired[Any] + d: ReadOnly[NotRequired[Any]] + +def _(o1: Outer1, o2: Outer2, o3: Outer3, o4: Outer4): + _: Outer1 = o3 + _: Outer1 = o4 + + _: Outer2 = o3 + _: Outer2 = o4 + + _: Outer3 = o1 + _: Outer3 = o2 + _: Outer3 = o3 + _: Outer3 = o4 + + _: Outer4 = o1 + _: Outer4 = o2 + _: Outer4 = o3 + _: Outer4 = o4 +``` + ## Key-based access ### Reading @@ -561,10 +811,10 @@ def _( reveal_type(being["name"]) # revealed: str - # TODO: A type of `int | None | Unknown` might be better here. The `str` is mixed in - # because `Animal.__getitem__` can only return `str`. + # TODO: A type of `int | None | Unknown` might be better here. `str` is because + # `Person | Animal` reduces to `Animal`, and `Animal.__getitem__` can only return `str`. # error: [invalid-key] "Invalid key for TypedDict `Animal`" - reveal_type(being["age"]) # revealed: int | None | str + reveal_type(being["age"]) # revealed: str ``` ### Writing @@ -835,8 +1085,7 @@ def combine(p: Person, e: Employee): reveal_type(p | p) # revealed: Person reveal_type(e | e) # revealed: Employee - # TODO: Should be `Person` once we support subtyping for TypedDicts - reveal_type(p | e) # revealed: Person | Employee + reveal_type(p | e) # revealed: Person ``` When inheriting from a `TypedDict` with a different `total` setting, inherited fields maintain their @@ -1165,4 +1414,5 @@ reveal_type(actual_td) # revealed: ActualTypedDict reveal_type(actual_td["name"]) # revealed: str ``` +[subtyping section]: https://typing.python.org/en/latest/spec/typeddict.html#subtyping-between-typeddict-types [`typeddict`]: https://typing.python.org/en/latest/spec/typeddict.html diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index 518c0f1c59..7112be56f6 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -2078,11 +2078,14 @@ impl<'db> Type<'db> { ConstraintSet::from(false) } - (Type::TypedDict(_), _) => { - // TODO: Implement assignability and subtyping for TypedDict - ConstraintSet::from(relation.is_assignability()) - } - + (Type::TypedDict(self_typeddict), _) => self_typeddict.has_relation_to_impl( + db, + target, + inferable, + relation, + relation_visitor, + disjointness_visitor, + ), // A non-`TypedDict` cannot subtype a `TypedDict` (_, Type::TypedDict(_)) => ConstraintSet::from(false), diff --git a/crates/ty_python_semantic/src/types/class.rs b/crates/ty_python_semantic/src/types/class.rs index 5b2295f066..555f19af72 100644 --- a/crates/ty_python_semantic/src/types/class.rs +++ b/crates/ty_python_semantic/src/types/class.rs @@ -3882,6 +3882,7 @@ pub enum KnownClass { SupportsIndex, Iterable, Iterator, + Mapping, // typing_extensions ExtensionsTypeVar, // must be distinct from typing.TypeVar, backports new features // Collections @@ -3994,6 +3995,7 @@ impl KnownClass { | Self::ABCMeta | Self::Iterable | Self::Iterator + | Self::Mapping // Evaluating `NotImplementedType` in a boolean context was deprecated in Python 3.9 // and raises a `TypeError` in Python >=3.14 // (see https://docs.python.org/3/library/constants.html#NotImplemented) @@ -4078,6 +4080,7 @@ impl KnownClass { | KnownClass::SupportsIndex | KnownClass::Iterable | KnownClass::Iterator + | KnownClass::Mapping | KnownClass::ChainMap | KnownClass::Counter | KnownClass::DefaultDict @@ -4161,6 +4164,7 @@ impl KnownClass { | KnownClass::SupportsIndex | KnownClass::Iterable | KnownClass::Iterator + | KnownClass::Mapping | KnownClass::ChainMap | KnownClass::Counter | KnownClass::DefaultDict @@ -4244,6 +4248,7 @@ impl KnownClass { | KnownClass::SupportsIndex | KnownClass::Iterable | KnownClass::Iterator + | KnownClass::Mapping | KnownClass::ChainMap | KnownClass::Counter | KnownClass::DefaultDict @@ -4356,7 +4361,8 @@ impl KnownClass { | Self::BuiltinFunctionType | Self::ProtocolMeta | Self::Template - | KnownClass::Path => false, + | Self::Path + | Self::Mapping => false, } } @@ -4429,6 +4435,7 @@ impl KnownClass { | KnownClass::SupportsIndex | KnownClass::Iterable | KnownClass::Iterator + | KnownClass::Mapping | KnownClass::ChainMap | KnownClass::Counter | KnownClass::DefaultDict @@ -4517,6 +4524,7 @@ impl KnownClass { Self::Super => "super", Self::Iterable => "Iterable", Self::Iterator => "Iterator", + Self::Mapping => "Mapping", // For example, `typing.List` is defined as `List = _Alias()` in typeshed Self::StdlibAlias => "_Alias", // This is the name the type of `sys.version_info` has in typeshed, @@ -4805,6 +4813,7 @@ impl KnownClass { | Self::StdlibAlias | Self::Iterable | Self::Iterator + | Self::Mapping | Self::ProtocolMeta | Self::SupportsIndex => KnownModule::Typing, Self::TypeAliasType @@ -4924,6 +4933,7 @@ impl KnownClass { | Self::InitVar | Self::Iterable | Self::Iterator + | Self::Mapping | Self::NamedTupleFallback | Self::NamedTupleLike | Self::ConstraintSet @@ -5012,6 +5022,7 @@ impl KnownClass { | Self::InitVar | Self::Iterable | Self::Iterator + | Self::Mapping | Self::NamedTupleFallback | Self::NamedTupleLike | Self::ConstraintSet @@ -5073,6 +5084,7 @@ impl KnownClass { "TypeVar" => &[Self::TypeVar, Self::ExtensionsTypeVar], "Iterable" => &[Self::Iterable], "Iterator" => &[Self::Iterator], + "Mapping" => &[Self::Mapping], "ParamSpec" => &[Self::ParamSpec], "ParamSpecArgs" => &[Self::ParamSpecArgs], "ParamSpecKwargs" => &[Self::ParamSpecKwargs], @@ -5205,6 +5217,7 @@ impl KnownClass { | Self::TypeVarTuple | Self::Iterable | Self::Iterator + | Self::Mapping | Self::ProtocolMeta | Self::NewType => matches!(module, KnownModule::Typing | KnownModule::TypingExtensions), Self::Deprecated => matches!(module, KnownModule::Warnings | KnownModule::TypingExtensions), diff --git a/crates/ty_python_semantic/src/types/typed_dict.rs b/crates/ty_python_semantic/src/types/typed_dict.rs index e07dbe6e60..7ae03c9ce4 100644 --- a/crates/ty_python_semantic/src/types/typed_dict.rs +++ b/crates/ty_python_semantic/src/types/typed_dict.rs @@ -12,7 +12,11 @@ use super::diagnostic::{ report_missing_typed_dict_key, }; use super::{ApplyTypeMappingVisitor, Type, TypeMapping, visitor}; -use crate::types::TypeContext; +use crate::types::constraints::ConstraintSet; +use crate::types::generics::InferableTypeVars; +use crate::types::{ + HasRelationToVisitor, IsDisjointVisitor, KnownClass, TypeContext, TypeRelation, +}; use crate::{Db, FxOrderMap}; use ordermap::OrderSet; @@ -76,6 +80,243 @@ impl<'db> TypedDictType<'db> { ), } } + + pub(crate) fn has_relation_to_impl( + self, + db: &'db dyn Db, + target: Type<'db>, + inferable: InferableTypeVars<'_, 'db>, + relation: TypeRelation<'db>, + relation_visitor: &HasRelationToVisitor<'db>, + disjointness_visitor: &IsDisjointVisitor<'db>, + ) -> ConstraintSet<'db> { + if let Type::TypedDict(target_typed_dict) = target { + self.has_relation_to_other_typeddict_impl( + db, + target_typed_dict, + inferable, + relation, + relation_visitor, + disjointness_visitor, + ) + } else { + Self::has_relation_to_non_typeddict_impl(db, target) + } + } + + // Subtyping between `TypedDict`s follows the algorithm described at: + // https://typing.python.org/en/latest/spec/typeddict.html#subtyping-between-typeddict-types + fn has_relation_to_other_typeddict_impl( + self, + db: &'db dyn Db, + target: TypedDictType<'db>, + inferable: InferableTypeVars<'_, 'db>, + relation: TypeRelation<'db>, + relation_visitor: &HasRelationToVisitor<'db>, + disjointness_visitor: &IsDisjointVisitor<'db>, + ) -> ConstraintSet<'db> { + let self_items = self.items(db); + let target_items = target.items(db); + // Many rules violations short-circuit with "never", but asking whether one field is + // [relation] to/of another can produce more complicated constraints, and we collect those. + let mut constraints = ConstraintSet::from(true); + for (target_item_name, target_item_field) in &target_items { + let field_constraints = if target_item_field.is_required() { + // required target fields + let Some(self_item_field) = self_items.get(target_item_name) else { + // Self is missing a required field. + return ConstraintSet::from(false); + }; + if !self_item_field.is_required() { + // A required field is not required in self. + return ConstraintSet::from(false); + } + relation_visitor.visit( + ( + self_item_field.declared_ty, + target_item_field.declared_ty, + relation, + ), + || { + if target_item_field.is_read_only() { + // For `ReadOnly[]` fields in the target, the corresponding fields in + // self need to have the same assignability/subtyping/etc relation + // individually that we're looking for overall between the + // `TypedDict`s. + self_item_field.declared_ty.has_relation_to_impl( + db, + target_item_field.declared_ty, + inferable, + relation, + relation_visitor, + disjointness_visitor, + ) + } else { + if self_item_field.is_read_only() { + // A read-only field can't be assigned to a mutable target. + return ConstraintSet::from(false); + } + // For mutable fields in the target, the relation needs to apply both + // ways, or else mutating the target could violate the structural + // invariants of self. For fully-static types, this is "equivalence". + // For gradual types, it depends on the relation, but mutual + // assignability is "consistency". + self_item_field + .declared_ty + .has_relation_to_impl( + db, + target_item_field.declared_ty, + inferable, + relation, + relation_visitor, + disjointness_visitor, + ) + .intersect( + db, + target_item_field.declared_ty.has_relation_to_impl( + db, + self_item_field.declared_ty, + inferable, + relation, + relation_visitor, + disjointness_visitor, + ), + ) + } + }, + ) + } else { + // `NotRequired[]` target fields + if target_item_field.is_read_only() { + // As above, for `NotRequired[]` + `ReadOnly[]` fields in the target. It's + // tempting to refactor things and unify some of these calls to + // `has_relation_to_impl`, but this branch will get more complicated when we + // add support for `closed` and `extra_items` (which is why the rules in the + // spec are structured like they are), and following the structure of the spec + // makes it easier to check the logic here. + if let Some(self_item_field) = self_items.get(target_item_name) { + relation_visitor.visit( + ( + self_item_field.declared_ty, + target_item_field.declared_ty, + relation, + ), + || { + self_item_field.declared_ty.has_relation_to_impl( + db, + target_item_field.declared_ty, + inferable, + relation, + relation_visitor, + disjointness_visitor, + ) + }, + ) + } else { + // Self is missing this not-required, read-only item. However, since all + // `TypeDict`s by default are allowed to have "extra items" of any type + // (until we support `closed` and explicit `extra_items`), this key could + // actually turn out to have a value. To make sure this is type-safe, the + // not-required field in the target needs to be assignable from `object`. + // TODO: `closed` and `extra_items` support will go here. + KnownClass::Object.to_instance(db).when_assignable_to( + db, + target_item_field.declared_ty, + inferable, + ) + } + } else { + // As above, for `NotRequired[]` mutable fields in the target. Again the logic + // is largely the same for now, but it will get more complicated with `closed` + // and `extra_items`. + if let Some(self_item_field) = self_items.get(target_item_name) { + if self_item_field.is_read_only() { + // A read-only field can't be assigned to a mutable target. + return ConstraintSet::from(false); + } + if self_item_field.is_required() { + // A required field can't be assigned to a not-required, mutable field + // in the target, because `del` is allowed on the target field. + return ConstraintSet::from(false); + } + relation_visitor.visit( + ( + self_item_field.declared_ty, + target_item_field.declared_ty, + relation, + ), + || { + // As above, for mutable fields in the target, the relation needs + // to apply both ways. + self_item_field + .declared_ty + .has_relation_to_impl( + db, + target_item_field.declared_ty, + inferable, + relation, + relation_visitor, + disjointness_visitor, + ) + .intersect( + db, + target_item_field.declared_ty.has_relation_to_impl( + db, + self_item_field.declared_ty, + inferable, + relation, + relation_visitor, + disjointness_visitor, + ), + ) + }, + ) + } else { + // Self is missing this not-required, mutable field. This isn't ok if self + // has read-only extra items, which all `TypedDict`s effectively do until + // we support `closed` and explicit `extra_items`. See "A subtle + // interaction between two structural assignability rules prevents + // unsoundness" in `typed_dict.md`. + // TODO: `closed` and `extra_items` support will go here. + ConstraintSet::from(false) + } + } + }; + constraints.intersect(db, field_constraints); + if constraints.is_never_satisfied(db) { + return constraints; + } + } + constraints + } + + // The only non-`TypedDict` that a `TypedDict` is assignable to is `Mapping[str, object]`. + // Although every instance of `TypedDict` is also an instance of `dict[str, object]`, a + // `TypedDict` still isn't assignable to `dict`, because mutating the `dict` could break + // `TypeDict`'s "structural" invariants. + // TODO: When we support `closed` and/or `extra_items`, we could allow assignments to other + // compatible `Mapping`s. `extra_items` could also allow for some assignments to `dict`, as + // long as `total=False`. (But then again, does anyone want a non-total `TypedDict` where all + // key types are a supertype of the extra items type?) + fn has_relation_to_non_typeddict_impl( + db: &'db dyn Db, + target: Type<'db>, + ) -> ConstraintSet<'db> { + debug_assert!(!matches!(target, Type::TypedDict(_))); + if let Some(specialization) = target.known_specialization(db, KnownClass::Mapping) + && let &[key_ty, value_ty] = specialization.types(db) + // The key type must be exactly `str`. + && key_ty == KnownClass::Str.to_instance(db) + // The value type must be `object` (or a gradual type that's consistent with `object`). + && KnownClass::Object + .to_instance(db) + .is_assignable_to(db, value_ty) + { + ConstraintSet::from(true) + } else { + ConstraintSet::from(false) + } + } } pub(crate) fn walk_typed_dict_type<'db, V: visitor::TypeVisitor<'db> + ?Sized>( From b2d6f35366169a9f3de31327555a7bb10509f7ef Mon Sep 17 00:00:00 2001 From: Jack O'Connor Date: Fri, 14 Nov 2025 17:14:53 -0800 Subject: [PATCH 2/2] test that structural assignment works for recursive TypedDicts too --- .../resources/mdtest/typed_dict.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/crates/ty_python_semantic/resources/mdtest/typed_dict.md b/crates/ty_python_semantic/resources/mdtest/typed_dict.md index 1a2bc65794..dd865ea09b 100644 --- a/crates/ty_python_semantic/resources/mdtest/typed_dict.md +++ b/crates/ty_python_semantic/resources/mdtest/typed_dict.md @@ -1246,6 +1246,20 @@ nested: Node = {"name": "n1", "parent": {"name": "n2", "parent": {"name": "n3", nested_invalid: Node = {"name": "n1", "parent": {"name": "n2", "parent": {"name": 3, "parent": None}}} ``` +Structural assignment works for recursive `TypedDict`s too: + +```py +class Person(TypedDict): + name: str + parent: Person | None + +def _(node: Node, person: Person): + _: Person = node + _: Node = person + +_: Node = Person(name="Alice", parent=Node(name="Bob", parent=Person(name="Charlie", parent=None))) +``` + ## Function/assignment syntax This is not yet supported. Make sure that we do not emit false positives for this syntax: