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
2 changes: 2 additions & 0 deletions HISTORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ Our backwards-compatibility policy can be found [here](https://github.com/python

## NEXT (UNRELEASED)

- Support more recursive types on 3.14+ with specialized factories for [`annotationlib.ForwardRef`](https://docs.python.org/3/library/annotationlib.html#annotationlib.ForwardRef).
([#740](https://github.com/python-attrs/cattrs/issues/740) [#741](https://github.com/python-attrs/cattrs/pull/741))
- Fix an `AttributeError` in `cattrs` internals that could be triggered by using the `include_subclasses` strategy in a `structure_hook_factory`
([#721](https://github.com/python-attrs/cattrs/issues/721), [#722](https://github.com/python-attrs/cattrs/pull/722))
- Add `CattrsError` exception type: all exceptions raised by `cattrs` inherit from this.
Expand Down
17 changes: 17 additions & 0 deletions src/cattrs/converters.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@
from attrs import has as attrs_has
from typing_extensions import Self

try:
from annotationlib import ForwardRef as AnnotationForwardRef
except ImportError:
AnnotationForwardRef = None

from ._compat import (
ANIES,
FrozenSetSubscriptable,
Expand Down Expand Up @@ -897,6 +902,8 @@ def _structure_dict(self, obj: Mapping[T, V], cl: Any) -> dict[T, V]:
def _structure_optional(self, obj, union):
if obj is None:
return None
if AnnotationForwardRef is not None and isinstance(union, AnnotationForwardRef):
union = union.evaluate()
union_params = union.__args__
other = union_params[0] if union_params[1] is NoneType else union_params[1]
# We can't actually have a Union of a Union, so this is safe.
Expand Down Expand Up @@ -1171,6 +1178,11 @@ def __init__(
is_frozenset,
lambda cl: self.gen_unstructure_iterable(cl, unstructure_to=frozenset),
)
if AnnotationForwardRef is not None:
self.register_unstructure_hook_factory(
lambda t: isinstance(t, AnnotationForwardRef),
lambda t: self.get_unstructure_hook(t.evaluate()),
)
self.register_unstructure_hook_factory(
is_optional, self.gen_unstructure_optional
)
Expand All @@ -1189,6 +1201,11 @@ def __init__(
is_defaultdict, defaultdict_structure_factory
)
self.register_structure_hook_factory(is_typeddict, self.gen_structure_typeddict)
if AnnotationForwardRef is not None:
self.register_structure_hook_factory(
lambda t: isinstance(t, AnnotationForwardRef),
lambda t: self.get_structure_hook(t.evaluate()),
)
self.register_structure_hook_factory(
lambda t: get_newtype_base(t) is not None, self.get_structure_newtype
)
Expand Down
2 changes: 2 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ def converter_cls(request):
settings.load_profile("fast" if environ.get("FAST") == "1" else "tests")

collect_ignore_glob = []
if sys.version_info < (3, 14):
collect_ignore_glob.append("test_gen_dict_649.py")
if sys.version_info < (3, 12):
collect_ignore_glob.append("*_695.py")
if platform.python_implementation() == "PyPy":
Expand Down
49 changes: 49 additions & 0 deletions tests/test_gen_dict_649.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"""`gen` tests under PEP 649 (deferred evaluation of annotations)."""

from dataclasses import dataclass
from typing import TypedDict

from attrs import define

from cattrs import Converter


@define
class A:
a: A | None # noqa: F821


@dataclass
class B:
b: B | None # noqa: F821


class C(TypedDict):
c: C | None # noqa: F821


def test_roundtrip(genconverter: Converter):
"""A simple roundtrip works."""
initial = A(A(None))
raw = genconverter.unstructure(initial)

assert raw == {"a": {"a": None}}
assert genconverter.structure(raw, A) == initial


def test_roundtrip_dataclass(genconverter: Converter):
"""A simple roundtrip works for dataclasses."""
initial = B(B(None))
raw = genconverter.unstructure(initial)

assert raw == {"b": {"b": None}}
assert genconverter.structure(raw, B) == initial


def test_roundtrip_typeddict(genconverter: Converter):
"""A simple roundtrip works for TypedDicts."""
initial: C = {"c": {"c": None}}
raw = genconverter.unstructure(initial)

assert raw == {"c": {"c": None}}
assert genconverter.structure(raw, C) == initial
Loading