diff --git a/src/cattrs/strategies/_unions.py b/src/cattrs/strategies/_unions.py index b69ba763..eef50ea0 100644 --- a/src/cattrs/strategies/_unions.py +++ b/src/cattrs/strategies/_unions.py @@ -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", @@ -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__} diff --git a/tests/strategies/test_union_passthrough_695.py b/tests/strategies/test_union_passthrough_695.py index 7e430ed2..97735b4f 100644 --- a/tests/strategies/test_union_passthrough_695.py +++ b/tests/strategies/test_union_passthrough_695.py @@ -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