Skip to content

Commit 53fd2ad

Browse files
committed
Flesh out union handling and add docs
1 parent fbdb947 commit 53fd2ad

11 files changed

+215
-37
lines changed

Diff for: docs/customizing.rst

+8-7
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,14 @@ Customizing class un/structuring
55
This section deals with customizing the unstructuring and structuring processes
66
in ``cattrs``.
77

8+
Using ``cattr.gen.GenConverter``
9+
********************************
10+
11+
The ``cattr.gen`` module contains a ``Converter`` subclass, the ``GenConverter``.
12+
The ``GenConverter``, upon first encountering an ``attrs`` class, will use
13+
the generation functions mentioned here to generate the specialized hooks for it,
14+
register the hooks and use them.
15+
816
Manual un/structuring hooks
917
***************************
1018

@@ -100,10 +108,3 @@ keyword in Python.
100108
>>> c.structure({'class': 1}, ExampleClass)
101109
ExampleClass(klass=1)
102110

103-
Using ``cattr.gen.GenConverter``
104-
********************************
105-
106-
The ``cattr.gen`` module also contains a ``Converter`` subclass, the ``GenConverter``.
107-
The ``GenConverter``, upon first encountering an ``attrs`` class, will use
108-
the mentioned generation functions to generate the specialized hooks for it,
109-
register the hooks and use them.

Diff for: docs/index.rst

+1
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ Contents:
1818
structuring
1919
unstructuring
2020
customizing
21+
unions
2122
contributing
2223
history
2324

Diff for: docs/unions.rst

+67
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
========================
2+
Tips for Handling Unions
3+
========================
4+
5+
This sections contains information for advanced union handling.
6+
7+
As mentioned in the structuring section, ``cattrs`` is able to handle simple
8+
unions of ``attrs`` classes automatically. More complex cases require
9+
converter customization (since there are many ways of handling unions).
10+
11+
Unstructuring unions with extra metadata
12+
****************************************
13+
14+
Let's assume a simple scenario of two classes, ``ClassA`` and ``ClassB`, both
15+
of which have no distinct fields and so cannot be used automatically with
16+
``cattrs``.
17+
18+
.. code-block:: python
19+
20+
@attr.define
21+
class ClassA:
22+
a_string: str
23+
24+
@attr.define
25+
class ClassB:
26+
a_string: str
27+
28+
A naive approach to unstructuring either of these would yield identical
29+
dictionaries, and not enough information to restructure the classes.
30+
31+
.. code-block:: python
32+
33+
>>> converter.unstructure(ClassA("test"))
34+
{'a_string': 'test'} # Is this ClassA or ClassB? Who knows!
35+
36+
What we can do is ensure some extra information is present in the
37+
unstructured data, and then use that information to help structure later.
38+
39+
First, we register an unstructure hook for the `Union[ClassA, ClassB]` type.
40+
41+
.. code-block:: python
42+
43+
>>> converter.register_unstructure_hook(
44+
... Union[ClassA, ClassB],
45+
... lambda o: {"_type": type(o).__name__, **converter.unstructure(o)}
46+
... )
47+
>>> converter.unstructure(ClassA("test"), unstructure_as=Union[ClassA, ClassB])
48+
{'_type': 'ClassA', 'a_string': 'test'}
49+
50+
Note that when unstructuring, we had to provide the `unstructure_as` parameter
51+
or `cattrs` would have just applied the usual unstructuring rules to `ClassA`,
52+
instead of our special union hook.
53+
54+
Now that the unstructured data contains some information, we can create a
55+
structuring hook to put it to use:
56+
57+
.. code-block:: python
58+
59+
>>> converter.register_structure_hook(
60+
... Union[ClassA, ClassB],
61+
... lambda o, _: converter.structure(o, ClassA if o["_type"] == "ClassA" else ClassB)
62+
... )
63+
>>> converter.structure({"_type": "ClassA", "a_string": "test"}, Union[ClassA, ClassB])
64+
ClassA(a_string='test')
65+
66+
In the future, `cattrs` will gain additional tools to make union handling even
67+
easier and automate generating these hooks.

Diff for: src/cattr/_compat.py

+3-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import sys
22
from typing import (
3+
Any,
34
Dict,
45
FrozenSet,
56
List,
@@ -46,7 +47,7 @@ def is_union_type(obj):
4647
and obj.__origin__ is Union
4748
)
4849

49-
def is_sequence(type):
50+
def is_sequence(type: Any) -> bool:
5051
return type is List or (
5152
type.__class__ is _GenericAlias
5253
and type.__origin__ is not Union
@@ -112,7 +113,7 @@ def is_union_type(obj):
112113
and obj.__origin__ is Union
113114
)
114115

