Skip to content

Commit

Permalink
Improve optionals customization
Browse files Browse the repository at this point in the history
  • Loading branch information
Tinche committed Mar 30, 2024
1 parent 2c5cbd1 commit 308c37a
Show file tree
Hide file tree
Showing 4 changed files with 55 additions and 22 deletions.
28 changes: 21 additions & 7 deletions docs/defaulthooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,10 @@ Any of these hooks can be overriden if pure validation is required instead.
```{doctest}
>>> c = Converter()

>>> def validate(value, type):
>>> @c.register_structure_hook
... def validate(value, type) -> int:
... if not isinstance(value, type):
... raise ValueError(f'{value!r} not an instance of {type}')
...

>>> c.register_structure_hook(int, validate)

>>> c.structure("1", int)
Traceback (most recent call last):
Expand Down Expand Up @@ -110,12 +108,28 @@ Traceback (most recent call last):
...
TypeError: int() argument must be a string, a bytes-like object or a number, not 'NoneType'

>>> cattrs.structure(None, int | None)
>>> # None was returned.
>>> print(cattrs.structure(None, int | None))
None
```

Bare `Optional` s (non-parameterized, just `Optional`, as opposed to `Optional[str]`) aren't supported; `Optional[Any]` should be used instead.

`Optionals` handling can be customized using {meth}`register_structure_hook` and {meth}`register_unstructure_hook`.

```{doctest}
>>> converter = Converter()

>>> @converter.register_structure_hook
... def hook(val: Any, type: Any) -> str | None:
... if val in ("", None):
... return None
... return str(val)
...

>>> print(converter.structure("", str | None))
None
```


### Lists

Expand Down Expand Up @@ -585,4 +599,4 @@ Protocols are unstructured according to the actual runtime type of the value.

```{versionadded} 1.9.0

```
```
22 changes: 10 additions & 12 deletions docs/recipes.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,27 +10,23 @@ In certain situations, you might want to deviate from this behavior and use alte

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
```{doctest} point_group
>>> 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:
... 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:
... 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))
```
Expand All @@ -40,17 +36,18 @@ For example, consider the following `Point` class describing points in 2D space,

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}
```{doctest} point_group
>>> 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."""
... """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."""
Expand All @@ -61,7 +58,7 @@ A simple way to _statically_ set one of the `classmethod`s as initializer is to

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

```{doctest}
```{doctest} point_group
>>> c = Converter()
>>> c.register_structure_hook(Point, make_initializer_from(Point.from_polar, c))
Expand All @@ -78,7 +75,7 @@ A typical scenario would be when object structuring happens behind an API and yo

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

```{doctest}
```{doctest} point_group
>>> from inspect import signature
>>> from typing import Callable, TypedDict
Expand All @@ -90,6 +87,7 @@ In such situations, the following hook factory can help you achieve your goal:
... params = {p: t.annotation for p, t in signature(fn).parameters.items()}
... return TypedDict(f"{fn.__name__}_args", params)
>>> T = TypeVar("T")
>>> def make_initializer_selection_hook(
... initializer_key: str,
... converter: Converter,
Expand All @@ -116,7 +114,7 @@ In such situations, the following hook factory can help you achieve your goal:

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}
```{doctest} point_group
>>> c = Converter()
>>> c.register_structure_hook(Point, make_initializer_selection_hook("initializer", c))
Expand Down
5 changes: 3 additions & 2 deletions src/cattrs/converters.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
OriginMutableSet,
Sequence,
Set,
TypeAlias,
fields,
get_final_base,
get_newtype_base,
Expand Down Expand Up @@ -245,12 +246,12 @@ def __init__(
(is_namedtuple, namedtuple_structure_factory, "extended"),
(is_mapping, self._structure_dict),
(is_supported_union, self._gen_attrs_union_structure, True),
(is_optional, self._structure_optional),
(
lambda t: is_union_type(t) and t in self._union_struct_registry,
self._union_struct_registry.__getitem__,
True,
),
(is_optional, self._structure_optional),
(has, self._structure_attrs),
]
)
Expand Down Expand Up @@ -1382,4 +1383,4 @@ def copy(
return res


GenConverter = Converter
GenConverter: TypeAlias = Converter
22 changes: 21 additions & 1 deletion tests/test_optionals.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import pytest
from attrs import define

from cattrs import Converter
from cattrs import BaseConverter, Converter

from ._compat import is_py310_plus

Expand Down Expand Up @@ -51,3 +51,23 @@ class A:
pass

assert converter.unstructure(A(), Optional[Any]) == {}


def test_override_optional(converter: BaseConverter):
"""Optionals can be overridden using singledispatch."""

@converter.register_structure_hook
def _(val, _) -> Optional[int]:
if val in ("", None):
return None
return int(val)

assert converter.structure("", Optional[int]) is None

@converter.register_unstructure_hook
def _(val: Optional[int]) -> Any:
if val in (None, 0):
return None
return val

assert converter.unstructure(0, Optional[int]) is None

0 comments on commit 308c37a

Please sign in to comment.