Skip to content

Commit e3c61ec

Browse files
committed
Support annotationlib.ForwardRef
1 parent 3779c75 commit e3c61ec

File tree

3 files changed

+68
-0
lines changed

3 files changed

+68
-0
lines changed

src/cattrs/converters.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@
1515
from attrs import has as attrs_has
1616
from typing_extensions import Self
1717

18+
try:
19+
from annotationlib import ForwardRef as AnnotationForwardRef
20+
except ImportError:
21+
AnnotationForwardRef = None
22+
1823
from ._compat import (
1924
ANIES,
2025
FrozenSetSubscriptable,
@@ -897,6 +902,8 @@ def _structure_dict(self, obj: Mapping[T, V], cl: Any) -> dict[T, V]:
897902
def _structure_optional(self, obj, union):
898903
if obj is None:
899904
return None
905+
if AnnotationForwardRef is not None and isinstance(union, AnnotationForwardRef):
906+
union = union.evaluate()
900907
union_params = union.__args__
901908
other = union_params[0] if union_params[1] is NoneType else union_params[1]
902909
# We can't actually have a Union of a Union, so this is safe.
@@ -1171,6 +1178,11 @@ def __init__(
11711178
is_frozenset,
11721179
lambda cl: self.gen_unstructure_iterable(cl, unstructure_to=frozenset),
11731180
)
1181+
if AnnotationForwardRef is not None:
1182+
self.register_unstructure_hook_factory(
1183+
lambda t: isinstance(t, AnnotationForwardRef),
1184+
lambda t: self.get_unstructure_hook(t.evaluate()),
1185+
)
11741186
self.register_unstructure_hook_factory(
11751187
is_optional, self.gen_unstructure_optional
11761188
)
@@ -1189,6 +1201,11 @@ def __init__(
11891201
is_defaultdict, defaultdict_structure_factory
11901202
)
11911203
self.register_structure_hook_factory(is_typeddict, self.gen_structure_typeddict)
1204+
if AnnotationForwardRef is not None:
1205+
self.register_structure_hook_factory(
1206+
lambda t: isinstance(t, AnnotationForwardRef),
1207+
lambda t: self.get_structure_hook(t.evaluate()),
1208+
)
11921209
self.register_structure_hook_factory(
11931210
lambda t: get_newtype_base(t) is not None, self.get_structure_newtype
11941211
)

tests/conftest.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ def converter_cls(request):
3131
settings.load_profile("fast" if environ.get("FAST") == "1" else "tests")
3232

3333
collect_ignore_glob = []
34+
if sys.version_info < (3, 14):
35+
collect_ignore_glob.append("test_gen_dict_649.py")
3436
if sys.version_info < (3, 12):
3537
collect_ignore_glob.append("*_695.py")
3638
if platform.python_implementation() == "PyPy":

tests/test_gen_dict_649.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
"""`gen` tests under PEP 649 (deferred evaluation of annotations)."""
2+
3+
from dataclasses import dataclass
4+
from typing import TypedDict
5+
6+
from attrs import define
7+
8+
from cattrs import Converter
9+
10+
11+
@define
12+
class A:
13+
a: A | None # noqa: F821
14+
15+
16+
@dataclass
17+
class B:
18+
b: B | None # noqa: F821
19+
20+
21+
class C(TypedDict):
22+
c: C | None # noqa: F821
23+
24+
25+
def test_roundtrip(genconverter: Converter):
26+
"""A simple roundtrip works."""
27+
initial = A(A(None))
28+
raw = genconverter.unstructure(initial)
29+
30+
assert raw == {"a": {"a": None}}
31+
assert genconverter.structure(raw, A) == initial
32+
33+
34+
def test_roundtrip_dataclass(genconverter: Converter):
35+
"""A simple roundtrip works for dataclasses."""
36+
initial = B(B(None))
37+
raw = genconverter.unstructure(initial)
38+
39+
assert raw == {"b": {"b": None}}
40+
assert genconverter.structure(raw, B) == initial
41+
42+
43+
def test_roundtrip_typeddict(genconverter: Converter):
44+
"""A simple roundtrip works for TypedDicts."""
45+
initial: C = {"c": {"c": None}}
46+
raw = genconverter.unstructure(initial)
47+
48+
assert raw == {"c": {"c": None}}
49+
assert genconverter.structure(raw, C) == initial

0 commit comments

Comments
 (0)