Skip to content

Improve optionals customization #530

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

Merged
merged 2 commits into from
Mar 30, 2024
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
3 changes: 3 additions & 0 deletions HISTORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ Our backwards-compatibility policy can be found [here](https://github.com./python
([#486](https://github.com./python-attrs/cattrs/pull/486))
- **Minor change**: {py:func}`cattrs.gen.make_dict_structure_fn` will use the value for the `prefer_attrib_converters` parameter from the given converter by default now.
If you're using this function directly, the old behavior can be restored by passing in the desired values explicitly.
([#527](https://github.com./python-attrs/cattrs/issues/527) [#528](https://github.com./python-attrs/cattrs/pull/528))
- Introduce {meth}`BaseConverter.get_structure_hook` and {meth}`BaseConverter.get_unstructure_hook` methods.
([#432](https://github.com./python-attrs/cattrs/issues/432) [#472](https://github.com./python-attrs/cattrs/pull/472))
- {meth}`BaseConverter.register_structure_hook`, {meth}`BaseConverter.register_unstructure_hook`,
Expand Down Expand Up @@ -53,6 +54,8 @@ can now be used as decorators and have gained new features.
([#460](https://github.com./python-attrs/cattrs/issues/460) [#467](https://github.com./python-attrs/cattrs/pull/467))
- `typing_extensions.Any` is now supported and handled like `typing.Any`.
([#488](https://github.com./python-attrs/cattrs/issues/488) [#490](https://github.com./python-attrs/cattrs/pull/490))
- `Optional` types can now be consistently customized using `register_structure_hook` and `register_unstructure_hook`.
([#529](https://github.com./python-attrs/cattrs/issues/529) [#530](https://github.com./python-attrs/cattrs/pull/530))
- The BaseConverter now properly generates detailed validation errors for mappings.
([#496](https://github.com./python-attrs/cattrs/pull/496))
- [PEP 695](https://peps.python.org/pep-0695/) generics are now tested.
Expand Down
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
Loading