Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Subclassing support #312

Merged
merged 17 commits into from
May 22, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions HISTORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
- Add optional dependencies for `cattrs.preconf` third-party libraries. ([#337](https://github.com/python-attrs/cattrs/pull/337))
- All preconf converters now allow overriding the default `unstruct_collection_overrides` in `make_converter`.
([#350](https://github.com/python-attrs/cattrs/issues/350) [#353](https://github.com/python-attrs/cattrs/pull/353))
- Subclasses structuring and unstructuring is now supported via a custom `include_subclasses` strategy.
([#312](https://github.com/python-attrs/cattrs/pull/312))

## 22.2.0 (2022-10-03)

Expand Down
7 changes: 7 additions & 0 deletions docs/make.bat
Original file line number Diff line number Diff line change
Expand Up @@ -239,4 +239,11 @@ if "%1" == "pseudoxml" (
goto end
)

if "%1" == "apidoc" (
sphinx-apidoc -o . ../src/cattrs/ -f
if errorlevel 1 exit /b 1
echo.
goto end
)

:end
133 changes: 132 additions & 1 deletion docs/strategies.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ _cattrs_ ships with a number of _strategies_ for customizing un/structuring beha

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

## Tagged Unions
## Tagged Unions Strategy

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

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

```

## Include Subclasses Strategy

_Found at {py:func}`cattrs.strategies.include_subclasses`._

The _include subclass_ strategy allows the un/structuring of a base class to an instance of itself or one of its descendants.
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.

```{doctest} include_subclass

>>> from attrs import define
>>> from cattrs.strategies import include_subclasses
>>> from cattrs import Converter

>>> @define
... class Parent:
... a: int

>>> @define
... class Child(Parent):
... b: str

>>> converter = Converter()
>>> include_subclasses(Parent, converter)

>>> converter.unstructure(Child(a=1, b="foo"), unstructure_as=Parent)
{'a': 1, 'b': 'foo'}

>>> converter.structure({'a': 1, 'b': 'foo'}, Parent)
Child(a=1, b='foo')
```

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.
If we did not apply the `include_subclasses` strategy, this is what we would have obtained:

```python
>>> converter_no_subclasses = Converter()

>>> converter_no_subclasses.unstructure(Child(a=1, b="foo"), unstructure_as=Parent)
{'a': 1}

>>> converter_no_subclasses.structure({'a': 1, 'b': 'foo'}, Parent)
Parent(a=1)
```

Without the application of the strategy, in both unstructure and structure operations, we received a `Parent` instance.

```{note}
The handling of subclasses is an opt-in feature for two main reasons:
- Performance. While small and probably negligeable in most cases the subclass handling incurs more function calls and has a performance impact.
- 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.
```

```{warning}
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.
```

### Customization

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).
The automatic union type disambiguation function which is based on finding unique fields for each type of the union works as intended.

Sometimes, more disambiguation customization is required.
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.
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.
{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.

```python

>>> from functools import partial
>>> from attrs import define
>>> from cattrs.strategies import include_subclasses, configure_tagged_union
>>> from cattrs import Converter

>>> @define
... class Parent:
... a: int

>>> @define
... class Child1(Parent):
... b: str

>>> @define
... class Child2(Parent):
... b: int

>>> converter = Converter()
>>> union_strategy = partial(configure_tagged_union, tag_name="type_name")
>>> include_subclasses(Parent, converter, union_strategy=union_strategy)

>>> converter.unstructure(Child1(a=1, b="foo"), unstructure_as=Parent)
{'a': 1, 'b': 'foo', 'type_name': 'Child1'}

>>> converter.structure({'a': 1, 'b': 1, 'type_name': 'Child2'}, Parent)
Child2(a=1, b=1)
```

Other customizations available see are (see {py:func}`include_subclasses()<cattrs.strategies.include_subclasses>`):
- The exact list of subclasses that should participate to the union with the `subclasses` argument.
- Attribute overrides that permit the customization of attributes un/structuring like renaming an attribute.

Here is an example involving both customizations:

```python

>>> from attrs import define
>>> from cattrs.strategies import include_subclasses
>>> from cattrs import Converter, override

>>> @define
... class Parent:
... a: int

>>> @define
... class Child(Parent):
... b: str

>>> converter = Converter()
>>> include_subclasses(
... Parent,
... converter,
... subclasses=(Parent, Child),
... overrides={"b": override(rename="c")}
... )

>>> converter.unstructure(Child(a=1, b="foo"), unstructure_as=Parent)
{'a': 1, 'c': 'foo'}

>>> converter.structure({'a': 1, 'c': 'foo'}, Parent)
Child(a=1, b='foo')
```
4 changes: 3 additions & 1 deletion docs/structuring.md
Original file line number Diff line number Diff line change
Expand Up @@ -280,7 +280,7 @@ the first time an appropriate union is structured.
To support arbitrary unions, register a custom structuring hook for the union
(see [Registering custom structuring hooks](structuring.md#registering-custom-structuring-hooks)).

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

### `typing.Final`

Expand Down Expand Up @@ -456,6 +456,8 @@ attributes holding `attrs` classes and dataclasses.
B(b=A(a=1))
```

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.

## Registering Custom Structuring Hooks

_cattrs_ doesn't know how to structure non-_attrs_ classes by default,
Expand Down
2 changes: 1 addition & 1 deletion docs/unions.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ converter customization (since there are many ways of handling unions).
## Unstructuring unions with extra metadata

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

Expand Down
2 changes: 1 addition & 1 deletion docs/unstructuring.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ _attrs_ classes and dataclasses are supported out of the box.

## Mixing and Matching Strategies

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

Expand Down
3 changes: 2 additions & 1 deletion src/cattrs/strategies/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""High level strategies for converters."""
from ._subclasses import include_subclasses
from ._unions import configure_tagged_union

__all__ = ["configure_tagged_union"]
__all__ = ["configure_tagged_union", "include_subclasses"]
Loading