Skip to content

Commit 27e9c0d

Browse files
matmelTinche
andauthored
Subclassing support (#312)
* make.bat mirror update * subclasses with strategies as proposed by Tin. * Changes as-is not finished yet * Mind the scope! A closure factory should return only one inner function. If not, there is a risk of messing up all free variables like cl for instance here. All inner generated functions had the last iterated cl value which obviously did not work well... * Update unit tests for include_subclasses * Handle the subclasses argument for subclass strategy * Add failing override test with subclasses strategy * Added overrides argument in include_subclasses Allows to propagate overrides easily to child classes. * Initial work of inlcue_subclasses with union strategy * Adapted the code provided by Tin * New test parameters Some unstructuring tests with the union strategy fail: infinite recursion error. * Structuring / unstructuring work with tagged union strategy Overrides still need to be implemented. * Fixed overrides when there is no union strategy. The overrides with union strategy still fail though. Restructured the code in add_subclasses: * Skipping the classes without subclasses broke the overrides (without union strategy) and also the unstructuring of unknown class. * The hook functions are created inline and the closure variables are passed as default argument to workaround the scoping problems encontered before. * Overrides work also with union_strategy * circular reference test is also done with tagged union Unfortunately it does not work... * Tweak the include_subclasses strategy * Remove unused import * Added include_subclasses documentation * Make newer black happy --------- Co-authored-by: Tin Tvrtkovic <[email protected]>
1 parent 42f51b8 commit 27e9c0d

File tree

10 files changed

+718
-9
lines changed

10 files changed

+718
-9
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/make.bat

+7
Original file line numberDiff line numberDiff line change
@@ -239,4 +239,11 @@ if "%1" == "pseudoxml" (
239239
goto end
240240
)
241241

242+
if "%1" == "apidoc" (
243+
sphinx-apidoc -o . ../src/cattrs/ -f
244+
if errorlevel 1 exit /b 1
245+
echo.
246+
goto end
247+
)
248+
242249
:end

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/__init__.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
"""High level strategies for converters."""
2+
from ._subclasses import include_subclasses
23
from ._unions import configure_tagged_union
34

4-
__all__ = ["configure_tagged_union"]
5+
__all__ = ["configure_tagged_union", "include_subclasses"]

0 commit comments

Comments
 (0)