From c196f469c49d2181557e519341975286876c33a6 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Tue, 24 Mar 2026 23:09:18 -0400 Subject: [PATCH 1/5] [ty] Add core support for functional TypedDict --- .../mdtest/dataclasses/dataclasses.md | 8 +- .../resources/mdtest/typed_dict.md | 457 ++++++++++- crates/ty_python_semantic/src/types.rs | 31 - .../ty_python_semantic/src/types/call/bind.rs | 6 - crates/ty_python_semantic/src/types/class.rs | 145 ++-- .../src/types/class/known.rs | 10 + .../src/types/class/static_literal.rs | 483 +----------- .../src/types/class/typed_dict.rs | 716 ++++++++++++++++++ .../src/types/class_base.rs | 1 - crates/ty_python_semantic/src/types/enums.rs | 2 +- .../src/types/ide_support.rs | 4 + .../src/types/infer/builder.rs | 31 +- .../types/infer/builder/binary_expressions.rs | 3 - .../src/types/infer/builder/typed_dict.rs | 338 +++++++++ .../ty_python_semantic/src/types/instance.rs | 9 +- crates/ty_python_semantic/src/types/mro.rs | 8 + .../src/types/typed_dict.rs | 89 ++- .../ty_python_semantic/src/types/typevar.rs | 1 - 18 files changed, 1728 insertions(+), 614 deletions(-) create mode 100644 crates/ty_python_semantic/src/types/class/typed_dict.rs create mode 100644 crates/ty_python_semantic/src/types/infer/builder/typed_dict.rs diff --git a/crates/ty_python_semantic/resources/mdtest/dataclasses/dataclasses.md b/crates/ty_python_semantic/resources/mdtest/dataclasses/dataclasses.md index 7548792a72b588..9fdc90eef979bb 100644 --- a/crates/ty_python_semantic/resources/mdtest/dataclasses/dataclasses.md +++ b/crates/ty_python_semantic/resources/mdtest/dataclasses/dataclasses.md @@ -1959,16 +1959,16 @@ from typing import TypedDict TD = TypedDict("TD", {"x": int}) -# TODO: should emit `invalid-dataclass` +# error: [invalid-dataclass] "Cannot use `dataclass()` on a `TypedDict` class" dataclass(TD) -# TODO: should emit `invalid-dataclass` +# error: [invalid-dataclass] "Cannot use `dataclass()` on a `TypedDict` class" dataclass()(TD) -# TODO: should emit `invalid-dataclass` +# error: [invalid-dataclass] "Cannot use `dataclass()` on a `TypedDict` class" dataclass(TypedDict("Inline1", {"a": str})) -# TODO: should emit `invalid-dataclass` +# error: [invalid-dataclass] "Cannot use `dataclass()` on a `TypedDict` class" dataclass()(TypedDict("Inline2", {"a": str})) ``` diff --git a/crates/ty_python_semantic/resources/mdtest/typed_dict.md b/crates/ty_python_semantic/resources/mdtest/typed_dict.md index 2aae37182351f3..ae3c8385e98bc6 100644 --- a/crates/ty_python_semantic/resources/mdtest/typed_dict.md +++ b/crates/ty_python_semantic/resources/mdtest/typed_dict.md @@ -108,6 +108,15 @@ bob.update(string_key_updates) # error: [invalid-argument-type] bob.update(bad_key_updates) + +Require = TypedDict( + "Require", + {"source-path": str, "compiled-module-path": str}, + total=False, +) + +requirement: Require = {} +requirement.update({"source-path": "src", "compiled-module-path": "build"}) ``` `update()` treats the patch operand as partial even when the target `TypedDict` uses `Required` and @@ -2123,23 +2132,430 @@ _: Node = Person(name="Alice", parent=Node(name="Bob", parent=Person(name="Charl ## Function/assignment syntax -This is not yet supported. Make sure that we do not emit false positives for this syntax: +TypedDicts can be created using the functional syntax: + +```py +from typing_extensions import TypedDict +from ty_extensions import reveal_mro + +Movie = TypedDict("Movie", {"name": str, "year": int}) + +reveal_type(Movie) # revealed: +reveal_mro(Movie) # revealed: (, typing.TypedDict, ) + +movie = Movie(name="The Matrix", year=1999) + +reveal_type(movie) # revealed: Movie +reveal_type(movie["name"]) # revealed: str +reveal_type(movie["year"]) # revealed: int +``` + +An empty functional `TypedDict` should pass an empty dict for the `fields` argument: + +```py +from typing_extensions import TypedDict + +Empty = TypedDict("Empty", {}) +empty = Empty() + +reveal_type(Empty) # revealed: +reveal_type(empty) # revealed: Empty + +EmptyPartial = TypedDict("EmptyPartial", {}, total=False) +reveal_type(EmptyPartial()) # revealed: EmptyPartial +``` + +Omitting the `fields` argument entirely is an error: + +```py +from typing_extensions import TypedDict + +# error: [missing-argument] "No argument provided for required parameter `fields` of function `TypedDict`" +Empty = TypedDict("Empty") +reveal_type(Empty) # revealed: +``` + +Constructor validation also works with dict literals: ```py -from typing_extensions import TypedDict, Required +from typing_extensions import TypedDict + +Film = TypedDict("Film", {"title": str, "year": int}) + +# Valid usage +film1 = Film({"title": "The Matrix", "year": 1999}) +film2 = Film(title="Inception", year=2010) -# Alternative syntax -Message = TypedDict("Message", {"id": Required[int], "content": str}, total=False) +reveal_type(film1) # revealed: Film +reveal_type(film2) # revealed: Film -msg = Message(id=1, content="Hello") +# error: [invalid-argument-type] "Invalid argument to key "year" with declared type `int` on TypedDict `Film`: value of type `Literal["not a year"]`" +invalid_type = Film({"title": "Bad", "year": "not a year"}) -# No errors for yet-unsupported features (`closed`): +# error: [missing-typed-dict-key] "Missing required key 'year' in TypedDict `Film` constructor" +missing_key = Film({"title": "Incomplete"}) + +# error: [invalid-key] "Unknown key "director" for TypedDict `Film`" +extra_key = Film({"title": "Extra", "year": 2020, "director": "Someone"}) +``` + +Inline functional `TypedDict`s preserve their field types too: + +```py +from typing_extensions import TypedDict + +inline = TypedDict("Inline", {"x": int})(x=1) +reveal_type(inline["x"]) # revealed: int + +# error: [invalid-argument-type] "Invalid argument to key "x" with declared type `int` on TypedDict `InlineBad`: value of type `Literal["bad"]`" +inline_bad = TypedDict("InlineBad", {"x": int})(x="bad") +``` + +Inline functional `TypedDict`s resolve string forward references to existing names: + +```py +from typing_extensions import TypedDict + +class Director: + pass + +inline_ref = TypedDict("InlineRef", {"director": "Director"})(director=Director()) +reveal_type(inline_ref["director"]) # revealed: Director +``` + +## Function syntax with `total=False` + +The `total=False` keyword makes all fields optional by default: + +```py +from typing_extensions import TypedDict + +# With total=False, all fields are optional by default +PartialMovie = TypedDict("PartialMovie", {"name": str, "year": int}, total=False) + +# All fields are optional +partial = PartialMovie() +partial_with_name = PartialMovie(name="The Matrix") + +# Non-bool arguments are rejected: +# error: [invalid-argument-type] "Invalid argument to parameter `total` of `TypedDict()`" +TotalNone = TypedDict("TotalNone", {"id": int}, total=None) + +# Non-literal bool arguments are also rejected per the spec: +def f(total: bool) -> None: + # error: [invalid-argument-type] "Invalid argument to parameter `total` of `TypedDict()`" + TotalDynamic = TypedDict("TotalDynamic", {"id": int}, total=total) +``` + +## Function syntax with `closed` + +The `closed` keyword is accepted but not yet fully supported: + +```py +from typing_extensions import TypedDict + +# closed is accepted (no error) OtherMessage = TypedDict("OtherMessage", {"id": int, "content": str}, closed=True) -reveal_type(Message.__required_keys__) # revealed: @Todo(Functional TypedDicts) +# Non-bool arguments are rejected: +# error: [invalid-argument-type] "Invalid argument to parameter `closed` of `TypedDict()`" +ClosedNone = TypedDict("ClosedNone", {"id": int}, closed=None) + +# Non-literal bool arguments are also rejected per the spec: +def f(closed: bool) -> None: + # error: [invalid-argument-type] "Invalid argument to parameter `closed` of `TypedDict()`" + ClosedDynamic = TypedDict("ClosedDynamic", {"id": int}, closed=closed) +``` + +## Function syntax with `extra_items` + +The `extra_items` keyword is accepted and validated as an annotation expression: + +```py +from typing_extensions import ReadOnly, TypedDict + +# extra_items is accepted (no error) +MovieWithExtras = TypedDict("MovieWithExtras", {"name": str}, extra_items=bool) + +# Invalid type expressions are rejected: +# error: [invalid-syntax-in-forward-annotation] "Syntax error in forward annotation: Unexpected token at the end of an expression" +BadExtras = TypedDict("BadExtras", {"name": str}, extra_items="not a type expression") + +# Forward references in extra_items are supported: +TD = TypedDict("TD", {}, extra_items="TD | None") +reveal_type(TD) # revealed: + +class Foo(TypedDict("T", {}, extra_items="Foo | None")): ... + +reveal_type(Foo) # revealed: + +# Type qualifiers like ReadOnly are valid in extra_items (annotation expression, not type expression): +TD2 = TypedDict("TD2", {}, extra_items=ReadOnly[int]) + +class Bar(TypedDict("TD3", {}, extra_items=ReadOnly[int])): ... +``` + +## Function syntax with forward references + +Functional TypedDict supports forward references (string annotations): + +```py +from typing_extensions import TypedDict + +# Forward reference to a class defined below +MovieWithDirector = TypedDict("MovieWithDirector", {"title": str, "director": "Director"}) + +class Director: + name: str + +movie: MovieWithDirector = {"title": "The Matrix", "director": Director()} +reveal_type(movie) # revealed: MovieWithDirector +``` + +## Recursive functional `TypedDict` (unstringified forward reference) + +Forward references in functional `TypedDict` calls must be stringified, since the field types are +evaluated at runtime. An unstringified self-reference is an error: + +```py +from typing import TypedDict -# TODO: this should be an error -msg.content +# error: [unresolved-reference] "Name `T` used when not defined" +T = TypedDict("T", {"x": T | None}) +``` + +## Recursive functional `TypedDict` + +Functional `TypedDict`s can also be recursive, referencing themselves in field types: + +```py +from __future__ import annotations +from typing_extensions import TypedDict + +# Self-referencing TypedDict using functional syntax +TreeNode = TypedDict("TreeNode", {"value": int, "left": "TreeNode | None", "right": "TreeNode | None"}) + +reveal_type(TreeNode) # revealed: + +leaf: TreeNode = {"value": 1, "left": None, "right": None} +reveal_type(leaf["value"]) # revealed: Literal[1] +reveal_type(leaf["left"]) # revealed: None + +tree: TreeNode = { + "value": 10, + "left": {"value": 5, "left": None, "right": None}, + "right": {"value": 15, "left": None, "right": None}, +} + +# error: [invalid-argument-type] +bad_tree: TreeNode = {"value": 1, "left": "not a node", "right": None} +``` + +## Deprecated keyword-argument syntax + +The deprecated keyword-argument syntax (fields as keyword arguments instead of a dict) is rejected. +This syntax is deprecated since Python 3.11, and raises an exception on Python 3.13+: + +```py +from typing_extensions import TypedDict + +# error: [unknown-argument] "Argument `name` does not match any known parameter of function `TypedDict`" +# error: [unknown-argument] "Argument `year` does not match any known parameter of function `TypedDict`" +# error: [missing-argument] "No argument provided for required parameter `fields` of function `TypedDict`" +Movie2 = TypedDict("Movie2", name=str, year=int) +``` + +## Function syntax with invalid arguments + +```py +from typing_extensions import TypedDict + +# error: [invalid-argument-type] "Invalid argument to parameter `typename` of `TypedDict()`" +Bad1 = TypedDict(123, {"name": str}) + +# error: [invalid-argument-type] "Expected a dict literal for parameter `fields` of `TypedDict()`" +Bad2 = TypedDict("Bad2", "not a dict") + +def get_fields() -> dict[str, object]: + return {"name": str} + +# error: [invalid-argument-type] "Expected a dict literal for parameter `fields` of `TypedDict()`" +Bad2b = TypedDict("Bad2b", get_fields()) + +# error: [invalid-argument-type] "Invalid argument to parameter `total` of `TypedDict()`" +Bad3 = TypedDict("Bad3", {"name": str}, total="not a bool") + +# error: [invalid-argument-type] "Invalid argument to parameter `closed` of `TypedDict()`" +Bad4 = TypedDict("Bad4", {"name": str}, closed=123) + +tup = ("foo", "bar") +kw = {"name": str} + +# error: [invalid-argument-type] "Variadic positional arguments are not supported in `TypedDict()` calls" +Bad5 = TypedDict(*tup) + +# error: [invalid-argument-type] "Variadic keyword arguments are not supported in `TypedDict()` calls" +Bad6 = TypedDict("Bad6", {"name": str}, **kw) + +# error: [invalid-argument-type] "Variadic positional and keyword arguments are not supported in `TypedDict()` calls" +Bad7 = TypedDict(*tup, **kw) + +# error: [invalid-argument-type] "Variadic keyword arguments are not supported in `TypedDict()` calls" +# error: [unknown-argument] "Argument `random_other_arg` does not match any known parameter of function `TypedDict`" +Bad7b = TypedDict("Bad7b", **kw, random_other_arg=56) + +kwargs = {"x": int} + +# error: [invalid-argument-type] "Expected a dict literal with string-literal keys for parameter `fields` of `TypedDict()`" +# error: [invalid-type-form] +Bad8 = TypedDict("Bad8", {**kwargs}) + +def get_name() -> str: + return "x" + +name = get_name() + +# error: [invalid-argument-type] "Expected a string-literal key in the `fields` dict of `TypedDict()`" +Bad9 = TypedDict("Bad9", {name: int}) + +# error: [invalid-argument-type] "Expected a string-literal key in the `fields` dict of `TypedDict()`" +# error: [invalid-type-form] +Bad10 = TypedDict("Bad10", {name: 42}) + +# error: [invalid-argument-type] "Expected a string-literal key in the `fields` dict of `TypedDict()`" +class Bad11(TypedDict("Bad11", {name: 42})): ... +``` + +## Functional `TypedDict` with unknown fields + +When a functional `TypedDict` has unparseable fields (e.g., non-literal keys), the resulting type +behaves like a `TypedDict` with no known fields. This is consistent with pyright and mypy: + +```py +from typing import TypedDict + +def get_name() -> str: + return "x" + +key = get_name() + +# error: [invalid-argument-type] "Expected a string-literal key in the `fields` dict of `TypedDict()`" +Bad = TypedDict("Bad", {key: int}) + +# No known fields, so keyword arguments are rejected +# error: [invalid-key] +b = Bad(x=1) +reveal_type(b) # revealed: Bad + +# Field access reports unknown keys +# error: [invalid-key] +reveal_type(b["x"]) # revealed: Unknown +``` + +## Equivalence between functional and class-based `TypedDict` + +Functional and class-based `TypedDict`s with the same fields are structurally equivalent: + +```py +from typing import TypedDict +from typing_extensions import assert_type +from ty_extensions import is_assignable_to, is_equivalent_to, static_assert + +class ClassBased(TypedDict): + name: str + age: int + +Functional = TypedDict("Functional", {"name": str, "age": int}) + +static_assert(is_equivalent_to(ClassBased, Functional)) +static_assert(is_assignable_to(ClassBased, Functional)) +static_assert(is_assignable_to(Functional, ClassBased)) + +cb: ClassBased = {"name": "Alice", "age": 30} +assert_type(cb, Functional) + +fn: Functional = {"name": "Bob", "age": 25} +assert_type(fn, ClassBased) +``` + +## Subtyping between functional and class-based `TypedDict` + +A functional `TypedDict` is not a subtype of a class-based one when the field types differ: + +```py +from typing import TypedDict +from ty_extensions import is_assignable_to, static_assert + +class StrFields(TypedDict): + x: str + +IntFields = TypedDict("IntFields", {"x": int}) + +static_assert(not is_assignable_to(IntFields, StrFields)) +static_assert(not is_assignable_to(StrFields, IntFields)) +``` + +## Methods on functional `TypedDict` + +Functional `TypedDict`s support the same synthesized methods as class-based ones: + +```py +from typing import TypedDict + +Person = TypedDict("Person", {"name": str, "age": int}) + +def _(p: Person) -> None: + # __getitem__ + reveal_type(p["name"]) # revealed: str + reveal_type(p["age"]) # revealed: int + + # get() + reveal_type(p.get("name")) # revealed: str + reveal_type(p.get("name", "default")) # revealed: str + reveal_type(p.get("unknown")) # revealed: Unknown | None + + # setdefault() + reveal_type(p.setdefault("name", "Alice")) # revealed: str + + # __contains__ + reveal_type("name" in p) # revealed: bool + + # __setitem__ + p["name"] = "Alice" + # error: [invalid-assignment] + p["name"] = 42 + + # __delitem__ on required fields is an error + # error: [invalid-argument-type] + del p["name"] +``` + +Functional `TypedDict`s with `total=False` have optional fields that support `pop` and `del`: + +```py +from typing import TypedDict + +Partial = TypedDict("Partial", {"name": str, "extra": int}, total=False) + +def _(p: Partial) -> None: + reveal_type(p.get("name")) # revealed: str | None + reveal_type(p.get("name", "default")) # revealed: str + reveal_type(p.pop("name")) # revealed: str + reveal_type(p.pop("name", "fallback")) # revealed: str + del p["extra"] +``` + +## Merge operators on functional `TypedDict` + +```py +from typing import TypedDict + +Foo = TypedDict("Foo", {"x": int, "y": str}) + +def _(a: Foo, b: Foo) -> None: + reveal_type(a | b) # revealed: Foo + reveal_type(a | {"x": 1}) # revealed: Foo + reveal_type(a | {"x": 1, "y": "a", "z": True}) # revealed: dict[str, object] ``` ## Error cases @@ -2194,9 +2610,6 @@ def f(): # fine MyFunctionalTypedDict = TypedDict("MyFunctionalTypedDict", {"not-an-identifier": Required[int]}) - -class FunctionalTypedDictSubclass(MyFunctionalTypedDict): - y: NotRequired[int] # fine ``` ### Nested `Required` and `NotRequired` @@ -3236,19 +3649,6 @@ class Child(Base): y: str ``` -The functional `TypedDict` syntax is not yet fully supported, so we don't currently emit an error -for it. Once functional `TypedDict` support is added, this should also emit an error: - -```py -from dataclasses import dataclass -from typing import TypedDict - -# TODO: This should error once functional TypedDict is supported -@dataclass -class Foo(TypedDict("Foo", {"x": int, "y": str})): - pass -``` - ## Class header validation @@ -3570,10 +3970,11 @@ The functional syntax also supports `extra_items`: ```py MovieFunctional = TypedDict("MovieFunctional", {"name": str}, extra_items=bool) -d: MovieFunctional = {"name": "Blade Runner", "novel_adaptation": True} +# TODO: should be OK (extra key with correct type), no errors +d: MovieFunctional = {"name": "Blade Runner", "novel_adaptation": True} # error: [invalid-key] -# TODO: should be error: [invalid-argument-type] -e: MovieFunctional = {"name": "Blade Runner", "year": 1982} +# TODO: should be error: [invalid-argument-type] (wrong type for extra key), not [invalid-key] +e: MovieFunctional = {"name": "Blade Runner", "year": 1982} # error: [invalid-key] ``` ### `extra_items` parameter must be a valid annotation expression; the only legal type qualifier is `ReadOnly` diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index 32db314e75cf98..c6f70b0906dc60 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -929,7 +929,6 @@ impl<'db> Type<'db> { DynamicType::Todo(_) | DynamicType::TodoStarredExpression | DynamicType::TodoUnpack - | DynamicType::TodoFunctionalTypedDict | DynamicType::TodoTypeVarTuple => true, }) } @@ -3772,32 +3771,6 @@ impl<'db> Type<'db> { } }, - Type::SpecialForm(SpecialFormType::TypedDict) => { - Binding::single( - self, - Signature::new( - Parameters::new( - db, - [ - Parameter::positional_only(Some(Name::new_static("typename"))) - .with_annotated_type(KnownClass::Str.to_instance(db)), - Parameter::positional_only(Some(Name::new_static("fields"))) - .with_annotated_type(KnownClass::Dict.to_instance(db)) - .with_default_type(Type::any()), - Parameter::keyword_only(Name::new_static("total")) - .with_annotated_type(KnownClass::Bool.to_instance(db)) - .with_default_type(Type::bool_literal(true)), - // Future compatibility, in case new keyword arguments will be added: - Parameter::keyword_variadic(Name::new_static("kwargs")) - .with_annotated_type(Type::any()), - ], - ), - Type::Dynamic(DynamicType::TodoFunctionalTypedDict), - ), - ) - .into() - } - Type::NominalInstance(_) | Type::ProtocolInstance(_) | Type::NewTypeInstance(_) => { // Note that for objects that have a (possibly not callable!) `__call__` attribute, // we will get the signature of the `__call__` attribute, but will pass in the type @@ -6038,7 +6011,6 @@ impl<'db> Type<'db> { | DynamicType::TodoStarredExpression | DynamicType::TodoTypeVarTuple | DynamicType::UnspecializedTypeVar - | DynamicType::TodoFunctionalTypedDict ) | Self::Callable(_) | Self::TypeIs(_) @@ -6521,8 +6493,6 @@ pub enum DynamicType<'db> { TodoStarredExpression, /// A special Todo-variant for `TypeVarTuple` instances encountered in type expressions TodoTypeVarTuple, - /// A special Todo-variant for functional `TypedDict`s. - TodoFunctionalTypedDict, /// A type that is determined to be divergent during recursive type inference. Divergent(DivergentType), } @@ -6549,7 +6519,6 @@ impl std::fmt::Display for DynamicType<'_> { DynamicType::TodoUnpack => f.write_str("@Todo(typing.Unpack)"), DynamicType::TodoStarredExpression => f.write_str("@Todo(StarredExpression)"), DynamicType::TodoTypeVarTuple => f.write_str("@Todo(TypeVarTuple)"), - DynamicType::TodoFunctionalTypedDict => f.write_str("@Todo(Functional TypedDicts)"), DynamicType::Divergent(_) => f.write_str("Divergent"), } } diff --git a/crates/ty_python_semantic/src/types/call/bind.rs b/crates/ty_python_semantic/src/types/call/bind.rs index f120dfb33ce4aa..735b3d64d202c3 100644 --- a/crates/ty_python_semantic/src/types/call/bind.rs +++ b/crates/ty_python_semantic/src/types/call/bind.rs @@ -2067,12 +2067,6 @@ impl<'db> Bindings<'db> { _ => {} }, - Type::SpecialForm(SpecialFormType::TypedDict) => { - overload.set_return_type(Type::Dynamic( - crate::types::DynamicType::TodoFunctionalTypedDict, - )); - } - // Not a special case _ => {} } diff --git a/crates/ty_python_semantic/src/types/class.rs b/crates/ty_python_semantic/src/types/class.rs index 916efb3b422fac..a83cae1959ca29 100644 --- a/crates/ty_python_semantic/src/types/class.rs +++ b/crates/ty_python_semantic/src/types/class.rs @@ -9,6 +9,7 @@ pub(super) use self::named_tuple::{ DynamicNamedTupleAnchor, DynamicNamedTupleLiteral, NamedTupleField, NamedTupleSpec, }; pub(crate) use self::static_literal::StaticClassLiteral; +pub(super) use self::typed_dict::{DynamicTypedDictAnchor, DynamicTypedDictLiteral}; use super::{ BoundTypeVarInstance, MemberLookupPolicy, MroIterator, SpecialFormType, SubclassOfType, Type, TypeQualifiers, class_base::ClassBase, function::FunctionType, @@ -55,6 +56,7 @@ mod dynamic_literal; mod known; mod named_tuple; mod static_literal; +mod typed_dict; /// A category of classes with code generation capabilities (with synthesized methods). #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, salsa::Update, get_size2::GetSize)] @@ -79,6 +81,7 @@ impl<'db> CodeGeneratorKind<'db> { } ClassLiteral::Dynamic(dynamic_class) => Self::from_dynamic_class(db, dynamic_class), ClassLiteral::DynamicNamedTuple(_) => Some(Self::NamedTuple), + ClassLiteral::DynamicTypedDict(_) => Some(Self::TypedDict), } } @@ -321,6 +324,8 @@ pub enum ClassLiteral<'db> { Dynamic(DynamicClassLiteral<'db>), /// A class created via `collections.namedtuple()` or `typing.NamedTuple()`. DynamicNamedTuple(DynamicNamedTupleLiteral<'db>), + /// A class created via functional `TypedDict("Name", {...})`. + DynamicTypedDict(DynamicTypedDictLiteral<'db>), } impl<'db> ClassLiteral<'db> { @@ -338,6 +343,7 @@ impl<'db> ClassLiteral<'db> { Self::Static(class) => class.name(db), Self::Dynamic(class) => class.name(db), Self::DynamicNamedTuple(namedtuple) => namedtuple.name(db), + Self::DynamicTypedDict(typeddict) => typeddict.name(db), } } @@ -363,6 +369,7 @@ impl<'db> ClassLiteral<'db> { Self::Static(class) => class.metaclass(db), Self::Dynamic(class) => class.metaclass(db), Self::DynamicNamedTuple(namedtuple) => namedtuple.metaclass(db), + Self::DynamicTypedDict(typeddict) => typeddict.metaclass(db), } } @@ -377,6 +384,7 @@ impl<'db> ClassLiteral<'db> { Self::Static(class) => class.class_member(db, name, policy), Self::Dynamic(class) => class.class_member(db, name, policy), Self::DynamicNamedTuple(namedtuple) => namedtuple.class_member(db, name, policy), + Self::DynamicTypedDict(typeddict) => typeddict.class_member(db, name, policy), } } @@ -392,7 +400,7 @@ impl<'db> ClassLiteral<'db> { ) -> PlaceAndQualifiers<'db> { match self { Self::Static(class) => class.class_member_from_mro(db, name, policy, mro_iter), - Self::Dynamic(_) | Self::DynamicNamedTuple(_) => { + Self::Dynamic(_) | Self::DynamicNamedTuple(_) | Self::DynamicTypedDict(_) => { // Dynamic classes don't have inherited generic context and are never `object`. let result = MroLookup::new(db, mro_iter).class_member(name, policy, None, false); match result { @@ -418,7 +426,9 @@ impl<'db> ClassLiteral<'db> { pub(crate) fn default_specialization(self, db: &'db dyn Db) -> ClassType<'db> { match self { Self::Static(class) => class.default_specialization(db), - Self::Dynamic(_) | Self::DynamicNamedTuple(_) => ClassType::NonGeneric(self), + Self::Dynamic(_) | Self::DynamicNamedTuple(_) | Self::DynamicTypedDict(_) => { + ClassType::NonGeneric(self) + } } } @@ -426,7 +436,9 @@ impl<'db> ClassLiteral<'db> { pub(crate) fn identity_specialization(self, db: &'db dyn Db) -> ClassType<'db> { match self { Self::Static(class) => class.identity_specialization(db), - Self::Dynamic(_) | Self::DynamicNamedTuple(_) => ClassType::NonGeneric(self), + Self::Dynamic(_) | Self::DynamicNamedTuple(_) | Self::DynamicTypedDict(_) => { + ClassType::NonGeneric(self) + } } } @@ -444,6 +456,7 @@ impl<'db> ClassLiteral<'db> { pub fn is_typed_dict(self, db: &'db dyn Db) -> bool { match self { Self::Static(class) => class.is_typed_dict(db), + Self::DynamicTypedDict(_) => true, Self::Dynamic(_) | Self::DynamicNamedTuple(_) => false, } } @@ -452,7 +465,7 @@ impl<'db> ClassLiteral<'db> { pub(crate) fn is_tuple(self, db: &'db dyn Db) -> bool { match self { Self::Static(class) => class.is_tuple(db), - Self::Dynamic(_) | Self::DynamicNamedTuple(_) => false, + Self::Dynamic(_) | Self::DynamicNamedTuple(_) | Self::DynamicTypedDict(_) => false, } } @@ -475,6 +488,7 @@ impl<'db> ClassLiteral<'db> { Self::Static(class) => class.file(db), Self::Dynamic(class) => class.scope(db).file(db), Self::DynamicNamedTuple(class) => class.scope(db).file(db), + Self::DynamicTypedDict(class) => class.scope(db).file(db), } } @@ -487,6 +501,7 @@ impl<'db> ClassLiteral<'db> { Self::Static(class) => class.header_range(db), Self::Dynamic(class) => class.header_range(db), Self::DynamicNamedTuple(class) => class.header_range(db), + Self::DynamicTypedDict(class) => class.header_range(db), } } @@ -501,8 +516,7 @@ impl<'db> ClassLiteral<'db> { Self::Static(class) => class.is_final(db), // Dynamic classes created via `type()`, `collections.namedtuple()`, etc. cannot be // marked as final. - Self::Dynamic(_) => false, - Self::DynamicNamedTuple(_) => false, + Self::Dynamic(_) | Self::DynamicNamedTuple(_) | Self::DynamicTypedDict(_) => false, } } @@ -519,7 +533,7 @@ impl<'db> ClassLiteral<'db> { match self { Self::Static(class) => class.has_own_ordering_method(db), Self::Dynamic(class) => class.has_own_ordering_method(db), - Self::DynamicNamedTuple(_) => false, + Self::DynamicNamedTuple(_) | Self::DynamicTypedDict(_) => false, } } @@ -527,7 +541,7 @@ impl<'db> ClassLiteral<'db> { pub(crate) fn as_static(self) -> Option> { match self { Self::Static(class) => Some(class), - Self::Dynamic(_) | Self::DynamicNamedTuple(_) => None, + Self::Dynamic(_) | Self::DynamicNamedTuple(_) | Self::DynamicTypedDict(_) => None, } } @@ -537,6 +551,7 @@ impl<'db> ClassLiteral<'db> { Self::Static(class) => Some(class.definition(db)), Self::Dynamic(class) => class.definition(db), Self::DynamicNamedTuple(namedtuple) => namedtuple.definition(db), + Self::DynamicTypedDict(typeddict) => typeddict.definition(db), } } @@ -551,6 +566,9 @@ impl<'db> ClassLiteral<'db> { Self::DynamicNamedTuple(namedtuple) => { namedtuple.definition(db).map(TypeDefinition::DynamicClass) } + Self::DynamicTypedDict(typeddict) => { + typeddict.definition(db).map(TypeDefinition::DynamicClass) + } } } @@ -568,6 +586,7 @@ impl<'db> ClassLiteral<'db> { Self::Static(class) => class.header_span(db), Self::Dynamic(class) => class.header_span(db), Self::DynamicNamedTuple(namedtuple) => namedtuple.header_span(db), + Self::DynamicTypedDict(typeddict) => typeddict.header_span(db), } } @@ -594,7 +613,8 @@ impl<'db> ClassLiteral<'db> { Self::Dynamic(class) => class.as_disjoint_base(db), // Dynamic namedtuples define `__slots__ = ()`, but `__slots__` must be // non-empty for a class to be a disjoint base. - Self::DynamicNamedTuple(_) => None, + // Dynamic TypedDicts don't define `__slots__`. + Self::DynamicNamedTuple(_) | Self::DynamicTypedDict(_) => None, } } @@ -602,7 +622,7 @@ impl<'db> ClassLiteral<'db> { pub(crate) fn to_non_generic_instance(self, db: &'db dyn Db) -> Type<'db> { match self { Self::Static(class) => class.to_non_generic_instance(db), - Self::Dynamic(_) | Self::DynamicNamedTuple(_) => { + Self::Dynamic(_) | Self::DynamicNamedTuple(_) | Self::DynamicTypedDict(_) => { Type::instance(db, ClassType::NonGeneric(self)) } } @@ -625,7 +645,9 @@ impl<'db> ClassLiteral<'db> { ) -> ClassType<'db> { match self { Self::Static(class) => class.apply_specialization(db, f), - Self::Dynamic(_) | Self::DynamicNamedTuple(_) => ClassType::NonGeneric(self), + Self::Dynamic(_) | Self::DynamicNamedTuple(_) | Self::DynamicTypedDict(_) => { + ClassType::NonGeneric(self) + } } } @@ -640,6 +662,7 @@ impl<'db> ClassLiteral<'db> { Self::Static(class) => class.instance_member(db, specialization, name), Self::Dynamic(class) => class.instance_member(db, name), Self::DynamicNamedTuple(namedtuple) => namedtuple.instance_member(db, name), + Self::DynamicTypedDict(typeddict) => typeddict.instance_member(db, name), } } @@ -647,7 +670,9 @@ impl<'db> ClassLiteral<'db> { pub(crate) fn top_materialization(self, db: &'db dyn Db) -> ClassType<'db> { match self { Self::Static(class) => class.top_materialization(db), - Self::Dynamic(_) | Self::DynamicNamedTuple(_) => ClassType::NonGeneric(self), + Self::Dynamic(_) | Self::DynamicNamedTuple(_) | Self::DynamicTypedDict(_) => { + ClassType::NonGeneric(self) + } } } @@ -661,6 +686,7 @@ impl<'db> ClassLiteral<'db> { ) -> PlaceAndQualifiers<'db> { match self { Self::Static(class) => class.typed_dict_member(db, specialization, name, policy), + Self::DynamicTypedDict(typeddict) => typeddict.class_member(db, name, policy), Self::Dynamic(_) | Self::DynamicNamedTuple(_) => Place::Undefined.into(), } } @@ -676,7 +702,7 @@ impl<'db> ClassLiteral<'db> { Self::Dynamic(class) => { Self::Dynamic(class.with_dataclass_params(db, dataclass_params)) } - Self::DynamicNamedTuple(_) => self, + Self::DynamicNamedTuple(_) | Self::DynamicTypedDict(_) => self, } } @@ -691,6 +717,10 @@ impl<'db> ClassLiteral<'db> { Self::DynamicNamedTuple(namedtuple) => { [Type::from(namedtuple.tuple_base_class(db))].into() } + Self::DynamicTypedDict(_) => { + // TypedDicts always inherit from `dict` + Box::default() + } } } } @@ -713,6 +743,12 @@ impl<'db> From> for ClassLiteral<'db> { } } +impl<'db> From> for ClassLiteral<'db> { + fn from(literal: DynamicTypedDictLiteral<'db>) -> Self { + ClassLiteral::DynamicTypedDict(literal) + } +} + /// Represents a class type, which might be a non-generic class, or a specialization of a generic /// class. #[derive( @@ -800,7 +836,11 @@ impl<'db> ClassType<'db> { ) -> Option<(StaticClassLiteral<'db>, Option>)> { match self { Self::NonGeneric(ClassLiteral::Static(class)) => Some((class, None)), - Self::NonGeneric(ClassLiteral::Dynamic(_) | ClassLiteral::DynamicNamedTuple(_)) => None, + Self::NonGeneric( + ClassLiteral::Dynamic(_) + | ClassLiteral::DynamicNamedTuple(_) + | ClassLiteral::DynamicTypedDict(_), + ) => None, Self::Generic(generic) => Some((generic.origin(db), Some(generic.specialization(db)))), } } @@ -814,7 +854,11 @@ impl<'db> ClassType<'db> { ) -> Option<(StaticClassLiteral<'db>, Option>)> { match self { Self::NonGeneric(ClassLiteral::Static(class)) => Some((class, None)), - Self::NonGeneric(ClassLiteral::Dynamic(_) | ClassLiteral::DynamicNamedTuple(_)) => None, + Self::NonGeneric( + ClassLiteral::Dynamic(_) + | ClassLiteral::DynamicNamedTuple(_) + | ClassLiteral::DynamicTypedDict(_), + ) => None, Self::Generic(generic) => Some(( generic.origin(db), Some( @@ -1251,6 +1295,9 @@ impl<'db> ClassType<'db> { Self::NonGeneric(ClassLiteral::DynamicNamedTuple(namedtuple)) => { return namedtuple.own_class_member(db, name); } + Self::NonGeneric(ClassLiteral::DynamicTypedDict(typeddict)) => { + return typeddict.own_class_member(db, name); + } Self::NonGeneric(ClassLiteral::Static(class)) => (class, None), Self::Generic(generic) => (generic.origin(db), Some(generic.specialization(db))), }; @@ -1534,6 +1581,9 @@ impl<'db> ClassType<'db> { Self::NonGeneric(ClassLiteral::DynamicNamedTuple(namedtuple)) => { namedtuple.instance_member(db, name) } + Self::NonGeneric(ClassLiteral::DynamicTypedDict(typeddict)) => { + typeddict.instance_member(db, name) + } Self::NonGeneric(ClassLiteral::Static(class)) => { if class.is_typed_dict(db) { return Place::Undefined.into(); @@ -1569,7 +1619,11 @@ impl<'db> ClassType<'db> { .origin(db) .converter_input_type_for_field(db, name) .map(|ty| ty.apply_optional_specialization(db, Some(generic.specialization(db)))), - Self::NonGeneric(ClassLiteral::Dynamic(_) | ClassLiteral::DynamicNamedTuple(_)) => None, + Self::NonGeneric( + ClassLiteral::Dynamic(_) + | ClassLiteral::DynamicNamedTuple(_) + | ClassLiteral::DynamicTypedDict(_), + ) => None, } } @@ -1583,6 +1637,7 @@ impl<'db> ClassType<'db> { Self::NonGeneric(ClassLiteral::DynamicNamedTuple(namedtuple)) => { namedtuple.own_instance_member(db, name) } + Self::NonGeneric(ClassLiteral::DynamicTypedDict(_)) => Member::default(), Self::NonGeneric(ClassLiteral::Static(class_literal)) => { class_literal.own_instance_member(db, name) } @@ -1845,9 +1900,11 @@ impl<'db> VarianceInferable<'db> for ClassType<'db> { fn variance_of(self, db: &'db dyn Db, typevar: BoundTypeVarInstance<'db>) -> TypeVarVariance { match self { Self::NonGeneric(ClassLiteral::Static(class)) => class.variance_of(db, typevar), - Self::NonGeneric(ClassLiteral::Dynamic(_) | ClassLiteral::DynamicNamedTuple(_)) => { - TypeVarVariance::Bivariant - } + Self::NonGeneric( + ClassLiteral::Dynamic(_) + | ClassLiteral::DynamicNamedTuple(_) + | ClassLiteral::DynamicTypedDict(_), + ) => TypeVarVariance::Bivariant, Self::Generic(generic) => generic.variance_of(db, typevar), } } @@ -2039,52 +2096,13 @@ impl<'db> VarianceInferable<'db> for ClassLiteral<'db> { fn variance_of(self, db: &'db dyn Db, typevar: BoundTypeVarInstance<'db>) -> TypeVarVariance { match self { Self::Static(class) => class.variance_of(db, typevar), - Self::Dynamic(_) | Self::DynamicNamedTuple(_) => TypeVarVariance::Bivariant, + Self::Dynamic(_) | Self::DynamicNamedTuple(_) | Self::DynamicTypedDict(_) => { + TypeVarVariance::Bivariant + } } } } -pub(super) fn synthesize_typed_dict_update_member<'db>( - db: &'db dyn Db, - instance_ty: Type<'db>, - keyword_parameters: &[Parameter<'db>], -) -> Type<'db> { - let update_patch_ty = if let Type::TypedDict(typed_dict) = instance_ty { - Type::TypedDict(typed_dict.to_update_patch(db)) - } else { - instance_ty - }; - - let value_ty = UnionBuilder::new(db) - .add(update_patch_ty) - .add(KnownClass::Iterable.to_specialized_instance( - db, - &[Type::heterogeneous_tuple( - db, - [KnownClass::Str.to_instance(db), Type::object()], - )], - )) - .build(); - - let update_signature = Signature::new( - Parameters::new( - db, - [ - Parameter::positional_only(Some(Name::new_static("self"))) - .with_annotated_type(instance_ty), - Parameter::positional_only(Some(Name::new_static("value"))) - .with_annotated_type(value_ty) - .with_default_type(Type::none(db)), - ] - .into_iter() - .chain(keyword_parameters.iter().cloned()), - ), - Type::none(db), - ); - - Type::function_like_callable(db, update_signature) -} - /// Performs member lookups over an MRO (Method Resolution Order). /// /// This struct encapsulates the shared logic for looking up class and instance @@ -2367,6 +2385,11 @@ impl<'db> QualifiedClassName<'db> { let scope = namedtuple.scope(self.db); (scope.file(self.db), scope.file_scope_id(self.db), 0) } + ClassLiteral::DynamicTypedDict(typeddict) => { + // Dynamic TypedDicts don't have a body scope; start from the enclosing scope. + let scope = typeddict.scope(self.db); + (scope.file(self.db), scope.file_scope_id(self.db), 0) + } }; display::qualified_name_components_from_scope(self.db, file, file_scope_id, skip_count) diff --git a/crates/ty_python_semantic/src/types/class/known.rs b/crates/ty_python_semantic/src/types/class/known.rs index e071628535b646..f053b27ffff4cf 100644 --- a/crates/ty_python_semantic/src/types/class/known.rs +++ b/crates/ty_python_semantic/src/types/class/known.rs @@ -1076,6 +1076,16 @@ impl KnownClass { .unwrap_or_else(SubclassOfType::subclass_of_unknown) } + pub(crate) fn to_specialized_subclass_of<'db>( + self, + db: &'db dyn Db, + specialization: &[Type<'db>], + ) -> Type<'db> { + self.to_specialized_class_type(db, specialization) + .map(|class_type| SubclassOfType::from(db, class_type)) + .unwrap_or_else(SubclassOfType::subclass_of_unknown) + } + /// Return `true` if this symbol can be resolved to a class definition `class` in typeshed, /// *and* `class` is a subclass of `other`. pub(crate) fn is_subclass_of<'db>(self, db: &'db dyn Db, other: ClassType<'db>) -> bool { diff --git a/crates/ty_python_semantic/src/types/class/static_literal.rs b/crates/ty_python_semantic/src/types/class/static_literal.rs index ab3c466265295e..3bf18b5f7bc310 100644 --- a/crates/ty_python_semantic/src/types/class/static_literal.rs +++ b/crates/ty_python_semantic/src/types/class/static_literal.rs @@ -37,7 +37,6 @@ use crate::{ ClassMemberResult, CodeGeneratorKind, DisjointBase, Field, FieldKind, InstanceMemberResult, MetaclassError, MetaclassErrorKind, MethodDecorator, MroLookup, NamedTupleField, SlotsKind, synthesize_namedtuple_class_member, - synthesize_typed_dict_update_member, }, context::InferContext, declaration_type, definition_expression_type, determine_upper_bound, @@ -55,12 +54,18 @@ use crate::{ mro::{Mro, MroIterator}, signatures::CallableSignature, tuple::{Tuple, TupleSpec, TupleType}, - typed_dict::{TypedDictParams, typed_dict_params_from_class_def}, + typed_dict::{TypedDictField, TypedDictParams, typed_dict_params_from_class_def}, variance::VarianceInferable, visitor::{TypeCollector, TypeVisitor, walk_type_with_recursion_guard}, }, }; +use super::typed_dict::{ + synthesize_typed_dict_delitem, synthesize_typed_dict_get, synthesize_typed_dict_getitem, + synthesize_typed_dict_merge, synthesize_typed_dict_pop, synthesize_typed_dict_setdefault, + synthesize_typed_dict_setitem, synthesize_typed_dict_update, +}; + /// Representation of a class definition statement in the AST: either a non-generic class, or a /// generic class that has not been specialized. /// @@ -216,8 +221,8 @@ impl<'db> StaticClassLiteral<'db> { return Some(ty); } } - // Dynamic namedtuples don't define their own ordering methods. - ClassLiteral::DynamicNamedTuple(_) => {} + // Dynamic namedtuples and TypedDicts don't define their own ordering methods. + ClassLiteral::DynamicNamedTuple(_) | ClassLiteral::DynamicTypedDict(_) => {} } } } @@ -656,8 +661,7 @@ impl<'db> StaticClassLiteral<'db> { return known.is_typed_dict_subclass(); } - self.iter_mro(db, None) - .any(|base| matches!(base, ClassBase::TypedDict)) + self.iter_mro(db, None).contains(&ClassBase::TypedDict) } /// Return `true` if this class is, or inherits from, a `NamedTuple` (inherits from @@ -668,7 +672,7 @@ impl<'db> StaticClassLiteral<'db> { .filter_map(ClassBase::into_class) .any(|base| match base.class_literal(db) { ClassLiteral::DynamicNamedTuple(_) => true, - ClassLiteral::Dynamic(_) => false, + ClassLiteral::Dynamic(_) | ClassLiteral::DynamicTypedDict(_) => false, ClassLiteral::Static(class) => class .explicit_bases(db) .contains(&Type::SpecialForm(SpecialFormType::NamedTuple)), @@ -1344,6 +1348,12 @@ impl<'db> StaticClassLiteral<'db> { Some(Type::function_like_callable(db, signature)) }; + let td_fields = || { + self.fields(db, specialization, field_policy) + .iter() + .map(|(name, field)| (name, TypedDictField::from_field(field))) + }; + match (field_policy, name) { (CodeGeneratorKind::DataclassLike(_), "__init__") => { if !self.has_dataclass_param(db, field_policy, DataclassFlags::INIT) { @@ -1549,460 +1559,31 @@ impl<'db> StaticClassLiteral<'db> { Type::heterogeneous_tuple(db, slots) }) } - (CodeGeneratorKind::TypedDict, "__setitem__") => { - let fields = self.fields(db, specialization, field_policy); - - // Add (key type, value type) overloads for all TypedDict items ("fields") that are not read-only: - - let mut writeable_fields = fields - .iter() - .filter(|(_, field)| !field.is_read_only()) - .peekable(); - - if writeable_fields.peek().is_none() { - // If there are no writeable fields, synthesize a `__setitem__` that takes - // a `key` of type `Never` to signal that no keys are accepted. This leads - // to slightly more user-friendly error messages compared to returning an - // empty overload set. - return Some(Type::Callable(CallableType::new( - db, - CallableSignature::single(Signature::new( - Parameters::new( - db, - [ - Parameter::positional_only(Some(Name::new_static("self"))) - .with_annotated_type(instance_ty), - Parameter::positional_only(Some(Name::new_static("key"))) - .with_annotated_type(Type::Never), - Parameter::positional_only(Some(Name::new_static("value"))) - .with_annotated_type(Type::any()), - ], - ), - Type::none(db), - )), - CallableTypeKind::FunctionLike, - ))); - } - - let overloads = writeable_fields.map(|(name, field)| { - let key_type = Type::string_literal(db, name); - - Signature::new( - Parameters::new( - db, - [ - Parameter::positional_only(Some(Name::new_static("self"))) - .with_annotated_type(instance_ty), - Parameter::positional_only(Some(Name::new_static("key"))) - .with_annotated_type(key_type), - Parameter::positional_only(Some(Name::new_static("value"))) - .with_annotated_type(field.declared_ty), - ], - ), - Type::none(db), - ) - }); - - Some(Type::Callable(CallableType::new( - db, - CallableSignature::from_overloads(overloads), - CallableTypeKind::FunctionLike, - ))) - } (CodeGeneratorKind::TypedDict, "__getitem__") => { - let fields = self.fields(db, specialization, field_policy); - - // Add (key -> value type) overloads for all TypedDict items ("fields"): - let overloads = fields.iter().map(|(name, field)| { - let key_type = Type::string_literal(db, name); - - Signature::new( - Parameters::new( - db, - [ - Parameter::positional_only(Some(Name::new_static("self"))) - .with_annotated_type(instance_ty), - Parameter::positional_only(Some(Name::new_static("key"))) - .with_annotated_type(key_type), - ], - ), - field.declared_ty, - ) - }); - - Some(Type::Callable(CallableType::new( - db, - CallableSignature::from_overloads(overloads), - CallableTypeKind::FunctionLike, - ))) + Some(synthesize_typed_dict_getitem(db, instance_ty, td_fields())) + } + (CodeGeneratorKind::TypedDict, "__setitem__") => { + Some(synthesize_typed_dict_setitem(db, instance_ty, td_fields())) } (CodeGeneratorKind::TypedDict, "__delitem__") => { - let fields = self.fields(db, specialization, field_policy); - - // Only non-required fields can be deleted. Required fields cannot be deleted - // because that would violate the TypedDict's structural type. - let mut deletable_fields = fields - .iter() - .filter(|(_, field)| !field.is_required()) - .peekable(); - - if deletable_fields.peek().is_none() { - // If there are no deletable fields (all fields are required), synthesize a - // `__delitem__` that takes a `key` of type `Never` to signal that no keys - // can be deleted. - return Some(Type::Callable(CallableType::new( - db, - CallableSignature::single(Signature::new( - Parameters::new( - db, - [ - Parameter::positional_only(Some(Name::new_static("self"))) - .with_annotated_type(instance_ty), - Parameter::positional_only(Some(Name::new_static("key"))) - .with_annotated_type(Type::Never), - ], - ), - Type::none(db), - )), - CallableTypeKind::FunctionLike, - ))); - } - - // Otherwise, add overloads for all deletable fields. - let overloads = deletable_fields.map(|(name, _field)| { - let key_type = Type::string_literal(db, name); - - Signature::new( - Parameters::new( - db, - [ - Parameter::positional_only(Some(Name::new_static("self"))) - .with_annotated_type(instance_ty), - Parameter::positional_only(Some(Name::new_static("key"))) - .with_annotated_type(key_type), - ], - ), - Type::none(db), - ) - }); - - Some(Type::Callable(CallableType::new( - db, - CallableSignature::from_overloads(overloads), - CallableTypeKind::FunctionLike, - ))) + Some(synthesize_typed_dict_delitem(db, instance_ty, td_fields())) } (CodeGeneratorKind::TypedDict, "get") => { - let overloads = self - .fields(db, specialization, field_policy) - .iter() - .flat_map(|(name, field)| { - let key_type = Type::string_literal(db, name); - - // For a required key, `.get()` always returns the value type. For a non-required key, - // `.get()` returns the union of the value type and the type of the default argument - // (which defaults to `None`). - - // TODO: For now, we use two overloads here. They can be merged into a single function - // once the generics solver takes default arguments into account. - - let get_sig = Signature::new( - Parameters::new( - db, - [ - Parameter::positional_only(Some(Name::new_static("self"))) - .with_annotated_type(instance_ty), - Parameter::positional_only(Some(Name::new_static("key"))) - .with_annotated_type(key_type), - ], - ), - if field.is_required() { - field.declared_ty - } else { - UnionType::from_two_elements(db, field.declared_ty, Type::none(db)) - }, - ); - - let t_default = BoundTypeVarInstance::synthetic( - db, - Name::new_static("T"), - TypeVarVariance::Covariant, - ); - - let get_with_default_sig = Signature::new_generic( - Some(GenericContext::from_typevar_instances(db, [t_default])), - Parameters::new( - db, - [ - Parameter::positional_only(Some(Name::new_static("self"))) - .with_annotated_type(instance_ty), - Parameter::positional_only(Some(Name::new_static("key"))) - .with_annotated_type(key_type), - Parameter::positional_only(Some(Name::new_static("default"))) - .with_annotated_type(Type::TypeVar(t_default)), - ], - ), - if field.is_required() { - field.declared_ty - } else { - UnionType::from_two_elements( - db, - field.declared_ty, - Type::TypeVar(t_default), - ) - }, - ); - - [get_sig, get_with_default_sig] - }) - // Fallback overloads for unknown keys - .chain(std::iter::once({ - Signature::new( - Parameters::new( - db, - [ - Parameter::positional_only(Some(Name::new_static("self"))) - .with_annotated_type(instance_ty), - Parameter::positional_only(Some(Name::new_static("key"))) - .with_annotated_type(KnownClass::Str.to_instance(db)), - ], - ), - UnionType::from_two_elements(db, Type::unknown(), Type::none(db)), - ) - })) - .chain(std::iter::once({ - let t_default = BoundTypeVarInstance::synthetic( - db, - Name::new_static("T"), - TypeVarVariance::Covariant, - ); - - Signature::new_generic( - Some(GenericContext::from_typevar_instances(db, [t_default])), - Parameters::new( - db, - [ - Parameter::positional_only(Some(Name::new_static("self"))) - .with_annotated_type(instance_ty), - Parameter::positional_only(Some(Name::new_static("key"))) - .with_annotated_type(KnownClass::Str.to_instance(db)), - Parameter::positional_only(Some(Name::new_static("default"))) - .with_annotated_type(Type::TypeVar(t_default)), - ], - ), - UnionType::from_two_elements( - db, - Type::unknown(), - Type::TypeVar(t_default), - ), - ) - })); - - Some(Type::Callable(CallableType::new( - db, - CallableSignature::from_overloads(overloads), - CallableTypeKind::FunctionLike, - ))) + Some(synthesize_typed_dict_get(db, instance_ty, td_fields())) } (CodeGeneratorKind::TypedDict, "pop") => { - let fields = self.fields(db, specialization, field_policy); - let overloads = fields - .iter() - .filter(|(_, field)| { - // Only synthesize `pop` for fields that are not required. - !field.is_required() - }) - .flat_map(|(name, field)| { - let key_type = Type::string_literal(db, name); - - // TODO: Similar to above: consider merging these two overloads into one - - // `.pop()` without default - let pop_sig = Signature::new( - Parameters::new( - db, - [ - Parameter::positional_only(Some(Name::new_static("self"))) - .with_annotated_type(instance_ty), - Parameter::positional_only(Some(Name::new_static("key"))) - .with_annotated_type(key_type), - ], - ), - field.declared_ty, - ); - - // `.pop()` with a default value - let t_default = BoundTypeVarInstance::synthetic( - db, - Name::new_static("T"), - TypeVarVariance::Covariant, - ); - - let pop_with_default_sig = Signature::new_generic( - Some(GenericContext::from_typevar_instances(db, [t_default])), - Parameters::new( - db, - [ - Parameter::positional_only(Some(Name::new_static("self"))) - .with_annotated_type(instance_ty), - Parameter::positional_only(Some(Name::new_static("key"))) - .with_annotated_type(key_type), - Parameter::positional_only(Some(Name::new_static("default"))) - .with_annotated_type(Type::TypeVar(t_default)), - ], - ), - UnionType::from_two_elements( - db, - field.declared_ty, - Type::TypeVar(t_default), - ), - ); - - [pop_sig, pop_with_default_sig] - }); - - Some(Type::Callable(CallableType::new( - db, - CallableSignature::from_overloads(overloads), - CallableTypeKind::FunctionLike, - ))) + Some(synthesize_typed_dict_pop(db, instance_ty, td_fields())) } - (CodeGeneratorKind::TypedDict, "setdefault") => { - let fields = self.fields(db, specialization, field_policy); - let overloads = fields.iter().map(|(name, field)| { - let key_type = Type::string_literal(db, name); - - // `setdefault` always returns the field type - Signature::new( - Parameters::new( - db, - [ - Parameter::positional_only(Some(Name::new_static("self"))) - .with_annotated_type(instance_ty), - Parameter::positional_only(Some(Name::new_static("key"))) - .with_annotated_type(key_type), - Parameter::positional_only(Some(Name::new_static("default"))) - .with_annotated_type(field.declared_ty), - ], - ), - field.declared_ty, - ) - }); - - Some(Type::Callable(CallableType::new( - db, - CallableSignature::from_overloads(overloads), - CallableTypeKind::FunctionLike, - ))) + (CodeGeneratorKind::TypedDict, "setdefault") => Some(synthesize_typed_dict_setdefault( + db, + instance_ty, + td_fields(), + )), + (CodeGeneratorKind::TypedDict, "update") => { + Some(synthesize_typed_dict_update(db, instance_ty, td_fields())) } (CodeGeneratorKind::TypedDict, name @ ("__or__" | "__ror__" | "__ior__")) => { - // For a TypedDict `TD`, synthesize overloaded signatures: - // - // ```python - // # Overload 1 (all operators): exact same TypedDict - // def __or__(self, value: TD, /) -> TD: ... - // - // # Overload 2 (__or__ / __ror__ only): partial TypedDict (all fields optional) - // def __or__(self, value: Partial[TD], /) -> TD: ... - // - // # Overload 3 (__or__ / __ror__ only): generic dict fallback - // def __or__(self, value: dict[str, Any], /) -> dict[str, object]: ... - // ``` - let mut overloads = vec![Signature::new( - Parameters::new( - db, - [ - Parameter::positional_only(Some(Name::new_static("self"))) - .with_annotated_type(instance_ty), - Parameter::positional_only(Some(Name::new_static("value"))) - .with_annotated_type(instance_ty), - ], - ), - instance_ty, - )]; - - if name != "__ior__" { - // `__ior__` intentionally stays exact. `|=` gets its patch-compatible - // fallback during inference so complete dict literals can still be inferred - // against the full TypedDict schema first. - - // A partial version of this TypedDict (all fields optional) so that dict - // literals and compatible TypedDicts with subset updates can preserve the - // TypedDict type. - let partial_ty = if let Type::TypedDict(td) = instance_ty { - Type::TypedDict(td.to_partial(db)) - } else { - instance_ty - }; - - let dict_param_ty = KnownClass::Dict.to_specialized_instance( - db, - &[KnownClass::Str.to_instance(db), Type::any()], - ); - - // We use `object` because a `closed=False` TypedDict (the default) can - // contain arbitrary additional keys with arbitrary value types. - let dict_return_ty = KnownClass::Dict.to_specialized_instance( - db, - &[ - KnownClass::Str.to_instance(db), - KnownClass::Object.to_instance(db), - ], - ); - - overloads.push(Signature::new( - Parameters::new( - db, - [ - Parameter::positional_only(Some(Name::new_static("self"))) - .with_annotated_type(instance_ty), - Parameter::positional_only(Some(Name::new_static("value"))) - .with_annotated_type(partial_ty), - ], - ), - instance_ty, - )); - overloads.push(Signature::new( - Parameters::new( - db, - [ - Parameter::positional_only(Some(Name::new_static("self"))) - .with_annotated_type(instance_ty), - Parameter::positional_only(Some(Name::new_static("value"))) - .with_annotated_type(dict_param_ty), - ], - ), - dict_return_ty, - )); - } - - Some(Type::Callable(CallableType::new( - db, - CallableSignature::from_overloads(overloads), - CallableTypeKind::FunctionLike, - ))) - } - (CodeGeneratorKind::TypedDict, "update") => { - let keyword_parameters: Vec<_> = if let Type::TypedDict(typed_dict) = instance_ty { - typed_dict - .to_update_patch(db) - .items(db) - .iter() - .map(|(name, field)| { - Parameter::keyword_only(name.clone()) - .with_annotated_type(field.declared_ty) - .with_default_type(field.declared_ty) - }) - .collect() - } else { - Vec::new() - }; - - Some(synthesize_typed_dict_update_member( - db, - instance_ty, - &keyword_parameters, - )) + Some(synthesize_typed_dict_merge(db, instance_ty, name)) } _ => None, } diff --git a/crates/ty_python_semantic/src/types/class/typed_dict.rs b/crates/ty_python_semantic/src/types/class/typed_dict.rs new file mode 100644 index 00000000000000..94502a3aa6372b --- /dev/null +++ b/crates/ty_python_semantic/src/types/class/typed_dict.rs @@ -0,0 +1,716 @@ +use std::borrow::Borrow; + +use ruff_db::diagnostic::Span; +use ruff_db::parsed::parsed_module; +use ruff_python_ast as ast; +use ruff_python_ast::NodeIndex; +use ruff_python_ast::name::Name; +use ruff_text_size::{Ranged, TextRange}; + +use crate::Db; +use crate::place::PlaceAndQualifiers; +use crate::semantic_index::definition::Definition; +use crate::semantic_index::scope::ScopeId; +use crate::types::callable::CallableTypeKind; +use crate::types::generics::GenericContext; +use crate::types::member::Member; +use crate::types::mro::Mro; +use crate::types::signatures::{CallableSignature, Parameter, Parameters, Signature}; +use crate::types::typed_dict::{ + TypedDictField, TypedDictSchema, deferred_functional_typed_dict_schema, +}; +use crate::types::{ + BoundTypeVarInstance, CallableType, ClassBase, ClassType, KnownClass, MemberLookupPolicy, Type, + TypeVarVariance, UnionBuilder, UnionType, +}; + +/// Synthesize the `__getitem__` method for a `TypedDict`. +pub(super) fn synthesize_typed_dict_getitem<'db, N, F>( + db: &'db dyn Db, + instance_ty: Type<'db>, + fields: impl IntoIterator, +) -> Type<'db> +where + N: Borrow, + F: Borrow>, +{ + let overloads = fields.into_iter().map(|(field_name, field)| { + let field_name = field_name.borrow(); + let field = field.borrow(); + let key_type = Type::string_literal(db, field_name.as_str()); + Signature::new( + Parameters::new( + db, + [ + Parameter::positional_only(Some(Name::new_static("self"))) + .with_annotated_type(instance_ty), + Parameter::positional_only(Some(Name::new_static("key"))) + .with_annotated_type(key_type), + ], + ), + field.declared_ty, + ) + }); + + Type::Callable(CallableType::new( + db, + CallableSignature::from_overloads(overloads), + CallableTypeKind::FunctionLike, + )) +} + +/// Synthesize the `__setitem__` method for a `TypedDict`. +pub(super) fn synthesize_typed_dict_setitem<'db, N, F>( + db: &'db dyn Db, + instance_ty: Type<'db>, + fields: impl IntoIterator, +) -> Type<'db> +where + N: Borrow, + F: Borrow>, +{ + let mut writeable_fields = fields + .into_iter() + .filter(|(_, field)| !(*field).borrow().is_read_only()) + .peekable(); + + if writeable_fields.peek().is_none() { + return Type::Callable(CallableType::new( + db, + CallableSignature::single(Signature::new( + Parameters::new( + db, + [ + Parameter::positional_only(Some(Name::new_static("self"))) + .with_annotated_type(instance_ty), + Parameter::positional_only(Some(Name::new_static("key"))) + .with_annotated_type(Type::Never), + Parameter::positional_only(Some(Name::new_static("value"))) + .with_annotated_type(Type::any()), + ], + ), + Type::none(db), + )), + CallableTypeKind::FunctionLike, + )); + } + + let overloads = writeable_fields.map(|(field_name, field)| { + let field_name = field_name.borrow(); + let field = field.borrow(); + let key_type = Type::string_literal(db, field_name.as_str()); + Signature::new( + Parameters::new( + db, + [ + Parameter::positional_only(Some(Name::new_static("self"))) + .with_annotated_type(instance_ty), + Parameter::positional_only(Some(Name::new_static("key"))) + .with_annotated_type(key_type), + Parameter::positional_only(Some(Name::new_static("value"))) + .with_annotated_type(field.declared_ty), + ], + ), + Type::none(db), + ) + }); + + Type::Callable(CallableType::new( + db, + CallableSignature::from_overloads(overloads), + CallableTypeKind::FunctionLike, + )) +} + +/// Synthesize the `__delitem__` method for a `TypedDict`. +pub(super) fn synthesize_typed_dict_delitem<'db, N, F>( + db: &'db dyn Db, + instance_ty: Type<'db>, + fields: impl IntoIterator, +) -> Type<'db> +where + N: Borrow, + F: Borrow>, +{ + let mut deletable_fields = fields + .into_iter() + .filter(|(_, field)| !(*field).borrow().is_required()) + .peekable(); + + if deletable_fields.peek().is_none() { + return Type::Callable(CallableType::new( + db, + CallableSignature::single(Signature::new( + Parameters::new( + db, + [ + Parameter::positional_only(Some(Name::new_static("self"))) + .with_annotated_type(instance_ty), + Parameter::positional_only(Some(Name::new_static("key"))) + .with_annotated_type(Type::Never), + ], + ), + Type::none(db), + )), + CallableTypeKind::FunctionLike, + )); + } + + let overloads = deletable_fields.map(|(field_name, _)| { + let field_name = field_name.borrow(); + let key_type = Type::string_literal(db, field_name.as_str()); + Signature::new( + Parameters::new( + db, + [ + Parameter::positional_only(Some(Name::new_static("self"))) + .with_annotated_type(instance_ty), + Parameter::positional_only(Some(Name::new_static("key"))) + .with_annotated_type(key_type), + ], + ), + Type::none(db), + ) + }); + + Type::Callable(CallableType::new( + db, + CallableSignature::from_overloads(overloads), + CallableTypeKind::FunctionLike, + )) +} + +/// Synthesize the `get` method for a `TypedDict`. +pub(super) fn synthesize_typed_dict_get<'db, N, F>( + db: &'db dyn Db, + instance_ty: Type<'db>, + fields: impl IntoIterator, +) -> Type<'db> +where + N: Borrow, + F: Borrow>, +{ + let overloads = fields + .into_iter() + .flat_map(|(field_name, field)| { + let field_name = field_name.borrow(); + let field = field.borrow(); + let key_type = Type::string_literal(db, field_name.as_str()); + + let get_sig = Signature::new( + Parameters::new( + db, + [ + Parameter::positional_only(Some(Name::new_static("self"))) + .with_annotated_type(instance_ty), + Parameter::positional_only(Some(Name::new_static("key"))) + .with_annotated_type(key_type), + ], + ), + if field.is_required() { + field.declared_ty + } else { + UnionType::from_two_elements(db, field.declared_ty, Type::none(db)) + }, + ); + + let t_default = BoundTypeVarInstance::synthetic( + db, + Name::new_static("T"), + TypeVarVariance::Covariant, + ); + + let get_with_default_sig = Signature::new_generic( + Some(GenericContext::from_typevar_instances(db, [t_default])), + Parameters::new( + db, + [ + Parameter::positional_only(Some(Name::new_static("self"))) + .with_annotated_type(instance_ty), + Parameter::positional_only(Some(Name::new_static("key"))) + .with_annotated_type(key_type), + Parameter::positional_only(Some(Name::new_static("default"))) + .with_annotated_type(Type::TypeVar(t_default)), + ], + ), + if field.is_required() { + field.declared_ty + } else { + UnionType::from_two_elements(db, field.declared_ty, Type::TypeVar(t_default)) + }, + ); + + [get_sig, get_with_default_sig] + }) + // Fallback overloads for unknown keys + .chain(std::iter::once(Signature::new( + Parameters::new( + db, + [ + Parameter::positional_only(Some(Name::new_static("self"))) + .with_annotated_type(instance_ty), + Parameter::positional_only(Some(Name::new_static("key"))) + .with_annotated_type(KnownClass::Str.to_instance(db)), + ], + ), + UnionType::from_two_elements(db, Type::unknown(), Type::none(db)), + ))) + .chain(std::iter::once({ + let t_default = BoundTypeVarInstance::synthetic( + db, + Name::new_static("T"), + TypeVarVariance::Covariant, + ); + + Signature::new_generic( + Some(GenericContext::from_typevar_instances(db, [t_default])), + Parameters::new( + db, + [ + Parameter::positional_only(Some(Name::new_static("self"))) + .with_annotated_type(instance_ty), + Parameter::positional_only(Some(Name::new_static("key"))) + .with_annotated_type(KnownClass::Str.to_instance(db)), + Parameter::positional_only(Some(Name::new_static("default"))) + .with_annotated_type(Type::TypeVar(t_default)), + ], + ), + UnionType::from_two_elements(db, Type::unknown(), Type::TypeVar(t_default)), + ) + })); + + Type::Callable(CallableType::new( + db, + CallableSignature::from_overloads(overloads), + CallableTypeKind::FunctionLike, + )) +} + +/// Synthesize the `update` method for a `TypedDict`. +pub(super) fn synthesize_typed_dict_update<'db, N, F>( + db: &'db dyn Db, + instance_ty: Type<'db>, + fields: impl IntoIterator, +) -> Type<'db> +where + N: Borrow, + F: Borrow>, +{ + let keyword_parameters: Vec<_> = fields + .into_iter() + .map(|(field_name, field)| { + let field_name = field_name.borrow(); + let field = field.borrow(); + let ty = if field.is_read_only() { + Type::Never + } else { + field.declared_ty + }; + Parameter::keyword_only(field_name.clone()) + .with_annotated_type(ty) + .with_default_type(ty) + }) + .collect(); + + let update_patch_ty = if let Type::TypedDict(typed_dict) = instance_ty { + Type::TypedDict(typed_dict.to_update_patch(db)) + } else { + instance_ty + }; + + let value_ty = UnionBuilder::new(db) + .add(update_patch_ty) + .add(KnownClass::Iterable.to_specialized_instance( + db, + &[Type::heterogeneous_tuple( + db, + [KnownClass::Str.to_instance(db), Type::object()], + )], + )) + .build(); + + let update_signature = Signature::new( + Parameters::new( + db, + [ + Parameter::positional_only(Some(Name::new_static("self"))) + .with_annotated_type(instance_ty), + Parameter::positional_only(Some(Name::new_static("value"))) + .with_annotated_type(value_ty) + .with_default_type(Type::none(db)), + ] + .into_iter() + .chain(keyword_parameters), + ), + Type::none(db), + ); + + Type::function_like_callable(db, update_signature) +} + +/// Synthesize the `pop` method for a `TypedDict`. +pub(super) fn synthesize_typed_dict_pop<'db, N, F>( + db: &'db dyn Db, + instance_ty: Type<'db>, + fields: impl IntoIterator, +) -> Type<'db> +where + N: Borrow, + F: Borrow>, +{ + let overloads = fields + .into_iter() + .filter(|(_, field)| !(*field).borrow().is_required()) + .flat_map(|(field_name, field)| { + let field_name = field_name.borrow(); + let field = field.borrow(); + let key_type = Type::string_literal(db, field_name.as_str()); + + let pop_sig = Signature::new( + Parameters::new( + db, + [ + Parameter::positional_only(Some(Name::new_static("self"))) + .with_annotated_type(instance_ty), + Parameter::positional_only(Some(Name::new_static("key"))) + .with_annotated_type(key_type), + ], + ), + field.declared_ty, + ); + + let t_default = BoundTypeVarInstance::synthetic( + db, + Name::new_static("T"), + TypeVarVariance::Covariant, + ); + + let pop_with_default_sig = Signature::new_generic( + Some(GenericContext::from_typevar_instances(db, [t_default])), + Parameters::new( + db, + [ + Parameter::positional_only(Some(Name::new_static("self"))) + .with_annotated_type(instance_ty), + Parameter::positional_only(Some(Name::new_static("key"))) + .with_annotated_type(key_type), + Parameter::positional_only(Some(Name::new_static("default"))) + .with_annotated_type(Type::TypeVar(t_default)), + ], + ), + UnionType::from_two_elements(db, field.declared_ty, Type::TypeVar(t_default)), + ); + + [pop_sig, pop_with_default_sig] + }); + + Type::Callable(CallableType::new( + db, + CallableSignature::from_overloads(overloads), + CallableTypeKind::FunctionLike, + )) +} + +/// Synthesize the `setdefault` method for a `TypedDict`. +pub(super) fn synthesize_typed_dict_setdefault<'db, N, F>( + db: &'db dyn Db, + instance_ty: Type<'db>, + fields: impl IntoIterator, +) -> Type<'db> +where + N: Borrow, + F: Borrow>, +{ + let overloads = fields.into_iter().map(|(field_name, field)| { + let field_name = field_name.borrow(); + let field = field.borrow(); + let key_type = Type::string_literal(db, field_name.as_str()); + + Signature::new( + Parameters::new( + db, + [ + Parameter::positional_only(Some(Name::new_static("self"))) + .with_annotated_type(instance_ty), + Parameter::positional_only(Some(Name::new_static("key"))) + .with_annotated_type(key_type), + Parameter::positional_only(Some(Name::new_static("default"))) + .with_annotated_type(field.declared_ty), + ], + ), + field.declared_ty, + ) + }); + + Type::Callable(CallableType::new( + db, + CallableSignature::from_overloads(overloads), + CallableTypeKind::FunctionLike, + )) +} + +/// Synthesize a merge operator (`__or__`, `__ror__`, or `__ior__`) for a `TypedDict`. +pub(super) fn synthesize_typed_dict_merge<'db>( + db: &'db dyn Db, + instance_ty: Type<'db>, + name: &str, +) -> Type<'db> { + let mut overloads = vec![Signature::new( + Parameters::new( + db, + [ + Parameter::positional_only(Some(Name::new_static("self"))) + .with_annotated_type(instance_ty), + Parameter::positional_only(Some(Name::new_static("value"))) + .with_annotated_type(instance_ty), + ], + ), + instance_ty, + )]; + + if name != "__ior__" { + let partial_ty = if let Type::TypedDict(td) = instance_ty { + Type::TypedDict(td.to_partial(db)) + } else { + instance_ty + }; + + let dict_param_ty = KnownClass::Dict + .to_specialized_instance(db, &[KnownClass::Str.to_instance(db), Type::any()]); + + let dict_return_ty = KnownClass::Dict.to_specialized_instance( + db, + &[ + KnownClass::Str.to_instance(db), + KnownClass::Object.to_instance(db), + ], + ); + + overloads.push(Signature::new( + Parameters::new( + db, + [ + Parameter::positional_only(Some(Name::new_static("self"))) + .with_annotated_type(instance_ty), + Parameter::positional_only(Some(Name::new_static("value"))) + .with_annotated_type(partial_ty), + ], + ), + instance_ty, + )); + overloads.push(Signature::new( + Parameters::new( + db, + [ + Parameter::positional_only(Some(Name::new_static("self"))) + .with_annotated_type(instance_ty), + Parameter::positional_only(Some(Name::new_static("value"))) + .with_annotated_type(dict_param_ty), + ], + ), + dict_return_ty, + )); + } + + Type::Callable(CallableType::new( + db, + CallableSignature::from_overloads(overloads), + CallableTypeKind::FunctionLike, + )) +} + +/// Represents a `TypedDict` created via the functional form: +/// ```python +/// Movie = TypedDict("Movie", {"name": str, "year": int}) +/// Movie = TypedDict("Movie", {"name": str, "year": int}, total=False) +/// ``` +/// +/// The type of `Movie` would be `type[Movie]` where `Movie` is a `DynamicTypedDictLiteral`. +/// +/// The field schema is represented by a separate [`TypedDictSchema`]. +#[derive(Clone, Debug, PartialEq, Eq, Hash, salsa::Update, get_size2::GetSize)] +pub enum DynamicTypedDictAnchor<'db> { + /// The `TypedDict()` call is assigned to a variable. + /// + /// The `Definition` uniquely identifies this `TypedDict`. Field types are computed lazily + /// during deferred inference so recursive `TypedDict` definitions can resolve correctly. + Definition(Definition<'db>), + + /// The `TypedDict()` call is "dangling" (not assigned to a variable). + /// + /// The offset is relative to the enclosing scope's anchor node index. The eagerly + /// computed `spec` preserves field types for inline uses like + /// `TypedDict("Point", {"x": int})(x=1)`. + ScopeOffset { + scope: ScopeId<'db>, + offset: u32, + schema: TypedDictSchema<'db>, + }, +} + +#[salsa::interned(debug, heap_size = ruff_memory_usage::heap_size)] +pub struct DynamicTypedDictLiteral<'db> { + /// The name of the TypedDict (from the first argument). + #[returns(ref)] + pub name: Name, + + /// The anchor for this dynamic TypedDict, providing stable identity. + /// + /// - `Definition`: The call is assigned to a variable. The definition + /// uniquely identifies this TypedDict and can be used to find the call. + /// - `ScopeOffset`: The call is "dangling" (not assigned). The offset + /// is relative to the enclosing scope's anchor node index, and the + /// eagerly computed spec is stored on the anchor. + #[returns(ref)] + pub anchor: DynamicTypedDictAnchor<'db>, +} + +impl get_size2::GetSize for DynamicTypedDictLiteral<'_> {} + +#[salsa::tracked] +impl<'db> DynamicTypedDictLiteral<'db> { + /// Returns the definition where this `TypedDict` is created, if it was assigned to a variable. + pub(crate) fn definition(self, db: &'db dyn Db) -> Option> { + match self.anchor(db) { + DynamicTypedDictAnchor::Definition(definition) => Some(*definition), + DynamicTypedDictAnchor::ScopeOffset { .. } => None, + } + } + + /// Returns the scope in which this dynamic `TypedDict` was created. + pub(crate) fn scope(self, db: &'db dyn Db) -> ScopeId<'db> { + match self.anchor(db) { + DynamicTypedDictAnchor::Definition(definition) => definition.scope(db), + DynamicTypedDictAnchor::ScopeOffset { scope, .. } => *scope, + } + } + + /// Returns an instance type for this dynamic `TypedDict`. + pub(crate) fn to_instance(self, db: &'db dyn Db) -> Type<'db> { + Type::instance(db, ClassType::NonGeneric(self.into())) + } + + /// Returns the range of the `TypedDict` call expression. + pub(crate) fn header_range(self, db: &'db dyn Db) -> TextRange { + let scope = self.scope(db); + let file = scope.file(db); + let module = parsed_module(db, file).load(db); + + match self.anchor(db) { + DynamicTypedDictAnchor::Definition(definition) => { + // For definitions, get the range from the definition's value. + // The TypedDict call is the value of the assignment. + definition + .kind(db) + .value(&module) + .expect( + "DynamicTypedDictAnchor::Definition should only be used for assignments", + ) + .range() + } + DynamicTypedDictAnchor::ScopeOffset { offset, .. } => { + // For dangling calls, compute the absolute index from the offset. + let scope_anchor = scope.node(db).node_index().unwrap_or(NodeIndex::from(0)); + let anchor_u32 = scope_anchor + .as_u32() + .expect("anchor should not be NodeIndex::NONE"); + let absolute_index = NodeIndex::from(anchor_u32 + offset); + + // Get the node and return its range. + let node: &ast::ExprCall = module + .get_by_index(absolute_index) + .try_into() + .expect("scope offset should point to ExprCall"); + node.range() + } + } + } + + /// Returns a [`Span`] pointing to the `TypedDict` call expression. + pub(super) fn header_span(self, db: &'db dyn Db) -> Span { + Span::from(self.scope(db).file(db)).with_range(self.header_range(db)) + } + + pub(crate) fn items(self, db: &'db dyn Db) -> &'db TypedDictSchema<'db> { + match self.anchor(db) { + DynamicTypedDictAnchor::Definition(definition) => { + deferred_functional_typed_dict_schema(db, *definition) + } + DynamicTypedDictAnchor::ScopeOffset { schema, .. } => schema, + } + } + + /// Get the MRO for this `TypedDict`. + /// + /// Functional `TypedDict` classes have the same MRO as class-based ones: + /// [self, `TypedDict`, object] + #[salsa::tracked(returns(ref), heap_size = ruff_memory_usage::heap_size)] + pub(crate) fn mro(self, db: &'db dyn Db) -> Mro<'db> { + let self_base = ClassBase::Class(ClassType::NonGeneric(self.into())); + let object_class = ClassType::object(db); + Mro::from([ + self_base, + ClassBase::TypedDict, + ClassBase::Class(object_class), + ]) + } + + /// Get the metaclass of this `TypedDict`. + /// + /// `TypedDict`s use `type` as their metaclass. + #[expect(clippy::unused_self)] + pub(crate) fn metaclass(self, db: &'db dyn Db) -> Type<'db> { + KnownClass::Type.to_class_literal(db) + } + + /// Look up a class-level member defined directly on this `TypedDict` (not inherited). + pub(super) fn own_class_member(self, db: &'db dyn Db, name: &str) -> Member<'db> { + let instance_ty = self.to_instance(db); + + let synthesized = match name { + "__getitem__" => synthesize_typed_dict_getitem(db, instance_ty, self.items(db)), + "__setitem__" => synthesize_typed_dict_setitem(db, instance_ty, self.items(db)), + "__delitem__" => synthesize_typed_dict_delitem(db, instance_ty, self.items(db)), + "get" => synthesize_typed_dict_get(db, instance_ty, self.items(db)), + "update" => synthesize_typed_dict_update(db, instance_ty, self.items(db)), + "pop" => synthesize_typed_dict_pop(db, instance_ty, self.items(db)), + "setdefault" => synthesize_typed_dict_setdefault(db, instance_ty, self.items(db)), + "__or__" | "__ror__" | "__ior__" => synthesize_typed_dict_merge(db, instance_ty, name), + _ => return Member::default(), + }; + + Member::definitely_declared(synthesized) + } + + /// Look up a class-level member by name (including superclasses). + pub(crate) fn class_member( + self, + db: &'db dyn Db, + name: &str, + policy: MemberLookupPolicy, + ) -> PlaceAndQualifiers<'db> { + // First check synthesized members (like __getitem__, __init__, get, etc.). + let member = self.own_class_member(db, name); + if !member.is_undefined() { + return member.inner; + } + + // Fall back to TypedDictFallback for methods like __contains__, items, keys, etc. + // This mirrors the behavior of StaticClassLiteral::typed_dict_member. + KnownClass::TypedDictFallback + .to_class_literal(db) + .find_name_in_mro_with_policy(db, name, policy) + .expect( + "`find_name_in_mro_with_policy` will return `Some()` when called on class literal", + ) + } + + /// Look up an instance member by name (including superclasses). + #[expect(clippy::unused_self)] + pub(crate) fn instance_member(self, db: &'db dyn Db, name: &str) -> PlaceAndQualifiers<'db> { + // Fall back to TypedDictFallback for instance members. + KnownClass::TypedDictFallback + .to_instance(db) + .instance_member(db, name) + } +} diff --git a/crates/ty_python_semantic/src/types/class_base.rs b/crates/ty_python_semantic/src/types/class_base.rs index 22a730a33ec8f0..5138f51ebc4bb3 100644 --- a/crates/ty_python_semantic/src/types/class_base.rs +++ b/crates/ty_python_semantic/src/types/class_base.rs @@ -59,7 +59,6 @@ impl<'db> ClassBase<'db> { ClassBase::Dynamic(DynamicType::UnspecializedTypeVar) => "UnspecializedTypeVar", ClassBase::Dynamic( DynamicType::Todo(_) - | DynamicType::TodoFunctionalTypedDict | DynamicType::TodoUnpack | DynamicType::TodoStarredExpression | DynamicType::TodoTypeVarTuple, diff --git a/crates/ty_python_semantic/src/types/enums.rs b/crates/ty_python_semantic/src/types/enums.rs index ab4461f5d46c6e..6d847349059b5c 100644 --- a/crates/ty_python_semantic/src/types/enums.rs +++ b/crates/ty_python_semantic/src/types/enums.rs @@ -174,7 +174,7 @@ pub(crate) fn enum_metadata<'db>( // ``` return None; } - ClassLiteral::DynamicNamedTuple(..) => return None, + ClassLiteral::DynamicNamedTuple(..) | ClassLiteral::DynamicTypedDict(..) => return None, }; // This is a fast path to avoid traversing the MRO of known classes diff --git a/crates/ty_python_semantic/src/types/ide_support.rs b/crates/ty_python_semantic/src/types/ide_support.rs index a315034544b0bd..d3aa6b61a8364b 100644 --- a/crates/ty_python_semantic/src/types/ide_support.rs +++ b/crates/ty_python_semantic/src/types/ide_support.rs @@ -1808,6 +1808,10 @@ fn class_literal_to_hierarchy_info( (header_range, header_range) } } + ClassLiteral::DynamicTypedDict(typeddict) => { + let header_range = typeddict.header_range(db); + (header_range, header_range) + } }; TypeHierarchyClass { diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index 52ba391e4ca079..5403c0193171bf 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -129,6 +129,7 @@ mod named_tuple; mod paramspec_validation; mod subscript; mod type_expression; +mod typed_dict; mod typevar; use super::comparisons::{self, BinaryComparisonVisitor}; @@ -2889,6 +2890,8 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { Some(definition), namedtuple_kind, ) + } else if callable_type == Type::SpecialForm(SpecialFormType::TypedDict) { + self.infer_typeddict_call_expression(call_expr, Some(definition)) } else { match callable_type .as_class_literal() @@ -3091,6 +3094,10 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } _ => {} } + if func_ty == Type::SpecialForm(SpecialFormType::TypedDict) { + self.infer_functional_typeddict_deferred(arguments); + return; + } let mut constraint_tys = Vec::new(); for arg in arguments.args.iter().skip(1) { let constraint = self.infer_type_expression(arg); @@ -4136,17 +4143,8 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { TypeQualifiers::REQUIRED | TypeQualifiers::NOT_REQUIRED | TypeQualifiers::READ_ONLY, ) { let in_typed_dict = current_scope.kind() == ScopeKind::Class - && nearest_enclosing_class(self.db(), self.index, self.scope()).is_some_and( - |class| { - class.iter_mro(self.db(), None).any(|base| { - matches!( - base, - ClassBase::TypedDict - | ClassBase::Dynamic(DynamicType::TodoFunctionalTypedDict) - ) - }) - }, - ); + && nearest_enclosing_class(self.db(), self.index, self.scope()) + .is_some_and(|class| class.is_typed_dict(self.db())); if !in_typed_dict { for qualifier in [ TypeQualifiers::REQUIRED, @@ -5964,13 +5962,6 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } } - // Avoid false positives for the functional `TypedDict` form, which is currently - // unsupported. - if let Some(Type::Dynamic(DynamicType::TodoFunctionalTypedDict)) = tcx.annotation { - return KnownClass::Dict - .to_specialized_instance(self.db(), &[Type::unknown(), Type::unknown()]); - } - let items = items .iter() .map(|item| [item.key.as_ref(), Some(&item.value)]) @@ -7101,6 +7092,10 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { return self.infer_namedtuple_call_expression(call_expression, None, namedtuple_kind); } + if callable_type == Type::SpecialForm(SpecialFormType::TypedDict) { + return self.infer_typeddict_call_expression(call_expression, None); + } + // We don't call `Type::try_call`, because we want to perform type inference on the // arguments after matching them to parameters, but before checking that the argument types // are assignable to any parameter annotations. diff --git a/crates/ty_python_semantic/src/types/infer/builder/binary_expressions.rs b/crates/ty_python_semantic/src/types/infer/builder/binary_expressions.rs index bd36a0649da657..b230d1dfa20f3f 100644 --- a/crates/ty_python_semantic/src/types/infer/builder/binary_expressions.rs +++ b/crates/ty_python_semantic/src/types/infer/builder/binary_expressions.rs @@ -357,9 +357,6 @@ impl<'db> TypeInferenceBuilder<'db, '_> { (typevar @ Type::Dynamic(DynamicType::UnspecializedTypeVar), _, _) | (_, typevar @ Type::Dynamic(DynamicType::UnspecializedTypeVar), _) => Some(typevar), - (todo @ Type::Dynamic(DynamicType::TodoFunctionalTypedDict), _, _) - | (_, todo @ Type::Dynamic(DynamicType::TodoFunctionalTypedDict), _) => Some(todo), - // When both operands are the same constrained TypeVar (e.g., `T: (int, str)`), // we check if the operation is valid for each constraint paired with itself. // This is different from treating it as a union, where we'd check all combinations. diff --git a/crates/ty_python_semantic/src/types/infer/builder/typed_dict.rs b/crates/ty_python_semantic/src/types/infer/builder/typed_dict.rs new file mode 100644 index 00000000000000..0c799895ede3e9 --- /dev/null +++ b/crates/ty_python_semantic/src/types/infer/builder/typed_dict.rs @@ -0,0 +1,338 @@ +use ruff_python_ast::name::Name; +use ruff_python_ast::{self as ast, NodeIndex}; + +use super::TypeInferenceBuilder; +use crate::semantic_index::definition::Definition; +use crate::types::class::{ClassLiteral, DynamicTypedDictAnchor, DynamicTypedDictLiteral}; +use crate::types::diagnostic::{ + INVALID_ARGUMENT_TYPE, MISSING_ARGUMENT, TOO_MANY_POSITIONAL_ARGUMENTS, UNKNOWN_ARGUMENT, +}; +use crate::types::typed_dict::{TypedDictSchema, functional_typed_dict_field}; +use crate::types::{IntersectionType, KnownClass, Type, TypeContext}; + +impl<'db> TypeInferenceBuilder<'db, '_> { + /// Infer a `TypedDict(name, fields)` call expression. + /// + /// This method *does not* call `infer_expression` on the object being called; + /// it is assumed that the type for this AST node has already been inferred before this method is called. + pub(super) fn infer_typeddict_call_expression( + &mut self, + call_expr: &ast::ExprCall, + definition: Option>, + ) -> Type<'db> { + let db = self.db(); + + let ast::Arguments { + args, + keywords, + range: _, + node_index: _, + } = &call_expr.arguments; + + let has_starred = args.iter().any(ast::Expr::is_starred_expr); + let has_double_starred = keywords.iter().any(|kw| kw.arg.is_none()); + + // The fallback type reflects the fact that if the call were successful, + // it would return a class that is a subclass of `Mapping[str, object]` + // with an unknown set of fields. + let fallback = || { + let spec = &[KnownClass::Str.to_instance(db), Type::object()]; + let str_object_map = KnownClass::Mapping.to_specialized_subclass_of(db, spec); + IntersectionType::from_two_elements(db, str_object_map, Type::unknown()) + }; + + // Emit diagnostic for unsupported variadic arguments. + if (has_starred || has_double_starred) + && let Some(builder) = self.context.report_lint(&INVALID_ARGUMENT_TYPE, call_expr) + { + let arg_type = if has_starred && has_double_starred { + "Variadic positional and keyword arguments are" + } else if has_starred { + "Variadic positional arguments are" + } else { + "Variadic keyword arguments are" + }; + builder.into_diagnostic(format_args!( + "{arg_type} not supported in `TypedDict()` calls" + )); + } + + let Some(name_arg) = args.first() else { + for arg in args { + self.infer_expression(arg, TypeContext::default()); + } + for kw in keywords { + self.infer_expression(&kw.value, TypeContext::default()); + } + + if !has_starred + && !has_double_starred + && let Some(builder) = self.context.report_lint(&MISSING_ARGUMENT, call_expr) + { + builder.into_diagnostic( + "No argument provided for required parameter `typename` of function `TypedDict`", + ); + } + + return fallback(); + }; + + let name_type = self.infer_expression(name_arg, TypeContext::default()); + let fields_arg = args.get(1); + + for arg in args.iter().skip(2) { + self.infer_expression(arg, TypeContext::default()); + } + + if has_starred || has_double_starred { + for kw in keywords { + self.infer_expression(&kw.value, TypeContext::default()); + if let Some(arg) = &kw.arg { + if !matches!(arg.id.as_str(), "total" | "closed" | "extra_items") + && let Some(builder) = self.context.report_lint(&UNKNOWN_ARGUMENT, kw) + { + builder.into_diagnostic(format_args!( + "Argument `{}` does not match any known parameter of function `TypedDict`", + arg.id + )); + } + } + } + return fallback(); + } + + if args.len() > 2 + && let Some(builder) = self + .context + .report_lint(&TOO_MANY_POSITIONAL_ARGUMENTS, &args[2]) + { + builder.into_diagnostic(format_args!( + "Too many positional arguments to function `TypedDict`: expected 2, got {}", + args.len() + )); + } + + let mut total = true; + + for kw in keywords { + let Some(arg) = &kw.arg else { + continue; + }; + + match arg.id.as_str() { + arg_name @ ("total" | "closed") => { + let kw_type = self.infer_expression(&kw.value, TypeContext::default()); + if kw_type + .as_literal_value() + .is_none_or(|literal| !literal.is_bool()) + && let Some(builder) = + self.context.report_lint(&INVALID_ARGUMENT_TYPE, &kw.value) + { + let mut diagnostic = builder.into_diagnostic(format_args!( + "Invalid argument to parameter `{arg_name}` of `TypedDict()`" + )); + diagnostic.set_primary_message(format_args!( + "Expected either `True` or `False`, got object of type `{}`", + kw_type.display(db) + )); + } + + if arg_name == "total" { + if kw_type.bool(db).is_always_false() { + total = false; + } else if !kw_type.bool(db).is_always_true() { + total = true; + } + } + } + "extra_items" => { + if definition.is_none() { + self.infer_annotation_expression(&kw.value, self.deferred_state); + } + } + unknown_kwarg => { + self.infer_expression(&kw.value, TypeContext::default()); + if let Some(builder) = self.context.report_lint(&UNKNOWN_ARGUMENT, kw) { + builder.into_diagnostic(format_args!( + "Argument `{unknown_kwarg}` does not match any known parameter of function `TypedDict`", + )); + } + } + } + } + + if fields_arg.is_none() + && let Some(builder) = self.context.report_lint(&MISSING_ARGUMENT, call_expr) + { + builder.into_diagnostic( + "No argument provided for required parameter `fields` of function `TypedDict`", + ); + } + + let name = if let Some(literal) = name_type.as_string_literal() { + Name::new(literal.value(db)) + } else { + if !name_type.is_assignable_to(db, KnownClass::Str.to_instance(db)) + && let Some(builder) = self.context.report_lint(&INVALID_ARGUMENT_TYPE, name_arg) + { + let mut diagnostic = builder.into_diagnostic(format_args!( + "Invalid argument to parameter `typename` of `TypedDict()`" + )); + diagnostic.set_primary_message(format_args!( + "Expected `str`, found `{}`", + name_type.display(db) + )); + } + Name::new_static("") + }; + + if let Some(definition) = definition { + self.deferred.insert(definition); + } + + if let Some(fields_arg) = fields_arg { + self.validate_fields_arg(fields_arg); + } + + let scope = self.scope(); + let anchor = match definition { + Some(definition) => DynamicTypedDictAnchor::Definition(definition), + None => { + let call_node_index = call_expr.node_index.load(); + let scope_anchor = scope.node(db).node_index().unwrap_or(NodeIndex::from(0)); + let anchor_u32 = scope_anchor + .as_u32() + .expect("scope anchor should not be NodeIndex::NONE"); + let call_u32 = call_node_index + .as_u32() + .expect("call node should not be NodeIndex::NONE"); + + let schema = if let Some(fields_arg) = fields_arg { + self.infer_dangling_typeddict_spec(fields_arg, total) + } else { + TypedDictSchema::default() + }; + + DynamicTypedDictAnchor::ScopeOffset { + scope, + offset: call_u32 - anchor_u32, + schema, + } + } + }; + + let typeddict = DynamicTypedDictLiteral::new(db, name, anchor); + + Type::ClassLiteral(ClassLiteral::DynamicTypedDict(typeddict)) + } + + fn infer_dangling_typeddict_spec( + &mut self, + fields_arg: &ast::Expr, + total: bool, + ) -> TypedDictSchema<'db> { + let db = self.db(); + let mut schema = TypedDictSchema::default(); + + let ast::Expr::Dict(dict_expr) = fields_arg else { + return schema; + }; + + for item in &dict_expr.items { + let Some(key) = &item.key else { + return TypedDictSchema::default(); + }; + + let key_ty = self.expression_type(key); + let Some(key_literal) = key_ty.as_string_literal() else { + return TypedDictSchema::default(); + }; + + let annotation = self.infer_annotation_expression(&item.value, self.deferred_state); + + schema.insert( + Name::new(key_literal.value(db)), + functional_typed_dict_field(annotation.inner_type(), total), + ); + } + + schema + } + + /// Infer field types for functional `TypedDict` in deferred phase. + /// + /// This is called during `infer_deferred_types` to infer field types after the `TypedDict` + /// definition is complete. This enables support for recursive `TypedDict`s where field types + /// may reference the `TypedDict` being defined. + pub(super) fn infer_functional_typeddict_deferred(&mut self, arguments: &ast::Arguments) { + if let Some(fields_arg) = arguments.args.get(1) { + self.infer_typeddict_field_types(fields_arg); + } + + if let Some(extra_items_kwarg) = arguments.find_keyword("extra_items") { + self.infer_annotation_expression(&extra_items_kwarg.value, self.deferred_state); + } + } + + /// Infer field types from a `TypedDict` fields dict argument. + fn infer_typeddict_field_types(&mut self, fields_arg: &ast::Expr) { + if let ast::Expr::Dict(dict_expr) = fields_arg { + for item in &dict_expr.items { + self.infer_annotation_expression(&item.value, self.deferred_state); + } + } + } + + fn validate_fields_arg(&mut self, fields_arg: &ast::Expr) { + let db = self.db(); + + if let ast::Expr::Dict(dict_expr) = fields_arg { + for (i, item) in dict_expr.items.iter().enumerate() { + let ast::DictItem { key, value: _ } = item; + + let Some(key) = key else { + if let Some(builder) = + self.context.report_lint(&INVALID_ARGUMENT_TYPE, fields_arg) + { + builder.into_diagnostic( + "Expected a dict literal with string-literal keys \ + for parameter `fields` of `TypedDict()`", + ); + } + for item in &dict_expr.items[i + 1..] { + if let Some(key) = &item.key { + self.infer_expression(key, TypeContext::default()); + } + } + return; + }; + + let key_ty = self.infer_expression(key, TypeContext::default()); + if key_ty.as_string_literal().is_none() { + if let Some(builder) = self.context.report_lint(&INVALID_ARGUMENT_TYPE, key) { + let mut diagnostic = builder.into_diagnostic( + "Expected a string-literal key \ + in the `fields` dict of `TypedDict()`", + ); + diagnostic + .set_primary_message(format_args!("Found `{}`", key_ty.display(db))); + } + for item in &dict_expr.items[i + 1..] { + if let Some(key) = &item.key { + self.infer_expression(key, TypeContext::default()); + } + } + return; + } + } + } else { + self.infer_expression(fields_arg, TypeContext::default()); + + if let Some(builder) = self.context.report_lint(&INVALID_ARGUMENT_TYPE, fields_arg) { + builder.into_diagnostic( + "Expected a dict literal for parameter `fields` of `TypedDict()`", + ); + } + } + } +} diff --git a/crates/ty_python_semantic/src/types/instance.rs b/crates/ty_python_semantic/src/types/instance.rs index d0e68810023683..0c69906633f09f 100644 --- a/crates/ty_python_semantic/src/types/instance.rs +++ b/crates/ty_python_semantic/src/types/instance.rs @@ -45,14 +45,11 @@ impl<'db> Type<'db> { pub(crate) fn instance(db: &'db dyn Db, class: ClassType<'db>) -> Self { match class.class_literal(db) { // Dynamic classes created via `type()` don't have special instance types. - // TODO: When we add functional TypedDict support, this branch should check - // for TypedDict and return `Type::typed_dict(class)` for that case. - ClassLiteral::Dynamic(_) => { - Type::NominalInstance(NominalInstanceType(NominalInstanceInner::NonTuple(class))) - } - ClassLiteral::DynamicNamedTuple(_) => { + ClassLiteral::Dynamic(_) | ClassLiteral::DynamicNamedTuple(_) => { Type::NominalInstance(NominalInstanceType(NominalInstanceInner::NonTuple(class))) } + // Functional TypedDicts return a TypedDict instance type. + ClassLiteral::DynamicTypedDict(_) => Type::typed_dict(class), ClassLiteral::Static(class_literal) => { let specialization = class.into_generic_alias().map(|g| g.specialization(db)); match class_literal.known(db) { diff --git a/crates/ty_python_semantic/src/types/mro.rs b/crates/ty_python_semantic/src/types/mro.rs index 5fa1e766a69592..dc28f179786612 100644 --- a/crates/ty_python_semantic/src/types/mro.rs +++ b/crates/ty_python_semantic/src/types/mro.rs @@ -534,6 +534,9 @@ impl<'db> MroIterator<'db> { ClassLiteral::DynamicNamedTuple(literal) => { ClassBase::Class(ClassType::NonGeneric(literal.into())) } + ClassLiteral::DynamicTypedDict(literal) => { + ClassBase::Class(ClassType::NonGeneric(literal.into())) + } } } @@ -563,6 +566,11 @@ impl<'db> MroIterator<'db> { full_mro_iter.next(); full_mro_iter } + ClassLiteral::DynamicTypedDict(literal) => { + let mut full_mro_iter = literal.mro(self.db).iter(); + full_mro_iter.next(); + full_mro_iter + } }) } } diff --git a/crates/ty_python_semantic/src/types/typed_dict.rs b/crates/ty_python_semantic/src/types/typed_dict.rs index 9d78910f6b9ae9..2c3c4f09d2dfd1 100644 --- a/crates/ty_python_semantic/src/types/typed_dict.rs +++ b/crates/ty_python_semantic/src/types/typed_dict.rs @@ -10,13 +10,17 @@ use ruff_python_ast::Arguments; use ruff_python_ast::{self as ast, AnyNodeRef, StmtClassDef, name::Name}; use ruff_text_size::Ranged; -use super::class::{ClassType, CodeGeneratorKind, Field}; +use super::class::{ClassLiteral, ClassType, CodeGeneratorKind, Field}; use super::context::InferContext; use super::diagnostic::{ self, INVALID_ARGUMENT_TYPE, INVALID_ASSIGNMENT, report_invalid_key_on_typed_dict, report_missing_typed_dict_key, }; -use super::{ApplyTypeMappingVisitor, IntersectionBuilder, Type, TypeMapping, visitor}; +use super::infer::infer_deferred_types; +use super::{ + ApplyTypeMappingVisitor, IntersectionBuilder, Type, TypeMapping, definition_expression_type, + visitor, +}; use crate::Db; use crate::semantic_index::definition::Definition; use crate::types::TypeContext; @@ -44,6 +48,15 @@ impl Default for TypedDictParams { } } +pub(super) fn functional_typed_dict_field( + declared_ty: Type<'_>, + total: bool, +) -> TypedDictField<'_> { + TypedDictFieldBuilder::new(declared_ty) + .required(total) + .build() +} + /// 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)] @@ -106,7 +119,13 @@ impl<'db> TypedDictType<'db> { } match self { - Self::Class(defining_class) => class_based_items(db, defining_class), + Self::Class(defining_class) => { + // Check if this is a dynamic TypedDict + if let ClassLiteral::DynamicTypedDict(class) = defining_class.class_literal(db) { + return class.items(db); + } + class_based_items(db, defining_class) + } Self::Synthesized(synthesized) => synthesized.items(db), } } @@ -491,6 +510,61 @@ pub(crate) fn walk_typed_dict_type<'db, V: visitor::TypeVisitor<'db> + ?Sized>( } } +#[salsa::tracked( + returns(ref), + cycle_initial = |_, _, _|TypedDictSchema::default(), + heap_size = ruff_memory_usage::heap_size +)] +pub(super) fn deferred_functional_typed_dict_schema<'db>( + db: &'db dyn Db, + definition: Definition<'db>, +) -> TypedDictSchema<'db> { + let module = parsed_module(db, definition.file(db)).load(db); + let node = definition + .kind(db) + .value(&module) + .expect("Expected `TypedDict` definition to be an assignment") + .as_call_expr() + .expect("Expected `TypedDict` definition r.h.s. to be a call expression"); + + let deferred_inference = infer_deferred_types(db, definition); + + let total = node.arguments.find_keyword("total").is_none_or(|total_kw| { + let total_ty = definition_expression_type(db, definition, &total_kw.value); + !total_ty.bool(db).is_always_false() + }); + + let mut schema = TypedDictSchema::default(); + + if let Some(fields_arg) = node.arguments.args.get(1) { + let ast::Expr::Dict(dict_expr) = fields_arg else { + return schema; + }; + + for item in &dict_expr.items { + let Some(key) = &item.key else { + return TypedDictSchema::default(); + }; + + let key_ty = definition_expression_type(db, definition, key); + let Some(key_lit) = key_ty.as_string_literal() else { + return TypedDictSchema::default(); + }; + + let field_ty = deferred_inference + .try_expression_type(&item.value) + .unwrap_or(Type::unknown()); + + schema.insert( + Name::new(key_lit.value(db)), + functional_typed_dict_field(field_ty, total), + ); + } + } + + schema +} + pub(super) fn typed_dict_params_from_class_def(class_stmt: &StmtClassDef) -> TypedDictParams { let mut typed_dict_params = TypedDictParams::default(); @@ -1140,6 +1214,15 @@ impl<'db> TypedDictField<'db> { self.first_declaration } + /// Create a `TypedDictField` from a [`Field`] with `FieldKind::TypedDict`. + pub(crate) fn from_field(field: &super::class::Field<'db>) -> Self { + TypedDictFieldBuilder::new(field.declared_ty) + .required(field.is_required()) + .read_only(field.is_read_only()) + .first_declaration(field.first_declaration) + .build() + } + pub(crate) fn apply_type_mapping_impl<'a>( self, db: &'db dyn Db, diff --git a/crates/ty_python_semantic/src/types/typevar.rs b/crates/ty_python_semantic/src/types/typevar.rs index e02b52e8e4dc92..6a0c77c0e671fc 100644 --- a/crates/ty_python_semantic/src/types/typevar.rs +++ b/crates/ty_python_semantic/src/types/typevar.rs @@ -541,7 +541,6 @@ impl<'db> TypeVarInstance<'db> { DynamicType::Todo(_) | DynamicType::TodoUnpack | DynamicType::TodoStarredExpression - | DynamicType::TodoFunctionalTypedDict | DynamicType::TodoTypeVarTuple => Parameters::todo(), DynamicType::Any | DynamicType::Unknown From 9f5784ab2e4e98f6bb7cb5e9442ea78790f8ccd9 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Fri, 27 Mar 2026 16:48:18 -0400 Subject: [PATCH 2/5] [ty] Support inheriting from functional TypedDict (#24227) ## Summary This PR adds handling for class-based TypedDicts that inherit from functional TypedDicts. Part of: https://github.com/astral-sh/ty/issues/3095. --- .../resources/mdtest/typed_dict.md | 42 +++++++++++++++ crates/ty_python_semantic/src/types/class.rs | 5 ++ .../src/types/class/static_literal.rs | 53 +++++++++++++++---- .../src/types/infer/builder.rs | 2 +- 4 files changed, 91 insertions(+), 11 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/typed_dict.md b/crates/ty_python_semantic/resources/mdtest/typed_dict.md index ae3c8385e98bc6..1d3679edb71574 100644 --- a/crates/ty_python_semantic/resources/mdtest/typed_dict.md +++ b/crates/ty_python_semantic/resources/mdtest/typed_dict.md @@ -2009,6 +2009,33 @@ emp_invalid1 = Employee(department="HR") emp_invalid2 = Employee(id=3) ``` +## Class-based inheritance from functional `TypedDict` + +Class-based TypedDicts can inherit from functional TypedDicts: + +```py +from typing import TypedDict + +Base = TypedDict("Base", {"a": int}, total=False) + +class Child(Base): + b: str + c: list[int] + +child1 = Child(b="hello", c=[1, 2, 3]) +child2 = Child(a=1, b="world", c=[]) + +reveal_type(child1["a"]) # revealed: int +reveal_type(child1["b"]) # revealed: str +reveal_type(child1["c"]) # revealed: list[int] + +# error: [missing-typed-dict-key] "Missing required key 'b' in TypedDict `Child` constructor" +bad_child1 = Child(c=[1]) + +# error: [missing-typed-dict-key] "Missing required key 'c' in TypedDict `Child` constructor" +bad_child2 = Child(b="test") +``` + ## Generic `TypedDict` `TypedDict`s can also be generic. @@ -2610,6 +2637,9 @@ def f(): # fine MyFunctionalTypedDict = TypedDict("MyFunctionalTypedDict", {"not-an-identifier": Required[int]}) + +class FunctionalTypedDictSubclass(MyFunctionalTypedDict): + y: NotRequired[int] # fine ``` ### Nested `Required` and `NotRequired` @@ -3649,6 +3679,18 @@ class Child(Base): y: str ``` +The functional `TypedDict` syntax also triggers this error: + +```py +from dataclasses import dataclass +from typing import TypedDict + +@dataclass +# error: [invalid-dataclass] +class Foo(TypedDict("Foo", {"x": int, "y": str})): + pass +``` + ## Class header validation diff --git a/crates/ty_python_semantic/src/types/class.rs b/crates/ty_python_semantic/src/types/class.rs index a83cae1959ca29..ef3f1336536bb1 100644 --- a/crates/ty_python_semantic/src/types/class.rs +++ b/crates/ty_python_semantic/src/types/class.rs @@ -907,6 +907,11 @@ impl<'db> ClassType<'db> { self.is_known(db, KnownClass::Object) } + /// Return `true` if this class is a `TypedDict`. + pub(crate) fn is_typed_dict(self, db: &'db dyn Db) -> bool { + self.class_literal(db).is_typed_dict(db) + } + pub(super) fn apply_type_mapping_impl<'a>( self, db: &'db dyn Db, diff --git a/crates/ty_python_semantic/src/types/class/static_literal.rs b/crates/ty_python_semantic/src/types/class/static_literal.rs index 3bf18b5f7bc310..0cbf587fac6927 100644 --- a/crates/ty_python_semantic/src/types/class/static_literal.rs +++ b/crates/ty_python_semantic/src/types/class/static_literal.rs @@ -34,9 +34,9 @@ use crate::{ call::{CallError, CallErrorKind}, callable::CallableTypeKind, class::{ - ClassMemberResult, CodeGeneratorKind, DisjointBase, Field, FieldKind, - InstanceMemberResult, MetaclassError, MetaclassErrorKind, MethodDecorator, MroLookup, - NamedTupleField, SlotsKind, synthesize_namedtuple_class_member, + ClassMemberResult, CodeGeneratorKind, DisjointBase, DynamicTypedDictLiteral, Field, + FieldKind, InstanceMemberResult, MetaclassError, MetaclassErrorKind, MethodDecorator, + MroLookup, NamedTupleField, SlotsKind, synthesize_namedtuple_class_member, }, context::InferContext, declaration_type, definition_expression_type, determine_upper_bound, @@ -1638,6 +1638,11 @@ impl<'db> StaticClassLiteral<'db> { specialization: Option>, field_policy: CodeGeneratorKind<'db>, ) -> FxIndexMap> { + enum FieldSource<'db> { + Static(StaticClassLiteral<'db>, Option>), + DynamicTypedDict(DynamicTypedDictLiteral<'db>), + } + if field_policy == CodeGeneratorKind::NamedTuple { // NamedTuples do not allow multiple inheritance, so it is sufficient to enumerate the // fields of this class only. @@ -1648,15 +1653,43 @@ impl<'db> StaticClassLiteral<'db> { .rev() .filter_map(|superclass| { let class = superclass.into_class()?; - // Dynamic classes don't have fields (no class body). - let (class_literal, specialization) = class.static_class_literal(db)?; - if field_policy.matches(db, class_literal.into(), specialization) { - Some((class_literal, specialization)) - } else { - None + + if let Some((class_literal, specialization)) = class.static_class_literal(db) { + if field_policy.matches(db, class_literal.into(), specialization) { + return Some(FieldSource::Static(class_literal, specialization)); + } + } + + if field_policy == CodeGeneratorKind::TypedDict + && let ClassLiteral::DynamicTypedDict(typeddict) = class.class_literal(db) + { + return Some(FieldSource::DynamicTypedDict(typeddict)); } + + None + }) + .flat_map(|source| match source { + FieldSource::Static(class, specialization) => { + class.own_fields(db, specialization, field_policy) + } + FieldSource::DynamicTypedDict(typeddict) => typeddict + .items(db) + .iter() + .map(|(name, td_field)| { + ( + name.clone(), + Field { + declared_ty: td_field.declared_ty, + kind: FieldKind::TypedDict { + is_required: td_field.is_required(), + is_read_only: td_field.is_read_only(), + }, + first_declaration: td_field.first_declaration(), + }, + ) + }) + .collect(), }) - .flat_map(|(class, specialization)| class.own_fields(db, specialization, field_policy)) // KW_ONLY sentinels are markers, not real fields. Exclude them so // they cannot shadow an inherited field with the same name. .filter(|(_, field)| !field.is_kw_only_sentinel(db)) diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index 5403c0193171bf..7fbb2c8aaebb92 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -7374,7 +7374,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { // Validate `TypedDict` constructor calls after argument type inference. if let Some(class) = class - && class.class_literal(self.db()).is_typed_dict(self.db()) + && class.is_typed_dict(self.db()) { validate_typed_dict_constructor( &self.context, From c3fdbbc8eb5cb55d05c4f4ee0689133879f3eba7 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Fri, 27 Mar 2026 16:49:31 -0400 Subject: [PATCH 3/5] [ty] Preserve qualifiers in functional TypedDicts (#24226) ## Summary Part of: https://github.com/astral-sh/ty/issues/3095. --- .../resources/mdtest/typed_dict.md | 78 ++++++++++++++++++- crates/ty_python_semantic/src/types/infer.rs | 15 +++- .../src/types/infer/builder.rs | 29 ++++++- .../infer/builder/annotation_expression.rs | 2 + .../src/types/infer/builder/typed_dict.rs | 6 +- .../src/types/typed_dict.rs | 40 +++++++++- 6 files changed, 160 insertions(+), 10 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/typed_dict.md b/crates/ty_python_semantic/resources/mdtest/typed_dict.md index 1d3679edb71574..3ba8988247ff30 100644 --- a/crates/ty_python_semantic/resources/mdtest/typed_dict.md +++ b/crates/ty_python_semantic/resources/mdtest/typed_dict.md @@ -2238,6 +2238,17 @@ reveal_type(inline["x"]) # revealed: int inline_bad = TypedDict("InlineBad", {"x": int})(x="bad") ``` +Inline functional `TypedDict`s preserve `ReadOnly` qualifiers: + +```py +from typing_extensions import TypedDict, ReadOnly + +inline_readonly = TypedDict("InlineReadOnly", {"id": ReadOnly[int]})(id=1) + +# error: [invalid-assignment] "Cannot assign to key "id" on TypedDict `InlineReadOnly`: key is marked read-only" +inline_readonly["id"] = 2 +``` + Inline functional `TypedDict`s resolve string forward references to existing names: ```py @@ -2274,6 +2285,38 @@ def f(total: bool) -> None: TotalDynamic = TypedDict("TotalDynamic", {"id": int}, total=total) ``` +## Function syntax with `Required` and `NotRequired` + +The `Required` and `NotRequired` wrappers can be used to override the default requiredness: + +```py +from typing_extensions import TypedDict, Required, NotRequired + +# With total=True (default), all fields are required unless wrapped in NotRequired +MovieWithOptional = TypedDict("MovieWithOptional", {"name": str, "year": NotRequired[int]}) + +# name is required, year is optional +# error: [missing-typed-dict-key] "Missing required key 'name' in TypedDict `MovieWithOptional` constructor" +empty_movie = MovieWithOptional() +movie_no_year = MovieWithOptional(name="The Matrix") +reveal_type(movie_no_year) # revealed: MovieWithOptional +reveal_type(movie_no_year["name"]) # revealed: str +reveal_type(movie_no_year["year"]) # revealed: int +``` + +```py +from typing_extensions import TypedDict, Required, NotRequired + +# With total=False, all fields are optional unless wrapped in Required +PartialWithRequired = TypedDict("PartialWithRequired", {"name": Required[str], "year": int}, total=False) + +# name is required, year is optional +# error: [missing-typed-dict-key] "Missing required key 'name' in TypedDict `PartialWithRequired` constructor" +empty_partial = PartialWithRequired() +partial_no_year = PartialWithRequired(name="The Matrix") +reveal_type(partial_no_year) # revealed: PartialWithRequired +``` + ## Function syntax with `closed` The `closed` keyword is accepted but not yet fully supported: @@ -2327,7 +2370,7 @@ class Bar(TypedDict("TD3", {}, extra_items=ReadOnly[int])): ... Functional TypedDict supports forward references (string annotations): ```py -from typing_extensions import TypedDict +from typing_extensions import TypedDict, NotRequired # Forward reference to a class defined below MovieWithDirector = TypedDict("MovieWithDirector", {"title": str, "director": "Director"}) @@ -2337,6 +2380,39 @@ class Director: movie: MovieWithDirector = {"title": "The Matrix", "director": Director()} reveal_type(movie) # revealed: MovieWithDirector + +# Forward reference to a class defined above +MovieWithDirector2 = TypedDict("MovieWithDirector2", {"title": str, "director": NotRequired["Director"]}) + +movie2: MovieWithDirector2 = {"title": "The Matrix"} +reveal_type(movie2) # revealed: MovieWithDirector2 +``` + +String annotations can also wrap the entire `Required` or `NotRequired` qualifier: + +```py +from typing_extensions import TypedDict, Required, NotRequired + +# NotRequired as a string annotation +TD = TypedDict("TD", {"required": str, "optional": "NotRequired[int]"}) + +# 'required' is required, 'optional' is not required +td1: TD = {"required": "hello"} # Valid - optional is not required +td2: TD = {"required": "hello", "optional": 42} # Valid - all keys provided +reveal_type(td1) # revealed: TD +reveal_type(td1["required"]) # revealed: Literal["hello"] +reveal_type(td1["optional"]) # revealed: int + +# error: [missing-typed-dict-key] "Missing required key 'required' in TypedDict `TD` constructor" +bad_td: TD = {"optional": 42} + +# Also works with Required in total=False TypedDicts +TD2 = TypedDict("TD2", {"required": "Required[str]", "optional": int}, total=False) + +# 'required' is required, 'optional' is not required +td3: TD2 = {"required": "hello"} # Valid +# error: [missing-typed-dict-key] "Missing required key 'required' in TypedDict `TD2` constructor" +bad_td2: TD2 = {"optional": 42} ``` ## Recursive functional `TypedDict` (unstringified forward reference) diff --git a/crates/ty_python_semantic/src/types/infer.rs b/crates/ty_python_semantic/src/types/infer.rs index 75f741f3346278..da38f8b2c93dec 100644 --- a/crates/ty_python_semantic/src/types/infer.rs +++ b/crates/ty_python_semantic/src/types/infer.rs @@ -54,7 +54,8 @@ use crate::types::function::{FunctionDecorators, FunctionType}; use crate::types::generics::Specialization; use crate::types::unpacker::{UnpackResult, Unpacker}; use crate::types::{ - ClassLiteral, KnownClass, StaticClassLiteral, Type, TypeAndQualifiers, declaration_type, + ClassLiteral, KnownClass, StaticClassLiteral, Type, TypeAndQualifiers, TypeQualifiers, + declaration_type, }; use crate::unpack::Unpack; use builder::TypeInferenceBuilder; @@ -737,6 +738,10 @@ struct DefinitionInferenceExtra<'db> { /// For function definitions, the undecorated type of the function. undecorated_type: Option>, + + /// Type qualifiers (`Required`, `NotRequired`, etc.) for annotation expressions. + /// Only populated for expressions that have non-empty qualifiers. + qualifiers: FxHashMap, } impl<'db> DefinitionInference<'db> { @@ -810,6 +815,14 @@ impl<'db> DefinitionInference<'db> { .or_else(|| self.fallback_type()) } + /// Get qualifiers for an annotation expression + pub(crate) fn qualifiers(&self, expression: impl Into) -> TypeQualifiers { + self.extra + .as_ref() + .and_then(|extra| extra.qualifiers.get(&expression.into()).copied()) + .unwrap_or_default() + } + #[track_caller] pub(crate) fn binding_type(&self, definition: Definition<'db>) -> Type<'db> { self.bindings diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index 7fbb2c8aaebb92..3b18b4555c9ff6 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -228,6 +228,10 @@ pub(super) struct TypeInferenceBuilder<'db, 'ast> { /// An expression cache shared across builders during multi-inference. expression_cache: Option>>>, + /// Type qualifiers (`Required`, `NotRequired`, etc.) for annotation expressions. + /// Only populated for expressions that have non-empty qualifiers. + qualifiers: FxHashMap, + /// Expressions that are string annotations string_annotations: FxHashSet, @@ -342,6 +346,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { inferring_vararg_annotation: false, expressions: FxHashMap::default(), expression_cache: None, + qualifiers: FxHashMap::default(), string_annotations: FxHashSet::default(), bindings: VecMap::default(), declarations: VecMap::default(), @@ -392,6 +397,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { self.deferred.extend(extra.deferred.iter().copied()); self.string_annotations .extend(extra.string_annotations.iter().copied()); + self.qualifiers.extend(extra.qualifiers.iter()); } } @@ -558,6 +564,13 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { .or(self.fallback_type()) } + /// Store qualifiers for an annotation expression. + fn store_qualifiers(&mut self, expr: &ast::Expr, qualifiers: TypeQualifiers) { + if !qualifiers.is_empty() { + self.qualifiers.insert(expr.into(), qualifiers); + } + } + /// Get the type of an expression from any scope in the same file. /// /// If the expression is in the current scope, and we are inferring the entire scope, just look @@ -3060,7 +3073,8 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } fn infer_assignment_deferred(&mut self, target: &ast::Expr, value: &'ast ast::Expr) { - // Infer deferred bounds/constraints/defaults of a legacy TypeVar / ParamSpec / NewType. + // Infer deferred bounds/constraints/defaults of a legacy TypeVar / ParamSpec / NewType, + // and field types for functional TypedDict. let ast::Expr::Call(ast::ExprCall { func, arguments, .. }) = value @@ -9028,6 +9042,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { let Self { context, mut expressions, + qualifiers: _, string_annotations, scope, bindings, @@ -9136,6 +9151,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { region: _, cycle_recovery: _, all_definitely_bound: _, + qualifiers: _, } = self; let diagnostics = context.finish(); @@ -9157,6 +9173,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { let Self { context, mut expressions, + mut qualifiers, string_annotations, scope, bindings, @@ -9187,8 +9204,10 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { || cycle_recovery.is_some() || undecorated_type.is_some() || !deferred.is_empty() - || !called_functions.is_empty()) + || !called_functions.is_empty() + || !qualifiers.is_empty()) .then(|| { + qualifiers.shrink_to_fit(); Box::new(DefinitionInferenceExtra { string_annotations, called_functions: called_functions @@ -9199,6 +9218,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { deferred: deferred.into_boxed_slice(), diagnostics, undecorated_type, + qualifiers, }) }); @@ -9244,6 +9264,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { deferred: _, bindings: _, declarations: _, + qualifiers: _, // Ignored; only relevant to definition regions undecorated_type: _, @@ -9310,6 +9331,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { called_functions: _, undecorated_type: _, all_definitely_bound: _, + qualifiers: _, } = *self; let mut builder = TypeInferenceBuilder::new(self.db(), region, index, self.module()); @@ -9361,6 +9383,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { index: _, region: _, return_types_and_ranges: _, + qualifiers: _, } = other; let diagnostics = context.finish(); @@ -9888,7 +9911,7 @@ impl<'db, 'ast> AddBinding<'db, 'ast> { /// necessarily guarantee that the passed-in value for `__setitem__` is stored and /// can be retrieved unmodified via `__getitem__`. Therefore, we currently only /// perform assignment-based narrowing on a few built-in classes (`list`, `dict`, - /// `bytesarray`, `TypedDict` and `collections` types) where we are confident that + /// `bytesarray`, `TypedDict`, and `collections` types) where we are confident that /// this kind of narrowing can be performed soundly. This is the same approach as /// pyright. TODO: Other standard library classes may also be considered safe. Also, /// subclasses of these safe classes that do not override `__getitem__/__setitem__` diff --git a/crates/ty_python_semantic/src/types/infer/builder/annotation_expression.rs b/crates/ty_python_semantic/src/types/infer/builder/annotation_expression.rs index c2a61281105248..0da1a370cce5c6 100644 --- a/crates/ty_python_semantic/src/types/infer/builder/annotation_expression.rs +++ b/crates/ty_python_semantic/src/types/infer/builder/annotation_expression.rs @@ -409,6 +409,8 @@ impl<'db> TypeInferenceBuilder<'db, '_> { }; self.store_expression_type(annotation, annotation_ty.inner_type()); + self.store_qualifiers(annotation, annotation_ty.qualifiers()); + annotation_ty } diff --git a/crates/ty_python_semantic/src/types/infer/builder/typed_dict.rs b/crates/ty_python_semantic/src/types/infer/builder/typed_dict.rs index 0c799895ede3e9..01b692eb26075e 100644 --- a/crates/ty_python_semantic/src/types/infer/builder/typed_dict.rs +++ b/crates/ty_python_semantic/src/types/infer/builder/typed_dict.rs @@ -252,7 +252,11 @@ impl<'db> TypeInferenceBuilder<'db, '_> { schema.insert( Name::new(key_literal.value(db)), - functional_typed_dict_field(annotation.inner_type(), total), + functional_typed_dict_field( + annotation.inner_type(), + annotation.qualifiers(), + total, + ), ); } diff --git a/crates/ty_python_semantic/src/types/typed_dict.rs b/crates/ty_python_semantic/src/types/typed_dict.rs index 2c3c4f09d2dfd1..547e7c0a7a7f80 100644 --- a/crates/ty_python_semantic/src/types/typed_dict.rs +++ b/crates/ty_python_semantic/src/types/typed_dict.rs @@ -18,8 +18,8 @@ use super::diagnostic::{ }; use super::infer::infer_deferred_types; use super::{ - ApplyTypeMappingVisitor, IntersectionBuilder, Type, TypeMapping, definition_expression_type, - visitor, + ApplyTypeMappingVisitor, IntersectionBuilder, Type, TypeMapping, TypeQualifiers, + definition_expression_type, visitor, }; use crate::Db; use crate::semantic_index::definition::Definition; @@ -50,10 +50,20 @@ impl Default for TypedDictParams { pub(super) fn functional_typed_dict_field( declared_ty: Type<'_>, + qualifiers: TypeQualifiers, total: bool, ) -> TypedDictField<'_> { + let required = if qualifiers.contains(TypeQualifiers::REQUIRED) { + true + } else if qualifiers.contains(TypeQualifiers::NOT_REQUIRED) { + false + } else { + total + }; + TypedDictFieldBuilder::new(declared_ty) - .required(total) + .required(required) + .read_only(qualifiers.contains(TypeQualifiers::READ_ONLY)) .build() } @@ -554,14 +564,36 @@ pub(super) fn deferred_functional_typed_dict_schema<'db>( let field_ty = deferred_inference .try_expression_type(&item.value) .unwrap_or(Type::unknown()); + let qualifiers = deferred_inference.qualifiers(&item.value); schema.insert( Name::new(key_lit.value(db)), - functional_typed_dict_field(field_ty, total), + functional_typed_dict_field(field_ty, qualifiers, total), ); } } + for keyword in &node.arguments.keywords { + let Some(arg) = &keyword.arg else { + continue; + }; + + match arg.id.as_str() { + "total" | "closed" | "extra_items" => continue, + field_name => { + let field_ty = deferred_inference + .try_expression_type(&keyword.value) + .unwrap_or(Type::unknown()); + let qualifiers = deferred_inference.qualifiers(&keyword.value); + + schema.insert( + Name::new(field_name), + functional_typed_dict_field(field_ty, qualifiers, total), + ); + } + } + } + schema } From 135460681127f995215d5b354c6bfa5a645cffc3 Mon Sep 17 00:00:00 2001 From: Alex Waygood Date: Sun, 29 Mar 2026 16:52:46 +0100 Subject: [PATCH 4/5] nitpicks --- .../src/types/class/static_literal.rs | 65 +-- .../src/types/class/typed_dict.rs | 422 ++++++++---------- .../src/types/infer/builder/typed_dict.rs | 45 +- .../src/types/typed_dict.rs | 25 +- 4 files changed, 230 insertions(+), 327 deletions(-) diff --git a/crates/ty_python_semantic/src/types/class/static_literal.rs b/crates/ty_python_semantic/src/types/class/static_literal.rs index 0cbf587fac6927..4609216fab985e 100644 --- a/crates/ty_python_semantic/src/types/class/static_literal.rs +++ b/crates/ty_python_semantic/src/types/class/static_literal.rs @@ -37,6 +37,7 @@ use crate::{ ClassMemberResult, CodeGeneratorKind, DisjointBase, DynamicTypedDictLiteral, Field, FieldKind, InstanceMemberResult, MetaclassError, MetaclassErrorKind, MethodDecorator, MroLookup, NamedTupleField, SlotsKind, synthesize_namedtuple_class_member, + typed_dict::synthesize_typed_dict_method, }, context::InferContext, declaration_type, definition_expression_type, determine_upper_bound, @@ -60,12 +61,6 @@ use crate::{ }, }; -use super::typed_dict::{ - synthesize_typed_dict_delitem, synthesize_typed_dict_get, synthesize_typed_dict_getitem, - synthesize_typed_dict_merge, synthesize_typed_dict_pop, synthesize_typed_dict_setdefault, - synthesize_typed_dict_setitem, synthesize_typed_dict_update, -}; - /// Representation of a class definition statement in the AST: either a non-generic class, or a /// generic class that has not been specialized. /// @@ -1348,12 +1343,6 @@ impl<'db> StaticClassLiteral<'db> { Some(Type::function_like_callable(db, signature)) }; - let td_fields = || { - self.fields(db, specialization, field_policy) - .iter() - .map(|(name, field)| (name, TypedDictField::from_field(field))) - }; - match (field_policy, name) { (CodeGeneratorKind::DataclassLike(_), "__init__") => { if !self.has_dataclass_param(db, field_policy, DataclassFlags::INIT) { @@ -1559,31 +1548,12 @@ impl<'db> StaticClassLiteral<'db> { Type::heterogeneous_tuple(db, slots) }) } - (CodeGeneratorKind::TypedDict, "__getitem__") => { - Some(synthesize_typed_dict_getitem(db, instance_ty, td_fields())) - } - (CodeGeneratorKind::TypedDict, "__setitem__") => { - Some(synthesize_typed_dict_setitem(db, instance_ty, td_fields())) - } - (CodeGeneratorKind::TypedDict, "__delitem__") => { - Some(synthesize_typed_dict_delitem(db, instance_ty, td_fields())) - } - (CodeGeneratorKind::TypedDict, "get") => { - Some(synthesize_typed_dict_get(db, instance_ty, td_fields())) - } - (CodeGeneratorKind::TypedDict, "pop") => { - Some(synthesize_typed_dict_pop(db, instance_ty, td_fields())) - } - (CodeGeneratorKind::TypedDict, "setdefault") => Some(synthesize_typed_dict_setdefault( - db, - instance_ty, - td_fields(), - )), - (CodeGeneratorKind::TypedDict, "update") => { - Some(synthesize_typed_dict_update(db, instance_ty, td_fields())) - } - (CodeGeneratorKind::TypedDict, name @ ("__or__" | "__ror__" | "__ior__")) => { - Some(synthesize_typed_dict_merge(db, instance_ty, name)) + (CodeGeneratorKind::TypedDict, name) => { + synthesize_typed_dict_method(db, instance_ty, name, || { + self.fields(db, specialization, field_policy) + .iter() + .map(|(name, field)| (name, TypedDictField::from_field(field))) + }) } _ => None, } @@ -1631,7 +1601,8 @@ impl<'db> StaticClassLiteral<'db> { #[salsa::tracked( returns(ref), cycle_initial=|_, _, _, _, _| FxIndexMap::default(), - heap_size=get_size2::GetSize::get_heap_size)] + heap_size=get_size2::GetSize::get_heap_size + )] pub(crate) fn fields( self, db: &'db dyn Db, @@ -1669,13 +1640,13 @@ impl<'db> StaticClassLiteral<'db> { None }) .flat_map(|source| match source { - FieldSource::Static(class, specialization) => { - class.own_fields(db, specialization, field_policy) - } - FieldSource::DynamicTypedDict(typeddict) => typeddict - .items(db) - .iter() - .map(|(name, td_field)| { + FieldSource::Static(class, specialization) => Either::Left( + class + .own_fields(db, specialization, field_policy) + .into_iter(), + ), + FieldSource::DynamicTypedDict(typeddict) => { + Either::Right(typeddict.items(db).iter().map(|(name, td_field)| { ( name.clone(), Field { @@ -1687,8 +1658,8 @@ impl<'db> StaticClassLiteral<'db> { first_declaration: td_field.first_declaration(), }, ) - }) - .collect(), + })) + } }) // KW_ONLY sentinels are markers, not real fields. Exclude them so // they cannot shadow an inherited field with the same name. diff --git a/crates/ty_python_semantic/src/types/class/typed_dict.rs b/crates/ty_python_semantic/src/types/class/typed_dict.rs index 94502a3aa6372b..c28b72893ba4a6 100644 --- a/crates/ty_python_semantic/src/types/class/typed_dict.rs +++ b/crates/ty_python_semantic/src/types/class/typed_dict.rs @@ -20,12 +20,38 @@ use crate::types::typed_dict::{ TypedDictField, TypedDictSchema, deferred_functional_typed_dict_schema, }; use crate::types::{ - BoundTypeVarInstance, CallableType, ClassBase, ClassType, KnownClass, MemberLookupPolicy, Type, - TypeVarVariance, UnionBuilder, UnionType, + BoundTypeVarInstance, CallableType, ClassBase, ClassLiteral, ClassType, KnownClass, + MemberLookupPolicy, Type, TypeVarVariance, UnionType, }; +pub(super) fn synthesize_typed_dict_method<'db, I, N, F>( + db: &'db dyn Db, + instance_ty: Type<'db>, + method_name: &str, + fields: impl Fn() -> I, +) -> Option> +where + I: IntoIterator, + N: Borrow, + F: Borrow>, +{ + match method_name { + "__getitem__" => Some(synthesize_typed_dict_getitem(db, instance_ty, fields())), + "__setitem__" => Some(synthesize_typed_dict_setitem(db, instance_ty, fields())), + "__delitem__" => Some(synthesize_typed_dict_delitem(db, instance_ty, fields())), + "get" => Some(synthesize_typed_dict_get(db, instance_ty, fields())), + "update" => Some(synthesize_typed_dict_update(db, instance_ty, fields())), + "pop" => Some(synthesize_typed_dict_pop(db, instance_ty, fields())), + "setdefault" => Some(synthesize_typed_dict_setdefault(db, instance_ty, fields())), + "__or__" | "__ror__" | "__ior__" => { + Some(synthesize_typed_dict_merge(db, instance_ty, method_name)) + } + _ => None, + } +} + /// Synthesize the `__getitem__` method for a `TypedDict`. -pub(super) fn synthesize_typed_dict_getitem<'db, N, F>( +fn synthesize_typed_dict_getitem<'db, N, F>( db: &'db dyn Db, instance_ty: Type<'db>, fields: impl IntoIterator, @@ -38,18 +64,12 @@ where let field_name = field_name.borrow(); let field = field.borrow(); let key_type = Type::string_literal(db, field_name.as_str()); - Signature::new( - Parameters::new( - db, - [ - Parameter::positional_only(Some(Name::new_static("self"))) - .with_annotated_type(instance_ty), - Parameter::positional_only(Some(Name::new_static("key"))) - .with_annotated_type(key_type), - ], - ), - field.declared_ty, - ) + let parameters = [ + Parameter::positional_only(Some(Name::new_static("self"))) + .with_annotated_type(instance_ty), + Parameter::positional_only(Some(Name::new_static("key"))).with_annotated_type(key_type), + ]; + Signature::new(Parameters::new(db, parameters), field.declared_ty) }); Type::Callable(CallableType::new( @@ -60,7 +80,7 @@ where } /// Synthesize the `__setitem__` method for a `TypedDict`. -pub(super) fn synthesize_typed_dict_setitem<'db, N, F>( +fn synthesize_typed_dict_setitem<'db, N, F>( db: &'db dyn Db, instance_ty: Type<'db>, fields: impl IntoIterator, @@ -75,44 +95,30 @@ where .peekable(); if writeable_fields.peek().is_none() { - return Type::Callable(CallableType::new( - db, - CallableSignature::single(Signature::new( - Parameters::new( - db, - [ - Parameter::positional_only(Some(Name::new_static("self"))) - .with_annotated_type(instance_ty), - Parameter::positional_only(Some(Name::new_static("key"))) - .with_annotated_type(Type::Never), - Parameter::positional_only(Some(Name::new_static("value"))) - .with_annotated_type(Type::any()), - ], - ), - Type::none(db), - )), - CallableTypeKind::FunctionLike, - )); + let parameters = [ + Parameter::positional_only(Some(Name::new_static("self"))) + .with_annotated_type(instance_ty), + Parameter::positional_only(Some(Name::new_static("key"))) + .with_annotated_type(Type::Never), + Parameter::positional_only(Some(Name::new_static("value"))) + .with_annotated_type(Type::any()), + ]; + let signature = Signature::new(Parameters::new(db, parameters), Type::none(db)); + return Type::function_like_callable(db, signature); } let overloads = writeable_fields.map(|(field_name, field)| { let field_name = field_name.borrow(); let field = field.borrow(); let key_type = Type::string_literal(db, field_name.as_str()); - Signature::new( - Parameters::new( - db, - [ - Parameter::positional_only(Some(Name::new_static("self"))) - .with_annotated_type(instance_ty), - Parameter::positional_only(Some(Name::new_static("key"))) - .with_annotated_type(key_type), - Parameter::positional_only(Some(Name::new_static("value"))) - .with_annotated_type(field.declared_ty), - ], - ), - Type::none(db), - ) + let parameters = [ + Parameter::positional_only(Some(Name::new_static("self"))) + .with_annotated_type(instance_ty), + Parameter::positional_only(Some(Name::new_static("key"))).with_annotated_type(key_type), + Parameter::positional_only(Some(Name::new_static("value"))) + .with_annotated_type(field.declared_ty), + ]; + Signature::new(Parameters::new(db, parameters), Type::none(db)) }); Type::Callable(CallableType::new( @@ -123,7 +129,7 @@ where } /// Synthesize the `__delitem__` method for a `TypedDict`. -pub(super) fn synthesize_typed_dict_delitem<'db, N, F>( +fn synthesize_typed_dict_delitem<'db, N, F>( db: &'db dyn Db, instance_ty: Type<'db>, fields: impl IntoIterator, @@ -138,39 +144,25 @@ where .peekable(); if deletable_fields.peek().is_none() { - return Type::Callable(CallableType::new( - db, - CallableSignature::single(Signature::new( - Parameters::new( - db, - [ - Parameter::positional_only(Some(Name::new_static("self"))) - .with_annotated_type(instance_ty), - Parameter::positional_only(Some(Name::new_static("key"))) - .with_annotated_type(Type::Never), - ], - ), - Type::none(db), - )), - CallableTypeKind::FunctionLike, - )); + let parameters = [ + Parameter::positional_only(Some(Name::new_static("self"))) + .with_annotated_type(instance_ty), + Parameter::positional_only(Some(Name::new_static("key"))) + .with_annotated_type(Type::Never), + ]; + let signature = Signature::new(Parameters::new(db, parameters), Type::none(db)); + return Type::function_like_callable(db, signature); } let overloads = deletable_fields.map(|(field_name, _)| { let field_name = field_name.borrow(); let key_type = Type::string_literal(db, field_name.as_str()); - Signature::new( - Parameters::new( - db, - [ - Parameter::positional_only(Some(Name::new_static("self"))) - .with_annotated_type(instance_ty), - Parameter::positional_only(Some(Name::new_static("key"))) - .with_annotated_type(key_type), - ], - ), - Type::none(db), - ) + let parameters = [ + Parameter::positional_only(Some(Name::new_static("self"))) + .with_annotated_type(instance_ty), + Parameter::positional_only(Some(Name::new_static("key"))).with_annotated_type(key_type), + ]; + Signature::new(Parameters::new(db, parameters), Type::none(db)) }); Type::Callable(CallableType::new( @@ -181,7 +173,7 @@ where } /// Synthesize the `get` method for a `TypedDict`. -pub(super) fn synthesize_typed_dict_get<'db, N, F>( +fn synthesize_typed_dict_get<'db, N, F>( db: &'db dyn Db, instance_ty: Type<'db>, fields: impl IntoIterator, @@ -197,16 +189,14 @@ where let field = field.borrow(); let key_type = Type::string_literal(db, field_name.as_str()); + let get_sig_params = [ + Parameter::positional_only(Some(Name::new_static("self"))) + .with_annotated_type(instance_ty), + Parameter::positional_only(Some(Name::new_static("key"))) + .with_annotated_type(key_type), + ]; let get_sig = Signature::new( - Parameters::new( - db, - [ - Parameter::positional_only(Some(Name::new_static("self"))) - .with_annotated_type(instance_ty), - Parameter::positional_only(Some(Name::new_static("key"))) - .with_annotated_type(key_type), - ], - ), + Parameters::new(db, get_sig_params), if field.is_required() { field.declared_ty } else { @@ -220,19 +210,17 @@ where TypeVarVariance::Covariant, ); + let get_with_default_sig_params = [ + Parameter::positional_only(Some(Name::new_static("self"))) + .with_annotated_type(instance_ty), + Parameter::positional_only(Some(Name::new_static("key"))) + .with_annotated_type(key_type), + Parameter::positional_only(Some(Name::new_static("default"))) + .with_annotated_type(Type::TypeVar(t_default)), + ]; let get_with_default_sig = Signature::new_generic( Some(GenericContext::from_typevar_instances(db, [t_default])), - Parameters::new( - db, - [ - Parameter::positional_only(Some(Name::new_static("self"))) - .with_annotated_type(instance_ty), - Parameter::positional_only(Some(Name::new_static("key"))) - .with_annotated_type(key_type), - Parameter::positional_only(Some(Name::new_static("default"))) - .with_annotated_type(Type::TypeVar(t_default)), - ], - ), + Parameters::new(db, get_with_default_sig_params), if field.is_required() { field.declared_ty } else { @@ -262,19 +250,18 @@ where TypeVarVariance::Covariant, ); + let parameterss = [ + Parameter::positional_only(Some(Name::new_static("self"))) + .with_annotated_type(instance_ty), + Parameter::positional_only(Some(Name::new_static("key"))) + .with_annotated_type(KnownClass::Str.to_instance(db)), + Parameter::positional_only(Some(Name::new_static("default"))) + .with_annotated_type(Type::TypeVar(t_default)), + ]; + Signature::new_generic( Some(GenericContext::from_typevar_instances(db, [t_default])), - Parameters::new( - db, - [ - Parameter::positional_only(Some(Name::new_static("self"))) - .with_annotated_type(instance_ty), - Parameter::positional_only(Some(Name::new_static("key"))) - .with_annotated_type(KnownClass::Str.to_instance(db)), - Parameter::positional_only(Some(Name::new_static("default"))) - .with_annotated_type(Type::TypeVar(t_default)), - ], - ), + Parameters::new(db, parameterss), UnionType::from_two_elements(db, Type::unknown(), Type::TypeVar(t_default)), ) })); @@ -287,7 +274,7 @@ where } /// Synthesize the `update` method for a `TypedDict`. -pub(super) fn synthesize_typed_dict_update<'db, N, F>( +fn synthesize_typed_dict_update<'db, N, F>( db: &'db dyn Db, instance_ty: Type<'db>, fields: impl IntoIterator, @@ -296,21 +283,18 @@ where N: Borrow, F: Borrow>, { - let keyword_parameters: Vec<_> = fields - .into_iter() - .map(|(field_name, field)| { - let field_name = field_name.borrow(); - let field = field.borrow(); - let ty = if field.is_read_only() { - Type::Never - } else { - field.declared_ty - }; - Parameter::keyword_only(field_name.clone()) - .with_annotated_type(ty) - .with_default_type(ty) - }) - .collect(); + let keyword_parameters = fields.into_iter().map(|(field_name, field)| { + let field_name = field_name.borrow(); + let field = field.borrow(); + let ty = if field.is_read_only() { + Type::Never + } else { + field.declared_ty + }; + Parameter::keyword_only(field_name.clone()) + .with_annotated_type(ty) + .with_default_type(ty) + }); let update_patch_ty = if let Type::TypedDict(typed_dict) = instance_ty { Type::TypedDict(typed_dict.to_update_patch(db)) @@ -318,38 +302,30 @@ where instance_ty }; - let value_ty = UnionBuilder::new(db) - .add(update_patch_ty) - .add(KnownClass::Iterable.to_specialized_instance( - db, - &[Type::heterogeneous_tuple( - db, - [KnownClass::Str.to_instance(db), Type::object()], - )], - )) - .build(); + let str_object_tuple = + Type::heterogeneous_tuple(db, [KnownClass::Str.to_instance(db), Type::object()]); - let update_signature = Signature::new( - Parameters::new( - db, - [ - Parameter::positional_only(Some(Name::new_static("self"))) - .with_annotated_type(instance_ty), - Parameter::positional_only(Some(Name::new_static("value"))) - .with_annotated_type(value_ty) - .with_default_type(Type::none(db)), - ] - .into_iter() - .chain(keyword_parameters), - ), - Type::none(db), + let value_ty = UnionType::from_two_elements( + db, + update_patch_ty, + KnownClass::Iterable.to_specialized_instance(db, &[str_object_tuple]), ); + let parameters = [ + Parameter::positional_only(Some(Name::new_static("self"))).with_annotated_type(instance_ty), + Parameter::positional_only(Some(Name::new_static("value"))) + .with_annotated_type(value_ty) + .with_default_type(Type::none(db)), + ] + .into_iter() + .chain(keyword_parameters); + + let update_signature = Signature::new(Parameters::new(db, parameters), Type::none(db)); Type::function_like_callable(db, update_signature) } /// Synthesize the `pop` method for a `TypedDict`. -pub(super) fn synthesize_typed_dict_pop<'db, N, F>( +fn synthesize_typed_dict_pop<'db, N, F>( db: &'db dyn Db, instance_ty: Type<'db>, fields: impl IntoIterator, @@ -366,18 +342,13 @@ where let field = field.borrow(); let key_type = Type::string_literal(db, field_name.as_str()); - let pop_sig = Signature::new( - Parameters::new( - db, - [ - Parameter::positional_only(Some(Name::new_static("self"))) - .with_annotated_type(instance_ty), - Parameter::positional_only(Some(Name::new_static("key"))) - .with_annotated_type(key_type), - ], - ), - field.declared_ty, - ); + let pop_parameters = [ + Parameter::positional_only(Some(Name::new_static("self"))) + .with_annotated_type(instance_ty), + Parameter::positional_only(Some(Name::new_static("key"))) + .with_annotated_type(key_type), + ]; + let pop_sig = Signature::new(Parameters::new(db, pop_parameters), field.declared_ty); let t_default = BoundTypeVarInstance::synthetic( db, @@ -385,19 +356,17 @@ where TypeVarVariance::Covariant, ); + let pop_with_default_parameters = [ + Parameter::positional_only(Some(Name::new_static("self"))) + .with_annotated_type(instance_ty), + Parameter::positional_only(Some(Name::new_static("key"))) + .with_annotated_type(key_type), + Parameter::positional_only(Some(Name::new_static("default"))) + .with_annotated_type(Type::TypeVar(t_default)), + ]; let pop_with_default_sig = Signature::new_generic( Some(GenericContext::from_typevar_instances(db, [t_default])), - Parameters::new( - db, - [ - Parameter::positional_only(Some(Name::new_static("self"))) - .with_annotated_type(instance_ty), - Parameter::positional_only(Some(Name::new_static("key"))) - .with_annotated_type(key_type), - Parameter::positional_only(Some(Name::new_static("default"))) - .with_annotated_type(Type::TypeVar(t_default)), - ], - ), + Parameters::new(db, pop_with_default_parameters), UnionType::from_two_elements(db, field.declared_ty, Type::TypeVar(t_default)), ); @@ -412,7 +381,7 @@ where } /// Synthesize the `setdefault` method for a `TypedDict`. -pub(super) fn synthesize_typed_dict_setdefault<'db, N, F>( +fn synthesize_typed_dict_setdefault<'db, N, F>( db: &'db dyn Db, instance_ty: Type<'db>, fields: impl IntoIterator, @@ -425,21 +394,15 @@ where let field_name = field_name.borrow(); let field = field.borrow(); let key_type = Type::string_literal(db, field_name.as_str()); - - Signature::new( - Parameters::new( - db, - [ - Parameter::positional_only(Some(Name::new_static("self"))) - .with_annotated_type(instance_ty), - Parameter::positional_only(Some(Name::new_static("key"))) - .with_annotated_type(key_type), - Parameter::positional_only(Some(Name::new_static("default"))) - .with_annotated_type(field.declared_ty), - ], - ), - field.declared_ty, - ) + let parameters = [ + Parameter::positional_only(Some(Name::new_static("self"))) + .with_annotated_type(instance_ty), + Parameter::positional_only(Some(Name::new_static("key"))).with_annotated_type(key_type), + Parameter::positional_only(Some(Name::new_static("default"))) + .with_annotated_type(field.declared_ty), + ]; + + Signature::new(Parameters::new(db, parameters), field.declared_ty) }); Type::Callable(CallableType::new( @@ -450,21 +413,21 @@ where } /// Synthesize a merge operator (`__or__`, `__ror__`, or `__ior__`) for a `TypedDict`. -pub(super) fn synthesize_typed_dict_merge<'db>( +fn synthesize_typed_dict_merge<'db>( db: &'db dyn Db, instance_ty: Type<'db>, name: &str, ) -> Type<'db> { - let mut overloads = vec![Signature::new( - Parameters::new( - db, - [ - Parameter::positional_only(Some(Name::new_static("self"))) - .with_annotated_type(instance_ty), - Parameter::positional_only(Some(Name::new_static("value"))) - .with_annotated_type(instance_ty), - ], - ), + let mut overloads: smallvec::SmallVec<[Signature<'db>; 3]>; + + let first_overload_parameters = [ + Parameter::positional_only(Some(Name::new_static("self"))).with_annotated_type(instance_ty), + Parameter::positional_only(Some(Name::new_static("value"))) + .with_annotated_type(instance_ty), + ]; + + overloads = smallvec::smallvec![Signature::new( + Parameters::new(db, first_overload_parameters,), instance_ty, )]; @@ -486,28 +449,25 @@ pub(super) fn synthesize_typed_dict_merge<'db>( ], ); + let overload_two_parameters = [ + Parameter::positional_only(Some(Name::new_static("self"))) + .with_annotated_type(instance_ty), + Parameter::positional_only(Some(Name::new_static("value"))) + .with_annotated_type(partial_ty), + ]; overloads.push(Signature::new( - Parameters::new( - db, - [ - Parameter::positional_only(Some(Name::new_static("self"))) - .with_annotated_type(instance_ty), - Parameter::positional_only(Some(Name::new_static("value"))) - .with_annotated_type(partial_ty), - ], - ), + Parameters::new(db, overload_two_parameters), instance_ty, )); + + let overload_three_parameters = [ + Parameter::positional_only(Some(Name::new_static("self"))) + .with_annotated_type(instance_ty), + Parameter::positional_only(Some(Name::new_static("value"))) + .with_annotated_type(dict_param_ty), + ]; overloads.push(Signature::new( - Parameters::new( - db, - [ - Parameter::positional_only(Some(Name::new_static("self"))) - .with_annotated_type(instance_ty), - Parameter::positional_only(Some(Name::new_static("value"))) - .with_annotated_type(dict_param_ty), - ], - ), + Parameters::new(db, overload_three_parameters), dict_return_ty, )); } @@ -552,7 +512,7 @@ pub enum DynamicTypedDictAnchor<'db> { pub struct DynamicTypedDictLiteral<'db> { /// The name of the TypedDict (from the first argument). #[returns(ref)] - pub name: Name, + pub(crate) name: Name, /// The anchor for this dynamic TypedDict, providing stable identity. /// @@ -562,7 +522,7 @@ pub struct DynamicTypedDictLiteral<'db> { /// is relative to the enclosing scope's anchor node index, and the /// eagerly computed spec is stored on the anchor. #[returns(ref)] - pub anchor: DynamicTypedDictAnchor<'db>, + pub(crate) anchor: DynamicTypedDictAnchor<'db>, } impl get_size2::GetSize for DynamicTypedDictLiteral<'_> {} @@ -586,8 +546,8 @@ impl<'db> DynamicTypedDictLiteral<'db> { } /// Returns an instance type for this dynamic `TypedDict`. - pub(crate) fn to_instance(self, db: &'db dyn Db) -> Type<'db> { - Type::instance(db, ClassType::NonGeneric(self.into())) + pub(crate) fn to_instance(self) -> Type<'db> { + Type::typed_dict(ClassType::NonGeneric(ClassLiteral::DynamicTypedDict(self))) } /// Returns the range of the `TypedDict` call expression. @@ -665,21 +625,9 @@ impl<'db> DynamicTypedDictLiteral<'db> { /// Look up a class-level member defined directly on this `TypedDict` (not inherited). pub(super) fn own_class_member(self, db: &'db dyn Db, name: &str) -> Member<'db> { - let instance_ty = self.to_instance(db); - - let synthesized = match name { - "__getitem__" => synthesize_typed_dict_getitem(db, instance_ty, self.items(db)), - "__setitem__" => synthesize_typed_dict_setitem(db, instance_ty, self.items(db)), - "__delitem__" => synthesize_typed_dict_delitem(db, instance_ty, self.items(db)), - "get" => synthesize_typed_dict_get(db, instance_ty, self.items(db)), - "update" => synthesize_typed_dict_update(db, instance_ty, self.items(db)), - "pop" => synthesize_typed_dict_pop(db, instance_ty, self.items(db)), - "setdefault" => synthesize_typed_dict_setdefault(db, instance_ty, self.items(db)), - "__or__" | "__ror__" | "__ior__" => synthesize_typed_dict_merge(db, instance_ty, name), - _ => return Member::default(), - }; - - Member::definitely_declared(synthesized) + synthesize_typed_dict_method(db, self.to_instance(), name, || self.items(db)) + .map(Member::definitely_declared) + .unwrap_or_default() } /// Look up a class-level member by name (including superclasses). diff --git a/crates/ty_python_semantic/src/types/infer/builder/typed_dict.rs b/crates/ty_python_semantic/src/types/infer/builder/typed_dict.rs index 01b692eb26075e..81f0d27152e221 100644 --- a/crates/ty_python_semantic/src/types/infer/builder/typed_dict.rs +++ b/crates/ty_python_semantic/src/types/infer/builder/typed_dict.rs @@ -84,24 +84,9 @@ impl<'db> TypeInferenceBuilder<'db, '_> { self.infer_expression(arg, TypeContext::default()); } - if has_starred || has_double_starred { - for kw in keywords { - self.infer_expression(&kw.value, TypeContext::default()); - if let Some(arg) = &kw.arg { - if !matches!(arg.id.as_str(), "total" | "closed" | "extra_items") - && let Some(builder) = self.context.report_lint(&UNKNOWN_ARGUMENT, kw) - { - builder.into_diagnostic(format_args!( - "Argument `{}` does not match any known parameter of function `TypedDict`", - arg.id - )); - } - } - } - return fallback(); - } - if args.len() > 2 + && !has_starred + && !has_double_starred && let Some(builder) = self .context .report_lint(&TOO_MANY_POSITIONAL_ARGUMENTS, &args[2]) @@ -161,6 +146,10 @@ impl<'db> TypeInferenceBuilder<'db, '_> { } } + if has_double_starred || has_starred { + return fallback(); + } + if fields_arg.is_none() && let Some(builder) = self.context.report_lint(&MISSING_ARGUMENT, call_expr) { @@ -222,10 +211,20 @@ impl<'db> TypeInferenceBuilder<'db, '_> { }; let typeddict = DynamicTypedDictLiteral::new(db, name, anchor); - Type::ClassLiteral(ClassLiteral::DynamicTypedDict(typeddict)) } + /// Infer the `TypedDictSchema` for an "inlined"/"dangling" functional `TypedDict` definition, + /// such as `class Foo(TypedDict("Bar", {"x": int})): ...`. + /// + /// Note that, as of 2026-03-29, support for these is not mandated by the spec, and they are not + /// supported by pyrefly or zuban. However, they are supported by pyright and mypy. We also + /// support inline schemas for `NamedTuple`s, so it makes sense to do the same for `TypedDict`s + /// out of consistency. + /// + /// This method uses `self.expression_type()` for all non-type expressions: it is assumed that + /// all non-type expressions have already been inferred by a call to `self.validate_fields_arg()`, + /// which is called before this method in the inference process. fn infer_dangling_typeddict_spec( &mut self, fields_arg: &ast::Expr, @@ -263,7 +262,11 @@ impl<'db> TypeInferenceBuilder<'db, '_> { schema } - /// Infer field types for functional `TypedDict` in deferred phase. + /// Infer field types for functional `TypedDict` assignments in deferred phase, for example: + /// + /// ```python + /// TD = TypedDict("TD", {"x": "TD | None"}, total=False) + /// ``` /// /// This is called during `infer_deferred_types` to infer field types after the `TypedDict` /// definition is complete. This enables support for recursive `TypedDict`s where field types @@ -287,6 +290,10 @@ impl<'db> TypeInferenceBuilder<'db, '_> { } } + /// Infer all non-type expressions in the `fields` argument of a functional `TypedDict` definition, + /// and emit diagnostics for invalid field keys. Type expressions are not inferred during this pass, + /// because it must be deferred for` TypedDict` definitions that may hold recursive references to + /// themselves. fn validate_fields_arg(&mut self, fields_arg: &ast::Expr) { let db = self.db(); diff --git a/crates/ty_python_semantic/src/types/typed_dict.rs b/crates/ty_python_semantic/src/types/typed_dict.rs index 547e7c0a7a7f80..f60b367a661bb2 100644 --- a/crates/ty_python_semantic/src/types/typed_dict.rs +++ b/crates/ty_python_semantic/src/types/typed_dict.rs @@ -561,9 +561,7 @@ pub(super) fn deferred_functional_typed_dict_schema<'db>( return TypedDictSchema::default(); }; - let field_ty = deferred_inference - .try_expression_type(&item.value) - .unwrap_or(Type::unknown()); + let field_ty = deferred_inference.expression_type(&item.value); let qualifiers = deferred_inference.qualifiers(&item.value); schema.insert( @@ -573,27 +571,6 @@ pub(super) fn deferred_functional_typed_dict_schema<'db>( } } - for keyword in &node.arguments.keywords { - let Some(arg) = &keyword.arg else { - continue; - }; - - match arg.id.as_str() { - "total" | "closed" | "extra_items" => continue, - field_name => { - let field_ty = deferred_inference - .try_expression_type(&keyword.value) - .unwrap_or(Type::unknown()); - let qualifiers = deferred_inference.qualifiers(&keyword.value); - - schema.insert( - Name::new(field_name), - functional_typed_dict_field(field_ty, qualifiers, total), - ); - } - } - } - schema } From bb4b4ffc959014beb1b8ffd0faa0b52d4b9c4a39 Mon Sep 17 00:00:00 2001 From: Alex Waygood Date: Sun, 29 Mar 2026 17:57:09 +0100 Subject: [PATCH 5/5] fix ecosystem false positive on `.copy()` calls --- .../resources/mdtest/typed_dict.md | 1 + crates/ty_python_semantic/src/types.rs | 5 +- crates/ty_python_semantic/src/types/class.rs | 20 ++++++-- .../src/types/class/static_literal.rs | 47 ++++++------------- .../src/types/class/typed_dict.rs | 34 ++++++++------ 5 files changed, 52 insertions(+), 55 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/typed_dict.md b/crates/ty_python_semantic/resources/mdtest/typed_dict.md index 3ba8988247ff30..7d27d448a070f0 100644 --- a/crates/ty_python_semantic/resources/mdtest/typed_dict.md +++ b/crates/ty_python_semantic/resources/mdtest/typed_dict.md @@ -2645,6 +2645,7 @@ def _(p: Partial) -> None: reveal_type(p.get("name", "default")) # revealed: str reveal_type(p.pop("name")) # revealed: str reveal_type(p.pop("name", "fallback")) # revealed: str + reveal_type(p.copy()) # revealed: Partial del p["extra"] ``` diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index c6f70b0906dc60..a986d0fb7a6ed9 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -7451,12 +7451,11 @@ impl<'db> TypeGuardLike<'db> for TypeGuardType<'db> { /// being added to the given class. pub(super) fn determine_upper_bound<'db>( db: &'db dyn Db, - class_literal: StaticClassLiteral<'db>, - specialization: Option>, + class_literal: ClassLiteral<'db>, is_known_base: impl Fn(ClassBase<'db>) -> bool, ) -> Type<'db> { let upper_bound = class_literal - .iter_mro(db, specialization) + .iter_mro(db) .take_while(|base| !is_known_base(*base)) .filter_map(ClassBase::into_class) .last() diff --git a/crates/ty_python_semantic/src/types/class.rs b/crates/ty_python_semantic/src/types/class.rs index ef3f1336536bb1..7f78dded42d609 100644 --- a/crates/ty_python_semantic/src/types/class.rs +++ b/crates/ty_python_semantic/src/types/class.rs @@ -432,6 +432,20 @@ impl<'db> ClassLiteral<'db> { } } + /// Returns the unknown specialization of this class. + /// + /// For non-generic classes, the class is returned unchanged. + /// For a non-specialized generic class, we return a generic alias that maps each of the class's + /// typevars to `Unknown`. + pub(crate) fn unknown_specialization(self, db: &'db dyn Db) -> ClassType<'db> { + match self { + Self::Static(class) => class.unknown_specialization(db), + Self::Dynamic(_) | Self::DynamicNamedTuple(_) | Self::DynamicTypedDict(_) => { + ClassType::NonGeneric(self) + } + } + } + /// Returns the identity specialization for this class (same as default for non-generic). pub(crate) fn identity_specialization(self, db: &'db dyn Db) -> ClassType<'db> { match self { @@ -662,7 +676,7 @@ impl<'db> ClassLiteral<'db> { Self::Static(class) => class.instance_member(db, specialization, name), Self::Dynamic(class) => class.instance_member(db, name), Self::DynamicNamedTuple(namedtuple) => namedtuple.instance_member(db, name), - Self::DynamicTypedDict(typeddict) => typeddict.instance_member(db, name), + Self::DynamicTypedDict(_) => PlaceAndQualifiers::default(), } } @@ -1586,9 +1600,7 @@ impl<'db> ClassType<'db> { Self::NonGeneric(ClassLiteral::DynamicNamedTuple(namedtuple)) => { namedtuple.instance_member(db, name) } - Self::NonGeneric(ClassLiteral::DynamicTypedDict(typeddict)) => { - typeddict.instance_member(db, name) - } + Self::NonGeneric(ClassLiteral::DynamicTypedDict(_)) => PlaceAndQualifiers::default(), Self::NonGeneric(ClassLiteral::Static(class)) => { if class.is_typed_dict(db) { return Place::Undefined.into(); diff --git a/crates/ty_python_semantic/src/types/class/static_literal.rs b/crates/ty_python_semantic/src/types/class/static_literal.rs index 4609216fab985e..593d81ca9bf2ab 100644 --- a/crates/ty_python_semantic/src/types/class/static_literal.rs +++ b/crates/ty_python_semantic/src/types/class/static_literal.rs @@ -37,7 +37,7 @@ use crate::{ ClassMemberResult, CodeGeneratorKind, DisjointBase, DynamicTypedDictLiteral, Field, FieldKind, InstanceMemberResult, MetaclassError, MetaclassErrorKind, MethodDecorator, MroLookup, NamedTupleField, SlotsKind, synthesize_namedtuple_class_member, - typed_dict::synthesize_typed_dict_method, + typed_dict::{synthesize_typed_dict_method, typed_dict_class_member}, }, context::InferContext, declaration_type, definition_expression_type, determine_upper_bound, @@ -1012,25 +1012,9 @@ impl<'db> StaticClassLiteral<'db> { match result { ClassMemberResult::Done(result) => result.finalize(db), - - ClassMemberResult::TypedDict => KnownClass::TypedDictFallback - .to_class_literal(db) - .find_name_in_mro_with_policy(db, name, policy) - .expect("Will return Some() when called on class literal") - .map_type(|ty| { - ty.apply_type_mapping( - db, - &TypeMapping::ReplaceSelf { - new_upper_bound: determine_upper_bound( - db, - self, - None, - ClassBase::is_typed_dict, - ), - }, - TypeContext::default(), - ) - }), + ClassMemberResult::TypedDict => { + typed_dict_class_member(db, ClassLiteral::Static(self), policy, name) + } } } @@ -1499,8 +1483,7 @@ impl<'db> StaticClassLiteral<'db> { &TypeMapping::ReplaceSelf { new_upper_bound: determine_upper_bound( db, - self, - specialization, + ClassLiteral::Static(self), |base| { base.into_class() .is_some_and(|c| c.is_known(db, KnownClass::Tuple)) @@ -1578,20 +1561,18 @@ impl<'db> StaticClassLiteral<'db> { .to_class_literal(db) .find_name_in_mro_with_policy(db, name, policy) .expect("`find_name_in_mro_with_policy` will return `Some()` when called on class literal") - .map_type(|ty| + .map_type(|ty| { + let new_upper_bound = determine_upper_bound( + db, + ClassLiteral::Static(self), + ClassBase::is_typed_dict + ); ty.apply_type_mapping( db, - &TypeMapping::ReplaceSelf { - new_upper_bound: determine_upper_bound( - db, - self, - specialization, - ClassBase::is_typed_dict - ) - }, - TypeContext::default(), + &TypeMapping::ReplaceSelf { new_upper_bound }, + TypeContext::default(), ) - ) + }) } } diff --git a/crates/ty_python_semantic/src/types/class/typed_dict.rs b/crates/ty_python_semantic/src/types/class/typed_dict.rs index c28b72893ba4a6..cef43d1ff98710 100644 --- a/crates/ty_python_semantic/src/types/class/typed_dict.rs +++ b/crates/ty_python_semantic/src/types/class/typed_dict.rs @@ -21,7 +21,8 @@ use crate::types::typed_dict::{ }; use crate::types::{ BoundTypeVarInstance, CallableType, ClassBase, ClassLiteral, ClassType, KnownClass, - MemberLookupPolicy, Type, TypeVarVariance, UnionType, + MemberLookupPolicy, Type, TypeContext, TypeMapping, TypeVarVariance, UnionType, + determine_upper_bound, }; pub(super) fn synthesize_typed_dict_method<'db, I, N, F>( @@ -645,20 +646,23 @@ impl<'db> DynamicTypedDictLiteral<'db> { // Fall back to TypedDictFallback for methods like __contains__, items, keys, etc. // This mirrors the behavior of StaticClassLiteral::typed_dict_member. - KnownClass::TypedDictFallback - .to_class_literal(db) - .find_name_in_mro_with_policy(db, name, policy) - .expect( - "`find_name_in_mro_with_policy` will return `Some()` when called on class literal", - ) + typed_dict_class_member(db, ClassLiteral::DynamicTypedDict(self), policy, name) } +} - /// Look up an instance member by name (including superclasses). - #[expect(clippy::unused_self)] - pub(crate) fn instance_member(self, db: &'db dyn Db, name: &str) -> PlaceAndQualifiers<'db> { - // Fall back to TypedDictFallback for instance members. - KnownClass::TypedDictFallback - .to_instance(db) - .instance_member(db, name) - } +pub(super) fn typed_dict_class_member<'db>( + db: &'db dyn Db, + self_class: ClassLiteral<'db>, + lookup_policy: MemberLookupPolicy, + name: &str, +) -> PlaceAndQualifiers<'db> { + KnownClass::TypedDictFallback + .to_class_literal(db) + .find_name_in_mro_with_policy(db, name, lookup_policy) + .expect("Will return Some() when called on class literal") + .map_type(|ty| { + let new_upper_bound = determine_upper_bound(db, self_class, ClassBase::is_typed_dict); + let mapping = TypeMapping::ReplaceSelf { new_upper_bound }; + ty.apply_type_mapping(db, &mapping, TypeContext::default()) + }) }