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

Tin/tagged unions #318

Merged
merged 11 commits into from
Nov 24, 2022
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
5 changes: 5 additions & 0 deletions HISTORY.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
=======
History
=======

22.3.0 (UNRELEASED)
-------------------
* Introduce the `tagged_union` strategy. (`#318 <https://github.com/python-attrs/cattrs/pull/318>`_ `#317 <https://github.com/python-attrs/cattrs/issues/317>`_)

22.2.0 (2022-10-03)
-------------------
* *Potentially breaking*: ``cattrs.Converter`` has been renamed to ``cattrs.BaseConverter``, and ``cattrs.GenConverter`` to ``cattrs.Converter``.
Expand Down
18 changes: 3 additions & 15 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -59,10 +59,10 @@ test-all: ## run tests on every Python version with tox
tox

coverage: ## check code coverage quickly with the default Python
coverage run --source cattr -m pytest
poetry run coverage run --source cattrs -m pytest

coverage report -m
coverage html
poetry run coverage report -m
poetry run coverage html
$(BROWSER) htmlcov/index.html

docs: ## generate Sphinx HTML documentation, including API docs
Expand All @@ -77,18 +77,6 @@ docs: ## generate Sphinx HTML documentation, including API docs
servedocs: docs ## compile the docs watching for changes
watchmedo shell-command -p '*.rst' -c '$(MAKE) -C docs html' -R -D .

release: clean ## package and upload a release
python setup.py sdist upload
python setup.py bdist_wheel upload

dist: clean ## builds source and wheel package
python setup.py sdist
python setup.py bdist_wheel
ls -l dist

install: clean ## install the package to the active Python's site-packages
python setup.py install

bench-cmp:
pytest bench --benchmark-compare

Expand Down
3 changes: 3 additions & 0 deletions docs/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -175,3 +175,6 @@ pseudoxml:
$(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml
@echo
@echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml."

apidoc:
sphinx-apidoc -o . ../src/cattrs/ -f
52 changes: 33 additions & 19 deletions docs/_static/custom.css
Original file line number Diff line number Diff line change
@@ -1,36 +1,50 @@
@import url('https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,400;0,700;1,400&family=Ubuntu+Mono:ital,wght@0,400;0,700;1,400;1,700&display=swap');
@import url("https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,400;0,700;1,400&family=Ubuntu+Mono:ital,wght@0,400;0,700;1,400;1,700&display=swap");

h2 {
margin-bottom: 0.5em;
h2,
h3 {
margin-bottom: 0.5em;
margin-top: 2rem;
}

div.article-container>article {
font-size: 19px;
line-height: 31px;
:target > h1:first-of-type,
:target > h2:first-of-type,
:target > h3:first-of-type,
span:target ~ h1:first-of-type,
span:target ~ h2:first-of-type,
span:target ~ h3:first-of-type,
span:target ~ h4:first-of-type,
span:target ~ h5:first-of-type,
span:target ~ h6:first-of-type {
text-decoration: underline dashed;
}

div.article-container > article {
font-size: 18px;
line-height: 31px;
}

div.admonition {
font-size: 15px;
line-height: 27px;
margin-top: 2em;
margin-bottom: 2em;
font-size: 15px;
line-height: 27px;
margin-top: 2em;
margin-bottom: 2em;
}

p.admonition-title {
font-size: 15px !important;
line-height: 20px !important;
font-size: 15px !important;
line-height: 20px !important;
}

article>li>a {
font-size: 19px;
line-height: 31px;
article > li > a {
font-size: 19px;
line-height: 31px;
}

div.tab-set {
margin-top: 1em;
margin-bottom: 2em;
margin-top: 1em;
margin-bottom: 2em;
}

div.tab-set pre {
padding: 1.25em;
}
padding: 1.25em;
}
1 change: 1 addition & 0 deletions docs/cattrs.rst
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ Subpackages
:maxdepth: 4

cattrs.preconf
cattrs.strategies

Submodules
----------
Expand Down
21 changes: 21 additions & 0 deletions docs/cattrs.strategies.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
cattrs.strategies package
=========================

Submodules
----------

cattrs.strategies.subclasses module
-----------------------------------

.. automodule:: cattrs.strategies.subclasses
:members:
:undoc-members:
:show-inheritance:

Module contents
---------------

.. automodule:: cattrs.strategies
:members:
:undoc-members:
:show-inheritance:
9 changes: 8 additions & 1 deletion docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@
"sphinx.ext.viewcode",
"sphinx.ext.doctest",
"sphinx.ext.autosectionlabel",
"sphinx_copybutton",
"myst_parser",
]

# Add any paths that contain templates here, relative to this directory.
Expand Down Expand Up @@ -121,7 +123,9 @@
"font-stack": "Roboto, -apple-system, BlinkMacSystemFont, Segoe UI, Helvetica, Arial, sans-serif, Apple Color Emoji, Segoe UI Emoji",
"font-stack--monospace": "'Ubuntu Mono', monospace",
"code-font-size": "90%",
}
"color-highlight-on-target": "transparent",
},
"dark_css_variables": {"color-highlight-on-target": "transparent"},
}

# Add any paths that contain custom themes here, relative to this directory.
Expand Down Expand Up @@ -285,3 +289,6 @@
)
autodoc_typehints = "description"
autosectionlabel_prefix_document = True
copybutton_prompt_text = r">>> |\.\.\. "
copybutton_prompt_is_regexp = True
myst_heading_anchors = 3
1 change: 1 addition & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
usage
structuring
unstructuring
strategies
validation
preconf
customizing
Expand Down
8 changes: 0 additions & 8 deletions docs/modules.rst
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,3 @@ cattrs
:maxdepth: 4

