From 2b058663fb52cecdc8f0b54ed644bcbd8cc22520 Mon Sep 17 00:00:00 2001 From: STerliakov Date: Sat, 29 Mar 2025 04:48:59 +0100 Subject: [PATCH 1/3] Support TypedDict aliases --- mypy/semanal_typeddict.py | 21 ++++++++++---- test-data/unit/check-typeddict.test | 43 +++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 5 deletions(-) diff --git a/mypy/semanal_typeddict.py b/mypy/semanal_typeddict.py index 0d6a0b7ff87f..8860860d6b10 100644 --- a/mypy/semanal_typeddict.py +++ b/mypy/semanal_typeddict.py @@ -30,6 +30,7 @@ StrExpr, TempNode, TupleExpr, + TypeAlias, TypedDictExpr, TypeInfo, ) @@ -50,6 +51,7 @@ TypedDictType, TypeOfAny, TypeVarLikeType, + get_proper_type, ) TPDICT_CLASS_ERROR: Final = ( @@ -192,8 +194,16 @@ def add_keys_and_types_from_base( ) -> None: base_args: list[Type] = [] if isinstance(base, RefExpr): - assert isinstance(base.node, TypeInfo) - info = base.node + if isinstance(base.node, TypeAlias): + # Only old TypeAlias / plain assignment, PEP695 `type` stmt + # cannot be used as a base class + target = get_proper_type(base.node.target) + assert isinstance(target, TypedDictType) + info = target.fallback.type + elif isinstance(base.node, TypeInfo): + info = base.node + else: + assert False elif isinstance(base, IndexExpr): assert isinstance(base.base, RefExpr) assert isinstance(base.base.node, TypeInfo) @@ -609,10 +619,11 @@ def build_typeddict_typeinfo( # Helpers def is_typeddict(self, expr: Expression) -> bool: - return ( - isinstance(expr, RefExpr) - and isinstance(expr.node, TypeInfo) + return isinstance(expr, RefExpr) and ( + isinstance(expr.node, TypeInfo) and expr.node.typeddict_type is not None + or isinstance(expr.node, TypeAlias) + and isinstance(get_proper_type(expr.node.target), TypedDictType) ) def fail(self, msg: str, ctx: Context, *, code: ErrorCode | None = None) -> None: diff --git a/test-data/unit/check-typeddict.test b/test-data/unit/check-typeddict.test index c5ebed57bbcd..ff3e418edcfd 100644 --- a/test-data/unit/check-typeddict.test +++ b/test-data/unit/check-typeddict.test @@ -4138,3 +4138,46 @@ Derived.Params(name="Robert") DerivedOverride.Params(name="Robert") [builtins fixtures/dict.pyi] [typing fixtures/typing-typeddict.pyi] + +[case testTypedDictAlias] +from typing import NotRequired, TypedDict +from typing_extensions import TypeAlias + +class Base(TypedDict): + foo: int + +Base1 = Base +class Child1(Base1): + bar: NotRequired[int] +c11: Child1 = {"foo": 0} +c12: Child1 = {"foo": 0, "bar": 1} +c13: Child1 = {"foo": 0, "bar": 1, "baz": "error"} # E: Extra key "baz" for TypedDict "Child1" + +Base2: TypeAlias = Base +class Child2(Base2): + bar: NotRequired[int] +c21: Child2 = {"foo": 0} +c22: Child2 = {"foo": 0, "bar": 1} +c23: Child2 = {"foo": 0, "bar": 1, "baz": "error"} # E: Extra key "baz" for TypedDict "Child2" +[builtins fixtures/dict.pyi] +[typing fixtures/typing-typeddict.pyi] + +[case testTypedDictAliasMultiple] +from typing import TypedDict + +class A(TypedDict, total=False): + y: int +class B(TypedDict, total=False): + x: str + +D = B + +class C(A, D): + pass + +c1: C = {} +c2: C = {"x": "x"} +c3: C = {"y": 1} +c4: C = {"x": "x", "y": 2} +[builtins fixtures/dict.pyi] +[typing fixtures/typing-typeddict.pyi] From 62e2ee6d7335d861ebe9392f909d07dd370fbb2f Mon Sep 17 00:00:00 2001 From: STerliakov Date: Sat, 29 Mar 2025 17:01:32 +0100 Subject: [PATCH 2/3] Handle generics (except for TypeAlias without explicit params - that does not work? --- mypy/semanal_typeddict.py | 70 +++++++++++++-------------- test-data/unit/check-typeddict.test | 75 +++++++++++++++++++++++++---- 2 files changed, 99 insertions(+), 46 deletions(-) diff --git a/mypy/semanal_typeddict.py b/mypy/semanal_typeddict.py index 8860860d6b10..8bf073d30f71 100644 --- a/mypy/semanal_typeddict.py +++ b/mypy/semanal_typeddict.py @@ -139,23 +139,18 @@ def analyze_typeddict_classdef(self, defn: ClassDef) -> tuple[bool, TypeInfo | N typeddict_bases_set.add("TypedDict") else: self.fail('Duplicate base class "TypedDict"', defn) - elif isinstance(expr, RefExpr) and self.is_typeddict(expr): - assert expr.fullname - if expr.fullname not in typeddict_bases_set: - typeddict_bases_set.add(expr.fullname) + elif ( + isinstance(expr, RefExpr) + and self.is_typeddict(expr) + or isinstance(expr, IndexExpr) + and self.is_typeddict(expr.base) + ): + info = self._parse_typeddict_base(expr, defn) + if info.fullname not in typeddict_bases_set: + typeddict_bases_set.add(info.fullname) typeddict_bases.append(expr) else: - assert isinstance(expr.node, TypeInfo) - self.fail(f'Duplicate base class "{expr.node.name}"', defn) - elif isinstance(expr, IndexExpr) and self.is_typeddict(expr.base): - assert isinstance(expr.base, RefExpr) - assert expr.base.fullname - if expr.base.fullname not in typeddict_bases_set: - typeddict_bases_set.add(expr.base.fullname) - typeddict_bases.append(expr) - else: - assert isinstance(expr.base.node, TypeInfo) - self.fail(f'Duplicate base class "{expr.base.node.name}"', defn) + self.fail(f'Duplicate base class "{info.name}"', defn) else: self.fail("All bases of a new TypedDict must be TypedDict types", defn) @@ -192,30 +187,13 @@ def add_keys_and_types_from_base( readonly_keys: set[str], ctx: Context, ) -> None: + info = self._parse_typeddict_base(base, ctx) base_args: list[Type] = [] - if isinstance(base, RefExpr): - if isinstance(base.node, TypeAlias): - # Only old TypeAlias / plain assignment, PEP695 `type` stmt - # cannot be used as a base class - target = get_proper_type(base.node.target) - assert isinstance(target, TypedDictType) - info = target.fallback.type - elif isinstance(base.node, TypeInfo): - info = base.node - else: - assert False - elif isinstance(base, IndexExpr): - assert isinstance(base.base, RefExpr) - assert isinstance(base.base.node, TypeInfo) - info = base.base.node + if isinstance(base, IndexExpr): args = self.analyze_base_args(base, ctx) if args is None: return base_args = args - else: - assert isinstance(base, CallExpr) - assert isinstance(base.analyzed, TypedDictExpr) - info = base.analyzed.info assert info.typeddict_type is not None base_typed_dict = info.typeddict_type @@ -241,6 +219,26 @@ def add_keys_and_types_from_base( required_keys.update(base_typed_dict.required_keys) readonly_keys.update(base_typed_dict.readonly_keys) + def _parse_typeddict_base(self, base: Expression, ctx: Context) -> TypeInfo: + if isinstance(base, RefExpr): + if isinstance(base.node, TypeInfo): + return base.node + elif isinstance(base.node, TypeAlias): + # Only old TypeAlias / plain assignment, PEP695 `type` stmt + # cannot be used as a base class + target = get_proper_type(base.node.target) + assert isinstance(target, TypedDictType) + return target.fallback.type + else: + assert False + elif isinstance(base, IndexExpr): + assert isinstance(base.base, RefExpr) + return self._parse_typeddict_base(base.base, ctx) + else: + assert isinstance(base, CallExpr) + assert isinstance(base.analyzed, TypedDictExpr) + return base.analyzed.info + def analyze_base_args(self, base: IndexExpr, ctx: Context) -> list[Type] | None: """Analyze arguments of base type expressions as types. @@ -537,7 +535,7 @@ def parse_typeddict_args( return "", [], [], True, [], False dictexpr = args[1] tvar_defs = self.api.get_and_bind_all_tvars([t for k, t in dictexpr.items]) - res = self.parse_typeddict_fields_with_types(dictexpr.items, call) + res = self.parse_typeddict_fields_with_types(dictexpr.items) if res is None: # One of the types is not ready, defer. return None @@ -546,7 +544,7 @@ def parse_typeddict_args( return args[0].value, items, types, total, tvar_defs, ok def parse_typeddict_fields_with_types( - self, dict_items: list[tuple[Expression | None, Expression]], context: Context + self, dict_items: list[tuple[Expression | None, Expression]] ) -> tuple[list[str], list[Type], bool] | None: """Parse typed dict items passed as pairs (name expression, type expression). diff --git a/test-data/unit/check-typeddict.test b/test-data/unit/check-typeddict.test index ff3e418edcfd..9e593038e170 100644 --- a/test-data/unit/check-typeddict.test +++ b/test-data/unit/check-typeddict.test @@ -4162,22 +4162,77 @@ c23: Child2 = {"foo": 0, "bar": 1, "baz": "error"} # E: Extra key "baz" for Typ [builtins fixtures/dict.pyi] [typing fixtures/typing-typeddict.pyi] -[case testTypedDictAliasMultiple] +[case testTypedDictAliasInheritance] from typing import TypedDict +from typing_extensions import TypeAlias -class A(TypedDict, total=False): - y: int -class B(TypedDict, total=False): +class A(TypedDict): x: str +class B(TypedDict): + y: int + +B1 = B +B2: TypeAlias = B -D = B +class C(A, B1): + pass +c1: C = {"y": 1} # E: Missing key "x" for TypedDict "C" +c2: C = {"x": "x", "y": 2} +c3: C = {"x": 1, "y": 2} # E: Incompatible types (expression has type "int", TypedDict item "x" has type "str") -class C(A, D): +class D(A, B2): pass +d1: D = {"y": 1} # E: Missing key "x" for TypedDict "D" +d2: D = {"x": "x", "y": 2} +d3: D = {"x": 1, "y": 2} # E: Incompatible types (expression has type "int", TypedDict item "x" has type "str") +[builtins fixtures/dict.pyi] +[typing fixtures/typing-typeddict.pyi] + +[case testTypedDictAliasDuplicateBases] +from typing import TypedDict +from typing_extensions import TypeAlias + +class A(TypedDict): + x: str + +A1 = A +A2 = A +A3: TypeAlias = A + +class E(A1, A2): pass # E: Duplicate base class "A" +class F(A1, A3): pass # E: Duplicate base class "A" +class G(A, A1): pass # E: Duplicate base class "A" + +class H(A, list): pass # E: All bases of a new TypedDict must be TypedDict types +[builtins fixtures/dict.pyi] +[typing fixtures/typing-typeddict.pyi] + +[case testTypedDictAliasGeneric] +from typing import Generic, TypedDict, TypeVar +from typing_extensions import TypeAlias -c1: C = {} -c2: C = {"x": "x"} -c3: C = {"y": 1} -c4: C = {"x": "x", "y": 2} +_T = TypeVar("_T") + +class A(Generic[_T], TypedDict): + x: _T + +A1 = A[_T] +A2: TypeAlias = A[_T] +Aint = A[int] + +class C(A1[_T]): + y: str +c1: C[int] = {"x": 0, "y": "a"} +c2: C[int] = {"x": "no", "y": "a"} # E: Incompatible types (expression has type "str", TypedDict item "x" has type "int") + +class D(A2[_T]): + y: str +d1: D[int] = {"x": 0, "y": "a"} +d2: D[int] = {"x": "no", "y": "a"} # E: Incompatible types (expression has type "str", TypedDict item "x" has type "int") + +class E(Aint): + y: str +e1: E = {"x": 0, "y": "a"} +e2: E = {"x": "no", "y": "a"} [builtins fixtures/dict.pyi] [typing fixtures/typing-typeddict.pyi] From c0b6185a939e2e8a523b67578ff62d29dae59322 Mon Sep 17 00:00:00 2001 From: STerliakov Date: Sat, 29 Mar 2025 18:13:47 +0100 Subject: [PATCH 3/3] Document that implicitly generic TypedDict aliases are not supported --- test-data/unit/check-typeddict.test | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/test-data/unit/check-typeddict.test b/test-data/unit/check-typeddict.test index 9e593038e170..d05fe7c66c48 100644 --- a/test-data/unit/check-typeddict.test +++ b/test-data/unit/check-typeddict.test @@ -4216,6 +4216,11 @@ _T = TypeVar("_T") class A(Generic[_T], TypedDict): x: _T +# This is by design - no_args aliases are only supported for instances +A0 = A +class B(A0[str]): # E: Bad number of arguments for type alias, expected 0, given 1 + y: int + A1 = A[_T] A2: TypeAlias = A[_T] Aint = A[int]