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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
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.3"
version = "0.2.4"
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.3"
__version__ = "0.2.4"

from .signature_analyzer import (
SignatureAnalyzer,
Expand Down
37 changes: 31 additions & 6 deletions src/python_introspect/annotation_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,25 @@
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]:
"""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.
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)
Expand All @@ -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):
Expand Down
36 changes: 35 additions & 1 deletion tests/test_dataclass_projection.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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)
Loading