Skip to content

Commit

Permalink
Recipes for initializer selection (#494)
Browse files Browse the repository at this point in the history
* Add initializer selection recipes

* Use semantic newlines

* Add "Cartesian" to docstring

* Assert equality of created points

* Run doctests
  • Loading branch information
AdrianSosic committed Mar 5, 2024
1 parent 2b10bcb commit 8196a2e
Show file tree
Hide file tree
Showing 2 changed files with 129 additions and 0 deletions.
1 change: 1 addition & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ caption: User Guide
customizing
strategies
recipes
validation
preconf
unions
Expand Down
128 changes: 128 additions & 0 deletions docs/recipes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
# Recipes

This page contains a collection of recipes for custom un-/structuring mechanisms.


## Switching Initializers

When structuring _attrs_ classes, _cattrs_ uses the classes' ``__init__`` method to instantiate objects by default.
In certain situations, you might want to deviate from this behavior and use alternative initializers instead.

For example, consider the following `Point` class describing points in 2D space, which offers two `classmethod`s for alternative creation:

```{doctest}
from __future__ import annotations
import math
from attrs import define
@define
class Point:
"""A point in 2D space."""
x: float
y: float
@classmethod
def from_tuple(cls, coordinates: tuple[float, float]) -> Point:
"""Create a point from a tuple of Cartesian coordinates."""
return Point(*coordinates)
@classmethod
def from_polar(cls, radius: float, angle: float) -> Point:
"""Create a point from its polar coordinates."""
return Point(radius * math.cos(angle), radius * math.sin(angle))
```


### Selecting an Alternative Initializer

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:

```{doctest}
from inspect import signature
from typing import Callable, TypedDict
from cattrs import Converter
from cattrs.dispatch import StructureHook
def signature_to_typed_dict(fn: Callable) -> type[TypedDict]:
"""Create a TypedDict reflecting a callable's signature."""
params = {p: t.annotation for p, t in signature(fn).parameters.items()}
return TypedDict(f"{fn.__name__}_args", params)
def make_initializer_from(fn: Callable, conv: Converter) -> StructureHook:
"""Return a structuring hook from a given callable."""
td = signature_to_typed_dict(fn)
td_hook = conv.get_structure_hook(td)
return lambda v, _: fn(**td_hook(v, td))
```

Now, you can easily structure `Point`s from the specified alternative representation:

```{doctest}
c = Converter()
c.register_structure_hook(Point, make_initializer_from(Point.from_polar, c))
p0 = Point(1.0, 0.0)
p1 = c.structure({"radius": 1.0, "angle": 0.0}, Point)
assert p0 == p1
```


### Dynamically Switching Between Initializers

In some cases, even more flexibility is required and the selection of the initializer must happen at runtime, requiring a dynamic approach.
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.

In such situations, the following hook factory can help you achieve your goal:

```{doctest}
from inspect import signature
from typing import Callable, TypedDict
from cattrs import Converter
from cattrs.dispatch import StructureHook
def signature_to_typed_dict(fn: Callable) -> type[TypedDict]:
"""Create a TypedDict reflecting a callable's signature."""
params = {p: t.annotation for p, t in signature(fn).parameters.items()}
return TypedDict(f"{fn.__name__}_args", params)
def make_initializer_selection_hook(
initializer_key: str,
converter: Converter,
) -> StructureHook:
"""Return a structuring hook that dynamically switches between initializers."""
def select_initializer_hook(specs: dict, cls: type[T]) -> T:
"""Deserialization with dynamic initializer selection."""
# If no initializer keyword is specified, use regular __init__
if initializer_key not in specs:
return converter.structure_attrs_fromdict(specs, cls)
# Otherwise, call the specified initializer with deserialized arguments
specs = specs.copy()
initializer_name = specs.pop(initializer_key)
initializer = getattr(cls, initializer_name)
td = signature_to_typed_dict(initializer)
td_hook = converter.get_structure_hook(td)
return initializer(**td_hook(specs, td))
return select_initializer_hook
```

Specifying the key that determines the initializer to be used now lets you dynamically select the `classmethod` as part of the object specification itself:

```{doctest}
c = Converter()
c.register_structure_hook(Point, make_initializer_selection_hook("initializer", c))
p0 = Point(1.0, 0.0)
p1 = c.structure({"initializer": "from_polar", "radius": 1.0, "angle": 0.0}, Point)
p2 = c.structure({"initializer": "from_tuple", "coordinates": (1.0, 0.0)}, Point)
assert p0 == p1 == p2
```

0 comments on commit 8196a2e

Please sign in to comment.