Skip to content

Commit 53a5c9d

Browse files
authored
Un/structure fallback factories (#441)
* Un/structure fallback factories * Finish up fallback hook factories * Tweak docs
1 parent afec587 commit 53a5c9d

File tree

16 files changed

+306
-144
lines changed

16 files changed

+306
-144
lines changed

HISTORY.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
([#405](https://github.com/python-attrs/cattrs/pull/405))
1919
- The `omit` parameter of {py:func}`cattrs.override` is now of type `bool | None` (from `bool`).
2020
`None` is the new default and means to apply default _cattrs_ handling to the attribute, which is to omit the attribute if it's marked as `init=False`, and keep it otherwise.
21+
- Converters can now be initialized with custom fallback hook factories for un/structuring.
22+
([#331](https://github.com/python-attrs/cattrs/issues/311) [#441](https://github.com/python-attrs/cattrs/pull/441))
2123
- Fix {py:func}`format_exception() <cattrs.v.format_exception>` parameter working for recursive calls to {py:func}`transform_error <cattrs.transform_error>`.
2224
([#389](https://github.com/python-attrs/cattrs/issues/389))
2325
- [_attrs_ aliases](https://www.attrs.org/en/stable/init.html#private-attributes-and-aliases) are now supported, although aliased fields still map to their attribute name instead of their alias by default when un/structuring.

docs/cattrs.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,14 @@ cattrs.errors module
4646
:undoc-members:
4747
:show-inheritance:
4848

49+
cattrs.fns module
50+
-----------------
51+
52+
.. automodule:: cattrs.fns
53+
:members:
54+
:undoc-members:
55+
:show-inheritance:
56+
4957
cattrs.v module
5058
---------------
5159

docs/conf.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,7 @@
284284
doctest_global_setup = (
285285
"import attr, cattr, cattrs;"
286286
"from attr import Factory, define, field;"
287+
"from cattrs import Converter;"
287288
"from typing import *;"
288289
"from enum import Enum, unique"
289290
)
@@ -292,3 +293,4 @@
292293
copybutton_prompt_text = r">>> |\.\.\. "
293294
copybutton_prompt_is_regexp = True
294295
myst_heading_anchors = 3
296+
autoclass_content = "both"

docs/converters.md

Lines changed: 47 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
11
# Converters
22

33
All _cattrs_ functionality is exposed through a {class}`cattrs.Converter` object.
4-
Global _cattrs_ functions, such as {meth}`cattrs.unstructure`, use a single
5-
global converter. Changes done to this global converter, such as registering new
6-
structure and unstructure hooks, affect all code using the global
7-
functions.
4+
Global _cattrs_ functions, such as {meth}`cattrs.unstructure`, use a single global converter.
5+
Changes done to this global converter, such as registering new structure and unstructure hooks, affect all code using the global functions.
86

97
## Global Converter
108

@@ -18,8 +16,7 @@ The following functions implicitly use this global converter:
1816

1917
Changes made to the global converter will affect the behavior of these functions.
2018

21-
Larger applications are strongly encouraged to create and customize a different,
22-
private instance of {class}`cattrs.Converter`.
19+
Larger applications are strongly encouraged to create and customize a different, private instance of {class}`cattrs.Converter`.
2320

2421
## Converter Objects
2522

@@ -32,14 +29,52 @@ Currently, a converter contains the following state:
3229
- a reference to an unstructuring strategy (either AS_DICT or AS_TUPLE).
3330
- a `dict_factory` callable, used for creating `dicts` when dumping _attrs_ classes using `AS_DICT`.
3431

35-
Converters may be cloned using the {meth}`cattrs.Converter.copy` method.
32+
Converters may be cloned using the {meth}`Converter.copy() <cattrs.BaseConverter.copy>` method.
3633
The new copy may be changed through the `copy` arguments, but will retain all manually registered hooks from the original.
3734

35+
### Fallback Hook Factories
36+
37+
By default, when a {class}`converter <cattrs.BaseConverter>` cannot handle a type it will:
38+
39+
* when unstructuring, pass the value through unchanged
40+
* when structuring, raise a {class}`cattrs.errors.StructureHandlerNotFoundError` asking the user to add configuration
41+
42+
These behaviors can be customized by providing custom [hook factories](usage.md#using-factory-hooks) when creating the converter.
43+
44+
```python
45+
>>> from pickle import dumps
46+
47+
>>> class Unsupported:
48+
... """An artisinal (non-attrs) class, unsupported by default."""
49+
50+
>>> converter = Converter(unstructure_fallback_factory=lambda _: dumps)
51+
>>> instance = Unsupported()
52+
>>> converter.unstructure(instance)
53+
b'\x80\x04\x95\x18\x00\x00\x00\x00\x00\x00\x00\x8c\x08__main__\x94\x8c\x04Test\x94\x93\x94)\x81\x94.'
54+
```
55+
56+
This also enables converters to be chained.
57+
58+
```python
59+
>>> parent = Converter()
60+
61+
>>> child = Converter(
62+
... unstructure_fallback_factory=parent._unstructure_func.dispatch,
63+
... structure_fallback_factory=parent._structure_func.dispatch,
64+
... )
65+
```
66+
67+
```{note}
68+
`Converter._structure_func.dispatch` and `Converter._unstructure_func.dispatch` are slated to become public APIs in a future release.
69+
```
70+
71+
```{versionadded} 23.2.0
72+
73+
```
74+
3875
## `cattrs.Converter`
3976

40-
The {class}`Converter <cattrs.Converter>` is a converter variant that automatically generates,
41-
compiles and caches specialized structuring and unstructuring hooks for _attrs_
42-
classes and dataclasses.
77+
The {class}`Converter <cattrs.Converter>` is a converter variant that automatically generates, compiles and caches specialized structuring and unstructuring hooks for _attrs_ classes, dataclasses and TypedDicts.
4378

4479
`Converter` differs from the {class}`cattrs.BaseConverter` in the following ways:
4580

@@ -53,7 +88,5 @@ The `Converter` used to be called `GenConverter`, and that alias is still presen
5388

5489
## `cattrs.BaseConverter`
5590

56-
The {class}`BaseConverter <cattrs.BaseConverter>` is a simpler and slower Converter variant. It does no
57-
code generation, so it may be faster on first-use which can be useful
58-
in specific cases, like CLI applications where startup time is more
59-
important than throughput.
91+
The {class}`BaseConverter <cattrs.BaseConverter>` is a simpler and slower `Converter` variant.
92+
It does no code generation, so it may be faster on first-use which can be useful in specific cases, like CLI applications where startup time is more important than throughput.

docs/customizing.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ This section deals with customizing the unstructuring and structuring processes
66

77
The default {class}`Converter <cattrs.Converter>`, upon first encountering an _attrs_ class, will use the generation functions mentioned here to generate the specialized hooks for it, register the hooks and use them.
88

9-
## Manual un/structuring hooks
9+
## Manual Un/structuring Hooks
1010

1111
You can write your own structuring and unstructuring functions and register
1212
them for types using {meth}`Converter.register_structure_hook() <cattrs.BaseConverter.register_structure_hook>` and

docs/usage.md

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -95,10 +95,9 @@ MyRecord(a_string='test', a_datetime=DateTime(2018, 7, 28, 18, 24, 0, tzinfo=Tim
9595
MyRecord(a_string='test', a_datetime=DateTime(2018, 7, 28, 18, 24, 0, tzinfo=Timezone('+02:00')))
9696
```
9797

98-
## Using factory hooks
98+
## Using Factory Hooks
9999

100-
For this example, let's assume you have some attrs classes with snake case attributes, and you want to
101-
un/structure them as camel case.
100+
For this example, let's assume you have some attrs classes with snake case attributes, and you want to un/structure them as camel case.
102101

103102
```{warning}
104103
A simpler and better approach to this problem is to simply make your class attributes camel case.
@@ -257,7 +256,7 @@ converter.register_structure_hook_factory(
257256
The `converter` instance will now un/structure every attrs class to camel case.
258257
Nothing has been omitted from this final example; it's complete.
259258

260-
## Using fallback key names
259+
## Using Fallback Key Names
261260

262261
Sometimes when structuring data, the input data may be in multiple formats that need to be converted into a common attribute.
263262

@@ -305,7 +304,7 @@ class MyInternalAttr:
305304

306305
_cattrs_ will now structure both key names into `new_field` on your class.
307306

308-
```
307+
```python
309308
converter.structure({"new_field": "foo"}, MyInternalAttr)
310309
converter.structure({"old_field": "foo"}, MyInternalAttr)
311310
```

src/cattrs/converters.py

Lines changed: 51 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@
1111
Dict,
1212
Iterable,
1313
List,
14-
NoReturn,
1514
Optional,
1615
Tuple,
1716
Type,
@@ -56,12 +55,13 @@
5655
is_union_type,
5756
)
5857
from .disambiguators import create_default_dis_func, is_supported_union
59-
from .dispatch import MultiStrategyDispatch
58+
from .dispatch import HookFactory, MultiStrategyDispatch, StructureHook, UnstructureHook
6059
from .errors import (
6160
IterableValidationError,
6261
IterableValidationNote,
6362
StructureHandlerNotFoundError,
6463
)
64+
from .fns import identity, raise_error
6565
from .gen import (
6666
AttributeOverride,
6767
DictStructureFn,
@@ -79,6 +79,8 @@
7979
from .gen.typeddicts import make_dict_structure_fn as make_typeddict_dict_struct_fn
8080
from .gen.typeddicts import make_dict_unstructure_fn as make_typeddict_dict_unstruct_fn
8181

82+
__all__ = ["UnstructureStrategy", "BaseConverter", "Converter", "GenConverter"]
83+
8284
NoneType = type(None)
8385
T = TypeVar("T")
8486
V = TypeVar("V")
@@ -127,7 +129,20 @@ def __init__(
127129
unstruct_strat: UnstructureStrategy = UnstructureStrategy.AS_DICT,
128130
prefer_attrib_converters: bool = False,
129131
detailed_validation: bool = True,
132+
unstructure_fallback_factory: HookFactory[UnstructureHook] = lambda _: identity,
133+
structure_fallback_factory: HookFactory[StructureHook] = lambda _: raise_error,
130134
) -> None:
135+
"""
136+
:param detailed_validation: Whether to use a slightly slower mode for detailed
137+
validation errors.
138+
:param unstructure_fallback_factory: A hook factory to be called when no
139+
registered unstructuring hooks match.
140+
:param structure_fallback_factory: A hook factory to be called when no
141+
registered structuring hooks match.
142+
143+
.. versionadded:: 23.2.0 *unstructure_fallback_factory*
144+
.. versionadded:: 23.2.0 *structure_fallback_factory*
145+
"""
131146
unstruct_strat = UnstructureStrategy(unstruct_strat)
132147
self._prefer_attrib_converters = prefer_attrib_converters
133148

@@ -143,13 +158,9 @@ def __init__(
143158

144159
self._dis_func_cache = lru_cache()(self._get_dis_func)
145160

146-
self._unstructure_func = MultiStrategyDispatch(self._unstructure_identity)
161+
self._unstructure_func = MultiStrategyDispatch(unstructure_fallback_factory)
147162
self._unstructure_func.register_cls_list(
148-
[
149-
(bytes, self._unstructure_identity),
150-
(str, self._unstructure_identity),
151-
(Path, str),
152-
]
163+
[(bytes, identity), (str, identity), (Path, str)]
153164
)
154165
self._unstructure_func.register_func_list(
155166
[
@@ -175,7 +186,7 @@ def __init__(
175186
# Per-instance register of to-attrs converters.
176187
# Singledispatch dispatches based on the first argument, so we
177188
# store the function and switch the arguments in self.loads.
178-
self._structure_func = MultiStrategyDispatch(BaseConverter._structure_error)
189+
self._structure_func = MultiStrategyDispatch(structure_fallback_factory)
179190
self._structure_func.register_func_list(
180191
[
181192
(lambda cl: cl is Any or cl is Optional or cl is None, lambda v, _: v),
@@ -237,7 +248,7 @@ def unstruct_strat(self) -> UnstructureStrategy:
237248
else UnstructureStrategy.AS_TUPLE
238249
)
239250

240-
def register_unstructure_hook(self, cls: Any, func: Callable[[Any], Any]) -> None:
251+
def register_unstructure_hook(self, cls: Any, func: UnstructureHook) -> None:
241252
"""Register a class-to-primitive converter function for a class.
242253
243254
The converter function should take an instance of the class and return
@@ -254,17 +265,15 @@ def register_unstructure_hook(self, cls: Any, func: Callable[[Any], Any]) -> Non
254265
self._unstructure_func.register_cls_list([(cls, func)])
255266

256267
def register_unstructure_hook_func(
257-
self, check_func: Callable[[Any], bool], func: Callable[[Any], Any]
268+
self, check_func: Callable[[Any], bool], func: UnstructureHook
258269
) -> None:
259270
"""Register a class-to-primitive converter function for a class, using
260271
a function to check if it's a match.
261272
"""
262273
self._unstructure_func.register_func_list([(check_func, func)])
263274

264275
def register_unstructure_hook_factory(
265-
self,
266-
predicate: Callable[[Any], bool],
267-
factory: Callable[[Any], Callable[[Any], Any]],
276+
self, predicate: Callable[[Any], bool], factory: HookFactory[UnstructureHook]
268277
) -> None:
269278
"""
270279
Register a hook factory for a given predicate.
@@ -276,9 +285,7 @@ def register_unstructure_hook_factory(
276285
"""
277286
self._unstructure_func.register_func_list([(predicate, factory, True)])
278287

279-
def register_structure_hook(
280-
self, cl: Any, func: Callable[[Any, Type[T]], T]
281-
) -> None:
288+
def register_structure_hook(self, cl: Any, func: StructureHook) -> None:
282289
"""Register a primitive-to-class converter function for a type.
283290
284291
The converter function should take two arguments:
@@ -300,17 +307,15 @@ def register_structure_hook(
300307
self._structure_func.register_cls_list([(cl, func)])
301308

302309
def register_structure_hook_func(
303-
self, check_func: Callable[[Type[T]], bool], func: Callable[[Any, Type[T]], T]
310+
self, check_func: Callable[[Type[T]], bool], func: StructureHook
304311
) -> None:
305312
"""Register a class-to-primitive converter function for a class, using
306313
a function to check if it's a match.
307314
"""
308315
self._structure_func.register_func_list([(check_func, func)])
309316

310317
def register_structure_hook_factory(
311-
self,
312-
predicate: Callable[[Any], bool],
313-
factory: Callable[[Any], Callable[[Any, Any], Any]],
318+
self, predicate: Callable[[Any], bool], factory: HookFactory[StructureHook]
314319
) -> None:
315320
"""
316321
Register a hook factory for a given predicate.
@@ -353,11 +358,6 @@ def _unstructure_enum(self, obj: Enum) -> Any:
353358
"""Convert an enum to its value."""
354359
return obj.value
355360

356-
@staticmethod
357-
def _unstructure_identity(obj: T) -> T:
358-
"""Just pass it through."""
359-
return obj
360-
361361
def _unstructure_seq(self, seq: Sequence[T]) -> Sequence[T]:
362362
"""Convert a sequence to primitive equivalents."""
363363
# We can reuse the sequence class, so tuples stay tuples.
@@ -388,12 +388,6 @@ def _unstructure_union(self, obj: Any) -> Any:
388388

389389
# Python primitives to classes.
390390

391-
@staticmethod
392-
def _structure_error(_, cl: Type) -> NoReturn:
393-
"""At the bottom of the condition stack, we explode if we can't handle it."""
394-
msg = f"Unsupported type: {cl!r}. Register a structure hook for it."
395-
raise StructureHandlerNotFoundError(msg, type_=cl)
396-
397391
def _gen_structure_generic(self, cl: Type[T]) -> DictStructureFn[T]:
398392
"""Create and return a hook for structuring generics."""
399393
return make_dict_structure_fn(
@@ -742,7 +736,11 @@ def copy(
742736
prefer_attrib_converters: Optional[bool] = None,
743737
detailed_validation: Optional[bool] = None,
744738
) -> "BaseConverter":
745-
"""Create a copy of the converter, keeping all existing custom hooks."""
739+
"""Create a copy of the converter, keeping all existing custom hooks.
740+
741+
:param detailed_validation: Whether to use a slightly slower mode for detailed
742+
validation errors.
743+
"""
746744
res = self.__class__(
747745
dict_factory if dict_factory is not None else self._dict_factory,
748746
unstruct_strat
@@ -786,12 +784,27 @@ def __init__(
786784
unstruct_collection_overrides: Mapping[Type, Callable] = {},
787785
prefer_attrib_converters: bool = False,
788786
detailed_validation: bool = True,
787+
unstructure_fallback_factory: HookFactory[UnstructureHook] = lambda _: identity,
788+
structure_fallback_factory: HookFactory[StructureHook] = lambda _: raise_error,
789789
):
790+
"""
791+
:param detailed_validation: Whether to use a slightly slower mode for detailed
792+
validation errors.
793+
:param unstructure_fallback_factory: A hook factory to be called when no
794+
registered unstructuring hooks match.
795+
:param structure_fallback_factory: A hook factory to be called when no
796+
registered structuring hooks match.
797+
798+
.. versionadded:: 23.2.0 *unstructure_fallback_factory*
799+
.. versionadded:: 23.2.0 *structure_fallback_factory*
800+
"""
790801
super().__init__(
791802
dict_factory=dict_factory,
792803
unstruct_strat=unstruct_strat,
793804
prefer_attrib_converters=prefer_attrib_converters,
794805
detailed_validation=detailed_validation,
806+
unstructure_fallback_factory=unstructure_fallback_factory,
807+
structure_fallback_factory=structure_fallback_factory,
795808
)
796809
self.omit_if_default = omit_if_default
797810
self.forbid_extra_keys = forbid_extra_keys
@@ -1042,7 +1055,11 @@ def copy(
10421055
prefer_attrib_converters: Optional[bool] = None,
10431056
detailed_validation: Optional[bool] = None,
10441057
) -> "Converter":
1045-
"""Create a copy of the converter, keeping all existing custom hooks."""
1058+
"""Create a copy of the converter, keeping all existing custom hooks.
1059+
1060+
:param detailed_validation: Whether to use a slightly slower mode for detailed
1061+
validation errors.
1062+
"""
10461063
res = self.__class__(
10471064
dict_factory if dict_factory is not None else self._dict_factory,
10481065
unstruct_strat

0 commit comments

Comments
 (0)