From c592688816f8ee5f3730c709a3d48a8f542c3566 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sat, 10 Oct 2026 06:35:22 -0400 Subject: [PATCH] Stop recursive type alias expansion on Python 3.10; release 0.2.4 Since 0.2.1 (0da0ee8), resolved_class_annotations expanded nested type strings left by Python 3.10's get_type_hints with no stop. A recursive alias such as WireValue = str | list["WireValue"] | dict[str, "WireValue"] re-evaluated its own name forever and raised RecursionError in dataclass_from_mapping (seen in zmqruntime's progress decode test). Expansion now tracks the names being expanded and leaves a self-reference as a ForwardRef, the form get_type_hints returns on Python 3.11+. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 +++ pyproject.toml | 2 +- src/python_introspect/__init__.py | 2 +- src/python_introspect/annotation_types.py | 37 +++++++++++++++++++---- tests/test_dataclass_projection.py | 36 +++++++++++++++++++++- 5 files changed, 73 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 19d88eb..e0b7ff0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ 0.2.2 is the first 0.2.x release published to PyPI. The 0.2.0 and 0.2.1 tags exist in git, but their publish runs were cancelled, so upgrading from PyPI goes straight from 0.1.16 to 0.2.2. The notes below cover every change in that jump. +## 0.2.4 + +Fixed: +- `dataclass_from_mapping` and `validate_annotated_dataclass` no longer raise `RecursionError` on Python 3.10 for fields declared with a recursive type alias such as `V = str | list["V"] | dict[str, "V"]`. Since 0.2.1, nested type strings were expanded on 3.10 with no stop, so a self-reference expanded forever. Expansion now stops at a self-reference and leaves it as a `ForwardRef`, which is what `get_type_hints` returns on Python 3.11 and later, so the decoder sees the same declaration on every version. + ## 0.2.3 Added: diff --git a/pyproject.toml b/pyproject.toml index 518697d..6cd0c1b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "python-introspect" -version = "0.2.3" +version = "0.2.4" description = "Pure Python introspection toolkit for function signatures, dataclasses, and type hints" readme = "README.md" requires-python = ">=3.10" diff --git a/src/python_introspect/__init__.py b/src/python_introspect/__init__.py index adc25a8..d131c8b 100644 --- a/src/python_introspect/__init__.py +++ b/src/python_introspect/__init__.py @@ -9,7 +9,7 @@ type resolution for framework-specific types (lazy configs, proxies, etc.) """ -__version__ = "0.2.3" +__version__ = "0.2.4" from .signature_analyzer import ( SignatureAnalyzer, diff --git a/src/python_introspect/annotation_types.py b/src/python_introspect/annotation_types.py index ff1a09a..09e09a8 100644 --- a/src/python_introspect/annotation_types.py +++ b/src/python_introspect/annotation_types.py @@ -5,7 +5,15 @@ import sys import types from enum import Enum -from typing import Annotated, Literal, Union, get_args, get_origin, get_type_hints +from typing import ( + Annotated, + ForwardRef, + Literal, + Union, + get_args, + get_origin, + get_type_hints, +) def resolved_class_annotations(owner: type) -> dict[str, object]: @@ -13,6 +21,9 @@ def resolved_class_annotations(owner: type) -> dict[str, object]: Python 3.10 leaves strings inside PEP 585 aliases unresolved. Complete those declarations without interpreting Literal values or Annotated metadata. + A recursive alias such as ``V = int | list["V"]`` names itself; the inner + reference is left as a ``ForwardRef``, the form ``get_type_hints`` produces + for it on Python 3.11 and later. """ annotations = get_type_hints(owner, include_extras=True) unresolved = set(annotations) @@ -29,20 +40,34 @@ def resolved_class_annotations(owner: type) -> dict[str, object]: return annotations -def _resolve_nested_type_strings(annotation: object, globalns: dict, localns: dict) -> object: +def _resolve_nested_type_strings( + annotation: object, + globalns: dict, + localns: dict, + expanding: frozenset[str] = frozenset(), +) -> object: if isinstance(annotation, str): - return _resolve_nested_type_strings(eval(annotation, globalns, localns), globalns, localns) + if annotation in expanding: + return ForwardRef(annotation) + return _resolve_nested_type_strings( + eval(annotation, globalns, localns), globalns, localns, expanding | {annotation} + ) if isinstance(annotation, list): - resolved = [_resolve_nested_type_strings(member, globalns, localns) for member in annotation] + resolved = [ + _resolve_nested_type_strings(member, globalns, localns, expanding) + for member in annotation + ] return annotation if all(a is b for a, b in zip(resolved, annotation)) else resolved origin = get_origin(annotation) members = get_args(annotation) if not members or origin is Literal: return annotation if origin is Annotated: - base = _resolve_nested_type_strings(members[0], globalns, localns) + base = _resolve_nested_type_strings(members[0], globalns, localns, expanding) return annotation if base is members[0] else Annotated[(base, *members[1:])] - resolved = tuple(_resolve_nested_type_strings(member, globalns, localns) for member in members) + resolved = tuple( + _resolve_nested_type_strings(member, globalns, localns, expanding) for member in members + ) if all(member is original for member, original in zip(resolved, members)): return annotation if isinstance(annotation, types.GenericAlias): diff --git a/tests/test_dataclass_projection.py b/tests/test_dataclass_projection.py index e0bbc6c..3817038 100644 --- a/tests/test_dataclass_projection.py +++ b/tests/test_dataclass_projection.py @@ -1,7 +1,7 @@ from dataclasses import asdict, dataclass, field from enum import Enum from typing import Annotated, Literal -from collections.abc import Sequence +from collections.abc import Mapping, Sequence import pytest @@ -297,3 +297,37 @@ def test_inherited_nested_types_use_the_declaring_module_namespace(monkeypatch): ) result = dataclass_from_mapping(child_module.Child, {"children": [{"name": "child"}]}) assert isinstance(result.children[0], base_module.Leaf) + + +WireValue = ( + str + | int + | float + | bool + | None + | list["WireValue"] + | tuple["WireValue", ...] + | dict[str, "WireValue"] +) + + +@dataclass(frozen=True) +class WireEnvelope: + sequence: int + event: Mapping[str, WireValue] | None + + +def test_recursive_type_alias_decodes_nested_values() -> None: + # Python 3.10 keeps "WireValue" as a string inside the PEP 585 aliases; + # resolving it must stop where the alias names itself. + payload = { + "sequence": 1, + "event": { + "phase": "compile", + "percent": 50.0, + "context": {"axes": ["A01"], "details": [{"ready": True, "error": None}]}, + }, + } + result = dataclass_from_mapping(WireEnvelope, payload) + assert result == WireEnvelope(1, payload["event"]) + validate_annotated_dataclass(result)