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 @@ -13,6 +13,8 @@ Our backwards-compatibility policy can be found [here](https://github.com/python

## NEXT

- {func}`configure_union_passthrough <cattrs.strategies.configure_union_passthrough>` keeps PEP 695 type aliases that have a dedicated structure hook in spillover, so the hook still runs when the alias appears inside a passthrough union (for example ``Trimmed | int``).
([#797](https://github.com/python-attrs/cattrs/issues/797))
- 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.
Expand Down
34 changes: 32 additions & 2 deletions src/cattrs/strategies/_unions.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@
from .. import BaseConverter
from .._compat import 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
from ..typealiases import (
get_type_alias_base,
is_type_alias,
type_alias_structure_factory,
)

__all__ = [
"configure_tagged_union",
Expand Down Expand Up @@ -179,10 +183,36 @@ def configure_union_passthrough(
.. versionchanged:: NEXT
Unions containing Python 3.15+ sentinels defer to sentinel handling,
matching sentinel names before passing through other values.
.. versionchanged:: NEXT
Type aliases with a dedicated structure hook stay in spillover instead
of being unwrapped to their base, so the hook still runs inside a
passthrough union.
"""

def _alias_has_dedicated_structure_hook(alias: Any) -> bool:
"""True when a structure hook was registered specifically for this alias.

The default type-alias factory only unwraps to the base type; a
dedicated hook (``register_structure_hook`` / ``_func``) must keep the
alias in spillover so it is structured through that hook.
"""
structure = converter._structure_func
if alias in structure._direct_dispatch:
return True
for predicate, handler, _, _ in structure._function_dispatch._handler_pairs:
try:
matched = predicate(alias)
except Exception: # noqa: S112
continue
if not matched:
continue
return handler is not type_alias_structure_factory
return False

def get_passthrough_base(type: Any) -> Any:
return get_type_alias_base(type) if is_type_alias(type) else type
if is_type_alias(type) and not _alias_has_dedicated_structure_hook(type):
return get_type_alias_base(type)
return type

args = {get_passthrough_base(type) for type in union.__args__}

Expand Down
29 changes: 29 additions & 0 deletions tests/strategies/test_union_passthrough_695.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,32 @@ def test_type_alias_union_member(converter: BaseConverter) -> None:
assert converter.structure({"field": "value"}, NewScalar | DataClass) == DataClass(
"value"
)


def test_type_alias_dedicated_hook_in_passthrough_union(
converter: BaseConverter,
) -> None:
"""A dedicated structure hook on a PEP 695 alias runs inside a passthrough union."""
type Trimmed = str

configure_union_passthrough(str | int, converter)
converter.register_structure_hook_func(
lambda t: t is Trimmed, lambda v, _: v.strip()
)

assert converter.structure(" x ", Trimmed) == "x"
assert converter.structure(" x ", Trimmed | int) == "x"
assert converter.structure(1, Trimmed | int) == 1


def test_type_alias_dedicated_hook_via_register_structure_hook(
converter: BaseConverter,
) -> None:
"""Same as above, but with register_structure_hook (direct dispatch)."""
type Trimmed = str

configure_union_passthrough(str | int, converter)
converter.register_structure_hook(Trimmed, lambda v, _: v.strip())

assert converter.structure(" y ", Trimmed | int) == "y"
assert converter.structure(2, Trimmed | int) == 2