From 07bea8aa378bdb11499d56a4b1f7924789285f2b Mon Sep 17 00:00:00 2001 From: Onkesh Bansal Date: Thu, 17 Sep 2026 18:36:04 -0400 Subject: [PATCH] Apply field renames when disambiguating on a Literal field `create_default_dis_func` computes attribute overrides up front, but the `use_literals` branch built its candidate names from the raw attribute names and looked the discriminator up in the payload under that same raw name. A union whose discriminator field was renamed with `override(rename=...)` therefore failed to structure with a bare `KeyError`, even though each member structured correctly on its own. Build the literal candidate names from the overridden names instead, and keep a per-class map back to the original names so the `Literal` args are still read off the right field. The unique-key branch already did this through `_usable_attribute_names`; this brings the two strategies in line. Fixes #779 Co-Authored-By: Claude Opus 5 --- HISTORY.md | 2 ++ src/cattrs/disambiguators.py | 26 ++++++++++++++++---------- tests/test_disambiguators.py | 22 ++++++++++++++++++++++ 3 files changed, 40 insertions(+), 10 deletions(-) diff --git a/HISTORY.md b/HISTORY.md index f81acc7a..8ee168c9 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -13,6 +13,8 @@ Our backwards-compatibility policy can be found [here](https://github.com/python ## NEXT (UNRELEASED) +- Fix `Literal` discriminators ignoring `override(rename=...)`, which made structuring a union of classes with a renamed discriminator field fail with a `KeyError`; the discriminator is now looked up under its renamed name, like the unique-field strategy already does. + ([#779](https://github.com/python-attrs/cattrs/issues/779) [#780](https://github.com/python-attrs/cattrs/pull/780)) - Fix heterogeneous tuples and `NamedTuple`s with a member type containing a quote in its `repr`, like `tuple[Literal["a"], int]`, crashing structuring code generation with `SyntaxError`; the index note is now embedded with `repr`. ([#777](https://github.com/python-attrs/cattrs/pull/777)) - Fix {func}`transform_error ` listing the extra keys of a `ForbiddenExtraKeysError` in set iteration order, which made the message differ between runs; the keys are now sorted, like the error's own `__str__` already sorts them. diff --git a/src/cattrs/disambiguators.py b/src/cattrs/disambiguators.py index f020c844..4003de48 100644 --- a/src/cattrs/disambiguators.py +++ b/src/cattrs/disambiguators.py @@ -69,14 +69,20 @@ def create_default_dis_func( # requirements for a discriminator field: # (... TODO: a single fallback is OK) # - it must always be enumerated - cls_candidates = [ - { - at.name - for at in adapted_fields(get_origin(cl) or cl) - if is_literal(at.type) - } - for cl in classes - ] + # Names here are the ones used in the payload, so renames are applied; + # `back_maps` recovers the original field name for each class. + cls_candidates = [] + back_maps = [] + for cl, override in zip(classes, overrides): + candidates = set() + back_map = {} + for at in adapted_fields(get_origin(cl) or cl): + name = _overriden_name(at, override.get(at.name)) + back_map[name] = at.name + if is_literal(at.type): + candidates.add(name) + cls_candidates.append(candidates) + back_maps.append(back_map) # literal field names common to all members discriminators: set[str] = cls_candidates[0] @@ -89,9 +95,9 @@ def create_default_dis_func( # maps Literal values (strings, ints...) to classes mapping = defaultdict(list) - for cl in classes: + for cl, back_map in zip(classes, back_maps): for key in get_args( - fields_dict(get_origin(cl) or cl)[discriminator].type + fields_dict(get_origin(cl) or cl)[back_map[discriminator]].type ): mapping[key].append(cl) diff --git a/tests/test_disambiguators.py b/tests/test_disambiguators.py index 2ae5090f..33349561 100644 --- a/tests/test_disambiguators.py +++ b/tests/test_disambiguators.py @@ -405,3 +405,25 @@ class B: assert converter.structure({"a": "a"}, Union[A, B]) == A() assert converter.structure({"b": "b"}, Union[A, B]) == B("b") + + +def test_renamed_literal_discriminator(converter: Converter): + """A renamed Literal discriminator field properly disambiguates.""" + + @define + class A: + kind: Literal["a"] + a_val: int + + @define + class B: + kind: Literal["b"] + b_val: int + + for cl in (A, B): + converter.register_structure_hook( + cl, make_dict_structure_fn(cl, converter, kind=override(rename="type")) + ) + + assert converter.structure({"type": "a", "a_val": 1}, Union[A, B]) == A("a", 1) + assert converter.structure({"type": "b", "b_val": 2}, Union[A, B]) == B("b", 2)