Skip to content
Closed
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 @@ -13,6 +13,8 @@ Our backwards-compatibility policy can be found [here](https://github.com/python

## NEXT

- The tagged union strategy ({func}`cattrs.strategies.configure_tagged_union`) now also handles the configured union extended with `None` (for example a field typed `Union[A, B] | None`), keeping the tag for members and passing `None` through.
([#345](https://github.com/python-attrs/cattrs/issues/345))
- Fix the msgspec converter passing typed deques directly to the encoder, which cannot serialize them. Deques are now unstructured into lists, including when nested in attrs classes or dataclasses.
([#818](https://github.com/python-attrs/cattrs/issues/818) [#819](https://github.com/python-attrs/cattrs/pull/819))

Expand Down
32 changes: 32 additions & 0 deletions src/cattrs/strategies/_unions.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from collections import defaultdict
from types import NoneType
from typing import Any, Callable, Union

from attrs import NOTHING, NothingType
Expand Down Expand Up @@ -49,6 +50,10 @@ def configure_tagged_union(

.. versionchanged:: 25.1
Type aliases of unions are now also supported.
.. versionchanged:: NEXT
The union extended with `None` (for example a field typed
`Union[A, B] | None`) is now handled too, with `None` passing
through.
"""
if is_type_alias(union):
union = union.__value__
Expand Down Expand Up @@ -125,6 +130,33 @@ def unstructure_tagged_union(
converter.register_unstructure_hook(union, unstructure_tagged_union)
converter.register_structure_hook(union, structure_tagged_union)

if NoneType not in args:
# Also handle the union extended with `None`, so fields typed
# `Optional[Union[...]]` (or `Union[...] | None`) keep the tag and
# can be disambiguated, with `None` passing through untouched.
optional_union = Union[(*args, NoneType)]

def structure_optional_tagged_union(
val: dict | None, _, _h=structure_tagged_union
) -> union:
if val is None:
return None
return _h(val, _)

def unstructure_optional_tagged_union(
val: union | None, _h=unstructure_tagged_union
) -> dict | None:
if val is None:
return None
return _h(val)

converter.register_unstructure_hook(
optional_union, unstructure_optional_tagged_union
)
converter.register_structure_hook(
optional_union, structure_optional_tagged_union
)

for cl in args:
tag = tag_generator(cl)
struct_handler = converter.get_structure_hook(cl)
Expand Down
45 changes: 45 additions & 0 deletions tests/strategies/test_tagged_unions.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,3 +177,48 @@ class Top:

data = c.unstructure(Top(u=[B(a="")]), Top)
c.structure(data, Top)


def test_optional_union(converter: BaseConverter) -> None:
"""The union extended with `None` is handled too.

Regression test for https://github.com/python-attrs/cattrs/issues/345
"""
union = Union[A, B]
configure_tagged_union(union, converter)

optional = Optional[union]

assert converter.unstructure(A(1), optional) == {"_type": "A", "a": 1}
assert converter.unstructure(B("1"), optional) == {"_type": "B", "a": "1"}
assert converter.unstructure(None, optional) is None

assert converter.structure({"_type": "A", "a": 1}, optional) == A(1)
assert converter.structure({"_type": "B", "a": 1}, optional) == B("1")
assert converter.structure(None, optional) is None


def test_optional_union_in_class(converter: BaseConverter) -> None:
"""Optional tagged unions work as class fields, in both spellings."""
union = Union[A, B]
configure_tagged_union(union, converter)

@define
class Holder:
c: Union[A, B, None]

@define
class TypingHolder:
c: Optional[Union[A, B]]

assert converter.unstructure(Holder(B("1"))) == {"c": {"_type": "B", "a": "1"}}
assert converter.unstructure(Holder(None)) == {"c": None}
assert converter.structure({"c": {"_type": "B", "a": "1"}}, Holder) == Holder(
B("1")
)
assert converter.structure({"c": None}, Holder) == Holder(None)

assert converter.unstructure(TypingHolder(B("1"))) == {
"c": {"_type": "B", "a": "1"}
}
assert converter.structure({"c": None}, TypingHolder) == TypingHolder(None)