diff --git a/crates/ty/docs/rules.md b/crates/ty/docs/rules.md
index 5ac36c4fb9..e32e8d3e53 100644
--- a/crates/ty/docs/rules.md
+++ b/crates/ty/docs/rules.md
@@ -39,7 +39,7 @@ def test(): -> "int":
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -63,7 +63,7 @@ Calling a non-callable object will raise a `TypeError` at runtime.
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -95,7 +95,7 @@ f(int) # error
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -126,7 +126,7 @@ a = 1
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -158,7 +158,7 @@ class C(A, B): ...
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -190,7 +190,7 @@ class B(A): ...
Default level: error ·
Preview (since 1.0.0) ·
Related issues ·
-View source
+View source
@@ -218,7 +218,7 @@ type B = A
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -245,7 +245,7 @@ class B(A, A): ...
Default level: error ·
Added in 0.0.1-alpha.12 ·
Related issues ·
-View source
+View source
@@ -357,7 +357,7 @@ def test(): -> "Literal[5]":
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -387,7 +387,7 @@ class C(A, B): ...
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -413,7 +413,7 @@ t[3] # IndexError: tuple index out of range
Default level: error ·
Added in 0.0.1-alpha.12 ·
Related issues ·
-View source
+View source
@@ -502,7 +502,7 @@ an atypical memory layout.
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -529,7 +529,7 @@ func("foo") # error: [invalid-argument-type]
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -557,7 +557,7 @@ a: int = ''
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -591,7 +591,7 @@ C.instance_var = 3 # error: Cannot assign to instance variable
Default level: error ·
Added in 0.0.1-alpha.19 ·
Related issues ·
-View source
+View source
@@ -627,7 +627,7 @@ asyncio.run(main())
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -651,7 +651,7 @@ class A(42): ... # error: [invalid-base]
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -678,7 +678,7 @@ with 1:
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -707,7 +707,7 @@ a: str
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -751,7 +751,7 @@ except ZeroDivisionError:
Default level: error ·
Added in 0.0.1-alpha.28 ·
Related issues ·
-View source
+View source
@@ -793,7 +793,7 @@ class D(A):
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -826,7 +826,7 @@ class C[U](Generic[T]): ...
Default level: error ·
Added in 0.0.1-alpha.17 ·
Related issues ·
-View source
+View source
@@ -865,7 +865,7 @@ carol = Person(name="Carol", age=25) # typo!
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -900,7 +900,7 @@ def f(t: TypeVar("U")): ...
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -934,7 +934,7 @@ class B(metaclass=f): ...
Default level: error ·
Added in 0.0.1-alpha.20 ·
Related issues ·
-View source
+View source
@@ -1041,7 +1041,7 @@ Correct use of `@override` is enforced by ty's `invalid-explicit-override` rule.
Default level: error ·
Added in 0.0.1-alpha.19 ·
Related issues ·
-View source
+View source
@@ -1095,7 +1095,7 @@ AttributeError: Cannot overwrite NamedTuple attribute _asdict
Default level: error ·
Preview (since 1.0.0) ·
Related issues ·
-View source
+View source
@@ -1125,7 +1125,7 @@ Baz = NewType("Baz", int | str) # error: invalid base for `typing.NewType`
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -1175,7 +1175,7 @@ def foo(x: int) -> int: ...
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -1201,7 +1201,7 @@ def f(a: int = ''): ...
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -1232,7 +1232,7 @@ P2 = ParamSpec("S2") # error: ParamSpec name must match the variable it's assig
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -1266,7 +1266,7 @@ TypeError: Protocols can only inherit from other protocols, got
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -1315,7 +1315,7 @@ def g():
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -1340,7 +1340,7 @@ def func() -> int:
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -1398,7 +1398,7 @@ TODO #14889
Default level: error ·
Added in 0.0.1-alpha.6 ·
Related issues ·
-View source
+View source
@@ -1425,7 +1425,7 @@ NewAlias = TypeAliasType(get_name(), int) # error: TypeAliasType name mus
Default level: error ·
Added in 0.0.1-alpha.29 ·
Related issues ·
-View source
+View source
@@ -1472,7 +1472,7 @@ Bar[int] # error: too few arguments
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -1502,7 +1502,7 @@ TYPE_CHECKING = ''
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -1532,7 +1532,7 @@ b: Annotated[int] # `Annotated` expects at least two arguments
Default level: error ·
Added in 0.0.1-alpha.11 ·
Related issues ·
-View source
+View source
@@ -1566,7 +1566,7 @@ f(10) # Error
Default level: error ·
Added in 0.0.1-alpha.11 ·
Related issues ·
-View source
+View source
@@ -1600,7 +1600,7 @@ class C:
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -1635,7 +1635,7 @@ T = TypeVar('T', bound=str) # valid bound TypeVar
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -1660,7 +1660,7 @@ func() # TypeError: func() missing 1 required positional argument: 'x'
Default level: error ·
Added in 0.0.1-alpha.20 ·
Related issues ·
-View source
+View source
@@ -1693,7 +1693,7 @@ alice["age"] # KeyError
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -1722,7 +1722,7 @@ func("string") # error: [no-matching-overload]
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -1746,7 +1746,7 @@ Subscripting an object that does not support it will raise a `TypeError` at runt
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -1772,7 +1772,7 @@ for i in 34: # TypeError: 'int' object is not iterable
Default level: error ·
Added in 0.0.1-alpha.29 ·
Related issues ·
-View source
+View source
@@ -1805,7 +1805,7 @@ class B(A):
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -1832,7 +1832,7 @@ f(1, x=2) # Error raised here
Default level: error ·
Added in 0.0.1-alpha.22 ·
Related issues ·
-View source
+View source
@@ -1890,7 +1890,7 @@ def test(): -> "int":
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -1920,7 +1920,7 @@ static_assert(int(2.0 * 3.0) == 6) # error: does not have a statically known tr
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -1949,7 +1949,7 @@ class B(A): ... # Error raised here
Default level: error ·
Preview (since 0.0.1-alpha.30) ·
Related issues ·
-View source
+View source
@@ -1983,7 +1983,7 @@ class F(NamedTuple):
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -2010,7 +2010,7 @@ f("foo") # Error raised here
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -2038,7 +2038,7 @@ def _(x: int):
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -2084,7 +2084,7 @@ class A:
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -2111,7 +2111,7 @@ f(x=1, y=2) # Error raised here
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -2139,7 +2139,7 @@ A().foo # AttributeError: 'A' object has no attribute 'foo'
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -2164,7 +2164,7 @@ import foo # ModuleNotFoundError: No module named 'foo'
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -2189,7 +2189,7 @@ print(x) # NameError: name 'x' is not defined
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -2226,7 +2226,7 @@ b1 < b2 < b1 # exception raised here
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -2254,7 +2254,7 @@ A() + A() # TypeError: unsupported operand type(s) for +: 'A' and 'A'
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -2279,7 +2279,7 @@ l[1:10:0] # ValueError: slice step cannot be zero
Default level: warn ·
Added in 0.0.1-alpha.20 ·
Related issues ·
-View source
+View source
@@ -2320,7 +2320,7 @@ class SubProto(BaseProto, Protocol):
Default level: warn ·
Added in 0.0.1-alpha.16 ·
Related issues ·
-View source
+View source
@@ -2408,7 +2408,7 @@ a = 20 / 0 # type: ignore
Default level: warn ·
Added in 0.0.1-alpha.22 ·
Related issues ·
-View source
+View source
@@ -2436,7 +2436,7 @@ A.c # AttributeError: type object 'A' has no attribute 'c'
Default level: warn ·
Added in 0.0.1-alpha.22 ·
Related issues ·
-View source
+View source
@@ -2468,7 +2468,7 @@ A()[0] # TypeError: 'A' object is not subscriptable
Default level: warn ·
Added in 0.0.1-alpha.22 ·
Related issues ·
-View source
+View source
@@ -2500,7 +2500,7 @@ from module import a # ImportError: cannot import name 'a' from 'module'
Default level: warn ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -2527,7 +2527,7 @@ cast(int, f()) # Redundant
Default level: warn ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -2551,7 +2551,7 @@ reveal_type(1) # NameError: name 'reveal_type' is not defined
Default level: warn ·
Added in 0.0.1-alpha.15 ·
Related issues ·
-View source
+View source
@@ -2609,7 +2609,7 @@ def g():
Default level: warn ·
Added in 0.0.1-alpha.7 ·
Related issues ·
-View source
+View source
@@ -2648,7 +2648,7 @@ class D(C): ... # error: [unsupported-base]
Default level: warn ·
Added in 0.0.1-alpha.22 ·
Related issues ·
-View source
+View source
@@ -2711,7 +2711,7 @@ def foo(x: int | str) -> int | str:
Default level: ignore ·
Preview (since 0.0.1-alpha.1) ·
Related issues ·
-View source
+View source
@@ -2735,7 +2735,7 @@ Dividing by zero raises a `ZeroDivisionError` at runtime.
Default level: ignore ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
diff --git a/crates/ty_python_semantic/resources/mdtest/snapshots/typed_dict.md_-_`TypedDict`_-_Redundant_cast_warni…_(75ac240a2d1f7108).snap b/crates/ty_python_semantic/resources/mdtest/snapshots/typed_dict.md_-_`TypedDict`_-_Redundant_cast_warni…_(75ac240a2d1f7108).snap
new file mode 100644
index 0000000000..16f2235fc9
--- /dev/null
+++ b/crates/ty_python_semantic/resources/mdtest/snapshots/typed_dict.md_-_`TypedDict`_-_Redundant_cast_warni…_(75ac240a2d1f7108).snap
@@ -0,0 +1,55 @@
+---
+source: crates/ty_test/src/lib.rs
+expression: snapshot
+---
+---
+mdtest name: typed_dict.md - `TypedDict` - Redundant cast warnings
+mdtest path: crates/ty_python_semantic/resources/mdtest/typed_dict.md
+---
+
+# Python source files
+
+## mdtest_snippet.py
+
+```
+ 1 | from typing import TypedDict, cast
+ 2 |
+ 3 | class Foo2(TypedDict):
+ 4 | x: int
+ 5 |
+ 6 | class Bar2(TypedDict):
+ 7 | x: int
+ 8 |
+ 9 | foo: Foo2 = {"x": 1}
+10 | _ = cast(Foo2, foo) # error: [redundant-cast]
+11 | _ = cast(Bar2, foo) # error: [redundant-cast]
+```
+
+# Diagnostics
+
+```
+warning[redundant-cast]: Value is already of type `Foo2`
+ --> src/mdtest_snippet.py:10:5
+ |
+ 9 | foo: Foo2 = {"x": 1}
+10 | _ = cast(Foo2, foo) # error: [redundant-cast]
+ | ^^^^^^^^^^^^^^^
+11 | _ = cast(Bar2, foo) # error: [redundant-cast]
+ |
+info: rule `redundant-cast` is enabled by default
+
+```
+
+```
+warning[redundant-cast]: Value is already of type `Bar2`
+ --> src/mdtest_snippet.py:11:5
+ |
+ 9 | foo: Foo2 = {"x": 1}
+10 | _ = cast(Foo2, foo) # error: [redundant-cast]
+11 | _ = cast(Bar2, foo) # error: [redundant-cast]
+ | ^^^^^^^^^^^^^^^
+ |
+info: `Bar2` is equivalent to `Foo2`
+info: rule `redundant-cast` is enabled by default
+
+```
diff --git a/crates/ty_python_semantic/resources/mdtest/typed_dict.md b/crates/ty_python_semantic/resources/mdtest/typed_dict.md
index aadf8249ae..ad15f28250 100644
--- a/crates/ty_python_semantic/resources/mdtest/typed_dict.md
+++ b/crates/ty_python_semantic/resources/mdtest/typed_dict.md
@@ -868,6 +868,172 @@ def _(o1: Outer1, o2: Outer2, o3: Outer3, o4: Outer4):
static_assert(is_subtype_of(Outer4, Outer4))
```
+## Structural equivalence
+
+Two `TypedDict`s with equivalent fields are equivalent types. This includes fields with gradual
+types:
+
+```py
+from typing_extensions import Any, TypedDict, ReadOnly, assert_type
+from ty_extensions import is_assignable_to, is_equivalent_to, static_assert
+
+class Foo(TypedDict):
+ x: int
+ y: Any
+
+# exactly the same fields
+class Bar(TypedDict):
+ x: int
+ y: Any
+
+# the same fields but in a different order
+class Baz(TypedDict):
+ y: Any
+ x: int
+
+static_assert(is_assignable_to(Foo, Bar))
+static_assert(is_equivalent_to(Foo, Bar))
+static_assert(is_assignable_to(Foo, Baz))
+static_assert(is_equivalent_to(Foo, Baz))
+
+foo: Foo = {"x": 1, "y": "hello"}
+assert_type(foo, Foo)
+assert_type(foo, Bar)
+assert_type(foo, Baz)
+```
+
+Equivalent `TypedDict`s within unions can also produce equivalent unions, which currently relies on
+"normalization" machinery:
+
+```py
+def f(var: Foo | int):
+ assert_type(var, Foo | int)
+ assert_type(var, Bar | int)
+ assert_type(var, Baz | int)
+ # TODO: Union simplification compares `TypedDict`s by name/identity to avoid cycles. This assert
+ # should also pass once that's fixed.
+ assert_type(var, Foo | Bar | Baz | int) # error: [type-assertion-failure]
+```
+
+Here are several cases that are not equivalent. In particular, assignability does not imply
+equivalence:
+
+```py
+class FewerFields(TypedDict):
+ x: int
+
+static_assert(is_assignable_to(Foo, FewerFields))
+static_assert(not is_equivalent_to(Foo, FewerFields))
+
+class DifferentMutability(TypedDict):
+ x: int
+ y: ReadOnly[Any]
+
+static_assert(is_assignable_to(Foo, DifferentMutability))
+static_assert(not is_equivalent_to(Foo, DifferentMutability))
+
+class MoreFields(TypedDict):
+ x: int
+ y: Any
+ z: str
+
+static_assert(not is_assignable_to(Foo, MoreFields))
+static_assert(not is_equivalent_to(Foo, MoreFields))
+
+class DifferentFieldStaticType(TypedDict):
+ x: str
+ y: Any
+
+static_assert(not is_assignable_to(Foo, DifferentFieldStaticType))
+static_assert(not is_equivalent_to(Foo, DifferentFieldStaticType))
+
+class DifferentFieldGradualType(TypedDict):
+ x: int
+ y: Any | str
+
+static_assert(is_assignable_to(Foo, DifferentFieldGradualType))
+static_assert(not is_equivalent_to(Foo, DifferentFieldGradualType))
+```
+
+## Structural equivalence understands the interaction between `Required`/`NotRequired` and `total`
+
+```py
+from ty_extensions import static_assert, is_equivalent_to
+from typing_extensions import TypedDict, Required, NotRequired
+
+class Foo1(TypedDict, total=False):
+ x: int
+ y: str
+
+class Foo2(TypedDict):
+ y: NotRequired[str]
+ x: NotRequired[int]
+
+static_assert(is_equivalent_to(Foo1, Foo2))
+static_assert(is_equivalent_to(Foo1 | int, int | Foo2))
+
+class Bar1(TypedDict, total=False):
+ x: int
+ y: Required[str]
+
+class Bar2(TypedDict):
+ y: str
+ x: NotRequired[int]
+
+static_assert(is_equivalent_to(Bar1, Bar2))
+static_assert(is_equivalent_to(Bar1 | int, int | Bar2))
+```
+
+## Assignability and equivalence work with recursive `TypedDict`s
+
+```py
+from typing_extensions import TypedDict
+from ty_extensions import static_assert, is_assignable_to, is_equivalent_to
+
+class Node1(TypedDict):
+ value: int
+ next: "Node1" | None
+
+class Node2(TypedDict):
+ value: int
+ next: "Node2" | None
+
+static_assert(is_assignable_to(Node1, Node2))
+static_assert(is_equivalent_to(Node1, Node2))
+
+class Person1(TypedDict):
+ name: str
+ friends: list["Person1"]
+
+class Person2(TypedDict):
+ name: str
+ friends: list["Person2"]
+
+static_assert(is_assignable_to(Person1, Person2))
+static_assert(is_equivalent_to(Person1, Person2))
+```
+
+## Redundant cast warnings
+
+
+
+Casting between equivalent types produces a redundant cast warning. When the types have different
+names, the warning makes that clear:
+
+```py
+from typing import TypedDict, cast
+
+class Foo2(TypedDict):
+ x: int
+
+class Bar2(TypedDict):
+ x: int
+
+foo: Foo2 = {"x": 1}
+_ = cast(Foo2, foo) # error: [redundant-cast]
+_ = cast(Bar2, foo) # error: [redundant-cast]
+```
+
## Key-based access
### Reading
diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs
index 796a3e6962..1ecb4504b6 100644
--- a/crates/ty_python_semantic/src/types.rs
+++ b/crates/ty_python_semantic/src/types.rs
@@ -1471,6 +1471,7 @@ impl<'db> Type<'db> {
/// - Strips the types of default values from parameters in `Callable` types: only whether a parameter
/// *has* or *does not have* a default value is relevant to whether two `Callable` types are equivalent.
/// - Converts class-based protocols into synthesized protocols
+ /// - Converts class-based typeddicts into synthesized typeddicts
#[must_use]
pub(crate) fn normalized(self, db: &'db dyn Db) -> Self {
self.normalized_impl(db, &NormalizedVisitor::default())
@@ -1529,10 +1530,9 @@ impl<'db> Type<'db> {
// Always normalize single-member enums to their class instance (`Literal[Single.VALUE]` => `Single`)
enum_literal.enum_class_instance(db)
}
- Type::TypedDict(_) => {
- // TODO: Normalize TypedDicts
- self
- }
+ Type::TypedDict(typed_dict) => visitor.visit(self, || {
+ Type::TypedDict(typed_dict.normalized_impl(db, visitor))
+ }),
Type::TypeAlias(alias) => alias.value_type(db).normalized_impl(db, visitor),
Type::NewTypeInstance(newtype) => {
visitor.visit(self, || {
@@ -3053,6 +3053,10 @@ impl<'db> Type<'db> {
left.is_equivalent_to_impl(db, right, inferable, visitor)
}
+ (Type::TypedDict(left), Type::TypedDict(right)) => visitor.visit((self, other), || {
+ left.is_equivalent_to_impl(db, right, inferable, visitor)
+ }),
+
_ => ConstraintSet::from(false),
}
}
@@ -7582,7 +7586,13 @@ impl<'db> Type<'db> {
Type::ProtocolInstance(protocol) => protocol.to_meta_type(db),
// `TypedDict` instances are instances of `dict` at runtime, but its important that we
// understand a more specific meta type in order to correctly handle `__getitem__`.
- Type::TypedDict(typed_dict) => SubclassOfType::from(db, typed_dict.defining_class()),
+ Type::TypedDict(typed_dict) => match typed_dict {
+ TypedDictType::Class(class) => SubclassOfType::from(db, class),
+ TypedDictType::Synthesized(_) => SubclassOfType::from(
+ db,
+ todo_type!("TypedDict synthesized meta-type").expect_dynamic(),
+ ),
+ },
Type::TypeAlias(alias) => alias.value_type(db).to_meta_type(db),
Type::NewTypeInstance(newtype) => Type::from(newtype.base_class_type(db)),
}
@@ -8291,7 +8301,7 @@ impl<'db> Type<'db> {
},
Self::TypedDict(typed_dict) => {
- Some(TypeDefinition::Class(typed_dict.defining_class().definition(db)))
+ typed_dict.definition(db).map(TypeDefinition::Class)
}
Self::Union(_) | Self::Intersection(_) => None,
diff --git a/crates/ty_python_semantic/src/types/diagnostic.rs b/crates/ty_python_semantic/src/types/diagnostic.rs
index 2706787998..23bb5b1a16 100644
--- a/crates/ty_python_semantic/src/types/diagnostic.rs
+++ b/crates/ty_python_semantic/src/types/diagnostic.rs
@@ -14,9 +14,7 @@ use crate::semantic_index::place::{PlaceTable, ScopedPlaceId};
use crate::semantic_index::{global_scope, place_table, use_def_map};
use crate::suppression::FileSuppressionId;
use crate::types::call::CallError;
-use crate::types::class::{
- CodeGeneratorKind, DisjointBase, DisjointBaseKind, Field, MethodDecorator,
-};
+use crate::types::class::{CodeGeneratorKind, DisjointBase, DisjointBaseKind, MethodDecorator};
use crate::types::function::{FunctionDecorators, FunctionType, KnownFunction, OverloadLiteral};
use crate::types::infer::UnsupportedComparisonError;
use crate::types::overrides::MethodKind;
@@ -26,6 +24,7 @@ use crate::types::string_annotation::{
RAW_STRING_TYPE_ANNOTATION,
};
use crate::types::tuple::TupleSpec;
+use crate::types::typed_dict::TypedDictSchema;
use crate::types::{
BoundTypeVarInstance, ClassType, DynamicType, LintDiagnosticGuard, Protocol,
ProtocolInstanceType, SpecialFormType, SubclassOfInner, Type, TypeContext, binding_type,
@@ -3471,7 +3470,7 @@ pub(crate) fn report_invalid_key_on_typed_dict<'db>(
typed_dict_ty: Type<'db>,
full_object_ty: Option>,
key_ty: Type<'db>,
- items: &FxIndexMap>,
+ items: &TypedDictSchema<'db>,
) {
let db = context.db();
if let Some(builder) = context.report_lint(&INVALID_KEY, key_node) {
diff --git a/crates/ty_python_semantic/src/types/display.rs b/crates/ty_python_semantic/src/types/display.rs
index 19ed71bbff..39e5a1caee 100644
--- a/crates/ty_python_semantic/src/types/display.rs
+++ b/crates/ty_python_semantic/src/types/display.rs
@@ -25,8 +25,8 @@ use crate::types::visitor::TypeVisitor;
use crate::types::{
BoundTypeVarIdentity, CallableType, CallableTypeKind, IntersectionType, KnownBoundMethodType,
KnownClass, KnownInstanceType, MaterializationKind, Protocol, ProtocolInstanceType,
- SpecialFormType, StringLiteralType, SubclassOfInner, Type, UnionType, WrapperDescriptorKind,
- visitor,
+ SpecialFormType, StringLiteralType, SubclassOfInner, Type, TypedDictType, UnionType,
+ WrapperDescriptorKind, visitor,
};
/// Settings for displaying types and signatures
@@ -900,12 +900,24 @@ impl<'db> FmtDetailed<'db> for DisplayRepresentation<'db> {
}
f.write_str("]")
}
- Type::TypedDict(typed_dict) => typed_dict
- .defining_class()
+ Type::TypedDict(TypedDictType::Class(defining_class)) => defining_class
.class_literal(self.db)
.0
.display_with(self.db, self.settings.clone())
.fmt_detailed(f),
+ Type::TypedDict(TypedDictType::Synthesized(synthesized)) => {
+ f.set_invalid_syntax();
+ f.write_str("')
+ }
Type::TypeAlias(alias) => {
f.write_str(alias.name(self.db))?;
match alias.specialization(self.db) {
@@ -2373,7 +2385,10 @@ mod tests {
use crate::Db;
use crate::db::tests::setup_db;
use crate::place::typing_extensions_symbol;
- use crate::types::{KnownClass, Parameter, Parameters, Signature, Type};
+ use crate::types::typed_dict::{
+ SynthesizedTypedDictType, TypedDictFieldBuilder, TypedDictSchema,
+ };
+ use crate::types::{KnownClass, Parameter, Parameters, Signature, Type, TypedDictType};
#[test]
fn string_literal_display() {
@@ -2418,6 +2433,37 @@ mod tests {
);
}
+ #[test]
+ fn synthesized_typeddict_display() {
+ let db = setup_db();
+
+ let mut items = TypedDictSchema::default();
+ items.insert(
+ Name::new("foo"),
+ TypedDictFieldBuilder::new(Type::IntLiteral(42))
+ .required(true)
+ .build(),
+ );
+ items.insert(
+ Name::new("bar"),
+ TypedDictFieldBuilder::new(Type::string_literal(&db, "hello"))
+ .required(true)
+ .build(),
+ );
+
+ let synthesized = SynthesizedTypedDictType::new(&db, items);
+ let type_ = Type::TypedDict(TypedDictType::Synthesized(synthesized));
+ // Fields are sorted internally, even prior to normalization.
+ assert_eq!(
+ type_.display(&db).to_string(),
+ "",
+ );
+ assert_eq!(
+ type_.normalized(&db).display(&db).to_string(),
+ "",
+ );
+ }
+
fn display_signature<'db>(
db: &'db dyn Db,
parameters: impl IntoIterator- >,
diff --git a/crates/ty_python_semantic/src/types/function.rs b/crates/ty_python_semantic/src/types/function.rs
index dae46bca03..cc2c358590 100644
--- a/crates/ty_python_semantic/src/types/function.rs
+++ b/crates/ty_python_semantic/src/types/function.rs
@@ -1028,18 +1028,6 @@ impl<'db> FunctionType<'db> {
relation_visitor: &HasRelationToVisitor<'db>,
disjointness_visitor: &IsDisjointVisitor<'db>,
) -> ConstraintSet<'db> {
- // A function type is the subtype of itself, and not of any other function type. However,
- // our representation of a function type includes any specialization that should be applied
- // to the signature. Different specializations of the same function type are only subtypes
- // of each other if they result in subtype signatures.
- if matches!(
- relation,
- TypeRelation::Subtyping | TypeRelation::Redundancy | TypeRelation::SubtypingAssuming(_)
- ) && self.normalized(db) == other.normalized(db)
- {
- return ConstraintSet::from(true);
- }
-
if self.literal(db) != other.literal(db) {
return ConstraintSet::from(false);
}
@@ -1621,10 +1609,16 @@ impl KnownFunction {
&& !any_over_type(db, *casted_type, &contains_unknown_or_todo, true)
{
if let Some(builder) = context.report_lint(&REDUNDANT_CAST, call_expression) {
- builder.into_diagnostic(format_args!(
- "Value is already of type `{}`",
- casted_type.display(db),
+ let source_display = source_type.display(db).to_string();
+ let casted_display = casted_type.display(db).to_string();
+ let mut diagnostic = builder.into_diagnostic(format_args!(
+ "Value is already of type `{casted_display}`",
));
+ if source_display != casted_display {
+ diagnostic.info(format_args!(
+ "`{casted_display}` is equivalent to `{source_display}`",
+ ));
+ }
}
}
}
diff --git a/crates/ty_python_semantic/src/types/subclass_of.rs b/crates/ty_python_semantic/src/types/subclass_of.rs
index 898a82e086..c6bb9d0378 100644
--- a/crates/ty_python_semantic/src/types/subclass_of.rs
+++ b/crates/ty_python_semantic/src/types/subclass_of.rs
@@ -8,7 +8,7 @@ use crate::types::{
ApplyTypeMappingVisitor, BoundTypeVarInstance, ClassType, DynamicType,
FindLegacyTypeVarsVisitor, HasRelationToVisitor, IsDisjointVisitor, KnownClass,
MaterializationKind, MemberLookupPolicy, NormalizedVisitor, SpecialFormType, Type, TypeContext,
- TypeMapping, TypeRelation, TypeVarBoundOrConstraints, todo_type,
+ TypeMapping, TypeRelation, TypeVarBoundOrConstraints, TypedDictType, todo_type,
};
use crate::{Db, FxOrderSet};
@@ -381,7 +381,12 @@ impl<'db> SubclassOfInner<'db> {
pub(crate) fn try_from_instance(db: &'db dyn Db, ty: Type<'db>) -> Option {
Some(match ty {
Type::NominalInstance(instance) => SubclassOfInner::Class(instance.class(db)),
- Type::TypedDict(typed_dict) => SubclassOfInner::Class(typed_dict.defining_class()),
+ Type::TypedDict(typed_dict) => match typed_dict {
+ TypedDictType::Class(class) => SubclassOfInner::Class(class),
+ TypedDictType::Synthesized(_) => SubclassOfInner::Dynamic(
+ todo_type!("type[T] for synthesized TypedDicts").expect_dynamic(),
+ ),
+ },
Type::TypeVar(bound_typevar) => SubclassOfInner::TypeVar(bound_typevar),
Type::Dynamic(DynamicType::Any) => SubclassOfInner::Dynamic(DynamicType::Any),
Type::Dynamic(DynamicType::Unknown) => SubclassOfInner::Dynamic(DynamicType::Unknown),
diff --git a/crates/ty_python_semantic/src/types/typed_dict.rs b/crates/ty_python_semantic/src/types/typed_dict.rs
index 14ee100d2d..e07fbb999d 100644
--- a/crates/ty_python_semantic/src/types/typed_dict.rs
+++ b/crates/ty_python_semantic/src/types/typed_dict.rs
@@ -1,3 +1,6 @@
+use std::collections::BTreeMap;
+use std::ops::{Deref, DerefMut};
+
use bitflags::bitflags;
use ruff_db::diagnostic::{Annotation, Diagnostic, Span, SubDiagnostic, SubDiagnosticSeverity};
use ruff_db::parsed::parsed_module;
@@ -12,10 +15,15 @@ use super::diagnostic::{
report_missing_typed_dict_key,
};
use super::{ApplyTypeMappingVisitor, Type, TypeMapping, visitor};
-use crate::types::constraints::ConstraintSet;
+use crate::Db;
+use crate::semantic_index::definition::Definition;
+use crate::types::class::FieldKind;
+use crate::types::constraints::{ConstraintSet, IteratorConstraintsExtension};
use crate::types::generics::InferableTypeVars;
-use crate::types::{HasRelationToVisitor, IsDisjointVisitor, TypeContext, TypeRelation};
-use crate::{Db, FxIndexMap};
+use crate::types::{
+ HasRelationToVisitor, IsDisjointVisitor, IsEquivalentVisitor, NormalizedVisitor, TypeContext,
+ TypeRelation,
+};
use ordermap::OrderSet;
@@ -41,24 +49,60 @@ impl Default for TypedDictParams {
/// Type that represents the set of all inhabitants (`dict` instances) that conform to
/// a given `TypedDict` schema.
#[derive(Debug, Copy, Clone, PartialEq, Eq, salsa::Update, Hash, get_size2::GetSize)]
-pub struct TypedDictType<'db> {
+pub enum TypedDictType<'db> {
/// A reference to the class (inheriting from `typing.TypedDict`) that specifies the
/// schema of this `TypedDict`.
- defining_class: ClassType<'db>,
+ Class(ClassType<'db>),
+ /// A `TypedDict` that doesn't correspond to a class definition, either because it's been
+ /// `normalized`, or because it's been synthesized to represent constraints.
+ Synthesized(SynthesizedTypedDictType<'db>),
}
impl<'db> TypedDictType<'db> {
pub(crate) fn new(defining_class: ClassType<'db>) -> Self {
- Self { defining_class }
+ Self::Class(defining_class)
}
- pub(crate) fn defining_class(self) -> ClassType<'db> {
- self.defining_class
+ pub(crate) fn defining_class(self) -> Option> {
+ match self {
+ Self::Class(defining_class) => Some(defining_class),
+ Self::Synthesized(_) => None,
+ }
}
- pub(crate) fn items(self, db: &'db dyn Db) -> &'db FxIndexMap> {
- let (class_literal, specialization) = self.defining_class.class_literal(db);
- class_literal.fields(db, specialization, CodeGeneratorKind::TypedDict)
+ pub(crate) fn items(self, db: &'db dyn Db) -> &'db TypedDictSchema<'db> {
+ #[salsa::tracked(returns(ref))]
+ fn class_based_items<'db>(db: &'db dyn Db, class: ClassType<'db>) -> TypedDictSchema<'db> {
+ let (class_literal, specialization) = class.class_literal(db);
+ class_literal
+ .fields(db, specialization, CodeGeneratorKind::TypedDict)
+ .into_iter()
+ .map(|(name, field)| {
+ let field = match field {
+ Field {
+ first_declaration,
+ declared_ty,
+ kind:
+ FieldKind::TypedDict {
+ is_required,
+ is_read_only,
+ },
+ } => TypedDictFieldBuilder::new(*declared_ty)
+ .required(*is_required)
+ .read_only(*is_read_only)
+ .first_declaration(*first_declaration)
+ .build(),
+ _ => unreachable!("TypedDict field expected"),
+ };
+ (name.clone(), field)
+ })
+ .collect()
+ }
+
+ match self {
+ Self::Class(defining_class) => class_based_items(db, defining_class),
+ Self::Synthesized(synthesized) => synthesized.items(db),
+ }
}
pub(crate) fn apply_type_mapping_impl<'a>(
@@ -69,12 +113,12 @@ impl<'db> TypedDictType<'db> {
visitor: &ApplyTypeMappingVisitor<'db>,
) -> Self {
// TODO: Materialization of gradual TypedDicts needs more logic
- Self {
- defining_class: self.defining_class.apply_type_mapping_impl(
- db,
- type_mapping,
- tcx,
- visitor,
+ match self {
+ Self::Class(defining_class) => {
+ Self::Class(defining_class.apply_type_mapping_impl(db, type_mapping, tcx, visitor))
+ }
+ Self::Synthesized(synthesized) => Self::Synthesized(
+ synthesized.apply_type_mapping_impl(db, type_mapping, tcx, visitor),
),
}
}
@@ -93,9 +137,9 @@ impl<'db> TypedDictType<'db> {
// First do a quick nominal check that (if it succeeds) means that we can avoid
// materializing the full `TypedDict` schema for either `self` or `target`.
// This should be cheaper in many cases, and also helps us avoid some cycles.
- if self
- .defining_class
- .is_subclass_of(db, target.defining_class)
+ if let Some(defining_class) = self.defining_class()
+ && let Some(target_defining_class) = target.defining_class()
+ && defining_class.is_subclass_of(db, target_defining_class)
{
return ConstraintSet::from(true);
}
@@ -246,6 +290,57 @@ impl<'db> TypedDictType<'db> {
}
constraints
}
+
+ pub fn definition(self, db: &'db dyn Db) -> Option> {
+ match self {
+ TypedDictType::Class(defining_class) => Some(defining_class.definition(db)),
+ TypedDictType::Synthesized(_) => None,
+ }
+ }
+
+ pub(crate) fn normalized_impl(self, db: &'db dyn Db, visitor: &NormalizedVisitor<'db>) -> Self {
+ match self {
+ TypedDictType::Class(_) => {
+ let synthesized = SynthesizedTypedDictType::new(db, self.items(db));
+ TypedDictType::Synthesized(synthesized.normalized_impl(db, visitor))
+ }
+ TypedDictType::Synthesized(synthesized) => {
+ TypedDictType::Synthesized(synthesized.normalized_impl(db, visitor))
+ }
+ }
+ }
+
+ pub(crate) fn is_equivalent_to_impl(
+ self,
+ db: &'db dyn Db,
+ other: TypedDictType<'db>,
+ inferable: InferableTypeVars<'_, 'db>,
+ visitor: &IsEquivalentVisitor<'db>,
+ ) -> ConstraintSet<'db> {
+ // TODO: `closed` and `extra_items` support will go here. Until then we don't look at the
+ // params at all, because `total` is already incorporated into `FieldKind`.
+
+ // Since both sides' fields are pre-sorted into `BTreeMap`s, we can iterate over them in
+ // sorted order instead of paying for a lookup for each field, as long as their lengths are
+ // the same.
+ if self.items(db).len() != other.items(db).len() {
+ return ConstraintSet::from(false);
+ }
+ self.items(db).iter().zip(other.items(db)).when_all(
+ db,
+ |((name, field), (other_name, other_field))| {
+ if name != other_name || field.flags != other_field.flags {
+ return ConstraintSet::from(false);
+ }
+ field.declared_ty.is_equivalent_to_impl(
+ db,
+ other_field.declared_ty,
+ inferable,
+ visitor,
+ )
+ },
+ )
+ }
}
pub(crate) fn walk_typed_dict_type<'db, V: visitor::TypeVisitor<'db> + ?Sized>(
@@ -253,7 +348,16 @@ pub(crate) fn walk_typed_dict_type<'db, V: visitor::TypeVisitor<'db> + ?Sized>(
typed_dict: TypedDictType<'db>,
visitor: &V,
) {
- visitor.visit_type(db, typed_dict.defining_class.into());
+ match typed_dict {
+ TypedDictType::Class(defining_class) => {
+ visitor.visit_type(db, defining_class.into());
+ }
+ TypedDictType::Synthesized(synthesized) => {
+ for field in synthesized.items(db).values() {
+ visitor.visit_type(db, field.declared_ty);
+ }
+ }
+ }
}
pub(super) fn typed_dict_params_from_class_def(class_stmt: &StmtClassDef) -> TypedDictParams {
@@ -631,3 +735,173 @@ pub(super) fn validate_typed_dict_dict_literal<'db>(
Err(provided_keys)
}
}
+
+#[salsa::interned(debug)]
+pub struct SynthesizedTypedDictType<'db> {
+ #[returns(ref)]
+ pub(crate) items: TypedDictSchema<'db>,
+}
+
+// The Salsa heap is tracked separately.
+impl get_size2::GetSize for SynthesizedTypedDictType<'_> {}
+
+impl<'db> SynthesizedTypedDictType<'db> {
+ pub(super) fn apply_type_mapping_impl<'a>(
+ self,
+ db: &'db dyn Db,
+ type_mapping: &TypeMapping<'a, 'db>,
+ tcx: TypeContext<'db>,
+ visitor: &ApplyTypeMappingVisitor<'db>,
+ ) -> Self {
+ let items = self
+ .items(db)
+ .iter()
+ .map(|(name, field)| {
+ let field = field
+ .clone()
+ .apply_type_mapping_impl(db, type_mapping, tcx, visitor);
+
+ (name.clone(), field)
+ })
+ .collect::>();
+
+ SynthesizedTypedDictType::new(db, items)
+ }
+
+ pub(crate) fn normalized_impl(self, db: &'db dyn Db, visitor: &NormalizedVisitor<'db>) -> Self {
+ let items = self
+ .items(db)
+ .iter()
+ .map(|(name, field)| {
+ let field = field.clone().normalized_impl(db, visitor);
+ (name.clone(), field)
+ })
+ .collect::>();
+ Self::new(db, items)
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Hash, Default, get_size2::GetSize, salsa::Update)]
+pub struct TypedDictSchema<'db>(BTreeMap>);
+
+impl<'db> Deref for TypedDictSchema<'db> {
+ type Target = BTreeMap>;
+
+ fn deref(&self) -> &Self::Target {
+ &self.0
+ }
+}
+
+impl DerefMut for TypedDictSchema<'_> {
+ fn deref_mut(&mut self) -> &mut Self::Target {
+ &mut self.0
+ }
+}
+
+impl<'a> IntoIterator for &'a TypedDictSchema<'_> {
+ type Item = (&'a Name, &'a TypedDictField<'a>);
+ type IntoIter = std::collections::btree_map::Iter<'a, Name, TypedDictField<'a>>;
+
+ fn into_iter(self) -> Self::IntoIter {
+ self.0.iter()
+ }
+}
+
+impl<'db> FromIterator<(Name, TypedDictField<'db>)> for TypedDictSchema<'db> {
+ fn from_iter)>>(iter: T) -> Self {
+ Self(iter.into_iter().collect())
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Hash, get_size2::GetSize, salsa::Update)]
+pub struct TypedDictField<'db> {
+ pub(super) declared_ty: Type<'db>,
+ flags: TypedDictFieldFlags,
+ first_declaration: Option>,
+}
+
+impl<'db> TypedDictField<'db> {
+ pub(crate) const fn is_required(&self) -> bool {
+ self.flags.contains(TypedDictFieldFlags::REQUIRED)
+ }
+
+ pub(crate) const fn is_read_only(&self) -> bool {
+ self.flags.contains(TypedDictFieldFlags::READ_ONLY)
+ }
+
+ pub(crate) fn apply_type_mapping_impl<'a>(
+ self,
+ db: &'db dyn Db,
+ type_mapping: &TypeMapping<'a, 'db>,
+ tcx: TypeContext<'db>,
+ visitor: &ApplyTypeMappingVisitor<'db>,
+ ) -> Self {
+ Self {
+ declared_ty: self
+ .declared_ty
+ .apply_type_mapping_impl(db, type_mapping, tcx, visitor),
+ flags: self.flags,
+ first_declaration: self.first_declaration,
+ }
+ }
+
+ pub(crate) fn normalized_impl(self, db: &'db dyn Db, visitor: &NormalizedVisitor<'db>) -> Self {
+ Self {
+ declared_ty: self.declared_ty.normalized_impl(db, visitor),
+ flags: self.flags,
+ // A normalized typed-dict field does not hold onto the original declaration,
+ // since a normalized typed-dict is an abstract type where equality does not depend
+ // on the source-code definition.
+ first_declaration: None,
+ }
+ }
+}
+
+pub(super) struct TypedDictFieldBuilder<'db> {
+ declared_ty: Type<'db>,
+ flags: TypedDictFieldFlags,
+ first_declaration: Option>,
+}
+
+impl<'db> TypedDictFieldBuilder<'db> {
+ pub(crate) fn new(declared_ty: Type<'db>) -> Self {
+ Self {
+ declared_ty,
+ flags: TypedDictFieldFlags::empty(),
+ first_declaration: None,
+ }
+ }
+
+ pub(crate) fn required(mut self, yes: bool) -> Self {
+ self.flags.set(TypedDictFieldFlags::REQUIRED, yes);
+ self
+ }
+
+ pub(crate) fn read_only(mut self, yes: bool) -> Self {
+ self.flags.set(TypedDictFieldFlags::READ_ONLY, yes);
+ self
+ }
+
+ pub(crate) fn first_declaration(mut self, definition: Option>) -> Self {
+ self.first_declaration = definition;
+ self
+ }
+
+ pub(crate) fn build(self) -> TypedDictField<'db> {
+ TypedDictField {
+ declared_ty: self.declared_ty,
+ flags: self.flags,
+ first_declaration: self.first_declaration,
+ }
+ }
+}
+
+bitflags! {
+ #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, salsa::Update)]
+ struct TypedDictFieldFlags: u8 {
+ const REQUIRED = 1 << 0;
+ const READ_ONLY = 1 << 1;
+ }
+}
+
+impl get_size2::GetSize for TypedDictFieldFlags {}