diff --git a/HISTORY.md b/HISTORY.md index bcc8f044..75c248c2 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -26,6 +26,8 @@ Our backwards-compatibility policy can be found [here](https://github.com/python ([#732](https://github.com/python-attrs/cattrs/pull/732)) - Support running the test suite without `cbor2` installed. ([#748](https://github.com/python-attrs/cattrs/pull/748)) +- The [union passthrough strategy](https://catt.rs/en/stable/strategies.html#union-passthrough) now supports PEP 695 type aliases as union members. + ([#753](https://github.com/python-attrs/cattrs/pull/753)) ## 26.1.0 (2026-02-18) diff --git a/docs/defaulthooks.md b/docs/defaulthooks.md index 8852bf0d..92e61868 100644 --- a/docs/defaulthooks.md +++ b/docs/defaulthooks.md @@ -640,6 +640,10 @@ Their hooks can also be overriden using [](customizing.md#predicate-hooks). Type aliases using [`typing.TypeAlias`](https://docs.python.org/3/library/typing.html#typing.TypeAlias) aren't supported since there is no way at runtime to distinguish them from their underlying types. ``` +```{versionchanged} 26.2.0 +The [union passthrough strategy](strategies.md#union-passthrough) also recognizes type aliases used as union members. +``` + ```python >>> from datetime import datetime, UTC diff --git a/docs/strategies.md b/docs/strategies.md index 56b7ddde..7ed68afb 100644 --- a/docs/strategies.md +++ b/docs/strategies.md @@ -367,6 +367,10 @@ The strategy also supports [NewTypes](https://mypy.readthedocs.io/en/stable/more 12 ``` +```{versionchanged} 26.2.0 +PEP 695 type aliases of supported types are supported when used as union members. +``` + Unions containing unsupported types can be handled if at least one union type is supported by the strategy; the supported union types will be checked before the rest (referred to as the _spillover_) is handed over to the converter again. For example, if `A` and `B` are arbitrary _attrs_ classes, the union `Literal[10] | A | B` cannot be handled directly by a JSON converter. diff --git a/src/cattrs/strategies/_unions.py b/src/cattrs/strategies/_unions.py index 14add983..b69ba763 100644 --- a/src/cattrs/strategies/_unions.py +++ b/src/cattrs/strategies/_unions.py @@ -5,7 +5,7 @@ from .. import BaseConverter from .._compat import get_newtype_base, is_literal, is_subclass, is_union_type -from ..typealiases import is_type_alias +from ..typealiases import get_type_alias_base, is_type_alias __all__ = [ "configure_tagged_union", @@ -173,8 +173,14 @@ def configure_union_passthrough( .. versionadded:: 23.2.0 .. versionchanged:: 25.2.0 Introduced the `accept_ints_as_floats` parameter. + .. versionchanged:: 26.2.0 + PEP 695 type aliases are now supported as union members. """ - args = set(union.__args__) + + def get_passthrough_base(type: Any) -> Any: + return get_type_alias_base(type) if is_type_alias(type) else type + + args = {get_passthrough_base(type) for type in union.__args__} def make_structure_native_union(exact_type: Any) -> Callable: # `exact_type` is likely to be a subset of the entire configured union (`args`). @@ -194,9 +200,10 @@ def make_structure_native_union(exact_type: Any) -> Callable: } non_literal_classes = { - get_newtype_base(t) or t + get_newtype_base(t) or get_passthrough_base(t) for t in exact_type.__args__ - if not is_literal(t) and ((get_newtype_base(t) or t) in args) + if not is_literal(t) + and ((get_newtype_base(t) or get_passthrough_base(t)) in args) } # We augment the set of allowed classes with any configured subclasses of @@ -211,7 +218,8 @@ def make_structure_native_union(exact_type: Any) -> Callable: spillover = { a for a in exact_type.__args__ - if (get_newtype_base(a) or a) not in non_literal_classes + if (get_newtype_base(a) or get_passthrough_base(a)) + not in non_literal_classes and not is_literal(a) } @@ -273,7 +281,9 @@ def contains_native_union(exact_type: Any) -> bool: for lit_arg in t.__args__ } non_literal_types = { - get_newtype_base(t) or t for t in type_args if not is_literal(t) + get_newtype_base(t) or get_passthrough_base(t) + for t in type_args + if not is_literal(t) } return (literal_classes | non_literal_types) & args diff --git a/tests/strategies/test_union_passthrough_695.py b/tests/strategies/test_union_passthrough_695.py new file mode 100644 index 00000000..7e430ed2 --- /dev/null +++ b/tests/strategies/test_union_passthrough_695.py @@ -0,0 +1,21 @@ +from dataclasses import dataclass + +from cattrs import BaseConverter +from cattrs.strategies import configure_union_passthrough + + +@dataclass +class DataClass: + field: str + + +def test_type_alias_union_member(converter: BaseConverter) -> None: + """Native union passthrough handles PEP 695 aliases in the exact union.""" + type NewScalar = str + + configure_union_passthrough(str | int, converter) + + assert converter.structure("value", NewScalar | DataClass) == "value" + assert converter.structure({"field": "value"}, NewScalar | DataClass) == DataClass( + "value" + )