cattrs

cattr
=====

.. toctree::
:maxdepth: 4

cattr
121 changes: 121 additions & 0 deletions docs/strategies.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
# Strategies

_cattrs_ ships with a number of _strategies_ for customizing un/structuring behavior.

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

## Tagged Unions

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

The _tagged union_ strategy allows for un/structuring a union of classes by including an additional field (the _tag_) in the unstructured representation.
Each tag value is associated with a member of the union.

```{doctest} tagged_unions

>>> from cattrs.strategies import configure_tagged_union
>>> from cattrs import Converter
>>> converter = Converter()

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

>>> @define
... class B:
... b: str

>>> configure_tagged_union(A | B, converter)

>>> converter.unstructure(A(1), unstructure_as=A | B)
{'a': 1, '_type': 'A'}

>>> converter.structure({'a': 1, '_type': 'A'}, A | B)
A(a=1)
```

By default, the tag field name is `_type` and the tag value is the class name of the union member.
Both the field name and value can be overriden.

The `tag_generator` parameter is a one-argument callable that will be called with every member of the union to generate a mapping of tag values to union members.
Here are some common `tag_generator` uses:

| Tag info available in | Recommended `tag_generator` |
| ----------------------------- | ------------------------------------------------------- |
| Name of the class | Use the default, or `lambda cl: cl.__name__` |
| A class variable (`classvar`) | `lambda cl: cl.classvar` |
| A dictionary (`mydict`) | `mydict.get` or `mydict.__getitem__` |
| An enum of possible values | Build a dictionary of classes to enum values and use it |

The union members aren't required to be attrs classes or dataclasses, although those work automatically.
They may be anything that cattrs can un/structure from/to a dictionary, for example a type with registered custom hooks.

A default member can be specified to be used if the tag is missing or is unknown.
This is useful for evolving APIs in a backwards-compatible way; an endpoint taking class `A` can be changed to take `A | B` with `A` as the default (for old clients which do not send the tag).

This strategy only applies in the context of the union; the normal un/structuring hooks are left untouched.
This also means union members can be reused in multiple unions easily.

```{doctest} tagged_unions

# Unstructuring as a union.
>>> converter.unstructure(A(1), unstructure_as=A | B)
{'a': 1, '_type': 'A'}

# Unstructuring as just an `A`.
>>> converter.unstructure(A(1))
{'a': 1}
```

### Real-life Case Study

The Apple App Store supports [server callbacks](https://developer.apple.com/documentation/appstoreservernotifications), by which Apple sends a JSON payload to a URL of your choice.
The payload can be interpreted as about a dozen different messages, based on the value of the `notificationType` field.

To keep the example simple we define two classes, one for the `REFUND` event and one for everything else.

```python

@define
class Refund:
originalTransactionId: str

@define
class OtherAppleNotification:
notificationType: str

AppleNotification = Refund | OtherAppleNotification

```

Next, we use the _tagged unions_ strategy to prepare our converter.
The tag value for the `Refund` event is `REFUND`, and we can let the `OtherAppleNotification` class handle all the other cases.
The `tag_generator` parameter is a callable, so we can give it the `get` method of a dictionary.

```python

>>> c = Converter()
>>> configure_tagged_union(
... AppleNotification,
... c,
... tag_name="notificationType",
... tag_generator={Refund: "REFUND"},
... default=OtherAppleNotification
... )

```

The converter is now ready to start structuring Apple notifications.

```python

>>> payload = {"notificationType": "REFUND", "originalTransactionId": "1"}
>>> notification = c.structure(payload, AppleNotification)

>>> match notification:
... case Refund(txn_id):
... print(f"Refund for {txn_id}!")
... case OtherAppleNotification(not_type):
... print("Can't handle this yet")

```
66 changes: 66 additions & 0 deletions docs/unions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# Tips for handling unions

This sections contains information for advanced union handling.

As mentioned in the structuring section, _cattrs_ is able to handle simple
unions of _attrs_ classes automatically. More complex cases require
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.
The example below has been left here for educational purposes, but you should prefer the strategy.
```

Let's assume a simple scenario of two classes, `ClassA` and `ClassB`, both
of which have no distinct fields and so cannot be used automatically with
_cattrs_.

```python
@define
class ClassA:
a_string: str

@define
class ClassB:
a_string: str
```

A naive approach to unstructuring either of these would yield identical
dictionaries, and not enough information to restructure the classes.

```python
>>> converter.unstructure(ClassA("test"))
{'a_string': 'test'} # Is this ClassA or ClassB? Who knows!
```

What we can do is ensure some extra information is present in the
unstructured data, and then use that information to help structure later.

First, we register an unstructure hook for the `Union[ClassA, ClassB]` type.

```python
>>> converter.register_unstructure_hook(
... Union[ClassA, ClassB],
... lambda o: {"_type": type(o).__name__, **converter.unstructure(o)}
... )
>>> converter.unstructure(ClassA("test"), unstructure_as=Union[ClassA, ClassB])
{'_type': 'ClassA', 'a_string': 'test'}
```

Note that when unstructuring, we had to provide the `unstructure_as` parameter
or _cattrs_ would have just applied the usual unstructuring rules to `ClassA`,
instead of our special union hook.

Now that the unstructured data contains some information, we can create a
structuring hook to put it to use:

```python
>>> converter.register_structure_hook(
... Union[ClassA, ClassB],
... lambda o, _: converter.structure(o, ClassA if o["_type"] == "ClassA" else ClassB)
... )
>>> converter.structure({"_type": "ClassA", "a_string": "test"}, Union[ClassA, ClassB])
ClassA(a_string='test')
```
Loading