From 85f2d58c01070e2d245bb392a305d575f1abc393 Mon Sep 17 00:00:00 2001 From: Andreas Hangauer Date: Wed, 26 Jan 2022 22:31:56 +0100 Subject: [PATCH 1/3] Implement more generic type resolution (as compared to `attrs.resolve_types`) --- src/cattr/__init__.py | 2 + src/cattr/_compat.py | 86 +++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 85 insertions(+), 3 deletions(-) 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 From 8f4be84607228ba54631d1e7c880d5d2a4b1aa1c Mon Sep 17 00:00:00 2001 From: Andreas Hangauer Date: Wed, 26 Jan 2022 22:34:12 +0100 Subject: [PATCH 2/3] add ForwardRef hooks and always do type resolution --- src/cattr/converters.py | 91 +++++++++++++++++++++++++++++++++++------ src/cattr/gen.py | 13 +++--- 2 files changed, 84 insertions(+), 20 deletions(-) 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 = {") From c55472e233c68611cc0eb82036995979705b6b68 Mon Sep 17 00:00:00 2001 From: Andreas Hangauer Date: Wed, 26 Jan 2022 22:34:48 +0100 Subject: [PATCH 3/3] Add ForwardRef tests These test all kinds of subtleties of ForwardRefs and their (automatic, semiautomatic or manual) resolution. As dataclasses, attrs classes and type aliases behave slightly different and, in addition to that, behave differently with respect to PEP 563 (the delayed evaluation of type hints) the tests are quite extensive. A critical test-case is also whether resolution works across modules, i.e. whether ForwardRefs are handled correctly when they are imported from another module. Correct operation in any of the above cases cannot be taken for granted --- tests/module.py | 37 +++++ tests/test_forwardref.py | 296 +++++++++++++++++++++++++++++++++++ tests/test_forwardref_563.py | 268 +++++++++++++++++++++++++++++++ 3 files changed, 601 insertions(+) create mode 100644 tests/module.py create mode 100644 tests/test_forwardref.py create mode 100644 tests/test_forwardref_563.py 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