Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: strategy for using class methods #405

Merged
merged 1 commit into from
Aug 24, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 66 additions & 1 deletion docs/strategies.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
```

Expand Down Expand Up @@ -258,3 +258,68 @@ 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.

If you want to (un)structured nested objects, just append a converter parameter
to your (un)structuring methods and you will receive the converter there:

```{doctest} class_methods

>>> @define
... class Nested:
... m: MyClass
...
... @classmethod
... def _structure(cls, data: dict, conv):
... return cls(conv.structure(data["n"], MyClass))
...
... def _unstructure(self, conv):
... return {"n": conv.unstructure(self.m)}

>>> print(converter.structure({"n": {"b": 42}}, Nested))
Nested(m=MyClass(a=43))
>>> print(converter.unstructure(Nested(MyClass(42))))
{'n': {'c': 41}}
```

```{versionadded} 23.2.0

```
3 changes: 2 additions & 1 deletion src/cattrs/strategies/__init__.py
Original file line number Diff line number Diff line change
@@ -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"]
64 changes: 64 additions & 0 deletions src/cattrs/strategies/_class_methods.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
"""Strategy for using class-specific (un)structuring methods."""

from inspect import signature
from typing import Any, Callable, Optional, Type, TypeVar

from cattrs import BaseConverter

T = TypeVar("T")


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.

If you want to (un)structured nested objects, just append a converter parameter
to your (un)structuring methods and you will receive the converter there.

.. versionadded:: 23.2.0
"""

if structure_method_name:

def make_class_method_structure(cl: Type[T]) -> Callable[[Any, Type[T]], T]:
fn = getattr(cl, structure_method_name)
n_parameters = len(signature(fn).parameters)
if n_parameters == 1:
return lambda v, _: fn(v)
if n_parameters == 2:
return lambda v, _: fn(v, converter)
raise TypeError("Provide a class method with one or two arguments.")

converter.register_structure_hook_factory(
lambda t: hasattr(t, structure_method_name), make_class_method_structure
)

if unstructure_method_name:

def make_class_method_unstructure(cl: Type[T]) -> Callable[[T], T]:
fn = getattr(cl, unstructure_method_name)
n_parameters = len(signature(fn).parameters)
if n_parameters == 1:
return fn
if n_parameters == 2:
return lambda self_: fn(self_, converter)
raise TypeError("Provide a method with no or one argument.")

converter.register_unstructure_hook_factory(
lambda t: hasattr(t, unstructure_method_name), make_class_method_unstructure
)
92 changes: 92 additions & 0 deletions tests/strategies/test_class_methods.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import itertools
from typing import Union

import pytest
from attrs import define
from hypothesis import given
from hypothesis.strategies import integers

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_not_nested(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
}


@given(integers(1, 5))
def test_nested_roundtrip(depth):
@define
class Nested:
a: Union["Nested", None]
c: int

@classmethod
def _structure(cls, data, conv):
b = data["b"]
return cls(None if b is None else conv.structure(b, cls), data["c"])

def _unstructure(self, conv):
return {"b": conv.unstructure(self.a), "c": self.c}

@staticmethod
def create(depth: int) -> Union["Nested", None]:
return None if depth == 0 else Nested(Nested.create(depth - 1), 42)

structured = Nested.create(depth)

converter = BaseConverter()
use_class_methods(converter, "_structure", "_unstructure")
assert structured == converter.structure(converter.unstructure(structured), Nested)