diff --git a/src/cattr/__init__.py b/src/cattr/__init__.py index afd9bb84..e83fd761 100644 --- a/src/cattr/__init__.py +++ b/src/cattr/__init__.py @@ -1,5 +1,6 @@ from .converters import Converter, GenConverter, UnstructureStrategy from .gen import override +from ._compat import resolve_types __all__ = ( "global_converter", @@ -11,6 +12,7 @@ "Converter", "GenConverter", "override", + "resolve_types", ) diff --git a/src/cattr/_compat.py b/src/cattr/_compat.py index 4bcd2c34..7a2c3429 100644 --- a/src/cattr/_compat.py +++ b/src/cattr/_compat.py @@ -1,8 +1,9 @@ import sys from dataclasses import MISSING from dataclasses import fields as dataclass_fields -from dataclasses import is_dataclass -from typing import Any, Dict, FrozenSet, List +from dataclasses import is_dataclass, make_dataclass +from dataclasses import Field as DataclassField +from typing import Any, Dict, FrozenSet, List, Optional from typing import Mapping as TypingMapping from typing import MutableMapping as TypingMutableMapping from typing import MutableSequence as TypingMutableSequence @@ -13,7 +14,8 @@ from attr import NOTHING, Attribute, Factory from attr import fields as attrs_fields -from attr import resolve_types +from attr import resolve_types as attrs_resolve_types +from attr import has as attrs_has version_info = sys.version_info[0:3] is_py37 = version_info[:2] == (3, 7) @@ -373,3 +375,81 @@ def copy_with(type, args): def is_generic_attrs(type): return is_generic(type) and has(type.__origin__) + + +def resolve_types( + cls: Any, + globalns: Optional[Dict[str, Any]] = None, + localns: Optional[Dict[str, Any]] = None, +): + """ + More generic version of `attrs.resolve_types`. + + While `attrs.resolve_types` resolves ForwardRefs + only for for the fields of a `attrs` classes (and + fails otherwise), this `resolve_types` also + supports dataclasses and type aliases. + + Even though often ForwardRefs outside of classes as e.g. + in type aliases can generally not be resolved automatically + (i.e. without explicit `globalns`, and `localns` context), + this is indeed sometimes possible and supported by Python. + This is for instance the case if the (internal) `module` + parameter of `ForwardRef` is set or we are dealing with + ForwardRefs in `TypedDict` or `NewType` types. + There may also be additions to typing.py module that there + will be more non-class types where ForwardRefs can automatically + be resolved. + + See + https://bugs.python.org/issue41249 + https://bugs.python.org/issue46369 + https://bugs.python.org/issue46373 + """ + allfields: List[Union[Attribute, DataclassField]] = [] + + if attrs_has(cls): + try: + attrs_resolve_types(cls, globalns, localns) + except NameError: + # ignore if ForwardRef cannot be resolved. + # We still want to allow manual registration of + # ForwardRefs (which will work with unevaluated ForwardRefs) + pass + allfields = fields(cls) + else: + if not is_dataclass(cls): + # we cannot call get_type_hints on type aliases + # directly, so put it in a field of a helper + # dataclass. + cls = make_dataclass("_resolve_helper", [("test", cls)]) + + # prevent resolving from cls.__module__ (which is what + # get_type_hints does if localns/globalns == None), as + # it would not be correct here. + # See: https://stackoverflow.com/questions/49457441 + if globalns is None: + globalns = {} + if localns is None: + localns = {} + else: + allfields = dataclass_fields(cls) + + try: + type_hints = get_type_hints(cls, globalns, localns) + for field in allfields: + field.type = type_hints.get(field.name, field.type) + except NameError: + pass + if not is_py39_plus: + # 3.8 and before did not recursively resolve ForwardRefs + # (likely a Python bug). Hence with PEP 563 (where all type + # annotations are initially treated as ForwardRefs) we + # need twice evaluation to properly resolve explicit ForwardRefs + fieldlist = [(field.name, field.type) for field in allfields] + cls2 = make_dataclass("_resolve_helper2", fieldlist) + cls2.__module__ = cls.__module__ + try: + get_type_hints(cls2, globalns, localns) + except NameError: + pass diff --git a/src/cattr/converters.py b/src/cattr/converters.py index aa08ab35..2eb426dc 100644 --- a/src/cattr/converters.py +++ b/src/cattr/converters.py @@ -3,11 +3,11 @@ from dataclasses import Field from enum import Enum from functools import lru_cache -from typing import Any, Callable, Dict, Optional, Tuple, Type, TypeVar, Union +from typing import Any, Callable, Dict, ForwardRef, Optional +from typing import Tuple, Type, TypeVar, Union from attr import Attribute from attr import has as attrs_has -from attr import resolve_types from ._compat import ( FrozenSetSubscriptable, @@ -35,6 +35,7 @@ is_sequence, is_tuple, is_union_type, + resolve_types, ) from .disambiguators import create_uniq_field_dis_func from .dispatch import MultiStrategyDispatch @@ -141,6 +142,11 @@ def __init__( (_subclass(Enum), self._unstructure_enum), (has, self._unstructure_attrs), (is_union_type, self._unstructure_union), + ( + lambda o: o.__class__ is ForwardRef, + self._gen_unstructure_forwardref, + True, + ), ] ) @@ -173,6 +179,11 @@ def __init__( ), (is_optional, self._structure_optional), (has, self._structure_attrs), + ( + lambda o: o.__class__ is ForwardRef, + self._gen_structure_forwardref, + True, + ), ] ) # Strings are sequences. @@ -215,14 +226,16 @@ def register_unstructure_hook( The converter function should take an instance of the class and return its Python equivalent. """ - if attrs_has(cls): - resolve_types(cls) + resolve_types(cls) if is_union_type(cls): self._unstructure_func.register_func_list( [(lambda t: t == cls, func)] ) else: - self._unstructure_func.register_cls_list([(cls, func)]) + singledispatch_ok = isinstance(cls, type) and not is_generic(cls) + self._unstructure_func.register_cls_list( + [(cls, func)], direct=not singledispatch_ok + ) def register_unstructure_hook_func( self, check_func: Callable[[Any], bool], func: Callable[[T], Any] @@ -230,7 +243,14 @@ def register_unstructure_hook_func( """Register a class-to-primitive converter function for a class, using a function to check if it's a match. """ - self._unstructure_func.register_func_list([(check_func, func)]) + + def factory_func(cls: T) -> Callable[[T], Any]: + resolve_types(cls) + return func + + self._unstructure_func.register_func_list( + [(check_func, factory_func, True)] + ) def register_unstructure_hook_factory( self, @@ -246,7 +266,14 @@ def register_unstructure_hook_factory( A factory is a callable that, given a type, produces an unstructuring hook for that type. This unstructuring hook will be cached. """ - self._unstructure_func.register_func_list([(predicate, factory, True)]) + + def factory_func(cls: T) -> Callable[[Any], Any]: + resolve_types(cls) + return factory(cls) + + self._unstructure_func.register_func_list( + [(predicate, factory_func, True)] + ) def register_structure_hook( self, cl: Any, func: Callable[[Any, Type[T]], T] @@ -260,13 +287,15 @@ def register_structure_hook( and return the instance of the class. The type may seem redundant, but is sometimes needed (for example, when dealing with generic classes). """ - if attrs_has(cl): - resolve_types(cl) + resolve_types(cl) if is_union_type(cl): self._union_struct_registry[cl] = func self._structure_func.clear_cache() else: - self._structure_func.register_cls_list([(cl, func)]) + singledispatch_ok = isinstance(cl, type) and not is_generic(cl) + self._structure_func.register_cls_list( + [(cl, func)], direct=not singledispatch_ok + ) def register_structure_hook_func( self, @@ -276,12 +305,19 @@ def register_structure_hook_func( """Register a class-to-primitive converter function for a class, using a function to check if it's a match. """ - self._structure_func.register_func_list([(check_func, func)]) + + def factory_func(cls: T) -> Callable[[Any, Type[T]], T]: + resolve_types(cls) + return func + + self._structure_func.register_func_list( + [(check_func, factory_func, True)] + ) def register_structure_hook_factory( self, predicate: Callable[[Any], bool], - factory: Callable[[Any], Callable[[Any], Any]], + factory: Callable[[Any], Callable[[Any, Type[T]], T]], ) -> None: """ Register a hook factory for a given predicate. @@ -292,7 +328,14 @@ def register_structure_hook_factory( A factory is a callable that, given a type, produces a structuring hook for that type. This structuring hook will be cached. """ - self._structure_func.register_func_list([(predicate, factory, True)]) + + def factory_func(cls: T) -> Callable[[Any, Type[T]], T]: + resolve_types(cls) + return factory(cls) + + self._structure_func.register_func_list( + [(predicate, factory_func, True)] + ) def structure(self, obj: Any, cl: Type[T]) -> T: """Convert unstructured Python data structures to structured data.""" @@ -355,6 +398,17 @@ def _unstructure_union(self, obj): """ return self._unstructure_func.dispatch(obj.__class__)(obj) + def _gen_unstructure_forwardref(self, cl): + if not cl.__forward_evaluated__: + raise ValueError( + f"ForwardRef({cl.__forward_arg__!r}) is not resolved." + " Consider resolving the parent type alias" + " manually with `cattr.resolve_types`" + " in the defining module or by registering a hook." + ) + cl = cl.__forward_value__ + return lambda o: self._unstructure_func.dispatch(cl)(o) + # Python primitives to classes. def _structure_error(self, _, cl): @@ -557,6 +611,17 @@ def _structure_tuple(self, obj, tup: Type[T]): for t, e in zip(tup_params, obj) ) + def _gen_structure_forwardref(self, cl): + if not cl.__forward_evaluated__: + raise ValueError( + f"ForwardRef({cl.__forward_arg__!r}) is not resolved." + " Consider resolving the parent type alias" + " manually with `cattr.resolve_types`" + " in the defining module or by registering a hook." + ) + cl = cl.__forward_value__ + return lambda o, t: self._structure_func.dispatch(cl)(o, cl) + @staticmethod def _get_dis_func(union): # type: (Type) -> Callable[..., Type] diff --git a/src/cattr/gen.py b/src/cattr/gen.py index 8c4e06c6..cf22c726 100644 --- a/src/cattr/gen.py +++ b/src/cattr/gen.py @@ -15,7 +15,7 @@ ) import attr -from attr import NOTHING, resolve_types +from attr import NOTHING from ._compat import ( adapted_fields, @@ -24,6 +24,7 @@ is_annotated, is_bare, is_generic, + resolve_types, ) from ._generics import deep_copy_with @@ -63,9 +64,8 @@ def make_dict_unstructure_fn( origin = get_origin(cl) attrs = adapted_fields(origin or cl) # type: ignore - if any(isinstance(a.type, str) for a in attrs): - # PEP 563 annotations - need to be resolved. - resolve_types(cl) + # PEP 563 annotations and ForwardRefs - need to be resolved. + resolve_types(cl) mapping = {} if is_generic(cl): @@ -245,9 +245,8 @@ def make_dict_structure_fn( attrs = adapted_fields(cl) is_dc = is_dataclass(cl) - if any(isinstance(a.type, str) for a in attrs): - # PEP 563 annotations - need to be resolved. - resolve_types(cl) + # PEP 563 annotations and ForwardRefs - need to be resolved. + resolve_types(cl) lines.append(f"def {fn_name}(o, *_):") lines.append(" res = {") diff --git a/tests/module.py b/tests/module.py new file mode 100644 index 00000000..3fe1250e --- /dev/null +++ b/tests/module.py @@ -0,0 +1,37 @@ +from dataclasses import dataclass +from typing import List, Tuple + +from attrs import define + +from cattr import resolve_types + + +@dataclass +class DClass: + ival: "IntType_1" + ilist: List["IntType_2"] + + +@define +class AClass: + ival: "IntType_3" + ilist: List["IntType_4"] + + +@define +class ModuleClass: + a: int + + +IntType_1 = int +IntType_2 = int +IntType_3 = int +IntType_4 = int + +RecursiveTypeAliasM = List[Tuple[ModuleClass, "RecursiveTypeAliasM"]] +RecursiveTypeAliasM_1 = List[Tuple[ModuleClass, "RecursiveTypeAliasM_1"]] +RecursiveTypeAliasM_2 = List[Tuple[ModuleClass, "RecursiveTypeAliasM_2"]] + +resolve_types(RecursiveTypeAliasM, globals(), locals()) +resolve_types(RecursiveTypeAliasM_1, globals(), locals()) +resolve_types(RecursiveTypeAliasM_2, globals(), locals()) diff --git a/tests/test_forwardref.py b/tests/test_forwardref.py new file mode 100644 index 00000000..c531c428 --- /dev/null +++ b/tests/test_forwardref.py @@ -0,0 +1,296 @@ +"""Test un/structuring class graphs with ForwardRef.""" +from typing import List, Tuple, ForwardRef +from dataclasses import dataclass + +import pytest + +from attr import define + +from cattr import Converter, GenConverter, resolve_types + +from . import module + + +@define +class A: + inner: List["A"] + + +@dataclass +class A_DC: + inner: List["A_DC"] + + +@pytest.mark.parametrize("converter_cls", [GenConverter, Converter]) +def test_simple_recursive(converter_cls): + c = converter_cls() + + orig = A([A([])]) + unstructured = c.unstructure(orig, A) + + assert unstructured == {"inner": [{"inner": []}]} + + assert c.structure(unstructured, A) == orig + + +@pytest.mark.parametrize("converter_cls", [GenConverter, Converter]) +def test_simple_recursive_dataclass(converter_cls): + c = converter_cls() + + orig = A_DC([A_DC([])]) + unstructured = c.unstructure(orig, A_DC) + + assert unstructured == {"inner": [{"inner": []}]} + + assert c.structure(unstructured, A_DC) == orig + + +@define +class A2: + val: "B_1" + + +@dataclass +class A2_DC: + val: "B_2" + + +B_1 = int +B_2 = int + + +@pytest.mark.parametrize("converter_cls", [GenConverter, Converter]) +def test_simple_ref(converter_cls): + c = converter_cls() + + orig = A2(1) + unstructured = c.unstructure(orig, A2) + + assert unstructured == {"val": 1} + + assert c.structure(unstructured, A2) == orig + + +@pytest.mark.parametrize("converter_cls", [GenConverter, Converter]) +def test_simple_ref_dataclass(converter_cls): + c = converter_cls() + + orig = A2_DC(1) + unstructured = c.unstructure(orig, A2_DC) + + assert unstructured == {"val": 1} + + assert c.structure(unstructured, A2_DC) == orig + + +@define +class A3: + val: List["B3_1"] + + +@dataclass +class A3_DC: + val: List["B3_2"] + + +B3_1 = int +B3_2 = int + + +@pytest.mark.parametrize("converter_cls", [GenConverter, Converter]) +def test_nested_ref(converter_cls): + c = converter_cls() + + orig = A3([1]) + unstructured = c.unstructure(orig, A3) + + assert unstructured == {"val": [1]} + + assert c.structure(unstructured, A3) == orig + + +@pytest.mark.parametrize("converter_cls", [GenConverter, Converter]) +def test_nested_ref_dataclass(converter_cls): + c = converter_cls() + + orig = A3_DC([1]) + unstructured = c.unstructure(orig, A3_DC) + + assert unstructured == {"val": [1]} + + assert c.structure(unstructured, A3_DC) == orig + + +@define +class AClassChild(module.AClass): + x: str + + +@pytest.mark.parametrize("converter_cls", [GenConverter, Converter]) +def test_nested_ref_imported(converter_cls): + c = converter_cls() + + orig = AClassChild(ival=1, ilist=[2, 3], x="4") + unstructured = c.unstructure(orig, AClassChild) + + assert unstructured == {"ival": 1, "ilist": [2, 3], "x": "4"} + + assert c.structure(unstructured, AClassChild) == orig + + +@dataclass +class DClassChild(module.DClass): + x: str + + +@pytest.mark.parametrize("converter_cls", [GenConverter, Converter]) +def test_nested_ref_imported_dataclass(converter_cls): + c = converter_cls() + + orig = DClassChild(ival=1, ilist=[2, 3], x="4") + unstructured = c.unstructure(orig, DClassChild) + + assert unstructured == {"ival": 1, "ilist": [2, 3], "x": "4"} + + assert c.structure(unstructured, DClassChild) == orig + + +@define +class Dummy: + a: int + + +RecursiveTypeAlias_1 = List[Tuple[Dummy, "RecursiveTypeAlias_1"]] +RecursiveTypeAlias_2 = List[Tuple[Dummy, "RecursiveTypeAlias_2"]] + + +@define +class ATest: + test: RecursiveTypeAlias_1 + + +@dataclass +class DTest: + test: RecursiveTypeAlias_2 + + +@pytest.mark.parametrize("converter_cls", [GenConverter, Converter]) +def test_recursive_type_alias_manual_registration(converter_cls): + c = converter_cls() + c.register_structure_hook( + ForwardRef("RecursiveTypeAlias_1"), + lambda obj, _: c.structure(obj, RecursiveTypeAlias_1), + ) + c.register_unstructure_hook( + ForwardRef("RecursiveTypeAlias_1"), + lambda obj: c.unstructure(obj, RecursiveTypeAlias_1), + ) + c.register_structure_hook( + ForwardRef("RecursiveTypeAlias_2"), + lambda obj, _: c.structure(obj, RecursiveTypeAlias_2), + ) + c.register_unstructure_hook( + ForwardRef("RecursiveTypeAlias_2"), + lambda obj: c.unstructure(obj, RecursiveTypeAlias_2), + ) + + orig = [(Dummy(1), [(Dummy(2), [(Dummy(3), [])])])] + unstructured = c.unstructure(orig, RecursiveTypeAlias_1) + + assert unstructured == [({"a": 1}, [({"a": 2}, [({"a": 3}, [])])])] + + assert c.structure(unstructured, RecursiveTypeAlias_1) == orig + + orig = ATest(test=[(Dummy(1), [(Dummy(2), [(Dummy(3), [])])])]) + unstructured = c.unstructure(orig, ATest) + + assert unstructured == { + "test": [({"a": 1}, [({"a": 2}, [({"a": 3}, [])])])] + } + + assert c.structure(unstructured, ATest) == orig + + orig = DTest(test=[(Dummy(1), [(Dummy(2), [(Dummy(3), [])])])]) + unstructured = c.unstructure(orig, DTest) + + assert unstructured == { + "test": [({"a": 1}, [({"a": 2}, [({"a": 3}, [])])])] + } + + assert c.structure(unstructured, DTest) == orig + + +RecursiveTypeAlias3 = List[Tuple[Dummy, "RecursiveTypeAlias3"]] + +resolve_types(RecursiveTypeAlias3, globals(), locals()) + + +@pytest.mark.parametrize("converter_cls", [GenConverter, Converter]) +def test_recursive_type_alias_cattr_resolution(converter_cls): + c = converter_cls() + + orig = [(Dummy(1), [(Dummy(2), [(Dummy(3), [])])])] + unstructured = c.unstructure(orig, RecursiveTypeAlias3) + + assert unstructured == [({"a": 1}, [({"a": 2}, [({"a": 3}, [])])])] + + assert c.structure(unstructured, RecursiveTypeAlias3) == orig + + +@define +class ATest4: + test: module.RecursiveTypeAliasM_1 + + +@dataclass +class DTest4: + test: module.RecursiveTypeAliasM_2 + + +@pytest.mark.parametrize("converter_cls", [GenConverter, Converter]) +def test_recursive_type_alias_imported(converter_cls): + c = converter_cls() + + orig = [ + ( + module.ModuleClass(1), + [(module.ModuleClass(2), [(module.ModuleClass(3), [])])], + ) + ] + unstructured = c.unstructure(orig, module.RecursiveTypeAliasM) + + assert unstructured == [({"a": 1}, [({"a": 2}, [({"a": 3}, [])])])] + + assert c.structure(unstructured, module.RecursiveTypeAliasM) == orig + + orig = ATest4( + test=[ + ( + module.ModuleClass(1), + [(module.ModuleClass(2), [(module.ModuleClass(3), [])])], + ) + ] + ) + unstructured = c.unstructure(orig, ATest4) + + assert unstructured == { + "test": [({"a": 1}, [({"a": 2}, [({"a": 3}, [])])])] + } + + assert c.structure(unstructured, ATest4) == orig + + orig = DTest4( + test=[ + ( + module.ModuleClass(1), + [(module.ModuleClass(2), [(module.ModuleClass(3), [])])], + ) + ] + ) + unstructured = c.unstructure(orig, DTest4) + + assert unstructured == { + "test": [({"a": 1}, [({"a": 2}, [({"a": 3}, [])])])] + } + + assert c.structure(unstructured, DTest4) == orig diff --git a/tests/test_forwardref_563.py b/tests/test_forwardref_563.py new file mode 100644 index 00000000..16dad774 --- /dev/null +++ b/tests/test_forwardref_563.py @@ -0,0 +1,268 @@ +"""Test un/structuring class graphs with ForwardRef.""" +# This file is almost same as test_forwardref.py but with +# PEP 563 (delayed evaluation of annotations) enabled. +# Even though with PEP 563 the explicit ForwardRefs +# (with string quotes) would not always be needed, they +# still can be used. +from __future__ import annotations +from typing import List, Tuple, ForwardRef +from dataclasses import dataclass + +import pytest + +from attr import define + +from cattr import Converter, GenConverter, resolve_types + +from . import module + + +@define +class A2: + val: "B_1" + + +@dataclass +class A2_DC: + val: "B_2" + + +B_1 = int +B_2 = int + + +@pytest.mark.parametrize("converter_cls", [GenConverter, Converter]) +def test_simple_ref(converter_cls): + c = converter_cls() + + orig = A2(1) + unstructured = c.unstructure(orig, A2) + + assert unstructured == {"val": 1} + + assert c.structure(unstructured, A2) == orig + + +@pytest.mark.parametrize("converter_cls", [GenConverter, Converter]) +def test_simple_ref_dataclass(converter_cls): + c = converter_cls() + + orig = A2_DC(1) + unstructured = c.unstructure(orig, A2_DC) + + assert unstructured == {"val": 1} + + assert c.structure(unstructured, A2_DC) == orig + + +@define +class A3: + val: List["B3_1"] + + +@dataclass +class A3_DC: + val: List["B3_2"] + + +B3_1 = int +B3_2 = int + + +@pytest.mark.parametrize("converter_cls", [GenConverter, Converter]) +def test_nested_ref(converter_cls): + c = converter_cls() + + orig = A3([1]) + unstructured = c.unstructure(orig, A3) + + assert unstructured == {"val": [1]} + + assert c.structure(unstructured, A3) == orig + + +@pytest.mark.parametrize("converter_cls", [GenConverter, Converter]) +def test_nested_ref_dataclass(converter_cls): + c = converter_cls() + + orig = A3_DC([1]) + unstructured = c.unstructure(orig, A3_DC) + + assert unstructured == {"val": [1]} + + assert c.structure(unstructured, A3_DC) == orig + + +@define +class AClassChild(module.AClass): + x: str + + +@pytest.mark.parametrize("converter_cls", [GenConverter, Converter]) +def test_nested_ref_imported(converter_cls): + c = converter_cls() + + orig = AClassChild(ival=1, ilist=[2, 3], x="4") + unstructured = c.unstructure(orig, AClassChild) + + assert unstructured == {"ival": 1, "ilist": [2, 3], "x": "4"} + + assert c.structure(unstructured, AClassChild) == orig + + +@dataclass +class DClassChild(module.DClass): + x: str + + +@pytest.mark.parametrize("converter_cls", [GenConverter, Converter]) +def test_nested_ref_imported_dataclass(converter_cls): + c = converter_cls() + + orig = DClassChild(ival=1, ilist=[2, 3], x="4") + unstructured = c.unstructure(orig, DClassChild) + + assert unstructured == {"ival": 1, "ilist": [2, 3], "x": "4"} + + assert c.structure(unstructured, DClassChild) == orig + + +@define +class Dummy: + a: int + + +RecursiveTypeAlias_1 = List[Tuple[Dummy, "RecursiveTypeAlias_1"]] +RecursiveTypeAlias_2 = List[Tuple[Dummy, "RecursiveTypeAlias_2"]] + + +@define +class ATest: + test: RecursiveTypeAlias_1 + + +@dataclass +class DTest: + test: RecursiveTypeAlias_2 + + +@pytest.mark.parametrize("converter_cls", [GenConverter, Converter]) +def test_recursive_type_alias_manual_registration(converter_cls): + c = converter_cls() + c.register_structure_hook( + ForwardRef("RecursiveTypeAlias_1"), + lambda obj, _: c.structure(obj, RecursiveTypeAlias_1), + ) + c.register_unstructure_hook( + ForwardRef("RecursiveTypeAlias_1"), + lambda obj: c.unstructure(obj, RecursiveTypeAlias_1), + ) + c.register_structure_hook( + ForwardRef("RecursiveTypeAlias_2"), + lambda obj, _: c.structure(obj, RecursiveTypeAlias_2), + ) + c.register_unstructure_hook( + ForwardRef("RecursiveTypeAlias_2"), + lambda obj: c.unstructure(obj, RecursiveTypeAlias_2), + ) + + orig = [(Dummy(1), [(Dummy(2), [(Dummy(3), [])])])] + unstructured = c.unstructure(orig, RecursiveTypeAlias_1) + + assert unstructured == [({"a": 1}, [({"a": 2}, [({"a": 3}, [])])])] + + assert c.structure(unstructured, RecursiveTypeAlias_1) == orig + + orig = ATest(test=[(Dummy(1), [(Dummy(2), [(Dummy(3), [])])])]) + unstructured = c.unstructure(orig, ATest) + + assert unstructured == { + "test": [({"a": 1}, [({"a": 2}, [({"a": 3}, [])])])] + } + + assert c.structure(unstructured, ATest) == orig + + orig = DTest(test=[(Dummy(1), [(Dummy(2), [(Dummy(3), [])])])]) + unstructured = c.unstructure(orig, DTest) + + assert unstructured == { + "test": [({"a": 1}, [({"a": 2}, [({"a": 3}, [])])])] + } + + assert c.structure(unstructured, DTest) == orig + + +RecursiveTypeAlias3 = List[Tuple[Dummy, "RecursiveTypeAlias3"]] + +resolve_types(RecursiveTypeAlias3, globals(), locals()) + + +@pytest.mark.parametrize("converter_cls", [GenConverter, Converter]) +def test_recursive_type_alias_cattr_resolution(converter_cls): + c = converter_cls() + + orig = [(Dummy(1), [(Dummy(2), [(Dummy(3), [])])])] + unstructured = c.unstructure(orig, RecursiveTypeAlias3) + + assert unstructured == [({"a": 1}, [({"a": 2}, [({"a": 3}, [])])])] + + assert c.structure(unstructured, RecursiveTypeAlias3) == orig + + +@define +class ATest4: + test: module.RecursiveTypeAliasM_1 + + +@dataclass +class DTest4: + test: module.RecursiveTypeAliasM_2 + + +@pytest.mark.parametrize("converter_cls", [GenConverter, Converter]) +def test_recursive_type_alias_imported(converter_cls): + c = converter_cls() + + orig = [ + ( + module.ModuleClass(1), + [(module.ModuleClass(2), [(module.ModuleClass(3), [])])], + ) + ] + unstructured = c.unstructure(orig, module.RecursiveTypeAliasM) + + assert unstructured == [({"a": 1}, [({"a": 2}, [({"a": 3}, [])])])] + + assert c.structure(unstructured, module.RecursiveTypeAliasM) == orig + + orig = ATest4( + test=[ + ( + module.ModuleClass(1), + [(module.ModuleClass(2), [(module.ModuleClass(3), [])])], + ) + ] + ) + unstructured = c.unstructure(orig, ATest4) + + assert unstructured == { + "test": [({"a": 1}, [({"a": 2}, [({"a": 3}, [])])])] + } + + assert c.structure(unstructured, ATest4) == orig + + orig = DTest4( + test=[ + ( + module.ModuleClass(1), + [(module.ModuleClass(2), [(module.ModuleClass(3), [])])], + ) + ] + ) + unstructured = c.unstructure(orig, DTest4) + + assert unstructured == { + "test": [({"a": 1}, [({"a": 2}, [({"a": 3}, [])])])] + } + + assert c.structure(unstructured, DTest4) == orig