Skip to content

Commit 308c37a

Browse files
committed
Improve optionals customization
1 parent 2c5cbd1 commit 308c37a

4 files changed

Lines changed: 55 additions & 22 deletions

File tree

docs/defaulthooks.md

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,10 @@ Any of these hooks can be overriden if pure validation is required instead.
3636
```{doctest}
3737
>>> c = Converter()
3838

39-
>>> def validate(value, type):
39+
>>> @c.register_structure_hook
40+
... def validate(value, type) -> int:
4041
... if not isinstance(value, type):
4142
... raise ValueError(f'{value!r} not an instance of {type}')
42-
...
43-
44-
>>> c.register_structure_hook(int, validate)
4543

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

113-
>>> cattrs.structure(None, int | None)
114-
>>> # None was returned.
111+
>>> print(cattrs.structure(None, int | None))
112+
None
115113
```
116114

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

117+
`Optionals` handling can be customized using {meth}`register_structure_hook` and {meth}`register_unstructure_hook`.
118+
119+
```{doctest}
120+
>>> converter = Converter()
121+
122+
>>> @converter.register_structure_hook
123+
... def hook(val: Any, type: Any) -> str | None:
124+
... if val in ("", None):
125+
... return None
126+
... return str(val)
127+
...
128+
129+
>>> print(converter.structure("", str | None))
130+
None
131+
```
132+
119133

120134
### Lists
121135

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

586600
```{versionadded} 1.9.0
587601

588-
```
602+
```

docs/recipes.md

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,27 +10,23 @@ In certain situations, you might want to deviate from this behavior and use alte
1010

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

13-
```{doctest}
14-
>>> from __future__ import annotations
15-
13+
```{doctest} point_group
1614
>>> import math
17-
1815
>>> from attrs import define
1916
20-
2117
>>> @define
2218
... class Point:
2319
... """A point in 2D space."""
2420
... x: float
2521
... y: float
2622
...
2723
... @classmethod
28-
... def from_tuple(cls, coordinates: tuple[float, float]) -> Point:
24+
... def from_tuple(cls, coordinates: tuple[float, float]) -> "Point":
2925
... """Create a point from a tuple of Cartesian coordinates."""
3026
... return Point(*coordinates)
3127
...
3228
... @classmethod
33-
... def from_polar(cls, radius: float, angle: float) -> Point:
29+
... def from_polar(cls, radius: float, angle: float) -> "Point":
3430
... """Create a point from its polar coordinates."""
3531
... return Point(radius * math.cos(angle), radius * math.sin(angle))
3632
```
@@ -40,17 +36,18 @@ For example, consider the following `Point` class describing points in 2D space,
4036

4137
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:
4238

43-
```{doctest}
39+
```{doctest} point_group
4440
>>> from inspect import signature
4541
>>> from typing import Callable, TypedDict
4642
4743
>>> from cattrs import Converter
4844
>>> from cattrs.dispatch import StructureHook
4945
5046
>>> def signature_to_typed_dict(fn: Callable) -> type[TypedDict]:
51-
... """Create a TypedDict reflecting a callable's signature."""
47+
... """Create a TypedDict reflecting a callable's signature."""
5248
... params = {p: t.annotation for p, t in signature(fn).parameters.items()}
5349
... return TypedDict(f"{fn.__name__}_args", params)
50+
...
5451
5552
>>> def make_initializer_from(fn: Callable, conv: Converter) -> StructureHook:
5653
... """Return a structuring hook from a given callable."""
@@ -61,7 +58,7 @@ A simple way to _statically_ set one of the `classmethod`s as initializer is to
6158

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

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

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

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

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

119-
```{doctest}
117+
```{doctest} point_group
120118
>>> c = Converter()
121119
>>> c.register_structure_hook(Point, make_initializer_selection_hook("initializer", c))
122120

src/cattrs/converters.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
OriginMutableSet,
2424
Sequence,
2525
Set,
26+
TypeAlias,
2627
fields,
2728
get_final_base,
2829
get_newtype_base,
@@ -245,12 +246,12 @@ def __init__(
245246
(is_namedtuple, namedtuple_structure_factory, "extended"),
246247
(is_mapping, self._structure_dict),
247248
(is_supported_union, self._gen_attrs_union_structure, True),
249+
(is_optional, self._structure_optional),
248250
(
249251
lambda t: is_union_type(t) and t in self._union_struct_registry,
250252
self._union_struct_registry.__getitem__,
251253
True,
252254
),
253-
(is_optional, self._structure_optional),
254255
(has, self._structure_attrs),
255256
]
256257
)
@@ -1382,4 +1383,4 @@ def copy(
13821383
return res
13831384

13841385

1385-
GenConverter = Converter
1386+
GenConverter: TypeAlias = Converter

tests/test_optionals.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import pytest
44
from attrs import define
55

6-
from cattrs import Converter
6+
from cattrs import BaseConverter, Converter
77

88
from ._compat import is_py310_plus
99

@@ -51,3 +51,23 @@ class A:
5151
pass
5252

5353
assert converter.unstructure(A(), Optional[Any]) == {}
54+
55+
56+
def test_override_optional(converter: BaseConverter):
57+
"""Optionals can be overridden using singledispatch."""
58+
59+
@converter.register_structure_hook
60+
def _(val, _) -> Optional[int]:
61+
if val in ("", None):
62+
return None
63+
return int(val)
64+
65+
assert converter.structure("", Optional[int]) is None
66+
67+
@converter.register_unstructure_hook
68+
def _(val: Optional[int]) -> Any:
69+
if val in (None, 0):
70+
return None
71+
return val
72+
73+
assert converter.unstructure(0, Optional[int]) is None

0 commit comments

Comments
 (0)