Skip to content

Commit 6a3b3c1

Browse files
authored
union passthrough (#403)
* union passthrough, first pass * 3.7 has no types.NoneType * No literals on 3.7 * More native unions work * Fix maybe? * Fixety fix * docs in progress * More native unions * More union passthrough tests, docs * Fix test * Add test and more docs * Pyyaml work, docs * Skip testing literals on 3.7 * No literals on 3.7 * Test with Literal from typing * Fix test * Fix native unions * Tweak tests on 3.8 * NewType tests * More NewType work * More NewTypes work * Fix test * Final tweaks
1 parent 9311a4d commit 6a3b3c1

17 files changed

Lines changed: 733 additions & 80 deletions

File tree

HISTORY.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
([#410](https://github.com/python-attrs/cattrs/issues/410) [#411](https://github.com/python-attrs/cattrs/pull/411))
1111
- Introduce the `use_class_methods` strategy. Learn more [here](https://catt.rs/en/latest/strategies.html#using-class-specific-structure-and-unstructure-methods).
1212
([#405](https://github.com/python-attrs/cattrs/pull/405))
13+
- Implement the `union passthrough` strategy, enabling much richer union handling for preconfigured converters. [Learn more here](https://catt.rs/en/stable/strategies.html#union-passthrough).
1314
- The `omit` parameter of {py:func}`cattrs.override` is now of type `bool | None` (from `bool`).
1415
`None` is the new default and means to apply default _cattrs_ handling to the attribute, which is to omit the attribute if it's marked as `init=False`, and keep it otherwise.
1516
- Fix {py:func}`format_exception() <cattrs.v.format_exception>` parameter working for recursive calls to {py:func}`transform_error <cattrs.transform_error>`.
@@ -40,6 +41,8 @@
4041
([#418](https://github.com/python-attrs/cattrs/issues/418))
4142
- Add support for `date` to preconfigured converters.
4243
([#420](https://github.com/python-attrs/cattrs/pull/420))
44+
- Add support for `datetime.date`s to the PyYAML preconfigured converter.
45+
([#393](https://github.com/python-attrs/cattrs/issues/393))
4346

4447
## 23.1.2 (2023-06-02)
4548

docs/_static/custom.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ span:target ~ h4:first-of-type,
101101
span:target ~ h5:first-of-type,
102102
span:target ~ h6:first-of-type {
103103
text-decoration: underline dashed;
104+
text-decoration-thickness: 1px;
104105
}
105106

106107
div.article-container > article {

docs/strategies.md

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,3 +323,62 @@ Nested(m=MyClass(a=43))
323323
```{versionadded} 23.2.0
324324
325325
```
326+
327+
## Union Passthrough
328+
329+
_Found at {py:func}`cattrs.strategies.configure_union_passthrough`._
330+
331+
The _union passthrough_ strategy enables a {py:class}`Converter <cattrs.BaseConverter>` to structure unions and subunions of given types.
332+
333+
A very common use case for _cattrs_ is processing data created by other serialization libraries, such as _JSON_ or _msgpack_.
334+
These libraries are able to directly produce values of unions inherent to the format.
335+
For example, every JSON library can differentiate between numbers, booleans, strings and null values since these values are represented differently in the wire format.
336+
This strategy enables _cattrs_ to offload the creation of these values to an underlying library and just validate the final value.
337+
So, _cattrs_ preconfigured JSON converters can handle the following type:
338+
339+
- `bool | int | float | str | None`
340+
341+
Continuing the JSON example, this strategy also enables structuring subsets of unions of these values.
342+
Accordingly, here are some examples of subset unions that are also supported:
343+
344+
- `bool | int`
345+
- `int | str`
346+
- `int | float | str`
347+
348+
The strategy also supports types including one or more [Literals](https://mypy.readthedocs.io/en/stable/literal_types.html#literal-types) of supported types. For example:
349+
350+
- `Literal["admin", "user"] | int`
351+
- `Literal[True] | str | int | float`
352+
353+
The strategy also supports [NewTypes](https://mypy.readthedocs.io/en/stable/more_types.html#newtypes) of these types. For example:
354+
355+
```python
356+
>>> from typing import NewType
357+
358+
>>> UserId = NewType("UserId", int)
359+
360+
>>> converter.loads("12", UserId)
361+
12
362+
```
363+
364+
Unions containing unsupported types can be handled if at least one union type is supported by the strategy; the supported union types will be checked before the rest (referred to as the _spillover_) is handed over to the converter again.
365+
366+
For example, if `A` and `B` are arbitrary _attrs_ classes, the union `Literal[10] | A | B` cannot be handled directly by a JSON converter.
367+
However, the strategy will check if the value being structured matches `Literal[10]` (because this type _is_ supported) and, if not, will pass it back to the converter to be structured as `A | B` (where a different strategy can handle it).
368+
369+
The strategy is designed to run in _O(1)_ at structure time; it doesn't depend on the size of the union and the ordering of union members.
370+
371+
This strategy has been preapplied to the following preconfigured converters:
372+
373+
- {py:class}`BsonConverter <cattrs.preconf.bson.BsonConverter>`
374+
- {py:class}`Cbor2Converter <cattrs.preconf.cbor2.Cbor2Converter>`
375+
- {py:class}`JsonConverter <cattrs.preconf.json.JsonConverter>`
376+
- {py:class}`MsgpackConverter <cattrs.preconf.msgpack.MsgpackConverter>`
377+
- {py:class}`OrjsonConverter <cattrs.preconf.orjson.OrjsonConverter>`
378+
- {py:class}`PyyamlConverter <cattrs.preconf.pyyaml.PyyamlConverter>`
379+
- {py:class}`TomlkitConverter <cattrs.preconf.tomlkit.TomlkitConverter>`
380+
- {py:class}`UjsonConverter <cattrs.preconf.ujson.UjsonConverter>`
381+
382+
```{versionadded} 23.2.0
383+
384+
```

pdm.lock

Lines changed: 30 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 27 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,32 @@ known_first_party = ["cattr"]
88
[tool.hatch.build.targets.wheel]
99
packages = ["src/cattr", "src/cattrs"]
1010

11+
12+
[tool.pdm.dev-dependencies]
13+
lint = [
14+
"isort>=5.11.5",
15+
"black>=23.3.0",
16+
"ruff>=0.0.277",
17+
]
18+
test = [
19+
"hypothesis>=6.79.4",
20+
"pytest>=7.4.0",
21+
"pytest-benchmark>=4.0.0",
22+
"immutables>=0.19",
23+
"typing-extensions>=4.7.1",
24+
"coverage>=7.2.7",
25+
]
26+
docs = [
27+
"sphinx>=5.3.0",
28+
"furo>=2023.3.27",
29+
"sphinx-copybutton>=0.5.2",
30+
"myst-parser>=1.0.0",
31+
"pendulum>=2.1.2",
32+
]
33+
bench = [
34+
"pyperf>=2.6.1",
35+
]
36+
1137
[build-system]
1238
requires = ["hatchling"]
1339
build-backend = "hatchling.build"
@@ -62,28 +88,6 @@ bson = [
6288
"pymongo>=4.4.0",
6389
]
6490

65-
[tool.pdm.dev-dependencies]
66-
lint = [
67-
"isort>=5.11.5",
68-
"black>=23.3.0",
69-
"ruff>=0.0.277",
70-
]
71-
test = [
72-
"hypothesis>=6.79.4",
73-
"pytest>=7.4.0",
74-
"pytest-benchmark>=4.0.0",
75-
"immutables>=0.19",
76-
"typing-extensions>=4.7.1",
77-
"coverage>=7.2.7",
78-
]
79-
docs = [
80-
"sphinx>=5.3.0",
81-
"furo>=2023.3.27",
82-
"sphinx-copybutton>=0.5.2",
83-
"myst-parser>=1.0.0",
84-
"pendulum>=2.1.2",
85-
]
86-
8791
[tool.pytest.ini_options]
8892
addopts = "-l --benchmark-sort=fullname --benchmark-warmup=true --benchmark-warmup-iterations=5 --benchmark-group-by=fullname"
8993

@@ -111,6 +115,7 @@ select = [
111115
"B", # flake8-bugbear
112116
"C4", # flake8-comprehensions
113117
"T10", # flake8-debugger
118+
"T20", # flake8-print
114119
"ISC", # flake8-implicit-str-concat
115120
"RET", # flake8-return
116121
"SIM", # flake8-simplify

src/cattrs/preconf/bson.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
"""Preconfigured converters for bson."""
22
from base64 import b85decode, b85encode
3-
from datetime import datetime, date
4-
from typing import Any, Type, TypeVar
3+
from datetime import date, datetime
4+
from typing import Any, Type, TypeVar, Union
55

6-
from bson import DEFAULT_CODEC_OPTIONS, CodecOptions, ObjectId, decode, encode
6+
from bson import DEFAULT_CODEC_OPTIONS, CodecOptions, Int64, ObjectId, decode, encode
77

88
from cattrs._compat import AbstractSet, is_mapping
99
from cattrs.gen import make_mapping_structure_fn
1010

1111
from ..converters import BaseConverter, Converter
12+
from ..strategies import configure_union_passthrough
1213
from . import validate_datetime
1314

1415
T = TypeVar("T")
@@ -83,6 +84,9 @@ def gen_structure_mapping(cl: Any):
8384
)
8485

8586
converter.register_structure_hook(ObjectId, lambda v, _: ObjectId(v))
87+
configure_union_passthrough(
88+
Union[str, bool, int, float, None, bytes, datetime, ObjectId, Int64], converter
89+
)
8690

8791
# datetime inherits from date, so identity unstructure hook used
8892
# here to prevent the date unstructure hook running.

src/cattrs/preconf/cbor2.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
"""Preconfigured converters for cbor2."""
2-
from datetime import datetime, timezone, date
3-
from typing import Any, Type, TypeVar
2+
from datetime import date, datetime, timezone
3+
from typing import Any, Type, TypeVar, Union
44

55
from cbor2 import dumps, loads
66

77
from cattrs._compat import AbstractSet
88

99
from ..converters import BaseConverter, Converter
10+
from ..strategies import configure_union_passthrough
1011

1112
T = TypeVar("T")
1213

@@ -32,6 +33,7 @@ def configure_converter(converter: BaseConverter):
3233
)
3334
converter.register_unstructure_hook(date, lambda v: v.isoformat())
3435
converter.register_structure_hook(date, lambda v, _: date.fromisoformat(v))
36+
configure_union_passthrough(Union[str, bool, int, float, None, bytes], converter)
3537

3638

3739
def make_converter(*args: Any, **kwargs: Any) -> Cbor2Converter:

src/cattrs/preconf/json.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
"""Preconfigured converters for the stdlib json."""
22
from base64 import b85decode, b85encode
3-
from datetime import datetime, date
3+
from datetime import date, datetime
44
from json import dumps, loads
55
from typing import Any, Type, TypeVar, Union
66

77
from cattrs._compat import AbstractSet, Counter
88

99
from ..converters import BaseConverter, Converter
10+
from ..strategies import configure_union_passthrough
1011

1112
T = TypeVar("T")
1213

@@ -36,6 +37,7 @@ def configure_converter(converter: BaseConverter):
3637
converter.register_structure_hook(datetime, lambda v, _: datetime.fromisoformat(v))
3738
converter.register_unstructure_hook(date, lambda v: v.isoformat())
3839
converter.register_structure_hook(date, lambda v, _: date.fromisoformat(v))
40+
configure_union_passthrough(Union[str, bool, int, float, None, bytes], converter)
3941

4042

4143
def make_converter(*args: Any, **kwargs: Any) -> JsonConverter:

src/cattrs/preconf/msgpack.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
"""Preconfigured converters for msgpack."""
2-
from datetime import datetime, timezone, date, time
3-
from typing import Any, Type, TypeVar
2+
from datetime import date, datetime, time, timezone
3+
from typing import Any, Type, TypeVar, Union
44

55
from msgpack import dumps, loads
66

77
from cattrs._compat import AbstractSet
88

99
from ..converters import BaseConverter, Converter
10+
from ..strategies import configure_union_passthrough
1011

1112
T = TypeVar("T")
1213

@@ -36,6 +37,7 @@ def configure_converter(converter: BaseConverter):
3637
converter.register_structure_hook(
3738
date, lambda v, _: datetime.fromtimestamp(v, timezone.utc).date()
3839
)
40+
configure_union_passthrough(Union[str, bool, int, float, None, bytes], converter)
3941

4042

4143
def make_converter(*args: Any, **kwargs: Any) -> MsgpackConverter:

src/cattrs/preconf/orjson.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from cattrs._compat import AbstractSet, is_mapping
1010

1111
from ..converters import BaseConverter, Converter
12+
from ..strategies import configure_union_passthrough
1213

1314
T = TypeVar("T")
1415

@@ -66,6 +67,7 @@ def key_handler(v):
6667
converter._unstructure_func.register_func_list(
6768
[(is_mapping, gen_unstructure_mapping, True)]
6869
)
70+
configure_union_passthrough(Union[str, bool, int, float, None], converter)
6971

7072

7173
def make_converter(*args: Any, **kwargs: Any) -> OrjsonConverter:

0 commit comments

Comments
 (0)