115-
def is_sequence(type):
116+
def is_sequence(type: Any) -> bool:
116117
return (
117118
type in (List, list, Sequence, MutableSequence)
118119
or (

Diff for: src/cattr/converters.py

+39-15
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ class Converter(object):
6565
"_unstructure_attrs",
6666
"_structure_attrs",
6767
"_dict_factory",
68-
"_union_registry",
68+
"_union_struct_registry",
6969
"_structure_func",
7070
)
7171

@@ -95,12 +95,13 @@ def __init__(
9595
)
9696
self._unstructure_func.register_func_list(
9797
[
98-
(_subclass(Mapping), self._unstructure_mapping),
99-
(_subclass(Sequence), self._unstructure_seq),
100-
(_subclass(Set), self._unstructure_seq),
101-
(_subclass(FrozenSet), self._unstructure_seq),
98+
(is_mapping, self._unstructure_mapping),
99+
(is_sequence, self._unstructure_seq),
100+
(is_mutable_set, self._unstructure_seq),
101+
(is_frozenset, self._unstructure_seq),
102102
(_subclass(Enum), self._unstructure_enum),
103103
(_is_attrs_class, self._unstructure_attrs),
104+
(is_union_type, self._unstructure_union),
104105
]
105106
)
106107

@@ -135,11 +136,15 @@ def __init__(
135136

136137
self._dict_factory = dict_factory
137138

138-
# Unions are instances now, not classes. We use a different registry.
139-
self._union_registry = {}
139+
# Unions are instances now, not classes. We use different registries.
140+
self._union_struct_registry: Dict[
141+
Any, Callable[[Any, Type[T]], T]
142+
] = {}
140143

141-
def unstructure(self, obj: Any) -> Any:
142-
return self._unstructure_func.dispatch(obj.__class__)(obj)
144+
def unstructure(self, obj: Any, unstructure_as=None) -> Any:
145+
return self._unstructure_func.dispatch(
146+
obj.__class__ if unstructure_as is None else unstructure_as
147+
)(obj)
143148

144149
@property
145150
def unstruct_strat(self) -> UnstructureStrategy:
@@ -151,14 +156,19 @@ def unstruct_strat(self) -> UnstructureStrategy:
151156
)
152157

153158
def register_unstructure_hook(
154-
self, cls: Type[T], func: Callable[[T], Any]
159+
self, cls: Any, func: Callable[[T], Any]
155160
) -> None:
156161
"""Register a class-to-primitive converter function for a class.
157162
158163
The converter function should take an instance of the class and return
159164
its Python equivalent.
160165
"""
161-
self._unstructure_func.register_cls_list([(cls, func)])
166+
if is_union_type(cls):
167+
self._unstructure_func.register_func_list(
168+
[(lambda t: t is cls, func)]
169+
)
170+
else:
171+
self._unstructure_func.register_cls_list([(cls, func)])
162172

163173
def register_unstructure_hook_func(
164174
self, check_func, func: Callable[[T], Any]
@@ -181,7 +191,7 @@ def register_structure_hook(
181191
is sometimes needed (for example, when dealing with generic classes).
182192
"""
183193
if is_union_type(cl):
184-
self._union_registry[cl] = func
194+
self._union_struct_registry[cl] = func
185195
else:
186196
self._structure_func.register_cls_list([(cl, func)])
187197

@@ -209,13 +219,19 @@ def unstructure_attrs_asdict(self, obj) -> Dict[str, Any]:
209219
for a in attrs:
210220
name = a.name
211221
v = getattr(obj, name)
212-
rv[name] = dispatch(v.__class__)(v)
222+
rv[name] = dispatch(a.type or v.__class__)(v)
213223
return rv
214224

215225
def unstructure_attrs_astuple(self, obj) -> Tuple[Any, ...]:
216226
"""Our version of `attrs.astuple`, so we can call back to us."""
217227
attrs = obj.__class__.__attrs_attrs__
218-
return tuple(self.unstructure(getattr(obj, a.name)) for a in attrs)
228+
dispatch = self._unstructure_func.dispatch
229+
res = list()
230+
for a in attrs:
231+
name = a.name
232+
v = getattr(obj, name)
233+
res.append(dispatch(a.type or v.__class__)(v))
234+
return tuple(res)
219235

220236
def _unstructure_enum(self, obj):
221237
"""Convert an enum to its value."""
@@ -242,6 +258,14 @@ def _unstructure_mapping(self, mapping):
242258
for k, v in mapping.items()
243259
)
244260

261+
def _unstructure_union(self, obj):
262+
"""
263+
Unstructure an object as a union.
264+
265+
By default, just unstructures the instance.
266+
"""
267+
return self._unstructure_func.dispatch(obj.__class__)(obj)
268+
245269
# Python primitives to classes.
246270

247271
def _structure_default(self, obj, cl):
@@ -396,7 +420,7 @@ def _structure_union(self, obj, union):
396420
return self._structure_func.dispatch(other)(obj, other)
397421

398422
# Check the union registry first.
399-
handler = self._union_registry.get(union)
423+
handler = self._union_struct_registry.get(union)
400424
if handler is not None:
401425
return handler(obj, union)
402426

Diff for: src/cattr/function_dispatch.py

+3-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from functools import lru_cache
2+
from typing import Any, Callable
23

34

45
class FunctionDispatch(object):
@@ -14,9 +15,9 @@ class FunctionDispatch(object):
1415

1516
def __init__(self):
1617
self._handler_pairs = []
17-
self.dispatch = lru_cache(64)(self._dispatch)
18+
self.dispatch = lru_cache(None)(self._dispatch)
1819

19-
def register(self, can_handle, func):
20+
def register(self, can_handle: Callable[[Any], bool], func):
2021
self._handler_pairs.insert(0, (can_handle, func))
2122
self.dispatch.cache_clear()
2223

Diff for: src/cattr/gen.py

+7-3
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ def make_dict_unstructure_fn(cl, converter, omit_if_default=False, **kwargs):
3737
override = kwargs.pop(attr_name, _neutral)
3838
kn = attr_name if override.rename is None else override.rename
3939
d = a.default
40+
unstruct_type_name = f"__cattr_type_{attr_name}"
41+
globs[unstruct_type_name] = a.type
4042
if d is not attr.NOTHING and (
4143
(omit_if_default and override.omit_if_default is not False)
4244
or override.omit_if_default
@@ -52,18 +54,20 @@ def make_dict_unstructure_fn(cl, converter, omit_if_default=False, **kwargs):
5254
else:
5355
post_lines.append(f" if i.{attr_name} != {def_name}():")
5456
post_lines.append(
55-
f" res['{kn}'] = __c_u(i.{attr_name})"
57+
f" res['{kn}'] = __c_u(i.{attr_name}, unstructure_as={unstruct_type_name})"
5658
)
5759
else:
5860
globs[def_name] = d
5961
post_lines.append(f" if i.{attr_name} != {def_name}:")
6062
post_lines.append(
61-
f" res['{kn}'] = __c_u(i.{attr_name})"
63+
f" res['{kn}'] = __c_u(i.{attr_name}, unstructure_as={unstruct_type_name})"
6264
)
6365

6466
else:
6567
# No default or no override.
66-
lines.append(f" '{kn}': __c_u(i.{attr_name}),")
68+
lines.append(
69+
f" '{kn}': __c_u(i.{attr_name}, unstructure_as={unstruct_type_name}),"
70+
)
6771
lines.append(" }")
6872

6973
total_lines = lines + post_lines + [" return res"]

Diff for: src/cattr/multistrategy_dispatch.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ class MultiStrategyDispatch(object):
2626

2727
def __init__(self, fallback_func):
2828
self._function_dispatch = FunctionDispatch()
29-
self._function_dispatch.register(lambda cls: True, fallback_func)
29+
self._function_dispatch.register(lambda _: True, fallback_func)
3030
self._single_dispatch = singledispatch(_DispatchNotFound)
3131
self.dispatch = lru_cache(maxsize=None)(self._dispatch)
3232

Diff for: tests/metadata/test_genconverter.py

+8-5
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ def test_union_field_roundtrip(cl_and_vals_a, cl_and_vals_b, strat):
7575
"""
7676
converter = Converter(unstruct_strat=strat)
7777
cl_a, vals_a = cl_and_vals_a
78-
cl_b, vals_b = cl_and_vals_b
78+
cl_b, _ = cl_and_vals_b
7979
a_field_names = {a.name for a in fields(cl_a)}
8080
b_field_names = {a.name for a in fields(cl_b)}
8181
assume(a_field_names)
@@ -91,7 +91,10 @@ class C(object):
9191
inst = C(a=cl_a(*vals_a))
9292

9393
if strat is UnstructureStrategy.AS_DICT:
94-
assert inst == converter.structure(converter.unstructure(inst), C)
94+
unstructured = converter.unstructure(inst)
95+
assert inst == converter.structure(
96+
converter.unstructure(unstructured), C
97+
)
9598
else:
9699
# Our disambiguation functions only support dictionaries for now.
97100
with pytest.raises(ValueError):
@@ -100,9 +103,9 @@ class C(object):
100103
def handler(obj, _):
101104
return converter.structure(obj, cl_a)
102105

103-
converter._union_registry[Union[cl_a, cl_b]] = handler
104-
assert inst == converter.structure(converter.unstructure(inst), C)
105-
del converter._union_registry[Union[cl_a, cl_b]]
106+
converter.register_structure_hook(Union[cl_a, cl_b], handler)
107+
unstructured = converter.unstructure(inst)
108+
assert inst == converter.structure(unstructured, C)
106109

107110

108111
@given(simple_typed_classes(defaults=False))

Diff for: tests/metadata/test_roundtrips.py

+1-2
Original file line numberDiff line numberDiff line change
@@ -91,9 +91,8 @@ class C(object):
9191
def handler(obj, _):
9292
return converter.structure(obj, cl_a)
9393

94-
converter._union_registry[Union[cl_a, cl_b]] = handler
94+
converter.register_structure_hook(Union[cl_a, cl_b], handler)
9595
assert inst == converter.structure(converter.unstructure(inst), C)
96-
del converter._union_registry[Union[cl_a, cl_b]]
9796

9897

9998
@given(simple_typed_classes(defaults=False))

0 commit comments

Comments
 (0)