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}`configure_tagged_union <cattrs.strategies.configure_tagged_union>`) now also applies to subsets of the configured union, including subsets with an additional `None` member, instead of falling back to the default disambiguation for them.
([#348](https://github.com/python-attrs/cattrs/issues/348))
- 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
Expand Up @@ -49,6 +49,9 @@ def configure_tagged_union(

.. versionchanged:: 25.1
Type aliases of unions are now also supported.
.. versionchanged:: NEXT
Subsets of the configured union (optionally including `None`) now
use the tagged strategy too.
"""
if is_type_alias(union):
union = union.__value__
Expand Down Expand Up @@ -125,6 +128,35 @@ def unstructure_tagged_union(
converter.register_unstructure_hook(union, unstructure_tagged_union)
converter.register_structure_hook(union, structure_tagged_union)

def is_tagged_subset(t: Any) -> bool:
"""A different union made up only of members of the configured union.

An additional `None` member is allowed, so optional subsets count too;
at least two real members are required, so a plain `Optional[member]`
is not claimed.
"""
if not is_union_type(t):
return False
members = set(t.__args__) - {type(None)}
return len(members) >= 2 and members != set(args) and members.issubset(args)

def structure_tagged_subset(val: dict | None, _: Any) -> union:
return None if val is None else structure_tagged_union(val, _)

def unstructure_tagged_subset(val: union | None) -> dict | None:
return None if val is None else unstructure_tagged_union(val)

# Subsets of the configured union reuse the same hooks: the tag alone
# decides the member, and every member of a subset is in `tag_to_hook`.
# Direct registrations for a subset (for example, configuring it
# separately) take precedence over these factories.
converter.register_structure_hook_factory(
is_tagged_subset, lambda _: structure_tagged_subset
)
converter.register_unstructure_hook_factory(
is_tagged_subset, lambda _: unstructure_tagged_subset
)

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

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


def test_subset_union(converter: BaseConverter) -> None:
"""Subsets of a configured union use the tagged strategy too."""

@define
class C:
a: list

@define
class Container:
u: Union[A, B]
opt: Optional[Union[A, B]] = None

configure_tagged_union(Union[A, B, C], converter)

assert converter.unstructure(B("1"), Union[A, B]) == {"_type": "B", "a": "1"}
assert converter.structure({"_type": "B", "a": 1}, Union[A, B]) == B("1")

assert converter.unstructure(Container(B("1"), A(1))) == {
"u": {"_type": "B", "a": "1"},
"opt": {"_type": "A", "a": 1},
}
assert converter.structure(
{"u": {"_type": "A", "a": 1}, "opt": None}, Container
) == Container(A(1), None)