Skip to content
Closed
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
27 changes: 25 additions & 2 deletions src/cattrs/strategies/_unions.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@

from .. import BaseConverter
from .._compat import get_newtype_base, is_literal, is_subclass, is_union_type
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 @@ -177,8 +181,27 @@ def configure_union_passthrough(
PEP 695 type aliases are now supported as union members.
"""

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.
"""
for predicate, handler, _, _ in converter._structure_func._function_dispatch._handler_pairs:
try:
matched = predicate(alias)
except Exception: # noqa: BLE001, 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
14 changes: 14 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,17 @@ 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