Skip to content

Commit 856fe63

Browse files
authored
Decorators for hooks (#487)
* Initial work on decorators * Docs * More decorators * exclude_also? * Enable hook factories to take converters * Fix lint * Docs * Tweak docs some more
1 parent 98abaac commit 856fe63

14 files changed

Lines changed: 414 additions & 60 deletions

HISTORY.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@ Our backwards-compatibility policy can be found [here](https://github.com/python
1919
([#486](https://github.com/python-attrs/cattrs/pull/486))
2020
- Introduce {meth}`BaseConverter.get_structure_hook` and {meth}`BaseConverter.get_unstructure_hook` methods.
2121
([#432](https://github.com/python-attrs/cattrs/issues/432) [#472](https://github.com/python-attrs/cattrs/pull/472))
22+
- {meth}`BaseConverter.register_structure_hook`, {meth}`BaseConverter.register_unstructure_hook`,
23+
{meth}`BaseConverter.register_unstructure_hook_factory` and {meth}`BaseConverter.register_structure_hook_factory`
24+
can now be used as decorators and have gained new features when used this way.
25+
See [here](https://catt.rs/en/latest/customizing.html#use-as-decorators) and [here](https://catt.rs/en/latest/customizing.html#id1) for more details.
26+
([#487](https://github.com/python-attrs/cattrs/pull/487))
2227
- Introduce the [_msgspec_](https://jcristharif.com/msgspec/) {mod}`preconf converter <cattrs.preconf.msgspec>`.
2328
Only JSON is supported for now, with other formats supported by _msgspec_ to come later.
2429
([#481](https://github.com/python-attrs/cattrs/pull/481))

docs/basics.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ A base hook can be obtained from a converter and then be subjected to the very r
6363
... return result
6464
```
6565

66-
(`cattrs.structure({}, Model)` is shorthand for `cattrs.get_structure_hook(Model)({}, Model)`.)
66+
(`cattrs.structure({}, Model)` is equivalent to `cattrs.get_structure_hook(Model)({}, Model)`.)
6767

6868
This new hook can be used directly or registered to a converter (the original instance, or a different one):
6969

@@ -72,7 +72,7 @@ This new hook can be used directly or registered to a converter (the original in
7272
```
7373

7474

75-
Now if we use this hook to structure a `Model`, through the magic of function composition✨ that hook will use our old `int_hook`.
75+
Now if we use this hook to structure a `Model`, through the magic of function composition✨ that hook will use our old `int_hook`.
7676

7777
```python
7878
>>> converter.structure({"a": "1"}, Model)

docs/conf.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,6 @@
4040
"sphinx.ext.autodoc",
4141
"sphinx.ext.viewcode",
4242
"sphinx.ext.doctest",
43-
"sphinx.ext.autosectionlabel",
4443
"sphinx_copybutton",
4544
"myst_parser",
4645
]

docs/customizing.md

Lines changed: 73 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,44 @@ Some examples of this are:
1717
* protocols, unless they are `runtime_checkable`
1818
* various modifiers, such as `Final` and `NotRequired`
1919
* newtypes and 3.12 type aliases
20+
* `typing.Annotated`
2021

2122
... and many others. In these cases, predicate functions should be used instead.
2223

24+
### Use as Decorators
25+
26+
{meth}`register_structure_hook() <cattrs.BaseConverter.register_structure_hook>` and {meth}`register_unstructure_hook() <cattrs.BaseConverter.register_unstructure_hook>` can also be used as _decorators_.
27+
When used this way they behave a little differently.
28+
29+
{meth}`register_structure_hook() <cattrs.BaseConverter.register_structure_hook>` will inspect the return type of the hook and register the hook for that type.
30+
31+
```python
32+
@converter.register_structure_hook
33+
def my_int_hook(val: Any, _) -> int:
34+
"""This hook will be registered for `int`s."""
35+
return int(val)
36+
```
37+
38+
{meth}`register_unstructure_hook() <cattrs.BaseConverter.register_unstructure_hook>` will inspect the type of the first argument and register the hook for that type.
39+
40+
```python
41+
from datetime import datetime
42+
43+
@converter.register_unstructure_hook
44+
def my_datetime_hook(val: datetime) -> str:
45+
"""This hook will be registered for `datetime`s."""
46+
return val.isoformat()
47+
```
48+
49+
The non-decorator approach is still recommended when dealing with lambdas, hooks produced elsewhere, unannotated hooks and situations where type introspection doesn't work.
50+
51+
```{versionadded} 24.1.0
52+
```
53+
2354
### Predicate Hooks
2455

25-
A predicate is a function that takes a type and returns true or false, depending on whether the associated hook can handle the given type.
56+
A _predicate_ is a function that takes a type and returns true or false
57+
depending on whether the associated hook can handle the given type.
2658

2759
The {meth}`register_unstructure_hook_func() <cattrs.BaseConverter.register_unstructure_hook_func>` and {meth}`register_structure_hook_func() <cattrs.BaseConverter.register_structure_hook_func>` are used
2860
to link un/structuring hooks to arbitrary types. These hooks are then called _predicate hooks_, and are very powerful.
@@ -64,9 +96,11 @@ Here's an example showing how to use hook factories to apply the `forbid_extra_k
6496

6597
```python
6698
>>> from attrs import define, has
99+
>>> from cattrs import Converter
67100
>>> from cattrs.gen import make_dict_structure_fn
68101

69-
>>> c = cattrs.Converter()
102+
>>> c = Converter()
103+
70104
>>> c.register_structure_hook_factory(
71105
... has,
72106
... lambda cl: make_dict_structure_fn(cl, c, _cattrs_forbid_extra_keys=True)
@@ -82,8 +116,44 @@ Traceback (most recent call last):
82116
cattrs.errors.ForbiddenExtraKeysError: Extra fields in constructor for E: else
83117
```
84118

85-
A complex use case for hook factories is described over at {ref}`usage:Using factory hooks`.
119+
A complex use case for hook factories is described over at [](usage.md#using-factory-hooks).
120+
121+
#### Use as Decorators
122+
123+
{meth}`register_unstructure_hook_factory() <cattrs.BaseConverter.register_unstructure_hook_factory>` and
124+
{meth}`register_structure_hook_factory() <cattrs.BaseConverter.register_structure_hook_factory>` can also be used as decorators.
125+
126+
When registered via decorators, hook factories can receive the current converter by exposing an additional required parameter.
127+
128+
Here's an example of using an unstructure hook factory to handle unstructuring [queues](https://docs.python.org/3/library/queue.html#queue.Queue).
129+
130+
```{doctest}
131+
>>> from queue import Queue
132+
>>> from typing import get_origin
133+
>>> from cattrs import Converter
134+
135+
>>> c = Converter()
86136

137+
>>> @c.register_unstructure_hook_factory(lambda t: get_origin(t) is Queue)
138+
... def queue_hook_factory(cl: Any, converter: Converter) -> Callable:
139+
... type_arg = get_args(cl)[0]
140+
... elem_handler = converter.get_unstructure_hook(type_arg)
141+
...
142+
... def unstructure_hook(v: Queue) -> list:
143+
... res = []
144+
... while not v.empty():
145+
... res.append(elem_handler(v.get_nowait()))
146+
... return res
147+
...
148+
... return unstructure_hook
149+
150+
>>> q = Queue()
151+
>>> q.put(1)
152+
>>> q.put(2)
153+
154+
>>> c.unstructure(q, unstructure_as=Queue[int])
155+
[1, 2]
156+
```
87157

88158
## Using `cattrs.gen` Generators
89159

pyproject.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,11 @@ source = [
112112
".tox/pypy*/site-packages",
113113
]
114114

115+
[tool.coverage.report]
116+
exclude_also = [
117+
"@overload",
118+
]
119+
115120
[tool.ruff]
116121
src = ["src", "tests"]
117122
select = [

src/cattrs/_compat.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
from collections.abc import Set as AbcSet
55
from dataclasses import MISSING, Field, is_dataclass
66
from dataclasses import fields as dataclass_fields
7+
from functools import partial
8+
from inspect import signature as _signature
79
from typing import AbstractSet as TypingAbstractSet
810
from typing import (
911
Any,
@@ -211,6 +213,11 @@ def get_final_base(type) -> Optional[type]:
211213
OriginAbstractSet = AbcSet
212214
OriginMutableSet = AbcMutableSet
213215

216+
signature = _signature
217+
218+
if sys.version_info >= (3, 10):
219+
signature = partial(_signature, eval_str=True)
220+
214221
if sys.version_info >= (3, 9):
215222
from collections import Counter
216223
from collections.abc import Mapping as AbcMapping

0 commit comments

Comments
 (0)