Skip to content

Commit cbc774a

Browse files
committed
Added include_subclasses documentation
1 parent b0e9f1d commit cbc774a

File tree

6 files changed

+159
-13
lines changed

6 files changed

+159
-13
lines changed

HISTORY.md

+2
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
- Add optional dependencies for `cattrs.preconf` third-party libraries. ([#337](https://github.com/python-attrs/cattrs/pull/337))
1717
- All preconf converters now allow overriding the default `unstruct_collection_overrides` in `make_converter`.
1818
([#350](https://github.com/python-attrs/cattrs/issues/350) [#353](https://github.com/python-attrs/cattrs/pull/353))
19+
- Subclasses structuring and unstructuring is now supported via a custom `include_subclasses` strategy.
20+
([#312](https://github.com/python-attrs/cattrs/pull/312))
1921

2022
## 22.2.0 (2022-10-03)
2123

docs/strategies.md

+132-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ _cattrs_ ships with a number of _strategies_ for customizing un/structuring beha
44

55
Strategies are prepackaged, high-level patterns for quickly and easily applying complex customizations to a converter.
66

7-
## Tagged Unions
7+
## Tagged Unions Strategy
88

99
_Found at {py:func}`cattrs.strategies.configure_tagged_union`._
1010

@@ -119,3 +119,134 @@ The converter is now ready to start structuring Apple notifications.
119119
... print("Can't handle this yet")
120120

121121
```
122+
123+
## Include Subclasses Strategy
124+
125+
_Found at {py:func}`cattrs.strategies.include_subclasses`._
126+
127+
The _include subclass_ strategy allows the un/structuring of a base class to an instance of itself or one of its descendants.
128+
Conceptually with this strategy, each time an un/structure operation for the base class is asked, `cattrs` machinery replaces that operation as if the union of the base class and its descendants had been asked instead.
129+
130+
```{doctest} include_subclass
131+
132+
>>> from attrs import define
133+
>>> from cattrs.strategies import include_subclasses
134+
>>> from cattrs import Converter
135+
136+
>>> @define
137+
... class Parent:
138+
... a: int
139+
140+
>>> @define
141+
... class Child(Parent):
142+
... b: str
143+
144+
>>> converter = Converter()
145+
>>> include_subclasses(Parent, converter)
146+
147+
>>> converter.unstructure(Child(a=1, b="foo"), unstructure_as=Parent)
148+
{'a': 1, 'b': 'foo'}
149+
150+
>>> converter.structure({'a': 1, 'b': 'foo'}, Parent)
151+
Child(a=1, b='foo')
152+
```
153+
154+
In the example above, we asked to unstructure then structure a `Child` instance as the `Parent` class and in both cases we correctly obtained back the unstructured and structured versions of the `Child` instance.
155+
If we did not apply the `include_subclasses` strategy, this is what we would have obtained:
156+
157+
```python
158+
>>> converter_no_subclasses = Converter()
159+
160+
>>> converter_no_subclasses.unstructure(Child(a=1, b="foo"), unstructure_as=Parent)
161+
{'a': 1}
162+
163+
>>> converter_no_subclasses.structure({'a': 1, 'b': 'foo'}, Parent)
164+
Parent(a=1)
165+
```
166+
167+
Without the application of the strategy, in both unstructure and structure operations, we received a `Parent` instance.
168+
169+
```{note}
170+
The handling of subclasses is an opt-in feature for two main reasons:
171+
- Performance. While small and probably negligeable in most cases the subclass handling incurs more function calls and has a performance impact.
172+
- Customization. The specific handling of subclasses can be different from one situation to the other. In particular there is not apparent universal good defaults for disambiguating the union type. Consequently The decision is left to the `cattrs` user.
173+
```
174+
175+
```{warning}
176+
To work properly, all subclasses must be defined when the `include_subclasses` strategy is applied to a `converter`. If subclasses types are defined later, for instance in the context of a plug-in mechanism using inheritance, then those late defined subclasses will not be part of the subclasses union type and will not be un/structured as expected.
177+
```
178+
179+
### Customization
180+
181+
In the example shown in the previous section, the default options for `include_subclasses` work well because the `Child` class has an attribute that do not exist in the `Parent` class (the `b` attribute).
182+
The automatic union type disambiguation function which is based on finding unique fields for each type of the union works as intended.
183+
184+
Sometimes, more disambiguation customization is required.
185+
For instance, the unstructuring operation would have failed if `Child` did not have an extra attribute or if a sibling of `Child` had also a `b` attribute.
186+
For those cases, a callable of 2 positional arguments (a union type and a converter) defining a [tagged union strategy](strategies.md#tagged-unions-strategy) can be passed to the `include_subclasses` strategy.
187+
{py:func}`configure_tagged_union()<cattrs.strategies.configure_tagged_union>` can be used as-is, but if you want to change its defaults, the [partial](https://docs.python.org/3/library/functools.html#functools.partial) function from the `functools` module in the standard library can come in handy.
188+
189+
```python
190+
191+
>>> from functools import partial
192+
>>> from attrs import define
193+
>>> from cattrs.strategies import include_subclasses, configure_tagged_union
194+
>>> from cattrs import Converter
195+
196+
>>> @define
197+
... class Parent:
198+
... a: int
199+
200+
>>> @define
201+
... class Child1(Parent):
202+
... b: str
203+
204+
>>> @define
205+
... class Child2(Parent):
206+
... b: int
207+
208+
>>> converter = Converter()
209+
>>> union_strategy = partial(configure_tagged_union, tag_name="type_name")
210+
>>> include_subclasses(Parent, converter, union_strategy=union_strategy)
211+
212+
>>> converter.unstructure(Child1(a=1, b="foo"), unstructure_as=Parent)
213+
{'a': 1, 'b': 'foo', 'type_name': 'Child1'}
214+
215+
>>> converter.structure({'a': 1, 'b': 1, 'type_name': 'Child2'}, Parent)
216+
Child2(a=1, b=1)
217+
```
218+
219+
Other customizations available see are (see {py:func}`include_subclasses()<cattrs.strategies.include_subclasses>`):
220+
- The exact list of subclasses that should participate to the union with the `subclasses` argument.
221+
- Attribute overrides that permit the customization of attributes un/structuring like renaming an attribute.
222+
223+
Here is an example involving both customizations:
224+
225+
```python
226+
227+
>>> from attrs import define
228+
>>> from cattrs.strategies import include_subclasses
229+
>>> from cattrs import Converter, override
230+
231+
>>> @define
232+
... class Parent:
233+
... a: int
234+
235+
>>> @define
236+
... class Child(Parent):
237+
... b: str
238+
239+
>>> converter = Converter()
240+
>>> include_subclasses(
241+
... Parent,
242+
... converter,
243+
... subclasses=(Parent, Child),
244+
... overrides={"b": override(rename="c")}
245+
... )
246+
247+
>>> converter.unstructure(Child(a=1, b="foo"), unstructure_as=Parent)
248+
{'a': 1, 'c': 'foo'}
249+
250+
>>> converter.structure({'a': 1, 'c': 'foo'}, Parent)
251+
Child(a=1, b='foo')
252+
```

docs/structuring.md

+3-1
Original file line numberDiff line numberDiff line change
@@ -280,7 +280,7 @@ the first time an appropriate union is structured.
280280
To support arbitrary unions, register a custom structuring hook for the union
281281
(see [Registering custom structuring hooks](structuring.md#registering-custom-structuring-hooks)).
282282

283-
Another option is to use a custom tagged union strategy (see [Strategies - Tagged Unions](strategies.md#tagged-unions)).
283+
Another option is to use a custom tagged union strategy (see [Strategies - Tagged Unions](strategies.md#tagged-unions-strategy)).
284284

285285
### `typing.Final`
286286

@@ -456,6 +456,8 @@ attributes holding `attrs` classes and dataclasses.
456456
B(b=A(a=1))
457457
```
458458
459+
Finally, if an `attrs` or `dataclass` class uses inheritance and as such has one or several subclasses, it can be structured automatically to its exact subtype by using the [include subclasses](strategies.md#include-subclasses-strategy) strategy.
460+
459461
## Registering Custom Structuring Hooks
460462
461463
_cattrs_ doesn't know how to structure non-_attrs_ classes by default,

docs/unions.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ converter customization (since there are many ways of handling unions).
99
## Unstructuring unions with extra metadata
1010

1111
```{note}
12-
_cattrs_ comes with the [tagged unions strategy](strategies.md#tagged-unions) for handling this exact use-case since version 22.3.
12+
_cattrs_ comes with the [tagged unions strategy](strategies.md#tagged-unions-strategy) for handling this exact use-case since version 23.1.
1313
The example below has been left here for educational purposes, but you should prefer the strategy.
1414
```
1515

docs/unstructuring.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ _attrs_ classes and dataclasses are supported out of the box.
161161

162162
## Mixing and Matching Strategies
163163

164-
Converters publicly expose two helper metods, {meth}`Converter.unstructure_attrs_asdict() <cattrs.BaseConverter.unstructure_attrs_asdict>`
164+
Converters publicly expose two helper methods, {meth}`Converter.unstructure_attrs_asdict() <cattrs.BaseConverter.unstructure_attrs_asdict>`
165165
and {meth}`Converter.unstructure_attrs_astuple() <cattrs.BaseConverter.unstructure_attrs_astuple>`.
166166
These methods can be used with custom unstructuring hooks to selectively apply one strategy to instances of particular classes.
167167

src/cattrs/strategies/_subclasses.py

+20-9
Original file line numberDiff line numberDiff line change
@@ -42,15 +42,26 @@ def include_subclasses(
4242
overrides: Optional[Dict[str, AttributeOverride]] = None,
4343
) -> None:
4444
"""
45-
Modify the given converter so that the attrs/dataclass `cl` is un/structured as if
46-
it was a union of itself and all its subclasses that are defined at the time when
47-
this strategy is applied.
48-
49-
Subclasses are detected using the `__subclasses__` method, or they can be explicitly
50-
provided.
51-
52-
overrides is a mapping of some or all the parent class field names to attribute
53-
overrides instantiated with :func:`cattrs.gen.override`
45+
Configure the converter so that the attrs/dataclass `cl` is un/structured as if it
46+
was a union of itself and all its subclasses that are defined at the time when this
47+
strategy is applied.
48+
49+
:param cl: A base `attrs` or `dataclass` class.
50+
:param converter: The `Converter` on which this strategy is applied. Do note that
51+
the strategy does not work for a :class:`cattrs.BaseConverter`.
52+
:param subclasses: A tuple of sublcasses whose ancestor is `cl`. If left as `None`,
53+
subclasses are detected using recursively the `__subclasses__` method of `cl`
54+
and its descendents.
55+
:param union_strategy: A callable of two arguments passed by position
56+
(`subclass_union`, `converter`) that defines the union strategy to use to
57+
disambiguate the subclasses union. If `None` (the default), the automatic unique
58+
field disambiguation is used which means that every single subclass
59+
participating in the union must have an attribute name that does not exist in
60+
any other sibling class.
61+
:param overrides: a mapping of `cl` attribute names to overrides (instantiated with
62+
:func:`cattrs.gen.override`) to customize un/structuring.
63+
64+
.. versionadded:: 23.1.0
5465
"""
5566
# Due to https://github.com/python-attrs/attrs/issues/1047
5667
collect()

0 commit comments

Comments
 (0)