From 8fad59181a5235da192c7d96ca014aeefb8b4fac Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 04:38:42 +0000 Subject: [PATCH] Fix two annotation-resolution regressions from the 3.13 rewrite Two independent regressions were introduced in the "Support Python 3.13" annotation-handling rework and would break every faust.Record downstream: 1. `annotations()` returned field types mangled by `_normalize_forwardref`, which rewrites any type whose `__qualname__` contains "" -- i.e. every class defined inside a function -- down to a bare `__name__` *string*, destroying the actual class object. faust.Record matches a field's resolved type by identity against its coercion mapping, so this silently disabled coercion/polymorphic handling for function-local model field types (and broke faust's model test suite). `_resolve_refs`/ `eval_type` already resolve string/ForwardRef annotations to real types, so no post-normalization is needed: return `fields` as-is (as 0.4.x did). `_normalize_forwardref` remains available as a comparison helper. 2. `eval_type` passed string annotations straight to `typing._eval_type`, whose `_type_check` rejects `ClassVar[...]` ("is not valid as type argument"). A *string* ClassVar is legitimate -- `from __future__ import annotations` stringizes every annotation -- so a Record with a quoted ClassVar (or any Record under future-annotations) raised at class creation. Catch that TypeError, evaluate the forward arg directly, and accept it when it is a ClassVar (callers drop it via skip_classvar). Adds regression tests for both. Verified against faust: its full model/table test suite goes from total collapse back to fully green, on Python 3.10-3.14. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01HHPL4VFWQRQPpjR1gXSKyL --- mode/utils/objects.py | 43 +++++++++++++++++++++++++++----- tests/unit/utils/test_objects.py | 32 ++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 6 deletions(-) diff --git a/mode/utils/objects.py b/mode/utils/objects.py index 9544952..5485d9b 100644 --- a/mode/utils/objects.py +++ b/mode/utils/objects.py @@ -366,11 +366,18 @@ def annotations( ) ) - # Normalize all field types for forward refs - normalized_fields = { - k: _normalize_forwardref(v) for k, v in fields.items() - } - return normalized_fields, defaults + # NOTE: Return the resolved field types as-is. `_resolve_refs`/`eval_type` + # above already turned string/ForwardRef annotations into real types, so + # nothing further is needed here. Do NOT run `_normalize_forwardref` over + # the result: it rewrites any type whose ``__qualname__`` contains + # "" (i.e. every class defined inside a function) down to a bare + # ``__name__`` *string*, destroying the actual class object. Callers such + # as faust.Record rely on identity of the returned types (e.g. matching a + # field type against a custom coercion mapping), which silently breaks + # when the type is replaced by its name string. `_normalize_forwardref` + # remains available as a comparison helper for callers/tests that want a + # canonical, hashable-by-name form. + return fields, defaults def local_annotations( @@ -453,6 +460,7 @@ def eval_type( """ invalid_types = invalid_types or set() alias_types = alias_types or {} + original_str = typ if isinstance(typ, str) else None if isinstance(typ, str): typ = ForwardRef(typ) # `typing._eval_type` (imported above as `_eval_type`) already resolves @@ -465,7 +473,30 @@ def eval_type( # attributes are not part of any stable API and are no longer present at # all on Python 3.14's ForwardRef, which raised AttributeError. mode's # floor is Python 3.9, well past the versions that workaround targeted). - typ = _eval_type(typ, globalns, localns) + if isinstance(typ, ForwardRef): + try: + typ = _eval_type(typ, globalns, localns) + except TypeError: + # `_eval_type` runs the forward ref through `typing._type_check`, + # which rejects `ClassVar[...]` with "is not valid as type + # argument". But a *string* ClassVar annotation is legitimate -- + # `from __future__ import annotations` stringizes every + # annotation, ClassVars included -- and callers (see + # `_resolve_refs` + `skip_classvar`) still want to see it so they + # can drop it. Evaluate the forward arg directly and accept it + # when it is a ClassVar; otherwise re-raise the real error. + src = ( + original_str + if original_str is not None + else typ.__forward_arg__ + ) + evaluated = eval(src, globalns, localns) # noqa: S307 + if _is_class_var(evaluated): + typ = evaluated + else: + raise + else: + typ = _eval_type(typ, globalns, localns) if typ in invalid_types: raise InvalidAnnotation(typ) return alias_types.get(typ, typ) diff --git a/tests/unit/utils/test_objects.py b/tests/unit/utils/test_objects.py index 0412894..e74867c 100644 --- a/tests/unit/utils/test_objects.py +++ b/tests/unit/utils/test_objects.py @@ -275,6 +275,38 @@ class Leaf(Middle): assert fields == {"foo": int} +def test_annotations__preserves_function_local_class_identity(): + # Regression test: a field annotated with a class defined inside a + # function (``__qualname__`` contains "") must be returned as the + # actual class object, not rewritten to its bare name string. Callers + # such as faust.Record match a field's resolved type by identity against + # a coercion mapping, which silently breaks if the type is replaced by a + # string. + class Local: + value: int + + class Holder: + item: Local + + fields, _ = annotations(Holder, globalns=globals(), localns=locals()) + + assert fields["item"] is Local + assert not isinstance(fields["item"], str) + + +def test_eval_type__string_classvar_is_not_rejected(): + # Regression test: a *string* ClassVar annotation (as produced by + # `from __future__ import annotations`) must not raise. Passing the + # forward ref straight to typing._eval_type runs typing._type_check, + # which rejects ClassVar[...] with "is not valid as type argument". + from typing import ClassVar as _ClassVar + + result = eval_type( + "ClassVar[int]", globalns={"ClassVar": _ClassVar}, localns={} + ) + assert result == _ClassVar[int] + + @pytest.mark.parametrize( "input,expected", [