diff --git a/HISTORY.md b/HISTORY.md index bdce29ff..6ad6eb38 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -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. diff --git a/src/cattrs/converters.py b/src/cattrs/converters.py index f787fe74..fb485817 100644 --- a/src/cattrs/converters.py +++ b/src/cattrs/converters.py @@ -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, @@ -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. @@ -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 ) @@ -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 ) diff --git a/tests/conftest.py b/tests/conftest.py index e154c2a1..5f6746a0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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": diff --git a/tests/test_gen_dict_649.py b/tests/test_gen_dict_649.py new file mode 100644 index 00000000..49a02a16 --- /dev/null +++ b/tests/test_gen_dict_649.py @@ -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