From 8ea096c5a39bdfaa042ed63957b308cd6bae89f8 Mon Sep 17 00:00:00 2001 From: dnwpark Date: Tue, 10 Feb 2026 16:20:41 -0800 Subject: [PATCH 01/11] Add test. --- tests/test_type_eval.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/test_type_eval.py b/tests/test_type_eval.py index a3f122a..bcab3eb 100644 --- a/tests/test_type_eval.py +++ b/tests/test_type_eval.py @@ -2017,6 +2017,26 @@ def g(self) -> int: ... ) +def test_update_class_members_05(): + # Generic base class with UpdateClass + class A[T]: + a: T + + def __init_subclass__[U]( + cls: type[U], + ) -> AttrsAsSets[U]: + super().__init_subclass__() + + class B(A[int]): + b: str + + attrs = eval_typing(Attrs[B]) + assert attrs.__args__ == ( + Member[Literal["a"], set[int], Never, Never, B], + Member[Literal["b"], set[str], Never, Never, B], + ) + + def test_update_class_inheritance_01(): # current class init subclass is not applied class A: From 97729052a21079a3e24229da6fbef2f284252b6f Mon Sep 17 00:00:00 2001 From: dnwpark Date: Tue, 10 Feb 2026 16:46:02 -0800 Subject: [PATCH 02/11] Get __init_subclass__ from origin if there is one. --- typemap/type_eval/_eval_operators.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/typemap/type_eval/_eval_operators.py b/typemap/type_eval/_eval_operators.py index 46c3747..1b9c749 100644 --- a/typemap/type_eval/_eval_operators.py +++ b/typemap/type_eval/_eval_operators.py @@ -206,7 +206,8 @@ def _eval_init_subclass( def _get_update_class_members( cls: type, base: type, ctx: typing.Any ) -> list[Member] | None: - init_subclass = base.__dict__.get("__init_subclass__") + origin = typing.get_origin(base) or base + init_subclass = origin.__dict__.get("__init_subclass__") if not init_subclass: return None init_subclass = inspect.unwrap(init_subclass) From 735090e5db7e13209f25059f920be33073b2a266 Mon Sep 17 00:00:00 2001 From: dnwpark Date: Tue, 10 Feb 2026 16:47:53 -0800 Subject: [PATCH 03/11] More tests. --- tests/test_type_eval.py | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/tests/test_type_eval.py b/tests/test_type_eval.py index bcab3eb..51d9cb6 100644 --- a/tests/test_type_eval.py +++ b/tests/test_type_eval.py @@ -2037,6 +2037,46 @@ class B(A[int]): ) +def test_update_class_members_06(): + # Generic derived class with UpdateClass + class A: + a: int + + def __init_subclass__[T]( + cls: type[T], + ) -> AttrsAsSets[T]: + super().__init_subclass__() + + class B[T](A): + b: T + + attrs = eval_typing(Attrs[B[int]]) + assert attrs.__args__ == ( + Member[Literal["a"], set[int], Never, Never, B[int]], + Member[Literal["b"], set[int], Never, Never, B[int]], + ) + + +def test_update_class_members_07(): + # Generic derived and base class with UpdateClass + class A[T]: + a: T + + def __init_subclass__[U]( + cls: type[U], + ) -> AttrsAsSets[U]: + super().__init_subclass__() + + class B[T](A[int]): + b: T + + attrs = eval_typing(Attrs[B[int]]) + assert attrs.__args__ == ( + Member[Literal["a"], set[int], Never, Never, B[int]], + Member[Literal["b"], set[int], Never, Never, B[int]], + ) + + def test_update_class_inheritance_01(): # current class init subclass is not applied class A: From 54f2656613cf5d2fe1617128bf3b6999258cf3a4 Mon Sep 17 00:00:00 2001 From: dnwpark Date: Tue, 10 Feb 2026 18:51:20 -0800 Subject: [PATCH 04/11] Properly handle generic derived classes. --- typemap/type_eval/_eval_operators.py | 45 +++++++++++++++++++++------- 1 file changed, 35 insertions(+), 10 deletions(-) diff --git a/typemap/type_eval/_eval_operators.py b/typemap/type_eval/_eval_operators.py index 1b9c749..fe37c64 100644 --- a/typemap/type_eval/_eval_operators.py +++ b/typemap/type_eval/_eval_operators.py @@ -16,6 +16,7 @@ from typemap.type_eval._eval_typing import ( _child_context, _eval_types, + EvalContext, ) from typemap.typing import ( Attrs, @@ -82,7 +83,7 @@ def _make_init_type(v): return typing.Literal[(v,)] -def cached_box(cls, *, ctx): +def cached_box(cls, *, ctx: EvalContext): if str(cls).startswith('typemap.typing'): return _apply_generic.box(cls) if cls in ctx.box_cache: @@ -186,26 +187,27 @@ def get_annotated_method_hints(cls, *, ctx): def _eval_init_subclass( - box: _apply_generic.Boxed, ctx: typing.Any + box: _apply_generic.Boxed, ctx: EvalContext ) -> _apply_generic.Boxed: """Get type after all __init_subclass__ with UpdateClass are evaluated.""" for abox in box.mro[1:]: # Skip the type itself with _child_context() as ctx: - if ms := _get_update_class_members( - box.cls, abox.alias_type(), ctx=ctx - ): + if ms := _get_update_class_members(box, abox.alias_type(), ctx=ctx): nbox = _apply_generic.box( - _create_updated_class(box.cls, ms, ctx=ctx) + _create_updated_class(box, ms, ctx=ctx) ) # We want to preserve the original cls for Members output - box = dataclasses.replace(nbox, orig_cls=box.canonical_cls) + box = dataclasses.replace( + nbox, orig_cls=box.canonical_cls, args=box.args + ) ctx.box_cache[box.cls] = box return box def _get_update_class_members( - cls: type, base: type, ctx: typing.Any + box: _apply_generic.Boxed, base: type, ctx: EvalContext ) -> list[Member] | None: + cls = box.cls origin = typing.get_origin(base) or base init_subclass = origin.__dict__.get("__init_subclass__") if not init_subclass: @@ -244,6 +246,12 @@ def _get_update_class_members( ret_annotation, substitution ) + # __init_subclass__ hooks may have annotations that reference the + # annotations of the class being defined (e.g. Attrs[T]). These + # will call cached_box(T) expecting the original boxed cls, not + # the updated one. + ctx.box_cache[box.cls] = box + # Evaluate the return annotation evaled_ret = _eval_types(ret_annotation, ctx=ctx) @@ -257,7 +265,10 @@ def _get_update_class_members( return None -def _create_updated_class(t: type, ms: list[Member], ctx) -> type: +def _create_updated_class( + box: _apply_generic.Boxed, ms: list[Member], ctx: EvalContext +) -> type: + t = box.cls dct: dict[str, object] = {} # Copy the module @@ -281,8 +292,22 @@ def _create_updated_class(t: type, ms: list[Member], ctx) -> type: _unpack_init(dct, member_name, init) # Create the updated class + + # If typing.Generic is a base, we need to use it with the type params + # applied. Additionally, use types.newclass to properly resolve the mro. + bases = tuple( + b.alias_type() + if b.cls is not typing.Generic + else typing.Generic[t.__type_params__] # type: ignore[index] + for b in box.bases + ) + + kwds = {} mcls = type(t) - cls = mcls(t.__name__, t.__bases__, dct) + if mcls is not type: + kwds["metaclass"] = mcls + + cls = types.new_class(t.__name__, bases, kwds, lambda ns: ns.update(dct)) return cls From 79c73cf96984ef341fa7dc4755056cd8726a7df8 Mon Sep 17 00:00:00 2001 From: dnwpark Date: Tue, 10 Feb 2026 18:58:59 -0800 Subject: [PATCH 05/11] More tests. --- tests/test_type_eval.py | 43 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/tests/test_type_eval.py b/tests/test_type_eval.py index 51d9cb6..8cfd06d 100644 --- a/tests/test_type_eval.py +++ b/tests/test_type_eval.py @@ -2056,9 +2056,46 @@ class B[T](A): Member[Literal["b"], set[int], Never, Never, B[int]], ) + attrs = eval_typing(Attrs[B[str]]) + assert attrs.__args__ == ( + Member[Literal["a"], set[int], Never, Never, B[str]], + Member[Literal["b"], set[str], Never, Never, B[str]], + ) + def test_update_class_members_07(): # Generic derived and base class with UpdateClass + # derived from generic base + class A[T]: + a: T + + def __init_subclass__[U]( + cls: type[U], + ) -> AttrsAsSets[U]: + super().__init_subclass__() + + class B[T](A[tuple[T]]): + b: T + + class C[T, U](A[U]): + c: T + + attrs = eval_typing(Attrs[B[int]]) + assert attrs.__args__ == ( + Member[Literal["a"], set[tuple[int]], Never, Never, B[int]], + Member[Literal["b"], set[int], Never, Never, B[int]], + ) + + attrs = eval_typing(Attrs[C[int, str]]) + assert attrs.__args__ == ( + Member[Literal["a"], set[str], Never, Never, C[int, str]], + Member[Literal["c"], set[int], Never, Never, C[int, str]], + ) + + +def test_update_class_members_08(): + # Generic derived and base class with UpdateClass + # derived from specialized base class A[T]: a: T @@ -2076,6 +2113,12 @@ class B[T](A[int]): Member[Literal["b"], set[int], Never, Never, B[int]], ) + attrs = eval_typing(Attrs[B[str]]) + assert attrs.__args__ == ( + Member[Literal["a"], set[int], Never, Never, B[str]], + Member[Literal["b"], set[str], Never, Never, B[str]], + ) + def test_update_class_inheritance_01(): # current class init subclass is not applied From cb1d6cd0b79979f4788dbe24a7823b38de55c43f Mon Sep 17 00:00:00 2001 From: dnwpark Date: Wed, 11 Feb 2026 09:30:46 -0800 Subject: [PATCH 06/11] Fix leaking annotation evals. --- typemap/type_eval/_apply_generic.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/typemap/type_eval/_apply_generic.py b/typemap/type_eval/_apply_generic.py index 47db9f0..e40c451 100644 --- a/typemap/type_eval/_apply_generic.py +++ b/typemap/type_eval/_apply_generic.py @@ -233,6 +233,10 @@ def get_annotations( if mod := sys.modules.get(obj.__module__): globs.update(vars(mod)) + # Make a copy in case we need to eval the annotations. We don't want to + # modify the original. + rr = dict(rr) + if isinstance(rr, dict) and any(isinstance(v, str) for v in rr.values()): args = dict(args) # Copy in any __type_params__ that aren't provided for, so that if From 5bf29f20505ac9c05019ede07e45415ca4ae0630 Mon Sep 17 00:00:00 2001 From: dnwpark Date: Wed, 11 Feb 2026 09:46:41 -0800 Subject: [PATCH 07/11] More tests. --- tests/test_type_eval.py | 51 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/tests/test_type_eval.py b/tests/test_type_eval.py index 8cfd06d..d5b856f 100644 --- a/tests/test_type_eval.py +++ b/tests/test_type_eval.py @@ -2120,6 +2120,57 @@ class B[T](A[int]): ) +def test_update_class_members_09(): + # Generic classes which use their type params in UpdateClass + class A[V]: + def __init_subclass__[T]( + cls: type[T], + ) -> UpdateClass[Member[Literal["a"], V], *Attrs[T]]: + super().__init_subclass__() + + class B[V](A[int]): + def __init_subclass__[T]( + cls: type[T], + ) -> UpdateClass[Member[Literal["b"], V], *Attrs[T]]: + super().__init_subclass__() + + class C[V](B[str]): + def __init_subclass__[T]( + cls: type[T], + ) -> UpdateClass[Member[Literal["c"], V], *Attrs[T]]: + super().__init_subclass__() + + class D[V](C[float]): + def __init_subclass__[T]( + cls: type[T], + ) -> UpdateClass[Member[Literal["d"], V]]: + super().__init_subclass__() + + attrs = eval_typing(Attrs[A[int]]) + assert attrs == tuple[()] + + attrs = eval_typing(Attrs[B[str]]) + assert attrs == tuple[Member[Literal["a"], int, Never, Never, B[str]]] + + attrs = eval_typing(Attrs[C[float]]) + assert ( + attrs + == tuple[ + Member[Literal["a"], int, Never, Never, C[float]], + Member[Literal["b"], str, Never, Never, C[float]], + ] + ) + + attrs = eval_typing(Attrs[D[bool]]) + assert ( + attrs + == tuple[ + Member[Literal["a"], int, Never, Never, D[bool]], + Member[Literal["b"], str, Never, Never, D[bool]], + Member[Literal["c"], float, Never, Never, D[bool]], + ] + ) + def test_update_class_inheritance_01(): # current class init subclass is not applied class A: From ee09814433497907ce01d44542235bf7df911c14 Mon Sep 17 00:00:00 2001 From: dnwpark Date: Wed, 11 Feb 2026 10:52:33 -0800 Subject: [PATCH 08/11] Handle type params which are used in UpdateClass. --- typemap/type_eval/_eval_operators.py | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/typemap/type_eval/_eval_operators.py b/typemap/type_eval/_eval_operators.py index fe37c64..7895bce 100644 --- a/typemap/type_eval/_eval_operators.py +++ b/typemap/type_eval/_eval_operators.py @@ -208,15 +208,26 @@ def _get_update_class_members( box: _apply_generic.Boxed, base: type, ctx: EvalContext ) -> list[Member] | None: cls = box.cls - origin = typing.get_origin(base) or base - init_subclass = origin.__dict__.get("__init_subclass__") + + # Get __init_subclass__ from the base class's origin if base is generic. + base_origin = typing.get_origin(base) or base + init_subclass = base_origin.__dict__.get("__init_subclass__") if not init_subclass: return None init_subclass = inspect.unwrap(init_subclass) args = {} + # Get any type params from the base class if it is generic + if (base_args := typing.get_args(base)) and ( + origin_params := getattr(base_origin, '__type_params__', None) + ): + args = dict( + zip((p.__name__ for p in origin_params), base_args, strict=True) + ) + + # Get type params from function if type_params := getattr(init_subclass, '__type_params__', None): - args[str(type_params[0])] = cls + args[type_params[0].__name__] = cls init_subclass_annos = _apply_generic.get_annotations(init_subclass, args) @@ -308,6 +319,9 @@ def _create_updated_class( kwds["metaclass"] = mcls cls = types.new_class(t.__name__, bases, kwds, lambda ns: ns.update(dct)) + # Explicitly set __type_params__. This normally doesn't work, but we are + # creating fake classes for the purpose of type evaluation. + cls.__type_params__ = t.__type_params__ return cls From 41bd11cfd2a490d98670f572930d777d3d3bf39a Mon Sep 17 00:00:00 2001 From: dnwpark Date: Wed, 11 Feb 2026 13:06:42 -0800 Subject: [PATCH 09/11] Use boxed base. --- typemap/type_eval/_eval_operators.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/typemap/type_eval/_eval_operators.py b/typemap/type_eval/_eval_operators.py index 7895bce..c675609 100644 --- a/typemap/type_eval/_eval_operators.py +++ b/typemap/type_eval/_eval_operators.py @@ -192,7 +192,7 @@ def _eval_init_subclass( """Get type after all __init_subclass__ with UpdateClass are evaluated.""" for abox in box.mro[1:]: # Skip the type itself with _child_context() as ctx: - if ms := _get_update_class_members(box, abox.alias_type(), ctx=ctx): + if ms := _get_update_class_members(box, abox, ctx=ctx): nbox = _apply_generic.box( _create_updated_class(box, ms, ctx=ctx) ) @@ -205,12 +205,14 @@ def _eval_init_subclass( def _get_update_class_members( - box: _apply_generic.Boxed, base: type, ctx: EvalContext + box: _apply_generic.Boxed, + boxed_base: _apply_generic.Boxed, + ctx: EvalContext, ) -> list[Member] | None: cls = box.cls # Get __init_subclass__ from the base class's origin if base is generic. - base_origin = typing.get_origin(base) or base + base_origin = boxed_base.cls init_subclass = base_origin.__dict__.get("__init_subclass__") if not init_subclass: return None @@ -218,7 +220,7 @@ def _get_update_class_members( args = {} # Get any type params from the base class if it is generic - if (base_args := typing.get_args(base)) and ( + if (base_args := boxed_base.args.values()) and ( origin_params := getattr(base_origin, '__type_params__', None) ): args = dict( From ea35e41612ea3f50b84c32f2ad3c64e399f94f92 Mon Sep 17 00:00:00 2001 From: dnwpark Date: Wed, 11 Feb 2026 13:59:43 -0800 Subject: [PATCH 10/11] More tests. --- tests/test_type_eval.py | 56 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/tests/test_type_eval.py b/tests/test_type_eval.py index d5b856f..09c407c 100644 --- a/tests/test_type_eval.py +++ b/tests/test_type_eval.py @@ -2171,6 +2171,45 @@ def __init_subclass__[T]( ] ) + +@pytest.mark.xfail(reason="Super sketchy....") +def test_update_class_members_10(): + # Generic classes which use other classes in their hierarchy + # within UpdateClass. + class A: + a: int + + def __init_subclass__[T]( + cls: type[T], + ) -> UpdateClass[ + Member[Literal["b"], B[str]], + Member[Literal["c"], C[float]], + Member[Literal["d"], D[bool]], + ]: + super().__init_subclass__() + + class B[T](A): + pass + + class C[T](B[T]): + pass + + class D[T](C[T]): + pass + + attrs = eval_typing(Attrs[C[int]]) + # A's __init_subclass__ adds Member["x", B[str]]; we get "a" from A and "x" from UpdateClass. + assert ( + attrs + == tuple[ + Member[Literal["a"], int, Never, Never, A], + Member[Literal["b"], B[str], Never, Never, C[int]], + Member[Literal["c"], C[float], Never, Never, C[int]], + Member[Literal["d"], D[bool], Never, Never, C[int]], + ] + ) + + def test_update_class_inheritance_01(): # current class init subclass is not applied class A: @@ -2288,6 +2327,23 @@ class C(B[float]): assert eval_typing(GetArg[C, A, Literal[1]]) is float +@pytest.mark.xfail(reason="TODO") +def test_update_class_empty_01(): + class A: + a: int + + def __init_subclass__[T]( + cls: type[T], + ) -> UpdateClass[()]: + super().__init_subclass__() + + class B(A): + b: int + + attrs = eval_typing(Attrs[B]) + assert attrs == tuple[()] + + ############## type XTest[X] = Annotated[X, 'blah'] From da650e40d8b15da7eef643e2676b464821318a4b Mon Sep 17 00:00:00 2001 From: dnwpark Date: Wed, 11 Feb 2026 14:45:47 -0800 Subject: [PATCH 11/11] Fix using box.cls instead of alias_type when caching updated classes. --- typemap/type_eval/_eval_operators.py | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/typemap/type_eval/_eval_operators.py b/typemap/type_eval/_eval_operators.py index c675609..fce90e6 100644 --- a/typemap/type_eval/_eval_operators.py +++ b/typemap/type_eval/_eval_operators.py @@ -200,7 +200,7 @@ def _eval_init_subclass( box = dataclasses.replace( nbox, orig_cls=box.canonical_cls, args=box.args ) - ctx.box_cache[box.cls] = box + ctx.box_cache[box.alias_type()] = box return box @@ -229,7 +229,7 @@ def _get_update_class_members( # Get type params from function if type_params := getattr(init_subclass, '__type_params__', None): - args[type_params[0].__name__] = cls + args[type_params[0].__name__] = box.alias_type() init_subclass_annos = _apply_generic.get_annotations(init_subclass, args) @@ -259,12 +259,6 @@ def _get_update_class_members( ret_annotation, substitution ) - # __init_subclass__ hooks may have annotations that reference the - # annotations of the class being defined (e.g. Attrs[T]). These - # will call cached_box(T) expecting the original boxed cls, not - # the updated one. - ctx.box_cache[box.cls] = box - # Evaluate the return annotation evaled_ret = _eval_types(ret_annotation, ctx=ctx)