diff --git a/HISTORY.md b/HISTORY.md index e2e023d1..fb01199a 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -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. diff --git a/docs/strategies.md b/docs/strategies.md index 7ed68afb..bde5fbcf 100644 --- a/docs/strategies.md +++ b/docs/strategies.md @@ -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` | @@ -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. diff --git a/src/cattrs/strategies/_unions.py b/src/cattrs/strategies/_unions.py index 52ac6a95..c0623ebc 100644 --- a/src/cattrs/strategies/_unions.py +++ b/src/cattrs/strategies/_unions.py @@ -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 @@ -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 @@ -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) @@ -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: @@ -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): @@ -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) diff --git a/tests/strategies/test_tagged_unions.py b/tests/strategies/test_tagged_unions.py index ba5ae49b..67ccf457 100644 --- a/tests/strategies/test_tagged_unions.py +++ b/tests/strategies/test_tagged_unions.py @@ -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 @@ -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] @@ -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"} @@ -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`.""" @@ -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 @@ -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 @@ -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(): diff --git a/tests/strategies/test_tagged_unions_695.py b/tests/strategies/test_tagged_unions_695.py index 42043a56..1625fb09 100644 --- a/tests/strategies/test_tagged_unions_695.py +++ b/tests/strategies/test_tagged_unions_695.py @@ -1,5 +1,8 @@ from __future__ import annotations +from copy import deepcopy + +import pytest from attrs import define from cattrs import BaseConverter, Converter @@ -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 @@ -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