diff --git a/HISTORY.md b/HISTORY.md index 942616e3..c34b9d9d 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 ({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)) diff --git a/src/cattrs/strategies/_unions.py b/src/cattrs/strategies/_unions.py index 52ac6a95..40c9c87d 100644 --- a/src/cattrs/strategies/_unions.py +++ b/src/cattrs/strategies/_unions.py @@ -1,4 +1,5 @@ from collections import defaultdict +from types import NoneType from typing import Any, Callable, Union from attrs import NOTHING, NothingType @@ -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__ @@ -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) diff --git a/tests/strategies/test_tagged_unions.py b/tests/strategies/test_tagged_unions.py index ba5ae49b..e1170c1e 100644 --- a/tests/strategies/test_tagged_unions.py +++ b/tests/strategies/test_tagged_unions.py @@ -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)