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 @@ -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)

Expand Down
4 changes: 4 additions & 0 deletions docs/defaulthooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions docs/strategies.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
22 changes: 16 additions & 6 deletions src/cattrs/strategies/_unions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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`).
Expand All @@ -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
Expand All @@ -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)
}

Expand Down Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions tests/strategies/test_union_passthrough_695.py
Original file line number Diff line number Diff line change
@@ -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"
)
Loading