Skip to content

Commit 8196a2e

Browse files
authored
Recipes for initializer selection (#494)
* Add initializer selection recipes * Use semantic newlines * Add "Cartesian" to docstring * Assert equality of created points * Run doctests
1 parent 2b10bcb commit 8196a2e

File tree

2 files changed

+129
-0
lines changed

2 files changed

+129
-0
lines changed

docs/index.md

+1
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ caption: User Guide
1919
2020
customizing
2121
strategies
22+
recipes
2223
validation
2324
preconf
2425
unions

docs/recipes.md

+128
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
# Recipes
2+
3+
This page contains a collection of recipes for custom un-/structuring mechanisms.
4+
5+
6+
## Switching Initializers
7+
8+
When structuring _attrs_ classes, _cattrs_ uses the classes' ``__init__`` method to instantiate objects by default.
9+
In certain situations, you might want to deviate from this behavior and use alternative initializers instead.
10+
11+
For example, consider the following `Point` class describing points in 2D space, which offers two `classmethod`s for alternative creation:
12+
13+
```{doctest}
14+
from __future__ import annotations
15+
16+
import math
17+
18+
from attrs import define
19+
20+
21+
@define
22+
class Point:
23+
"""A point in 2D space."""
24+
25+
x: float
26+
y: float
27+
28+
@classmethod
29+
def from_tuple(cls, coordinates: tuple[float, float]) -> Point:
30+
"""Create a point from a tuple of Cartesian coordinates."""
31+
return Point(*coordinates)
32+
33+
@classmethod
34+
def from_polar(cls, radius: float, angle: float) -> Point:
35+
"""Create a point from its polar coordinates."""
36+
return Point(radius * math.cos(angle), radius * math.sin(angle))
37+
```
38+
39+
40+
### Selecting an Alternative Initializer
41+
42+
A simple way to _statically_ set one of the `classmethod`s as initializer is to register a structuring hook that holds a reference to the respective callable:
43+
44+
```{doctest}
45+
from inspect import signature
46+
from typing import Callable, TypedDict
47+
48+
from cattrs import Converter
49+
from cattrs.dispatch import StructureHook
50+
51+
def signature_to_typed_dict(fn: Callable) -> type[TypedDict]:
52+
"""Create a TypedDict reflecting a callable's signature."""
53+
params = {p: t.annotation for p, t in signature(fn).parameters.items()}
54+
return TypedDict(f"{fn.__name__}_args", params)
55+
56+
def make_initializer_from(fn: Callable, conv: Converter) -> StructureHook:
57+
"""Return a structuring hook from a given callable."""
58+
td = signature_to_typed_dict(fn)
59+
td_hook = conv.get_structure_hook(td)
60+
return lambda v, _: fn(**td_hook(v, td))
61+
```
62+
63+
Now, you can easily structure `Point`s from the specified alternative representation:
64+
65+
```{doctest}
66+
c = Converter()
67+
c.register_structure_hook(Point, make_initializer_from(Point.from_polar, c))
68+
69+
p0 = Point(1.0, 0.0)
70+
p1 = c.structure({"radius": 1.0, "angle": 0.0}, Point)
71+
assert p0 == p1
72+
```
73+
74+
75+
### Dynamically Switching Between Initializers
76+
77+
In some cases, even more flexibility is required and the selection of the initializer must happen at runtime, requiring a dynamic approach.
78+
A typical scenario would be when object structuring happens behind an API and you want to let the user specify which representation of the object they wish to provide in their serialization string.
79+
80+
In such situations, the following hook factory can help you achieve your goal:
81+
82+
```{doctest}
83+
from inspect import signature
84+
from typing import Callable, TypedDict
85+
86+
from cattrs import Converter
87+
from cattrs.dispatch import StructureHook
88+
89+
def signature_to_typed_dict(fn: Callable) -> type[TypedDict]:
90+
"""Create a TypedDict reflecting a callable's signature."""
91+
params = {p: t.annotation for p, t in signature(fn).parameters.items()}
92+
return TypedDict(f"{fn.__name__}_args", params)
93+
94+
def make_initializer_selection_hook(
95+
initializer_key: str,
96+
converter: Converter,
97+
) -> StructureHook:
98+
"""Return a structuring hook that dynamically switches between initializers."""
99+
100+
def select_initializer_hook(specs: dict, cls: type[T]) -> T:
101+
"""Deserialization with dynamic initializer selection."""
102+
103+
# If no initializer keyword is specified, use regular __init__
104+
if initializer_key not in specs:
105+
return converter.structure_attrs_fromdict(specs, cls)
106+
107+
# Otherwise, call the specified initializer with deserialized arguments
108+
specs = specs.copy()
109+
initializer_name = specs.pop(initializer_key)
110+
initializer = getattr(cls, initializer_name)
111+
td = signature_to_typed_dict(initializer)
112+
td_hook = converter.get_structure_hook(td)
113+
return initializer(**td_hook(specs, td))
114+
115+
return select_initializer_hook
116+
```
117+
118+
Specifying the key that determines the initializer to be used now lets you dynamically select the `classmethod` as part of the object specification itself:
119+
120+
```{doctest}
121+
c = Converter()
122+
c.register_structure_hook(Point, make_initializer_selection_hook("initializer", c))
123+
124+
p0 = Point(1.0, 0.0)
125+
p1 = c.structure({"initializer": "from_polar", "radius": 1.0, "angle": 0.0}, Point)
126+
p2 = c.structure({"initializer": "from_tuple", "coordinates": (1.0, 0.0)}, Point)
127+
assert p0 == p1 == p2
128+
```

0 commit comments

Comments
 (0)