diff --git a/HISTORY.md b/HISTORY.md index cf2fd351..294f12c8 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -16,6 +16,8 @@ - Add optional dependencies for `cattrs.preconf` third-party libraries. ([#337](https://github.com/python-attrs/cattrs/pull/337)) - All preconf converters now allow overriding the default `unstruct_collection_overrides` in `make_converter`. ([#350](https://github.com/python-attrs/cattrs/issues/350) [#353](https://github.com/python-attrs/cattrs/pull/353)) +- Subclasses structuring and unstructuring is now supported via a custom `include_subclasses` strategy. + ([#312](https://github.com/python-attrs/cattrs/pull/312)) ## 22.2.0 (2022-10-03) diff --git a/docs/make.bat b/docs/make.bat index dc68b6e6..a48f35b6 100644 --- a/docs/make.bat +++ b/docs/make.bat @@ -239,4 +239,11 @@ if "%1" == "pseudoxml" ( goto end ) +if "%1" == "apidoc" ( + sphinx-apidoc -o . ../src/cattrs/ -f + if errorlevel 1 exit /b 1 + echo. + goto end +) + :end diff --git a/docs/strategies.md b/docs/strategies.md index 5b4cac76..62a50baa 100644 --- a/docs/strategies.md +++ b/docs/strategies.md @@ -4,7 +4,7 @@ _cattrs_ ships with a number of _strategies_ for customizing un/structuring beha Strategies are prepackaged, high-level patterns for quickly and easily applying complex customizations to a converter. -## Tagged Unions +## Tagged Unions Strategy _Found at {py:func}`cattrs.strategies.configure_tagged_union`._ @@ -119,3 +119,134 @@ The converter is now ready to start structuring Apple notifications. ... print("Can't handle this yet") ``` + +## Include Subclasses Strategy + +_Found at {py:func}`cattrs.strategies.include_subclasses`._ + +The _include subclass_ strategy allows the un/structuring of a base class to an instance of itself or one of its descendants. +Conceptually with this strategy, each time an un/structure operation for the base class is asked, `cattrs` machinery replaces that operation as if the union of the base class and its descendants had been asked instead. + +```{doctest} include_subclass + +>>> from attrs import define +>>> from cattrs.strategies import include_subclasses +>>> from cattrs import Converter + +>>> @define +... class Parent: +... a: int + +>>> @define +... class Child(Parent): +... b: str + +>>> converter = Converter() +>>> include_subclasses(Parent, converter) + +>>> converter.unstructure(Child(a=1, b="foo"), unstructure_as=Parent) +{'a': 1, 'b': 'foo'} + +>>> converter.structure({'a': 1, 'b': 'foo'}, Parent) +Child(a=1, b='foo') +``` + +In the example above, we asked to unstructure then structure a `Child` instance as the `Parent` class and in both cases we correctly obtained back the unstructured and structured versions of the `Child` instance. +If we did not apply the `include_subclasses` strategy, this is what we would have obtained: + +```python +>>> converter_no_subclasses = Converter() + +>>> converter_no_subclasses.unstructure(Child(a=1, b="foo"), unstructure_as=Parent) +{'a': 1} + +>>> converter_no_subclasses.structure({'a': 1, 'b': 'foo'}, Parent) +Parent(a=1) +``` + +Without the application of the strategy, in both unstructure and structure operations, we received a `Parent` instance. + +```{note} +The handling of subclasses is an opt-in feature for two main reasons: +- Performance. While small and probably negligeable in most cases the subclass handling incurs more function calls and has a performance impact. +- Customization. The specific handling of subclasses can be different from one situation to the other. In particular there is not apparent universal good defaults for disambiguating the union type. Consequently The decision is left to the `cattrs` user. +``` + +```{warning} +To work properly, all subclasses must be defined when the `include_subclasses` strategy is applied to a `converter`. If subclasses types are defined later, for instance in the context of a plug-in mechanism using inheritance, then those late defined subclasses will not be part of the subclasses union type and will not be un/structured as expected. +``` + +### Customization + +In the example shown in the previous section, the default options for `include_subclasses` work well because the `Child` class has an attribute that do not exist in the `Parent` class (the `b` attribute). +The automatic union type disambiguation function which is based on finding unique fields for each type of the union works as intended. + +Sometimes, more disambiguation customization is required. +For instance, the unstructuring operation would have failed if `Child` did not have an extra attribute or if a sibling of `Child` had also a `b` attribute. +For those cases, a callable of 2 positional arguments (a union type and a converter) defining a [tagged union strategy](strategies.md#tagged-unions-strategy) can be passed to the `include_subclasses` strategy. +{py:func}`configure_tagged_union()` can be used as-is, but if you want to change its defaults, the [partial](https://docs.python.org/3/library/functools.html#functools.partial) function from the `functools` module in the standard library can come in handy. + +```python + +>>> from functools import partial +>>> from attrs import define +>>> from cattrs.strategies import include_subclasses, configure_tagged_union +>>> from cattrs import Converter + +>>> @define +... class Parent: +... a: int + +>>> @define +... class Child1(Parent): +... b: str + +>>> @define +... class Child2(Parent): +... b: int + +>>> converter = Converter() +>>> union_strategy = partial(configure_tagged_union, tag_name="type_name") +>>> include_subclasses(Parent, converter, union_strategy=union_strategy) + +>>> converter.unstructure(Child1(a=1, b="foo"), unstructure_as=Parent) +{'a': 1, 'b': 'foo', 'type_name': 'Child1'} + +>>> converter.structure({'a': 1, 'b': 1, 'type_name': 'Child2'}, Parent) +Child2(a=1, b=1) +``` + +Other customizations available see are (see {py:func}`include_subclasses()`): +- The exact list of subclasses that should participate to the union with the `subclasses` argument. +- Attribute overrides that permit the customization of attributes un/structuring like renaming an attribute. + +Here is an example involving both customizations: + +```python + +>>> from attrs import define +>>> from cattrs.strategies import include_subclasses +>>> from cattrs import Converter, override + +>>> @define +... class Parent: +... a: int + +>>> @define +... class Child(Parent): +... b: str + +>>> converter = Converter() +>>> include_subclasses( +... Parent, +... converter, +... subclasses=(Parent, Child), +... overrides={"b": override(rename="c")} +... ) + +>>> converter.unstructure(Child(a=1, b="foo"), unstructure_as=Parent) +{'a': 1, 'c': 'foo'} + +>>> converter.structure({'a': 1, 'c': 'foo'}, Parent) +Child(a=1, b='foo') +``` \ No newline at end of file diff --git a/docs/structuring.md b/docs/structuring.md index 09200309..5d9254c7 100644 --- a/docs/structuring.md +++ b/docs/structuring.md @@ -280,7 +280,7 @@ the first time an appropriate union is structured. To support arbitrary unions, register a custom structuring hook for the union (see [Registering custom structuring hooks](structuring.md#registering-custom-structuring-hooks)). -Another option is to use a custom tagged union strategy (see [Strategies - Tagged Unions](strategies.md#tagged-unions)). +Another option is to use a custom tagged union strategy (see [Strategies - Tagged Unions](strategies.md#tagged-unions-strategy)). ### `typing.Final` @@ -456,6 +456,8 @@ attributes holding `attrs` classes and dataclasses. B(b=A(a=1)) ``` +Finally, if an `attrs` or `dataclass` class uses inheritance and as such has one or several subclasses, it can be structured automatically to its exact subtype by using the [include subclasses](strategies.md#include-subclasses-strategy) strategy. + ## Registering Custom Structuring Hooks _cattrs_ doesn't know how to structure non-_attrs_ classes by default, diff --git a/docs/unions.md b/docs/unions.md index f72a4b8c..7fdbbc9b 100644 --- a/docs/unions.md +++ b/docs/unions.md @@ -9,7 +9,7 @@ converter customization (since there are many ways of handling unions). ## Unstructuring unions with extra metadata ```{note} -_cattrs_ comes with the [tagged unions strategy](strategies.md#tagged-unions) for handling this exact use-case since version 22.3. +_cattrs_ comes with the [tagged unions strategy](strategies.md#tagged-unions-strategy) for handling this exact use-case since version 23.1. The example below has been left here for educational purposes, but you should prefer the strategy. ``` diff --git a/docs/unstructuring.md b/docs/unstructuring.md index 5e606b2d..474cc82a 100644 --- a/docs/unstructuring.md +++ b/docs/unstructuring.md @@ -161,7 +161,7 @@ _attrs_ classes and dataclasses are supported out of the box. ## Mixing and Matching Strategies -Converters publicly expose two helper metods, {meth}`Converter.unstructure_attrs_asdict() ` +Converters publicly expose two helper methods, {meth}`Converter.unstructure_attrs_asdict() ` and {meth}`Converter.unstructure_attrs_astuple() `. These methods can be used with custom unstructuring hooks to selectively apply one strategy to instances of particular classes. diff --git a/src/cattrs/strategies/__init__.py b/src/cattrs/strategies/__init__.py index af95dd96..0dfc39a2 100644 --- a/src/cattrs/strategies/__init__.py +++ b/src/cattrs/strategies/__init__.py @@ -1,4 +1,5 @@ """High level strategies for converters.""" +from ._subclasses import include_subclasses from ._unions import configure_tagged_union -__all__ = ["configure_tagged_union"] +__all__ = ["configure_tagged_union", "include_subclasses"] diff --git a/src/cattrs/strategies/_subclasses.py b/src/cattrs/strategies/_subclasses.py new file mode 100644 index 00000000..62d9e4af --- /dev/null +++ b/src/cattrs/strategies/_subclasses.py @@ -0,0 +1,231 @@ +"""Strategies for customizing subclass behaviors.""" +from gc import collect +from typing import Any, Callable, Dict, List, Optional, Tuple, Type, Union + +from ..converters import BaseConverter, Converter +from ..gen import ( + AttributeOverride, + _already_generating, + make_dict_structure_fn, + make_dict_unstructure_fn, +) + + +def _make_subclasses_tree(cl: Type) -> List[Type]: + return [cl] + [ + sscl for scl in cl.__subclasses__() for sscl in _make_subclasses_tree(scl) + ] + + +def _has_subclasses(cl: Type, given_subclasses: Tuple[Type, ...]) -> bool: + """Whether the given class has subclasses from `given_subclasses`.""" + actual = set(cl.__subclasses__()) + given = set(given_subclasses) + return bool(actual & given) + + +def _get_union_type(cl: Type, given_subclasses_tree: Tuple[Type]) -> Optional[Type]: + actual_subclass_tree = tuple(_make_subclasses_tree(cl)) + class_tree = tuple(set(actual_subclass_tree) & set(given_subclasses_tree)) + if len(class_tree) >= 2: + union_type = Union[class_tree] + else: + union_type = None + return union_type + + +def include_subclasses( + cl: Type, + converter: Converter, + subclasses: Optional[Tuple[Type, ...]] = None, + union_strategy: Optional[Callable[[Any, BaseConverter], Any]] = None, + overrides: Optional[Dict[str, AttributeOverride]] = None, +) -> None: + """ + Configure the converter so that the attrs/dataclass `cl` is un/structured as if it + was a union of itself and all its subclasses that are defined at the time when this + strategy is applied. + + :param cl: A base `attrs` or `dataclass` class. + :param converter: The `Converter` on which this strategy is applied. Do note that + the strategy does not work for a :class:`cattrs.BaseConverter`. + :param subclasses: A tuple of sublcasses whose ancestor is `cl`. If left as `None`, + subclasses are detected using recursively the `__subclasses__` method of `cl` + and its descendents. + :param union_strategy: A callable of two arguments passed by position + (`subclass_union`, `converter`) that defines the union strategy to use to + disambiguate the subclasses union. If `None` (the default), the automatic unique + field disambiguation is used which means that every single subclass + participating in the union must have an attribute name that does not exist in + any other sibling class. + :param overrides: a mapping of `cl` attribute names to overrides (instantiated with + :func:`cattrs.gen.override`) to customize un/structuring. + + .. versionadded:: 23.1.0 + """ + # Due to https://github.com/python-attrs/attrs/issues/1047 + collect() + if subclasses is not None: + parent_subclass_tree = (cl,) + subclasses + else: + parent_subclass_tree = tuple(_make_subclasses_tree(cl)) + + if overrides is None: + overrides = {} + + if union_strategy is None: + _include_subclasses_without_union_strategy( + cl, converter, parent_subclass_tree, overrides + ) + else: + _include_subclasses_with_union_strategy( + converter, parent_subclass_tree, union_strategy, overrides + ) + + +def _include_subclasses_without_union_strategy( + cl, + converter: Converter, + parent_subclass_tree: Tuple[Type], + overrides: Dict[str, AttributeOverride], +): + # The iteration approach is required if subclasses are more than one level deep: + for cl in parent_subclass_tree: + # We re-create a reduced union type to handle the following case: + # + # converter.structure(d, as=Child) + # + # In the above, the `as=Child` argument will be transformed to a union type of + # itself and its subtypes, that way we guarantee that the returned object will + # not be the parent. + subclass_union = _get_union_type(cl, parent_subclass_tree) + + def cls_is_cl(cls, _cl=cl): + return cls is _cl + + base_struct_hook = make_dict_structure_fn(cl, converter, **overrides) + base_unstruct_hook = make_dict_unstructure_fn(cl, converter, **overrides) + + if subclass_union is None: + + def struct_hook(val: dict, _, _cl=cl, _base_hook=base_struct_hook) -> cl: + return _base_hook(val, _cl) + + else: + dis_fn = converter._get_dis_func(subclass_union) + + def struct_hook( + val: dict, + _, + _c=converter, + _cl=cl, + _base_hook=base_struct_hook, + _dis_fn=dis_fn, + ) -> cl: + """ + If val is disambiguated to the class `cl`, use its base hook. + + If val is disambiguated to a subclass, dispatch on its exact runtime + type. + """ + dis_cl = _dis_fn(val) + if dis_cl is _cl: + return _base_hook(val, _cl) + return _c.structure(val, dis_cl) + + def unstruct_hook( + val: parent_subclass_tree[0], + _c=converter, + _cl=cl, + _base_hook=base_unstruct_hook, + ) -> Dict: + """ + If val is an instance of the class `cl`, use the hook. + + If val is an instance of a subclass, dispatch on its exact runtime type. + """ + if val.__class__ is _cl: + return _base_hook(val) + return _c.unstructure(val, unstructure_as=val.__class__) + + # This needs to use function dispatch, using singledispatch will again + # match A and all subclasses, which is not what we want. + converter.register_structure_hook_func(cls_is_cl, struct_hook) + converter.register_unstructure_hook_func(cls_is_cl, unstruct_hook) + + +def _include_subclasses_with_union_strategy( + converter: Converter, + union_classes: Tuple[Type, ...], + union_strategy: Callable[[Any, BaseConverter], Any], + overrides: Dict[str, AttributeOverride], +): + """ + This function is tricky because we're dealing with what is essentially a circular reference. + + We need to generate a structure hook for a class that is both: + * specific for that particular class and its own fields + * but should handle specific functions for all its descendants too + + Hence the dance with registering below. + """ + + parent_classes = [cl for cl in union_classes if _has_subclasses(cl, union_classes)] + if not parent_classes: + return + + original_unstruct_hooks = {} + original_struct_hooks = {} + for cl in union_classes: + # In the first pass, every class gets its own unstructure function according to + # the overrides. + # We just generate the hooks, and do not register them. This allows us to manipulate + # the _already_generating set to force runtime dispatch. + _already_generating.working_set = set(union_classes) - {cl} + try: + unstruct_hook = make_dict_unstructure_fn(cl, converter, **overrides) + struct_hook = make_dict_structure_fn(cl, converter, **overrides) + finally: + _already_generating.working_set = set() + original_unstruct_hooks[cl] = unstruct_hook + original_struct_hooks[cl] = struct_hook + + # Now that's done, we can register all the hooks and generate the + # union handler. The union handler needs them. + final_union = Union[union_classes] # type: ignore + + for cl, hook in original_unstruct_hooks.items(): + + def cls_is_cl(cls, _cl=cl): + return cls is _cl + + converter.register_unstructure_hook_func(cls_is_cl, hook) + + for cl, hook in original_struct_hooks.items(): + + def cls_is_cl(cls, _cl=cl): + return cls is _cl + + converter.register_structure_hook_func(cls_is_cl, hook) + + union_strategy(final_union, converter) + unstruct_hook = converter._unstructure_func.dispatch(final_union) + struct_hook = converter._structure_func.dispatch(final_union) + + for cl in union_classes: + # In the second pass, we overwrite the hooks with the union hook. + + def cls_is_cl(cls, _cl=cl): + return cls is _cl + + converter.register_unstructure_hook_func(cls_is_cl, unstruct_hook) + subclasses = tuple([c for c in union_classes if issubclass(c, cl)]) + if len(subclasses) > 1: + u = Union[subclasses] # type: ignore + union_strategy(u, converter) + struct_hook = converter._structure_func.dispatch(u) + + def sh(payload: dict, _, _u=u, _s=struct_hook) -> cl: + return _s(payload, _u) + + converter.register_structure_hook_func(cls_is_cl, sh) diff --git a/src/cattrs/strategies/_unions.py b/src/cattrs/strategies/_unions.py index 422de5ff..51be8e9b 100644 --- a/src/cattrs/strategies/_unions.py +++ b/src/cattrs/strategies/_unions.py @@ -42,14 +42,21 @@ def configure_tagged_union( """ args = union.__args__ tag_to_hook = {} + exact_cl_unstruct_hooks = {} for cl in args: tag = tag_generator(cl) - handler = converter._structure_func.dispatch(cl) + struct_handler = converter._structure_func.dispatch(cl) + unstruct_handler = converter._unstructure_func.dispatch(cl) - def structure_union_member(val: dict, _cl=cl, _h=handler) -> cl: + def structure_union_member(val: dict, _cl=cl, _h=struct_handler) -> cl: return _h(val, _cl) + def unstructure_union_member(val: union, _h=unstruct_handler) -> dict: + return _h(val) + tag_to_hook[tag] = structure_union_member + exact_cl_unstruct_hooks[cl] = unstructure_union_member + cl_to_tag = {cl: tag_generator(cl) for cl in args} if default is not NOTHING: @@ -62,9 +69,12 @@ def structure_default(val: dict, _cl=default, _h=default_handler): cl_to_tag = defaultdict(lambda: default, cl_to_tag) def unstructure_tagged_union( - val: union, _c=converter, _cl_to_tag=cl_to_tag, _tag_name=tag_name + val: union, + _exact_cl_unstruct_hooks=exact_cl_unstruct_hooks, + _cl_to_tag=cl_to_tag, + _tag_name=tag_name, ) -> Dict: - res = _c.unstructure(val) + res = _exact_cl_unstruct_hooks[val.__class__](val) res[_tag_name] = _cl_to_tag[val.__class__] return res diff --git a/tests/strategies/test_include_subclasses.py b/tests/strategies/test_include_subclasses.py new file mode 100644 index 00000000..febdea92 --- /dev/null +++ b/tests/strategies/test_include_subclasses.py @@ -0,0 +1,325 @@ +import typing +from copy import deepcopy +from functools import partial +from typing import Tuple + +import attr +import pytest + +from cattrs import Converter, override +from cattrs.errors import ClassValidationError +from cattrs.strategies import configure_tagged_union, include_subclasses + + +@attr.define +class Parent: + p: int + + +@attr.define +class Child1(Parent): + c1: int + + +@attr.define +class GrandChild(Child1): + g: int + + +@attr.define +class Child2(Parent): + c2: int + + +@attr.define +class UnionCompose: + a: typing.Union[Parent, Child1, Child2, GrandChild] + + +@attr.define +class NonUnionCompose: + a: Parent + + +@attr.define +class UnionContainer: + a: typing.List[typing.Union[Parent, Child1, Child2, GrandChild]] + + +@attr.define +class NonUnionContainer: + a: typing.List[Parent] + + +@attr.define +class CircularA: + a: int + other: "typing.List[CircularA]" + + +@attr.define +class CircularB(CircularA): + b: int + + +def _remove_type_name(unstructured: typing.Union[typing.Dict, typing.List]): + if isinstance(unstructured, list): + iterator = unstructured + elif isinstance(unstructured, dict): + if "type_name" in unstructured: + unstructured.pop("type_name") + iterator = unstructured.values() + for item in iterator: + if isinstance(item, (list, dict)): + _remove_type_name(item) + return unstructured + + +IDS_TO_STRUCT_UNSTRUCT = { + "parent-only": (Parent(1), dict(p=1, type_name="Parent")), + "child1-only": (Child1(1, 2), dict(p=1, c1=2, type_name="Child1")), + "child2-only": (Child2(1, 2), dict(p=1, c2=2, type_name="Child2")), + "grandchild-only": ( + GrandChild(1, 2, 3), + dict(p=1, c1=2, g=3, type_name="GrandChild"), + ), + "union-compose-parent": ( + UnionCompose(Parent(1)), + dict(a=dict(p=1, type_name="Parent")), + ), + "union-compose-child": ( + UnionCompose(Child1(1, 2)), + dict(a=dict(p=1, c1=2, type_name="Child1")), + ), + "union-compose-grandchild": ( + UnionCompose(GrandChild(1, 2, 3)), + dict(a=(dict(p=1, c1=2, g=3, type_name="GrandChild"))), + ), + "non-union-compose-parent": ( + NonUnionCompose(Parent(1)), + dict(a=dict(p=1, type_name="Parent")), + ), + "non-union-compose-child": ( + NonUnionCompose(Child1(1, 2)), + dict(a=dict(p=1, c1=2, type_name="Child1")), + ), + "non-union-compose-grandchild": ( + NonUnionCompose(GrandChild(1, 2, 3)), + dict(a=(dict(p=1, c1=2, g=3, type_name="GrandChild"))), + ), + "union-container": ( + UnionContainer([Parent(1), GrandChild(1, 2, 3)]), + dict( + a=[ + dict(p=1, type_name="Parent"), + dict(p=1, c1=2, g=3, type_name="GrandChild"), + ] + ), + ), + "non-union-container": ( + NonUnionContainer([Parent(1), GrandChild(1, 2, 3)]), + dict( + a=[ + dict(p=1, type_name="Parent"), + dict(p=1, c1=2, g=3, type_name="GrandChild"), + ] + ), + ), +} + + +@pytest.fixture( + params=["with-subclasses", "with-subclasses-and-tagged-union", "wo-subclasses"] +) +def conv_w_subclasses(request): + c = Converter() + if request.param == "with-subclasses": + include_subclasses(Parent, c) + include_subclasses(CircularA, c) + elif request.param == "with-subclasses-and-tagged-union": + union_strategy = partial(configure_tagged_union, tag_name="type_name") + include_subclasses(Parent, c, union_strategy=union_strategy) + include_subclasses(CircularA, c, union_strategy=union_strategy) + + return c, request.param + + +@pytest.mark.parametrize( + "struct_unstruct", IDS_TO_STRUCT_UNSTRUCT.values(), ids=IDS_TO_STRUCT_UNSTRUCT +) +def test_structuring_with_inheritance( + conv_w_subclasses: Tuple[Converter, bool], struct_unstruct +) -> None: + structured, unstructured = struct_unstruct + + converter, included_subclasses_param = conv_w_subclasses + if included_subclasses_param != "with-subclasses-and-tagged-union": + unstructured = _remove_type_name(deepcopy(unstructured)) + + if "wo-subclasses" in included_subclasses_param and isinstance( + structured, (NonUnionContainer, NonUnionCompose) + ): + pytest.xfail( + "Cannot structure subclasses if include_subclasses strategy is not used" + ) + assert converter.structure(unstructured, structured.__class__) == structured + + if structured.__class__ in {Child1, Child2, GrandChild}: + if "wo-subclasses" in included_subclasses_param: + pytest.xfail( + "Cannot structure subclasses if include_subclasses strategy is not used" + ) + assert converter.structure(unstructured, Parent) == structured + + if structured.__class__ == GrandChild: + assert converter.structure(unstructured, Child1) == structured + + if structured.__class__ in {Parent, Child1, Child2}: + with pytest.raises(ClassValidationError): + _ = converter.structure(unstructured, GrandChild) + + +def test_structure_as_union(): + converter = Converter() + include_subclasses(Parent, converter) + the_list = [dict(p=1, c1=2)] + res = converter.structure(the_list, typing.List[typing.Union[Parent, Child1]]) + assert res == [Child1(1, 2)] + + +def test_circular_reference(conv_w_subclasses): + c, included_subclasses_param = conv_w_subclasses + + struct = CircularB(a=1, other=[CircularB(a=2, other=[], b=3)], b=4) + unstruct = dict( + a=1, + other=[dict(a=2, other=[], b=3, type_name="CircularB")], + b=4, + type_name="CircularB", + ) + + if included_subclasses_param != "with-subclasses-and-tagged-union": + unstruct = _remove_type_name(unstruct) + + if "wo-subclasses" in included_subclasses_param: + pytest.xfail("Cannot succeed if include_subclasses strategy is not used") + + res = c.unstructure(struct) + + assert res == unstruct + + res = c.unstructure(struct, CircularA) + assert res == unstruct + + res = c.structure(unstruct, CircularA) + assert res == struct + + +@pytest.mark.parametrize( + "struct_unstruct", IDS_TO_STRUCT_UNSTRUCT.values(), ids=IDS_TO_STRUCT_UNSTRUCT +) +def test_unstructuring_with_inheritance( + conv_w_subclasses: Tuple[Converter, bool], struct_unstruct +): + structured, unstructured = struct_unstruct + converter, included_subclasses_param = conv_w_subclasses + + if "wo-subclasses" in included_subclasses_param: + if isinstance(structured, (NonUnionContainer, NonUnionCompose)): + pytest.xfail("Cannot succeed if include_subclasses strategy is not used") + + if included_subclasses_param != "with-subclasses-and-tagged-union": + unstructured = _remove_type_name(deepcopy(unstructured)) + + assert converter.unstructure(structured) == unstructured + + if structured.__class__ in {Child1, Child2, GrandChild}: + if "wo-subclasses" in included_subclasses_param: + pytest.xfail("Cannot succeed if include_subclasses strategy is not used") + assert converter.unstructure(structured, unstructure_as=Parent) == unstructured + + if structured.__class__ == GrandChild: + assert converter.unstructure(structured, unstructure_as=Child1) == unstructured + + +def test_structuring_unstructuring_unknown_subclass(): + @attr.define + class A: + a: int + + @attr.define + class A1(A): + a1: int + + converter = Converter() + include_subclasses(A, converter) + + # We define A2 after having created the custom un/structuring functions for A and A1 + @attr.define + class A2(A1): + a2: int + + # Even if A2 did not exist, unstructuring_as A works: + assert converter.unstructure(A2(1, 2, 3), unstructure_as=A) == dict(a=1, a1=2, a2=3) + + # As well as when unstructuring as A1, in other words, unstructuring works for + # unknown classes. + assert converter.unstructure(A2(1, 2, 3), unstructure_as=A1) == { + "a": 1, + "a1": 2, + "a2": 3, + } + + # But as expected, structuring unknown classes as their parent fails to give the + # correct answer. This is a documented drawback, we just confirm it. + assert converter.structure(dict(a=1, a1=2, a2=3), A) == A1(1, 2) + + +def test_structuring_with_subclasses_argument(): + c = Converter() + include_subclasses(Parent, c, subclasses=(Child1,)) + + structured_child, unstructured_child = IDS_TO_STRUCT_UNSTRUCT[ + "non-union-compose-child" + ] + unstructured_child = _remove_type_name(deepcopy(unstructured_child)) + assert c.structure(unstructured_child, NonUnionCompose) == structured_child + assert c.unstructure(structured_child) == unstructured_child + + structured_gchild, unstructured_gchild = IDS_TO_STRUCT_UNSTRUCT[ + "non-union-compose-grandchild" + ] + unstructured_gchild = _remove_type_name(deepcopy(unstructured_gchild)) + assert c.structure(unstructured_gchild, NonUnionCompose) == structured_child + assert c.unstructure(structured_gchild) == unstructured_gchild + + +@pytest.mark.parametrize( + "struct_unstruct", ["parent-only", "child1-only", "child2-only", "grandchild-only"] +) +@pytest.mark.parametrize( + "with_union_strategy", + [True, False], + ids=["with-union-strategy", "wo-union-strategy"], +) +def test_overrides(with_union_strategy: bool, struct_unstruct: str): + c = Converter() + union_strategy = ( + partial(configure_tagged_union, tag_name="type_name") + if with_union_strategy + else None + ) + include_subclasses( + Parent, c, overrides={"p": override(rename="u")}, union_strategy=union_strategy + ) + + structured, unstructured = IDS_TO_STRUCT_UNSTRUCT[struct_unstruct] + unstructured = unstructured.copy() + val = unstructured.pop("p") + unstructured["u"] = val + if not with_union_strategy: + unstructured.pop("type_name") + + assert c.unstructure(structured) == unstructured + assert c.structure(unstructured, Parent) == structured + assert c.structure(unstructured, structured.__class__) == structured