Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions docs/validation/recursive-sequence-python310.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion src/python_introspect/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
47 changes: 46 additions & 1 deletion src/python_introspect/annotation_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
5 changes: 2 additions & 3 deletions src/python_introspect/dataclass_projection.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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:
Expand Down
5 changes: 2 additions & 3 deletions src/python_introspect/validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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)
Expand Down
45 changes: 44 additions & 1 deletion tests/test_dataclass_projection.py
Original file line number Diff line number Diff line change
@@ -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):
Expand Down Expand Up @@ -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)
Loading