From 0da0ee8b6a2bf727990e9cf693b43159ac74060e Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Mon, 5 Oct 2026 14:02:44 -0400 Subject: [PATCH] fix: resolve recursive sequence declarations on Python 3.10 --- .../recursive-sequence-python310.md | 9 ++++ pyproject.toml | 2 +- src/python_introspect/__init__.py | 2 +- src/python_introspect/annotation_types.py | 47 ++++++++++++++++++- src/python_introspect/dataclass_projection.py | 5 +- src/python_introspect/validation.py | 5 +- tests/test_dataclass_projection.py | 45 +++++++++++++++++- 7 files changed, 105 insertions(+), 10 deletions(-) create mode 100644 docs/validation/recursive-sequence-python310.md diff --git a/docs/validation/recursive-sequence-python310.md b/docs/validation/recursive-sequence-python310.md new file mode 100644 index 0000000..5baabcd --- /dev/null +++ b/docs/validation/recursive-sequence-python310.md @@ -0,0 +1,9 @@ +# Recursive abstract sequence declarations on Python 3.10 + +Issue #13 reproduced using standalone CPython 3.10.22, with the actual unchanged decoder on merged main 43cdbf3: 3 failed, 29 passed across existing decoder/validation controls. The three failures were nested Sequence list/tuple reconstruction and unknown child-field rejection. The runtime was unpacked into a private maintenance packet; shared installed Python-introspect 0.2.0 and OpenHCS dependencies were not changed. + +The annotation_types owner completes unresolved nested type strings after standard get_type_hints, using the declaring class/module namespace. Decoder and dataclass validator consume that same declaration. Literal values and Annotated metadata remain values; inherited fields use the original declaring module. No version-specific escape, private typing API, annotation cache, or duplicate decoder is added. + +Receiving: full existing package suite plus two boundary controls, **145 PASS on CPython 3.10.22 and 145 PASS on CPython 3.12**. The added controls preserve Annotated/Literal metadata, direct nested-type validation, and inherited declarations across modules with conflicting names. Local raw logs: external-ready-cleanup-v1/pi310-red.log, pi310-green.log, pi312-green.log. Existing unrelated Ruff UP007 and SIM102 findings are not claimed repaired. + +Version 0.2.1 is a distinct patch declaration; v0.2.0 remains immutable. This PR does not publish wheels or change the shared installed environment. diff --git a/pyproject.toml b/pyproject.toml index 75ca28d..954d060 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "python-introspect" -version = "0.2.0" +version = "0.2.1" 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 ae06f9b..7dbbefe 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.0" +__version__ = "0.2.1" from .signature_analyzer import ( SignatureAnalyzer, diff --git a/src/python_introspect/annotation_types.py b/src/python_introspect/annotation_types.py index 12ac37d..a70a034 100644 --- a/src/python_introspect/annotation_types.py +++ b/src/python_introspect/annotation_types.py @@ -2,9 +2,54 @@ from __future__ import annotations +import sys import types from enum import Enum -from typing import Annotated, Union, get_args, get_origin +from typing import Annotated, Literal, Union, get_args, get_origin, get_type_hints + + +def resolved_class_annotations(owner: type) -> dict[str, object]: + """Resolve nested type strings in the class that declared each field. + + Python 3.10 leaves strings inside PEP 585 aliases unresolved. Complete + those declarations without interpreting Literal values or Annotated metadata. + """ + annotations = get_type_hints(owner, include_extras=True) + unresolved = set(annotations) + for declaration in owner.__mro__: + class_namespace = dict(vars(declaration)) + module = sys.modules.get(declaration.__module__) + module_namespace = {} if module is None else vars(module) + for name in class_namespace.get("__annotations__", {}): + if name in unresolved: + annotations[name] = _resolve_nested_type_strings( + annotations[name], class_namespace, module_namespace + ) + unresolved.remove(name) + return annotations + + +def _resolve_nested_type_strings(annotation: object, globalns: dict, localns: dict) -> object: + if isinstance(annotation, str): + return _resolve_nested_type_strings(eval(annotation, globalns, localns), globalns, localns) + if isinstance(annotation, list): + resolved = [_resolve_nested_type_strings(member, globalns, localns) 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) + 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) + if all(member is original for member, original in zip(resolved, members)): + return annotation + if isinstance(annotation, types.GenericAlias): + return types.GenericAlias(origin, resolved) + if is_union_type(annotation): + return Union[resolved] # noqa: UP007 - dynamically derived member tuple + return origin[resolved] def is_union_type(annotation: object) -> bool: diff --git a/src/python_introspect/dataclass_projection.py b/src/python_introspect/dataclass_projection.py index a7cd39a..2981241 100644 --- a/src/python_introspect/dataclass_projection.py +++ b/src/python_introspect/dataclass_projection.py @@ -13,10 +13,9 @@ TypeVar, get_args, get_origin, - get_type_hints, ) -from .annotation_types import is_union_type +from .annotation_types import is_union_type, resolved_class_annotations from .validation import validate_annotated_dataclass, validate_annotation_value DataclassT = TypeVar("DataclassT") @@ -53,7 +52,7 @@ def dataclass_from_mapping( f"{target_type.__name__} received undeclared field(s): {', '.join(extras)}." ) - annotations = get_type_hints(target_type, include_extras=True) + annotations = resolved_class_annotations(target_type) decoded_values: dict[str, object] = {} missing: list[str] = [] for declared_field in declared_fields: diff --git a/src/python_introspect/validation.py b/src/python_introspect/validation.py index 68dec0b..257b74d 100644 --- a/src/python_introspect/validation.py +++ b/src/python_introspect/validation.py @@ -14,12 +14,11 @@ TypeVar, get_args, get_origin, - get_type_hints, ) from annotated_types import Ge, Gt, Interval, Le, Len, Lt, MaxLen, MinLen, Predicate -from .annotation_types import is_union_type +from .annotation_types import is_union_type, resolved_class_annotations class AnnotationValidationError(ValueError): @@ -86,7 +85,7 @@ def validate_annotated_dataclass(instance: object) -> None: f"got {owner_type.__name__}." ) - annotations = get_type_hints(owner_type, include_extras=True) + annotations = resolved_class_annotations(owner_type) for declared_field in fields(instance): annotation = annotations.get(declared_field.name, declared_field.type) value = object.__getattribute__(instance, declared_field.name) diff --git a/tests/test_dataclass_projection.py b/tests/test_dataclass_projection.py index 7e6018e..e0bbc6c 100644 --- a/tests/test_dataclass_projection.py +++ b/tests/test_dataclass_projection.py @@ -1,10 +1,15 @@ from dataclasses import asdict, dataclass, field from enum import Enum +from typing import Annotated, Literal from collections.abc import Sequence import pytest -from python_introspect import dataclass_from_mapping, project_dataclass +from python_introspect import ( + dataclass_from_mapping, + project_dataclass, + validate_annotated_dataclass, +) class Mode(Enum): @@ -254,3 +259,41 @@ class Envelope: with pytest.raises(TypeError, match="ambiguously matches multiple members"): dataclass_from_mapping(Envelope, {"payload": {"value": 1}}) + + +@dataclass(frozen=True) +class AnnotatedSequenceEnvelope: + nodes: Annotated[Sequence["RecursiveSequenceNode"], "display text"] + state: Literal["unresolved_name"] = "unresolved_name" + + +def test_nested_resolution_preserves_annotation_metadata_and_literal_values(): + result = dataclass_from_mapping( + AnnotatedSequenceEnvelope, {"nodes": [{"name": "child"}]} + ) + assert result == AnnotatedSequenceEnvelope((RecursiveSequenceNode("child"),)) + with pytest.raises(TypeError, match=r"children\[0\]"): + validate_annotated_dataclass(RecursiveSequenceNode("parent", ({"name": "child"},))) + + +def test_inherited_nested_types_use_the_declaring_module_namespace(monkeypatch): + import sys + from types import ModuleType + + base_module = ModuleType("sequence_base_declaration") + child_module = ModuleType("sequence_child_declaration") + monkeypatch.setitem(sys.modules, base_module.__name__, base_module) + monkeypatch.setitem(sys.modules, child_module.__name__, child_module) + exec( + 'from dataclasses import dataclass\nfrom collections.abc import Sequence\n' + '@dataclass\nclass Leaf:\n name: str\n' + '@dataclass\nclass Base:\n children: Sequence["Leaf"]\n', + vars(base_module), + ) + exec( + 'from sequence_base_declaration import Base\n' + 'Leaf = int\nclass Child(Base):\n pass\n', + vars(child_module), + ) + result = dataclass_from_mapping(child_module.Child, {"children": [{"name": "child"}]}) + assert isinstance(result.children[0], base_module.Leaf)