diff --git a/HISTORY.md b/HISTORY.md index 942616e3..ad1d2bac 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}`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)) diff --git a/src/cattrs/strategies/_unions.py b/src/cattrs/strategies/_unions.py index 52ac6a95..33e06551 100644 --- a/src/cattrs/strategies/_unions.py +++ b/src/cattrs/strategies/_unions.py @@ -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__ @@ -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) diff --git a/tests/strategies/test_tagged_unions.py b/tests/strategies/test_tagged_unions.py index ba5ae49b..aa554e0d 100644 --- a/tests/strategies/test_tagged_unions.py +++ b/tests/strategies/test_tagged_unions.py @@ -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)