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)