Skip to content
Open
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 now supports `None` members, passing null values through while preserving tags for other members.
([#345](https://github.com/python-attrs/cattrs/issues/345))
- The msgspec converter now applies the same unstructuring hook to nested instances of recursive _attrs_ classes, so registered hooks and private attributes are no longer skipped below the top level.
([#803](https://github.com/python-attrs/cattrs/pull/803))
- Dict unstructuring hook factories now inherit the converter's `omit_if_default` setting when `_cattrs_omit_if_default` is omitted. Explicit `True` or `False` values and per-attribute overrides retain their precedence.
Expand Down
23 changes: 22 additions & 1 deletion docs/strategies.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ 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.
The `tag_generator` parameter is a one-argument callable that will be called with every non-`None` 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` |
Expand Down Expand Up @@ -71,6 +71,27 @@ This also means union members can be reused in multiple unions easily.
The strategy can also be called with a type alias of a union.
```

```{versionchanged} NEXT
The strategy supports `None` as a union member. Previously, these unions required custom hooks to handle `None`.
```

To handle nullable tagged fields, configure their complete type, including `None`.
`None` values pass through unchanged, while other values use the configured tags and default member:

```{doctest} tagged_unions

>>> nullable = A | B | None
>>> configure_tagged_union(nullable, converter)
>>> converter.unstructure(A(1), unstructure_as=nullable)
{'a': 1, '_type': 'A'}
>>> converter.structure({'a': 1, '_type': 'A'}, nullable)
A(a=1)
>>> converter.unstructure(None, unstructure_as=nullable) is None
True
>>> converter.structure(None, nullable) is None
True
```

### 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.
Expand Down
44 changes: 33 additions & 11 deletions src/cattrs/strategies/_unions.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from attrs import NOTHING, NothingType

from .. import BaseConverter
from .._compat import get_newtype_base, is_literal, is_subclass, is_union_type
from .._compat import NoneType, get_newtype_base, is_literal, is_subclass, is_union_type
from ..sentinels import is_sentinel
from ..typealiases import get_type_alias_base, is_type_alias

Expand Down Expand Up @@ -34,7 +34,7 @@ def configure_tagged_union(

:param converter: The converter to apply the strategy to.
:param tag_generator: A `tag_generator` function is used to map each
member of the union to a tag, which is then included in the
non-None member of the union to a tag, which is then included in the
unstructured payload. The default tag generator returns the name of
the class.
:param tag_name: The key under which the tag will be set in the
Expand All @@ -49,20 +49,25 @@ def configure_tagged_union(

.. versionchanged:: 25.1
Type aliases of unions are now also supported.
.. versionchanged:: NEXT
Unions containing None are now supported, passing None values through
unchanged.
"""
if is_type_alias(union):
union = union.__value__
args = union.__args__
has_none = NoneType in args
if has_none:
args = tuple(cl for cl in args if cl is not NoneType)

tag_to_hook = {}
exact_cl_unstruct_hooks = {}
cl_to_tag = {}

if default is not NOTHING:
default_handler = converter.get_structure_hook(default)

def structure_default(val: dict, _cl=default, _h=default_handler):
return _h(val, _cl)
def structure_default(val: dict, _cl=default):
return default_handler(val, _cl)

tag_to_hook = defaultdict(lambda: structure_default)
cl_to_tag = defaultdict(lambda: default)
Expand All @@ -74,13 +79,12 @@ def structure_tagged_union(
_,
_tag_to_hook=tag_to_hook,
_tag_name=tag_name,
_dh=default_handler,
_default=default,
) -> union:
if _tag_name in val:
val = val.copy()
return _tag_to_hook[val.pop(_tag_name)](val)
return _dh(val, _default)
return default_handler(val, _default)

else:

Expand All @@ -89,12 +93,11 @@ def structure_tagged_union(
_,
_tag_to_hook=tag_to_hook,
_tag_name=tag_name,
_dh=default_handler,
_default=default,
) -> union:
if _tag_name in val:
return _tag_to_hook[val[_tag_name]](val)
return _dh(val, _default)
return default_handler(val, _default)

else:
if getattr(converter, "forbid_extra_keys", False):
Expand Down Expand Up @@ -122,8 +125,27 @@ def unstructure_tagged_union(
res[_tag_name] = _cl_to_tag[val.__class__]
return res

converter.register_unstructure_hook(union, unstructure_tagged_union)
converter.register_structure_hook(union, structure_tagged_union)
if has_none:

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

def structure_nullable_tagged_union(
val: Any, cl: Any, _h=structure_tagged_union
) -> union:
return None if val is None else _h(val, cl)

converter.register_unstructure_hook(union, unstructure_nullable_tagged_union)
converter.register_structure_hook(union, structure_nullable_tagged_union)
else:
converter.register_unstructure_hook(union, unstructure_tagged_union)
converter.register_structure_hook(union, structure_tagged_union)

# Recursive defaults must see the registered union hooks during generation.
if default is not NOTHING:
default_handler = converter.get_structure_hook(default)

for cl in args:
tag = tag_generator(cl)
Expand Down
102 changes: 90 additions & 12 deletions tests/strategies/test_tagged_unions.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
from dataclasses import dataclass
from typing import Optional, Sequence, Union

import pytest
from attrs import define

from cattrs import BaseConverter, Converter
from cattrs import BaseConverter, ClassValidationError, Converter
from cattrs.strategies import configure_tagged_union


Expand All @@ -28,6 +30,47 @@ def test_defaults(converter: BaseConverter) -> None:
assert converter.structure({"_type": "B", "a": 1}, union) == B("1")


@pytest.mark.parametrize("container_decorator", [define, dataclass])
def test_nullable_union(converter: BaseConverter, container_decorator) -> None:
"""Nullable tagged unions preserve tags in attrs and dataclass fields."""
union = Union[A, B, None]

@container_decorator
class Container:
value: Union[A, B, None]

configure_tagged_union(
union, converter, tag_name="kind", tag_generator={A: "a", B: "b"}.__getitem__
)

for value, expected in (
(None, None),
(A(1), {"kind": "a", "a": 1}),
(B("1"), {"kind": "b", "a": "1"}),
):
data = converter.unstructure(value, union)
assert data == expected
assert converter.structure(data, union) == value
assert data == expected

instance = Container(value)
data = converter.unstructure(instance)
assert data == {"value": expected}
assert converter.structure(data, Container) == instance
assert data == {"value": expected}


def test_optional_class(converter: BaseConverter) -> None:
"""A tagged union may contain only one class alongside None."""
union = Optional[A]
configure_tagged_union(union, converter)

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


def test_tag_name(converter: BaseConverter) -> None:
"""Tag names are customizable."""
union = Union[A, B]
Expand Down Expand Up @@ -81,9 +124,10 @@ def test_tag_generator_dict(converter: BaseConverter) -> None:
assert converter.structure({"_type": "type:B", "a": 1}, union) == B("1")


def test_default_member(converter: BaseConverter) -> None:
@pytest.mark.parametrize("nullable", [False, True])
def test_default_member(converter: BaseConverter, nullable: bool) -> None:
"""Tagged unions can have default members."""
union = Union[A, B]
union = Union[A, B, None] if nullable else Union[A, B]
configure_tagged_union(union, converter, default=A)
assert converter.unstructure(A(1), union) == {"_type": "A", "a": 1}
assert converter.unstructure(B("1"), union) == {"_type": "B", "a": "1"}
Expand All @@ -96,6 +140,10 @@ def test_default_member(converter: BaseConverter) -> None:
assert converter.structure({"_type": "A", "a": 1}, union) == A(1)
assert converter.structure({"_type": "B", "a": 1}, union) == B("1")

if nullable:
assert converter.unstructure(None, union) is None
assert converter.structure(None, union) is None


def test_default_member_with_tag(converter: BaseConverter) -> None:
"""Members can access the tags, if not `forbid_extra_keys`."""
Expand Down Expand Up @@ -128,7 +176,8 @@ def test_default_member_validation(converter: BaseConverter) -> None:
assert converter.structure({"_type": "A", "a": "1"}, union) == A(1)


def test_forbid_extra_keys():
@pytest.mark.parametrize("nullable", [False, True])
def test_forbid_extra_keys(nullable: bool):
"""The strategy works when converters forbid extra keys."""

@define
Expand All @@ -140,13 +189,25 @@ class B:
pass

c = Converter(forbid_extra_keys=True)
configure_tagged_union(Union[A, B], c)
union = Union[A, B, None] if nullable else Union[A, B]
configure_tagged_union(union, c)

data = c.unstructure(A(), union)
assert c.structure(data, union) == A()
assert data == {"_type": "A"}

data = {"_type": "A", "extra": 1}
with pytest.raises(ClassValidationError):
c.structure(data, union)
assert data == {"_type": "A", "extra": 1}

data = c.unstructure(A(), Union[A, B])
c.structure(data, Union[A, B])
if nullable:
assert c.unstructure(None, union) is None
assert c.structure(None, union) is None


def test_forbid_extra_keys_default():
@pytest.mark.parametrize("nullable", [False, True])
def test_forbid_extra_keys_default(nullable: bool):
"""The strategy works when converters forbid extra keys."""

@define
Expand All @@ -158,13 +219,30 @@ class B:
pass

c = Converter(forbid_extra_keys=True)
configure_tagged_union(Union[A, B], c, default=A)
union = Union[A, B, None] if nullable else Union[A, B]
configure_tagged_union(union, c, default=A)

data = c.unstructure(A(), Union[A, B])
assert c.structure(data, Union[A, B]) == A()
data = c.unstructure(A(), union)
assert c.structure(data, union) == A()
assert data == {"_type": "A"}

data.pop("_type")
assert c.structure(data, Union[A, B]) == A()
assert c.structure(data, union) == A()
assert data == {}

data = {"_type": "unknown"}
assert c.structure(data, union) == A()
assert data == {"_type": "unknown"}

for data in ({"_type": "A", "extra": 1}, {"extra": 1}):
original = data.copy()
with pytest.raises(ClassValidationError):
c.structure(data, union)
assert data == original

if nullable:
assert c.unstructure(None, union) is None
assert c.structure(None, union) is None


def test_nested_sequence_union():
Expand Down
55 changes: 55 additions & 0 deletions tests/strategies/test_tagged_unions_695.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
from __future__ import annotations

from copy import deepcopy

import pytest
from attrs import define

from cattrs import BaseConverter, Converter
Expand All @@ -21,6 +24,20 @@ def test_type_alias(converter: BaseConverter):
assert converter.structure({"_type": "B", "a": 1}, AOrB) == B("1")


def test_nullable_type_alias(converter: BaseConverter):
"""Type aliases preserve explicit nullable tagged union handling."""
type AOrBOrNone = A | B | None

configure_tagged_union(AOrBOrNone, converter)

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


@define
class Lit:
value: float
Expand Down Expand Up @@ -53,3 +70,41 @@ def test_recursive_type_alias(genconverter: Converter):

assert genconverter.unstructure(val, Expr) == expected
assert genconverter.structure(expected, Expr) == val


@define
class Node:
value: int
next: Chain


type Chain = Node | None


@pytest.mark.parametrize("detailed_validation", [True, False])
@pytest.mark.parametrize("outer_tag", [None, "unknown", "Node"])
def test_recursive_nullable_alias_default(detailed_validation: bool, outer_tag):
"""Default members use the nullable tagged hook for recursive fields."""
converter = Converter(
forbid_extra_keys=True, detailed_validation=detailed_validation
)
configure_tagged_union(Chain, converter, default=Node)

data = {"value": 1, "next": {"_type": "Node", "value": 2, "next": None}}
if outer_tag is not None:
data["_type"] = outer_tag
original = deepcopy(data)

structured = converter.structure(data, Chain)
assert structured == Node(1, Node(2, None))
assert data == original

expected = {
"_type": "Node",
"value": 1,
"next": {"_type": "Node", "value": 2, "next": None},
}
unstructured = converter.unstructure(structured, Chain)
assert unstructured == expected
assert converter.structure(unstructured, Chain) == structured
assert unstructured == expected