Skip to content

Commit 7c569d6

Browse files
authored
feat: strategy for using class methods (#405)
1 parent 1f5781b commit 7c569d6

4 files changed

Lines changed: 224 additions & 2 deletions

File tree

docs/strategies.md

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,7 @@ Without the application of the strategy, in both unstructure and structure opera
172172

173173
```{note}
174174
The handling of subclasses is an opt-in feature for two main reasons:
175-
- Performance. While small and probably negligeable in most cases the subclass handling incurs more function calls and has a performance impact.
175+
- Performance. While small and probably negligeable in most cases the subclass handling incurs more function calls and has a performance impact.
176176
- 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.
177177
```
178178

@@ -258,3 +258,68 @@ Child(a=1, b='foo')
258258
```{versionadded} 23.1.0
259259
260260
```
261+
262+
263+
264+
### Using Class-Specific Structure and Unstructure Methods
265+
266+
_Found at {py:func}`cattrs.strategies.use_class_methods`._
267+
268+
The following strategy can be applied for both structuring and unstructuring (also simultaneously).
269+
270+
If a class requires special handling for (un)structuring, you can add a dedicated (un)structuring
271+
method:
272+
273+
```{doctest} class_methods
274+
275+
>>> from attrs import define
276+
>>> from cattrs import Converter
277+
>>> from cattrs.strategies import use_class_methods
278+
279+
>>> @define
280+
... class MyClass:
281+
... a: int
282+
...
283+
... @classmethod
284+
... def _structure(cls, data: dict):
285+
... return cls(data["b"] + 1) # expecting "b", not "a"
286+
...
287+
... def _unstructure(self):
288+
... return {"c": self.a - 1} # unstructuring as "c", not "a"
289+
290+
>>> converter = Converter()
291+
>>> use_class_methods(converter, "_structure", "_unstructure")
292+
>>> print(converter.structure({"b": 42}, MyClass))
293+
MyClass(a=43)
294+
>>> print(converter.unstructure(MyClass(42)))
295+
{'c': 41}
296+
```
297+
298+
Any class without a `_structure` or `_unstructure` method will use the default strategy for
299+
structuring or unstructuring, respectively. Feel free to use other names.
300+
301+
If you want to (un)structured nested objects, just append a converter parameter
302+
to your (un)structuring methods and you will receive the converter there:
303+
304+
```{doctest} class_methods
305+
306+
>>> @define
307+
... class Nested:
308+
... m: MyClass
309+
...
310+
... @classmethod
311+
... def _structure(cls, data: dict, conv):
312+
... return cls(conv.structure(data["n"], MyClass))
313+
...
314+
... def _unstructure(self, conv):
315+
... return {"n": conv.unstructure(self.m)}
316+
317+
>>> print(converter.structure({"n": {"b": 42}}, Nested))
318+
Nested(m=MyClass(a=43))
319+
>>> print(converter.unstructure(Nested(MyClass(42))))
320+
{'n': {'c': 41}}
321+
```
322+
323+
```{versionadded} 23.2.0
324+
325+
```

src/cattrs/strategies/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""High level strategies for converters."""
2+
from ._class_methods import use_class_methods
23
from ._subclasses import include_subclasses
34
from ._unions import configure_tagged_union
45

5-
__all__ = ["configure_tagged_union", "include_subclasses"]
6+
__all__ = ["configure_tagged_union", "include_subclasses", "use_class_methods"]
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
"""Strategy for using class-specific (un)structuring methods."""
2+
3+
from inspect import signature
4+
from typing import Any, Callable, Optional, Type, TypeVar
5+
6+
from cattrs import BaseConverter
7+
8+
T = TypeVar("T")
9+
10+
11+
def use_class_methods(
12+
converter: BaseConverter,
13+
structure_method_name: Optional[str] = None,
14+
unstructure_method_name: Optional[str] = None,
15+
) -> None:
16+
"""
17+
Configure the converter such that dedicated methods are used for (un)structuring
18+
the instance of a class if such methods are available. The default (un)structuring
19+
will be applied if such an (un)structuring methods cannot be found.
20+
21+
:param converter: The `Converter` on which this strategy is applied. You can use
22+
:class:`cattrs.BaseConverter` or any other derived class.
23+
:param structure_method_name: Optional string with the name of the class method
24+
which should be used for structuring. If not provided, no class method will be
25+
used for structuring.
26+
:param unstructure_method_name: Optional string with the name of the class method
27+
which should be used for unstructuring. If not provided, no class method will
28+
be used for unstructuring.
29+
30+
If you want to (un)structured nested objects, just append a converter parameter
31+
to your (un)structuring methods and you will receive the converter there.
32+
33+
.. versionadded:: 23.2.0
34+
"""
35+
36+
if structure_method_name:
37+
38+
def make_class_method_structure(cl: Type[T]) -> Callable[[Any, Type[T]], T]:
39+
fn = getattr(cl, structure_method_name)
40+
n_parameters = len(signature(fn).parameters)
41+
if n_parameters == 1:
42+
return lambda v, _: fn(v)
43+
if n_parameters == 2:
44+
return lambda v, _: fn(v, converter)
45+
raise TypeError("Provide a class method with one or two arguments.")
46+
47+
converter.register_structure_hook_factory(
48+
lambda t: hasattr(t, structure_method_name), make_class_method_structure
49+
)
50+
51+
if unstructure_method_name:
52+
53+
def make_class_method_unstructure(cl: Type[T]) -> Callable[[T], T]:
54+
fn = getattr(cl, unstructure_method_name)
55+
n_parameters = len(signature(fn).parameters)
56+
if n_parameters == 1:
57+
return fn
58+
if n_parameters == 2:
59+
return lambda self_: fn(self_, converter)
60+
raise TypeError("Provide a method with no or one argument.")
61+
62+
converter.register_unstructure_hook_factory(
63+
lambda t: hasattr(t, unstructure_method_name), make_class_method_unstructure
64+
)
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import itertools
2+
from typing import Union
3+
4+
import pytest
5+
from attrs import define
6+
from hypothesis import given
7+
from hypothesis.strategies import integers
8+
9+
from cattrs import BaseConverter
10+
from cattrs.strategies import use_class_methods
11+
12+
13+
@define
14+
class Base:
15+
a: int
16+
17+
18+
class Structure(Base):
19+
@classmethod
20+
def _structure(cls, data: dict):
21+
return cls(data["b"]) # expecting "b", not "a"
22+
23+
24+
class Unstructure(Base):
25+
def _unstructure(self):
26+
return {"c": self.a} # unstructuring as "c", not "a"
27+
28+
29+
class Both(Structure, Unstructure):
30+
pass
31+
32+
33+
@pytest.fixture
34+
def get_converter(converter: BaseConverter):
35+
def aux(structure: str, unstructure: str) -> BaseConverter:
36+
use_class_methods(converter, structure, unstructure)
37+
return converter
38+
39+
return aux
40+
41+
42+
@pytest.mark.parametrize(
43+
"cls,structure_method,unstructure_method",
44+
itertools.product(
45+
[Structure, Unstructure, Both],
46+
["_structure", "_undefined", None],
47+
["_unstructure", "_undefined", None],
48+
),
49+
)
50+
def test_not_nested(get_converter, structure_method, unstructure_method, cls) -> None:
51+
converter = get_converter(structure_method, unstructure_method)
52+
53+
assert converter.structure(
54+
{
55+
"b"
56+
if structure_method == "_structure" and hasattr(cls, "_structure")
57+
else "a": 42
58+
},
59+
cls,
60+
) == cls(42)
61+
62+
assert converter.unstructure(cls(42)) == {
63+
"c"
64+
if unstructure_method == "_unstructure" and hasattr(cls, "_unstructure")
65+
else "a": 42
66+
}
67+
68+
69+
@given(integers(1, 5))
70+
def test_nested_roundtrip(depth):
71+
@define
72+
class Nested:
73+
a: Union["Nested", None]
74+
c: int
75+
76+
@classmethod
77+
def _structure(cls, data, conv):
78+
b = data["b"]
79+
return cls(None if b is None else conv.structure(b, cls), data["c"])
80+
81+
def _unstructure(self, conv):
82+
return {"b": conv.unstructure(self.a), "c": self.c}
83+
84+
@staticmethod
85+
def create(depth: int) -> Union["Nested", None]:
86+
return None if depth == 0 else Nested(Nested.create(depth - 1), 42)
87+
88+
structured = Nested.create(depth)
89+
90+
converter = BaseConverter()
91+
use_class_methods(converter, "_structure", "_unstructure")
92+
assert structured == converter.structure(converter.unstructure(structured), Nested)

0 commit comments

Comments
 (0)