Skip to content

Commit b58a45b

Browse files
authored
Even better hook factories (#495)
* Even better hook factories * Improve hook factory types * Test deque validation, for coverage * Enum coverage * Fix lint
1 parent d6ec93f commit b58a45b

8 files changed

Lines changed: 179 additions & 48 deletions

File tree

HISTORY.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ Our backwards-compatibility policy can be found [here](https://github.com/python
2121
([#432](https://github.com/python-attrs/cattrs/issues/432) [#472](https://github.com/python-attrs/cattrs/pull/472))
2222
- {meth}`BaseConverter.register_structure_hook`, {meth}`BaseConverter.register_unstructure_hook`,
2323
{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.
24+
can now be used as decorators and have gained new features.
2525
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.
2626
([#487](https://github.com/python-attrs/cattrs/pull/487))
2727
- Introduce the [_msgspec_](https://jcristharif.com/msgspec/) {mod}`preconf converter <cattrs.preconf.msgspec>`.

docs/customizing.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -116,15 +116,15 @@ Traceback (most recent call last):
116116
cattrs.errors.ForbiddenExtraKeysError: Extra fields in constructor for E: else
117117
```
118118

119+
Hook factories can receive the current converter by exposing an additional required parameter.
120+
119121
A complex use case for hook factories is described over at [](usage.md#using-factory-hooks).
120122

121123
#### Use as Decorators
122124

123125
{meth}`register_unstructure_hook_factory() <cattrs.BaseConverter.register_unstructure_hook_factory>` and
124126
{meth}`register_structure_hook_factory() <cattrs.BaseConverter.register_structure_hook_factory>` can also be used as decorators.
125127

126-
When registered via decorators, hook factories can receive the current converter by exposing an additional required parameter.
127-
128128
Here's an example of using an unstructure hook factory to handle unstructuring [queues](https://docs.python.org/3/library/queue.html#queue.Queue).
129129

130130
```{doctest}
@@ -158,7 +158,9 @@ Here's an example of using an unstructure hook factory to handle unstructuring [
158158
## Using `cattrs.gen` Generators
159159

160160
The {mod}`cattrs.gen` module allows for generating and compiling specialized hooks for unstructuring _attrs_ classes, dataclasses and typed dicts.
161-
The default {class}`Converter <cattrs.Converter>`, upon first encountering one of these types, will use the generation functions mentioned here to generate specialized hooks for it, register the hooks and use them.
161+
The default {class}`Converter <cattrs.Converter>`, upon first encountering one of these types,
162+
will use the generation functions mentioned here to generate specialized hooks for it,
163+
register the hooks and use them.
162164

163165
One reason for generating these hooks in advance is that they can bypass a lot of _cattrs_ machinery and be significantly faster than normal _cattrs_.
164166
The hooks are also good building blocks for more complex customizations.

src/cattrs/converters.py

Lines changed: 52 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,17 @@ def is_literal_containing_enums(typ: type) -> bool:
123123
return is_literal(typ) and any(isinstance(val, Enum) for val in typ.__args__)
124124

125125

126+
def _is_extended_factory(factory: Callable) -> bool:
127+
"""Does this factory also accept a converter arg?"""
128+
# We use the original `inspect.signature` to not evaluate string
129+
# annotations.
130+
sig = inspect_signature(factory)
131+
return (
132+
len(sig.parameters) >= 2
133+
and (list(sig.parameters.values())[1]).default is Signature.empty
134+
)
135+
136+
126137
class BaseConverter:
127138
"""Converts between structured and unstructured data."""
128139

@@ -344,41 +355,36 @@ def register_unstructure_hook_factory(
344355
) -> UnstructureHookFactory:
345356
...
346357

358+
@overload
347359
def register_unstructure_hook_factory(
348-
self,
349-
predicate: Callable[[Any], bool],
350-
factory: UnstructureHookFactory | None = None,
351-
) -> (
352-
Callable[[UnstructureHookFactory], UnstructureHookFactory]
353-
| UnstructureHookFactory
354-
):
360+
self, predicate: Callable[[Any], bool], factory: ExtendedUnstructureHookFactory
361+
) -> ExtendedUnstructureHookFactory:
362+
...
363+
364+
def register_unstructure_hook_factory(self, predicate, factory=None):
355365
"""
356366
Register a hook factory for a given predicate.
357367
358-
May also be used as a decorator. When used as a decorator, the hook
359-
factory may expose an additional required parameter. In this case,
368+
The hook factory may expose an additional required parameter. In this case,
360369
the current converter will be provided to the hook factory as that
361370
parameter.
362371
372+
May also be used as a decorator.
373+
363374
:param predicate: A function that, given a type, returns whether the factory
364375
can produce a hook for that type.
365376
:param factory: A callable that, given a type, produces an unstructuring
366377
hook for that type. This unstructuring hook will be cached.
367378
368379
.. versionchanged:: 24.1.0
369380
This method may now be used as a decorator.
381+
The factory may also receive the converter as a second, required argument.
370382
"""
371383
if factory is None:
372384

373385
def decorator(factory):
374386
# Is this an extended factory (takes a converter too)?
375-
# We use the original `inspect.signature` to not evaluate string
376-
# annotations.
377-
sig = inspect_signature(factory)
378-
if (
379-
len(sig.parameters) >= 2
380-
and (list(sig.parameters.values())[1]).default is Signature.empty
381-
):
387+
if _is_extended_factory(factory):
382388
self._unstructure_func.register_func_list(
383389
[(predicate, factory, "extended")]
384390
)
@@ -388,7 +394,16 @@ def decorator(factory):
388394
)
389395

390396
return decorator
391-
self._unstructure_func.register_func_list([(predicate, factory, True)])
397+
398+
self._unstructure_func.register_func_list(
399+
[
400+
(
401+
predicate,
402+
factory,
403+
"extended" if _is_extended_factory(factory) else True,
404+
)
405+
]
406+
)
392407
return factory
393408

394409
def get_unstructure_hook(
@@ -483,36 +498,36 @@ def register_structure_hook_factory(
483498
) -> StructureHookFactory:
484499
...
485500

501+
@overload
486502
def register_structure_hook_factory(
487-
self,
488-
predicate: Callable[[Any], bool],
489-
factory: HookFactory[StructureHook] | None = None,
490-
) -> Callable[[StructureHookFactory, StructureHookFactory]] | StructureHookFactory:
503+
self, predicate: Callable[[Any], bool], factory: ExtendedStructureHookFactory
504+
) -> ExtendedStructureHookFactory:
505+
...
506+
507+
def register_structure_hook_factory(self, predicate, factory=None):
491508
"""
492509
Register a hook factory for a given predicate.
493510
494-
May also be used as a decorator. When used as a decorator, the hook
495-
factory may expose an additional required parameter. In this case,
511+
The hook factory may expose an additional required parameter. In this case,
496512
the current converter will be provided to the hook factory as that
497513
parameter.
498514
515+
May also be used as a decorator.
516+
499517
:param predicate: A function that, given a type, returns whether the factory
500518
can produce a hook for that type.
501519
:param factory: A callable that, given a type, produces a structuring
502520
hook for that type. This structuring hook will be cached.
503521
504522
.. versionchanged:: 24.1.0
505523
This method may now be used as a decorator.
524+
The factory may also receive the converter as a second, required argument.
506525
"""
507526
if factory is None:
508527
# Decorator use.
509528
def decorator(factory):
510529
# Is this an extended factory (takes a converter too)?
511-
sig = signature(factory)
512-
if (
513-
len(sig.parameters) >= 2
514-
and (list(sig.parameters.values())[1]).default is Signature.empty
515-
):
530+
if _is_extended_factory(factory):
516531
self._structure_func.register_func_list(
517532
[(predicate, factory, "extended")]
518533
)
@@ -522,7 +537,15 @@ def decorator(factory):
522537
)
523538

524539
return decorator
525-
self._structure_func.register_func_list([(predicate, factory, True)])
540+
self._structure_func.register_func_list(
541+
[
542+
(
543+
predicate,
544+
factory,
545+
"extended" if _is_extended_factory(factory) else True,
546+
)
547+
]
548+
)
526549
return factory
527550

528551
def structure(self, obj: UnstructuredValue, cl: type[T]) -> T:

tests/test_converter.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -856,3 +856,67 @@ def my_structure_hook_factory(type: Any, converter: BaseConverter) -> StructureH
856856
return lambda v, _: Test(int_handler(v[0]))
857857

858858
assert converter.structure((2,), Test) == Test(1)
859+
860+
861+
def test_hook_factories_with_converters(converter: BaseConverter):
862+
"""Hook factories with converters work."""
863+
864+
@define
865+
class Test:
866+
a: int
867+
868+
converter.register_unstructure_hook(int, lambda v: v + 1)
869+
870+
def my_hook_factory(type: Any, converter: BaseConverter) -> UnstructureHook:
871+
int_handler = converter.get_unstructure_hook(int)
872+
return lambda v: (int_handler(v.a),)
873+
874+
converter.register_unstructure_hook_factory(has, my_hook_factory)
875+
876+
assert converter.unstructure(Test(1)) == (2,)
877+
878+
converter.register_structure_hook(int, lambda v: v - 1)
879+
880+
def my_structure_hook_factory(type: Any, converter: BaseConverter) -> StructureHook:
881+
int_handler = converter.get_structure_hook(int)
882+
return lambda v, _: Test(int_handler(v[0]))
883+
884+
converter.register_structure_hook_factory(has, my_structure_hook_factory)
885+
886+
assert converter.structure((2,), Test) == Test(1)
887+
888+
889+
def test_hook_factories_with_converter_methods(converter: BaseConverter):
890+
"""What if the hook factories are methods (have `self`)?"""
891+
892+
@define
893+
class Test:
894+
a: int
895+
896+
converter.register_unstructure_hook(int, lambda v: v + 1)
897+
898+
class Converters:
899+
@classmethod
900+
def my_hook_factory(
901+
cls, type: Any, converter: BaseConverter
902+
) -> UnstructureHook:
903+
int_handler = converter.get_unstructure_hook(int)
904+
return lambda v: (int_handler(v.a),)
905+
906+
def my_structure_hook_factory(
907+
self, type: Any, converter: BaseConverter
908+
) -> StructureHook:
909+
int_handler = converter.get_structure_hook(int)
910+
return lambda v, _: Test(int_handler(v[0]))
911+
912+
converter.register_unstructure_hook_factory(has, Converters.my_hook_factory)
913+
914+
assert converter.unstructure(Test(1)) == (2,)
915+
916+
converter.register_structure_hook(int, lambda v: v - 1)
917+
918+
converter.register_structure_hook_factory(
919+
has, Converters().my_structure_hook_factory
920+
)
921+
922+
assert converter.structure((2,), Test) == Test(1)

tests/test_enums.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
"""Tests for enums."""
2+
from hypothesis import given
3+
from hypothesis.strategies import data, sampled_from
4+
from pytest import raises
5+
6+
from cattrs import BaseConverter
7+
from cattrs._compat import Literal
8+
9+
from .untyped import enums_of_primitives
10+
11+
12+
@given(data(), enums_of_primitives())
13+
def test_structuring_enums(data, enum):
14+
"""Test structuring enums by their values."""
15+
converter = BaseConverter()
16+
val = data.draw(sampled_from(list(enum)))
17+
18+
assert converter.structure(val.value, enum) == val
19+
20+
21+
@given(enums_of_primitives())
22+
def test_enum_failure(enum):
23+
"""Structuring literals with enums fails properly."""
24+
converter = BaseConverter()
25+
type = Literal[next(iter(enum))]
26+
27+
with raises(Exception) as exc_info:
28+
converter.structure("", type)
29+
30+
assert exc_info.value.args[0] == f" not in literal {type!r}"

tests/test_structure.py

Lines changed: 3 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""Test structuring of collections and primitives."""
22
from typing import Any, Dict, FrozenSet, List, MutableSet, Optional, Set, Tuple, Union
33

4-
import attr
4+
from attrs import define
55
from hypothesis import assume, given
66
from hypothesis.strategies import (
77
binary,
@@ -27,7 +27,6 @@
2727
from .untyped import (
2828
deque_seqs_of_primitives,
2929
dicts_of_primitives,
30-
enums_of_primitives,
3130
lists_of_primitives,
3231
primitive_strategies,
3332
seqs_of_primitives,
@@ -325,15 +324,6 @@ class Bar:
325324
assert exc.value.type_ is Bar
326325

327326

328-
@given(data(), enums_of_primitives())
329-
def test_structuring_enums(data, enum):
330-
"""Test structuring enums by their values."""
331-
converter = BaseConverter()
332-
val = data.draw(sampled_from(list(enum)))
333-
334-
assert converter.structure(val.value, enum) == val
335-
336-
337327
def test_structuring_unsupported():
338328
"""Loading unsupported classes should throw."""
339329
converter = BaseConverter()
@@ -373,12 +363,12 @@ class Bar(Foo):
373363
def test_structure_union_edge_case():
374364
converter = BaseConverter()
375365

376-
@attr.s(auto_attribs=True)
366+
@define
377367
class A:
378368
a1: Any
379369
a2: Optional[Any] = None
380370

381-
@attr.s(auto_attribs=True)
371+
@define
382372
class B:
383373
b1: Any
384374
b2: Optional[Any] = None

tests/test_validation.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""Tests for the extended validation mode."""
22
import pickle
3-
from typing import Dict, FrozenSet, List, Set, Tuple
3+
from typing import Deque, Dict, FrozenSet, List, Set, Tuple
44

55
import pytest
66
from attrs import define, field
@@ -82,6 +82,28 @@ def test_list_validation():
8282
]
8383

8484

85+
def test_deque_validation():
86+
"""Proper validation errors are raised structuring deques."""
87+
c = Converter(detailed_validation=True)
88+
89+
with pytest.raises(IterableValidationError) as exc:
90+
c.structure(["1", 2, "a", 3.0, "c"], Deque[int])
91+
92+
assert repr(exc.value.exceptions[0]) == repr(
93+
ValueError("invalid literal for int() with base 10: 'a'")
94+
)
95+
assert exc.value.exceptions[0].__notes__ == [
96+
"Structuring typing.Deque[int] @ index 2"
97+
]
98+
99+
assert repr(exc.value.exceptions[1]) == repr(
100+
ValueError("invalid literal for int() with base 10: 'c'")
101+
)
102+
assert exc.value.exceptions[1].__notes__ == [
103+
"Structuring typing.Deque[int] @ index 4"
104+
]
105+
106+
85107
@given(...)
86108
def test_mapping_validation(detailed_validation: bool):
87109
"""Proper validation errors are raised structuring mappings."""

tests/untyped.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838

3939

4040
@st.composite
41-
def enums_of_primitives(draw):
41+
def enums_of_primitives(draw: st.DrawFn) -> Enum:
4242
"""Generate enum classes with primitive values."""
4343
names = draw(
4444
st.sets(st.text(min_size=1).filter(lambda s: not s.endswith("_")), min_size=1)

0 commit comments

Comments
 (0)