From d6486781bbf10ba0c25d34124d9b84bd7eeaf6cf Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Wed, 18 Mar 2026 17:27:17 -0700 Subject: [PATCH] Fix UpdateClass to preserve non-overridden existing members UpdateClass was dropping all members declared in the current class that weren't explicitly returned by UpdateClass. Now returned members override existing ones, but existing annotations, methods, and defaults are preserved if not overridden. To remove a member, make its type Never. --- tests/test_dataclass_like.py | 19 +------ tests/test_type_eval.py | 84 +++++++++++++++++++++++----- typemap/type_eval/_eval_operators.py | 24 +++++++- 3 files changed, 95 insertions(+), 32 deletions(-) diff --git a/tests/test_dataclass_like.py b/tests/test_dataclass_like.py index 0f85847..e2f2202 100644 --- a/tests/test_dataclass_like.py +++ b/tests/test_dataclass_like.py @@ -8,8 +8,6 @@ import typemap_extensions as typing -import pytest - class FieldArgs(TypedDict, total=False): default: ReadOnly[object] @@ -37,6 +35,9 @@ class Field[T: FieldArgs](typing.InitField[T]): ) +# TODO: what could we do to make dataclass_ish work at runtime? + + # Begin PEP section: dataclass like __init__ """ @@ -133,30 +134,16 @@ class Hero(Model): from typemap.type_eval import format_helper -@pytest.mark.xfail(reason="UpateClass currently drops things") def test_dataclass_like_1(): tgt = eval_typing(Hero) fmt = format_helper.format_class(tgt) assert fmt == textwrap.dedent("""\ class Hero: - @classmethod - def __init_subclass__[T](cls: type[T]) -> typemap.typing.UpdateClass[InitFnType[T]]: ... id: int | None = None name: str age: int | None = Field(default=None) secret_name: str - def __init__(self: Self, *, id: int | None = ..., name: str, age: int | None = ..., secret_name: str) -> None: ... - """) - - -# XXX: Delete this test once above passes -def test_dataclass_like_1_temp(): - tgt = eval_typing(Hero) - fmt = format_helper.format_class(tgt) - - assert fmt == textwrap.dedent("""\ - class Hero: @classmethod def __init_subclass__[T](cls: type[T]) -> typemap.typing.UpdateClass[InitFnType[T]]: ... def __init__(self: Self, *, id: int | None = ..., name: str, age: int | None = ..., secret_name: str) -> None: ... diff --git a/tests/test_type_eval.py b/tests/test_type_eval.py index 4926d36..c7d52fd 100644 --- a/tests/test_type_eval.py +++ b/tests/test_type_eval.py @@ -1927,19 +1927,20 @@ def __init_subclass__( def f(self) -> int: ... class B(A): - b0: int # omitted + b0: int # kept b1: int # overridden # b2 added in UpdateClass - def g(self) -> int: ... # omitted + def g(self) -> int: ... # kept - # Attrs + # Attrs: UpdateClass members first (a2, b1, b2), then non-overridden (b0) attrs = eval_typing(Attrs[B]) assert attrs.__args__ == ( Member[Literal["a1"], int, Never, Never, A], Member[Literal["a2"], str, Never, Never, B], Member[Literal["b1"], str, Never, Never, B], Member[Literal["b2"], str, Never, Never, B], + Member[Literal["b0"], int, Never, Never, B], ) # Members @@ -1951,6 +1952,7 @@ def g(self) -> int: ... # omitted Member[Literal["a2"], str, Never, Never, B], Member[Literal["b1"], str, Never, Never, B], Member[Literal["b2"], str, Never, Never, B], + Member[Literal["b0"], int, Never, Never, B], Member[ Literal["__init_subclass__"], classmethod[ @@ -1973,6 +1975,13 @@ def g(self) -> int: ... # omitted object, A, ], + Member[ + Literal["g"], + Callable[Params[Param[Literal["self"], B]], int], + Literal["ClassVar"], + object, + B, + ], ] ) @@ -1983,12 +1992,12 @@ def g(self) -> int: ... # omitted GetMember[B, Literal["a2"]], GetMember[B, Literal["b1"]], GetMember[B, Literal["b2"]], + GetMember[B, Literal["b0"]], GetMember[B, Literal["__init_subclass__"]], GetMember[B, Literal["f"]], + GetMember[B, Literal["g"]], ] ) - m = eval_typing(GetMember[B, Literal["g"]]) - assert m == Never type MembersExceptInitSubclass[T] = tuple[ @@ -2018,13 +2027,13 @@ def __init_subclass__[T]( def f(self) -> int: ... class B(A): - b0: int # omitted + b0: int # kept b1: int # overridden # b2 added in UpdateClass - def g(self) -> int: ... # omitted + def g(self) -> int: ... # kept - # Attrs + # Attrs: UpdateClass members first (a2, b1, b2), then non-overridden (b0) attrs = eval_typing(Attrs[B]) assert ( attrs @@ -2033,6 +2042,7 @@ def g(self) -> int: ... # omitted Member[Literal["a2"], str, Never, Never, B], Member[Literal["b1"], str, Never, Never, B], Member[Literal["b2"], str, Never, Never, B], + Member[Literal["b0"], int, Never, Never, B], ] ) @@ -2045,6 +2055,7 @@ def g(self) -> int: ... # omitted Member[Literal["a2"], str, Never, Never, B], Member[Literal["b1"], str, Never, Never, B], Member[Literal["b2"], str, Never, Never, B], + Member[Literal["b0"], int, Never, Never, B], Member[ Literal["f"], Callable[Params[Param[Literal["self"], A]], int], @@ -2052,6 +2063,13 @@ def g(self) -> int: ... # omitted object, A, ], + Member[ + Literal["g"], + Callable[Params[Param[Literal["self"], B]], int], + Literal["ClassVar"], + object, + B, + ], ] ) @@ -2062,11 +2080,11 @@ def g(self) -> int: ... # omitted GetMember[B, Literal["a2"]], GetMember[B, Literal["b1"]], GetMember[B, Literal["b2"]], + GetMember[B, Literal["b0"]], GetMember[B, Literal["f"]], + GetMember[B, Literal["g"]], ] ) - m = eval_typing(GetMember[B, Literal["g"]]) - assert m == Never type AttrsAsSets[T] = UpdateClass[ @@ -2089,7 +2107,7 @@ def f(self) -> int: ... class B(A): b: str - def g(self) -> int: ... # omitted + def g(self) -> int: ... # kept # Attrs attrs = eval_typing(Attrs[B]) @@ -2115,6 +2133,13 @@ def g(self) -> int: ... # omitted object, A, ], + Member[ + Literal["g"], + Callable[Params[Param[Literal["self"], B]], int], + Literal["ClassVar"], + object, + B, + ], ] ) @@ -2124,10 +2149,9 @@ def g(self) -> int: ... # omitted GetMember[B, Literal["a"]], GetMember[B, Literal["b"]], GetMember[B, Literal["f"]], + GetMember[B, Literal["g"]], ] ) - m = eval_typing(GetMember[B, Literal["g"]]) - assert m == Never def test_update_class_members_04(): @@ -2581,7 +2605,39 @@ class B(A): b: int attrs = eval_typing(Attrs[B]) - assert attrs == tuple[Member[Literal["a"], int, Never, Never, A]] + assert ( + attrs + == tuple[ + Member[Literal["a"], int, Never, Never, A], + Member[Literal["b"], int, Never, Never, B], + ] + ) + + +def test_update_class_never_removes(): + # A member with type Never in UpdateClass removes it + class A: + a: int + b: str + c: float + + def __init_subclass__[T]( + cls: type[T], + ) -> UpdateClass[Member[Literal["b"], Never],]: + super().__init_subclass__() + + class B(A): + d: bool + + attrs = eval_typing(Attrs[B]) + assert ( + attrs + == tuple[ + Member[Literal["a"], int, Never, Never, A], + Member[Literal["c"], float, Never, Never, A], + Member[Literal["d"], bool, Never, Never, B], + ] + ) ############## diff --git a/typemap/type_eval/_eval_operators.py b/typemap/type_eval/_eval_operators.py index 2618505..48f1bd2 100644 --- a/typemap/type_eval/_eval_operators.py +++ b/typemap/type_eval/_eval_operators.py @@ -170,7 +170,8 @@ def get_annotated_type_hints(cls, *, ctx, attrs_only=False, **kwargs): hints[k] = ty, tuple(sorted(quals)), init, acls - return hints + # A type of Never in UpdateClass removes the member + return {k: v for k, v in hints.items() if v[0] is not typing.Never} def get_annotated_method_hints(cls, *, ctx): @@ -311,14 +312,18 @@ def _create_updated_class( # Copy the module dct["__module__"] = t.__module__ - # Process the new members from UpdateClass + # Process UpdateClass members first to establish their ordering, + # then append non-overridden existing members. dct["__annotations__"] = annos = {} + update_names: set[str] = set() + for m in ms: tname, typ, quals, init, _ = typing.get_args(m) member_name = _eval_literal(tname, ctx) typ = _eval_types(typ, ctx) tquals = _eval_types(quals, ctx) + update_names.add(member_name) if ( type_eval.issubtype(typing.Literal["ClassVar"], tquals) and _is_method_like(typ) @@ -330,6 +335,21 @@ def _create_updated_class( annos[member_name] = _add_quals(typ, tquals) _unpack_init(dct, member_name, init) + # Append non-overridden existing annotations (preserving their order) + for name, typ in getattr(t, '__annotations__', {}).items(): + if name not in update_names: + annos[name] = typ + + # Append non-overridden existing methods and annotation defaults + existing_annos = getattr(t, '__annotations__', {}) + for name, value in t.__dict__.items(): + if name in update_names or name in _apply_generic.EXCLUDED_ATTRIBUTES: + continue + if isinstance(inspect.unwrap(value), types.FunctionType): + dct[name] = value + elif name in existing_annos: + dct[name] = value + # Create the updated class # If typing.Generic is a base, we need to use it with the type params