From 9f08ab9c6062c5555fa0e45c720397ce4f372345 Mon Sep 17 00:00:00 2001 From: Eryx-Z <257642323+Eryx-Z@users.noreply.github.com> Date: Sat, 17 Oct 2026 04:43:09 +0800 Subject: [PATCH] fix: keep dedicated PEP 695 alias hooks in union passthrough spillover configure_union_passthrough was unwrapping every type alias to its base, so a custom structure hook registered for the alias never ran when the alias appeared inside a passthrough union (e.g. Trimmed | int). Only unwrap aliases that still use the default type-alias factory; leave aliases with a dedicated hook in spillover so converter.structure can apply them. Fixes #797 --- src/cattrs/strategies/_unions.py | 27 +++++++++++++++++-- .../strategies/test_union_passthrough_695.py | 14 ++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) 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