Skip to content
Open
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
2 changes: 2 additions & 0 deletions HISTORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <cattrs.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.
Expand Down
26 changes: 16 additions & 10 deletions src/cattrs/disambiguators.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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)

Expand Down
22 changes: 22 additions & 0 deletions tests/test_disambiguators.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Loading