From 88d3c8355e3cfa91766ae3252e37bef6b256483b Mon Sep 17 00:00:00 2001 From: vyrnsynx <153433026+vyrnsynx@users.noreply.github.com> Date: Sat, 24 Oct 2026 23:53:21 +0800 Subject: [PATCH] fix: keep dedicated PEP 695 alias hooks in union passthrough spillover configure_union_passthrough was unwrapping every PEP 695 type alias to its base type, so a custom structure hook registered for the alias never ran when the alias appeared inside a passthrough union (Trimmed | int returned the raw string). Only unwrap aliases that still use the default type-alias factory. Aliases with a dedicated hook stay in spillover so converter.structure applies them. Fixes #797 --- HISTORY.md | 2 ++ src/cattrs/strategies/_unions.py | 34 +++++++++++++++++-- .../strategies/test_union_passthrough_695.py | 29 ++++++++++++++++ 3 files changed, 63 insertions(+), 2 deletions(-) diff --git a/HISTORY.md b/HISTORY.md index e2e023d1..aa0ef64d 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -13,6 +13,8 @@ Our backwards-compatibility policy can be found [here](https://github.com/python ## NEXT +- {func}`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. diff --git a/src/cattrs/strategies/_unions.py b/src/cattrs/strategies/_unions.py index 52ac6a95..a9d3733a 100644 --- a/src/cattrs/strategies/_unions.py +++ b/src/cattrs/strategies/_unions.py @@ -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", @@ -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__} diff --git a/tests/strategies/test_union_passthrough_695.py b/tests/strategies/test_union_passthrough_695.py index 7e430ed2..14b7379a 100644 --- a/tests/strategies/test_union_passthrough_695.py +++ b/tests/strategies/test_union_passthrough_695.py @@ -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