diff --git a/docs/strategies.md b/docs/strategies.md index eadc6342..73189211 100644 --- a/docs/strategies.md +++ b/docs/strategies.md @@ -172,7 +172,7 @@ Without the application of the strategy, in both unstructure and structure opera ```{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. +- 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 user. ``` @@ -258,3 +258,46 @@ Child(a=1, b='foo') ```{versionadded} 23.1.0 ``` + + + +### Using Class-Specific Structure and Unstructure Methods + +_Found at {py:func}`cattrs.strategies.use_class_methods`._ + +The following strategy can be applied for both structuring and unstructuring (also simultaneously). + +If a class requires special handling for (un)structuring, you can add a dedicated (un)structuring +method: + +```{doctest} class_methods + +>>> from attrs import define +>>> from cattrs import Converter +>>> from cattrs.strategies import use_class_methods + +>>> @define +... class MyClass: +... a: int +... +... @classmethod +... def _structure(cls, data: dict): +... return cls(data["b"] + 1) # expecting "b", not "a" +... +... def _unstructure(self): +... return {"c": self.a - 1} # unstructuring as "c", not "a" + +>>> converter = Converter() +>>> use_class_methods(converter, "_structure", "_unstructure") +>>> print(converter.structure({"b": 42}, MyClass)) +MyClass(a=43) +>>> print(converter.unstructure(MyClass(42))) +{'c': 41} +``` + +Any class without a `_structure` or `_unstructure` method will use the default strategy for +structuring or unstructuring, respectively. Feel free to use other names. + +```{versionadded} 23.2.0 + +``` diff --git a/src/cattrs/strategies/__init__.py b/src/cattrs/strategies/__init__.py index 0dfc39a2..563caa06 100644 --- a/src/cattrs/strategies/__init__.py +++ b/src/cattrs/strategies/__init__.py @@ -1,5 +1,6 @@ """High level strategies for converters.""" +from ._class_methods import use_class_methods from ._subclasses import include_subclasses from ._unions import configure_tagged_union -__all__ = ["configure_tagged_union", "include_subclasses"] +__all__ = ["configure_tagged_union", "include_subclasses", "use_class_methods"] diff --git a/src/cattrs/strategies/_class_methods.py b/src/cattrs/strategies/_class_methods.py new file mode 100644 index 00000000..f9e3454d --- /dev/null +++ b/src/cattrs/strategies/_class_methods.py @@ -0,0 +1,38 @@ +"""Strategy for using class-specific (un)structuring methods.""" +from typing import Optional + +from cattrs import BaseConverter + + +def use_class_methods( + converter: BaseConverter, + structure_method_name: Optional[str] = None, + unstructure_method_name: Optional[str] = None, +) -> None: + """ + Configure the converter such that dedicated methods are used for (un)structuring + the instance of a class if such methods are available. The default (un)structuring + will be applied if such an (un)structuring methods cannot be found. + + :param converter: The `Converter` on which this strategy is applied. You can use + :class:`cattrs.BaseConverter` or any other derived class. + :param structure_method_name: Optional string with the name of the class method + which should be used for structuring. If not provided, no class method will be + used for structuring. + :param unstructure_method_name: Optional string with the name of the class method + which should be used for unstructuring. If not provided, no class method will + be used for unstructuring. + + .. versionadded:: 23.2.0 + """ + if structure_method_name: + converter.register_structure_hook_func( + lambda t: hasattr(t, structure_method_name), + lambda v, t: getattr(t, structure_method_name)(v), + ) + + if unstructure_method_name: + converter.register_unstructure_hook_func( + lambda t: hasattr(t, unstructure_method_name), + lambda v: getattr(v, unstructure_method_name)(), + ) diff --git a/tests/strategies/test_class_methods.py b/tests/strategies/test_class_methods.py new file mode 100644 index 00000000..63bb0690 --- /dev/null +++ b/tests/strategies/test_class_methods.py @@ -0,0 +1,63 @@ +import itertools + +import pytest +from attrs import define + +from cattrs import BaseConverter +from cattrs.strategies import use_class_methods + + +@define +class Base: + a: int + + +class Structure(Base): + @classmethod + def _structure(cls, data: dict): + return cls(data["b"]) # expecting "b", not "a" + + +class Unstructure(Base): + def _unstructure(self): + return {"c": self.a} # unstructuring as "c", not "a" + + +class Both(Structure, Unstructure): + pass + + +@pytest.fixture +def get_converter(converter: BaseConverter): + def aux(structure: str, unstructure: str) -> BaseConverter: + use_class_methods(converter, structure, unstructure) + return converter + + return aux + + +@pytest.mark.parametrize( + "cls,structure_method,unstructure_method", + itertools.product( + [Structure, Unstructure, Both], + ["_structure", "_undefined", None], + ["_unstructure", "_undefined", None], + ), +) +def test(get_converter, structure_method, unstructure_method, cls) -> None: + converter = get_converter(structure_method, unstructure_method) + + assert converter.structure( + { + "b" + if structure_method == "_structure" and hasattr(cls, "_structure") + else "a": 42 + }, + cls, + ) == cls(42) + + assert converter.unstructure(cls(42)) == { + "c" + if unstructure_method == "_unstructure" and hasattr(cls, "_unstructure") + else "a": 42 + }