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
1 change: 1 addition & 0 deletions HISTORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ Our backwards-compatibility policy can be found [here](https://github.com/python

## NEXT (UNRELEASED)

- Honor field renaming overrides when selecting literal discriminators for unions, so renamed union members can be structured from their serialized keys.
- 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
18 changes: 9 additions & 9 deletions src/cattrs/disambiguators.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ def create_default_dis_func(

.. versionchanged:: 24.1.0
Dataclasses are now supported.
.. versionchanged:: NEXT
Renaming overrides also apply to literal discriminator fields.
"""
if len(classes) < 2:
raise ValueError("At least two classes required.")
Expand All @@ -71,28 +73,26 @@ def create_default_dis_func(
# - it must always be enumerated
cls_candidates = [
{
at.name
_overriden_name(at, override.get(at.name)): at
for at in adapted_fields(get_origin(cl) or cl)
if is_literal(at.type)
}
for cl in classes
for cl, override in zip(classes, overrides)
]

# literal field names common to all members
discriminators: set[str] = cls_candidates[0]
discriminators: set[str] = set(cls_candidates[0])
for possible_discriminators in cls_candidates:
discriminators &= possible_discriminators
discriminators.intersection_update(possible_discriminators)

best_result = None
best_discriminator = None
for discriminator in discriminators:
# maps Literal values (strings, ints...) to classes
mapping = defaultdict(list)

for cl in classes:
for key in get_args(
fields_dict(get_origin(cl) or cl)[discriminator].type
):
for cl, candidates in zip(classes, cls_candidates):
for key in get_args(candidates[discriminator].type):
mapping[key].append(cl)

if best_result is None or max(len(v) for v in mapping.values()) <= max(
Expand All @@ -103,7 +103,7 @@ def create_default_dis_func(

if (
best_result
and best_discriminator
and best_discriminator is not None
and max(len(v) for v in best_result.values()) != len(classes)
):
final_mapping = {
Expand Down
92 changes: 91 additions & 1 deletion tests/test_disambiguators.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from cattrs import Converter
from cattrs.disambiguators import create_default_dis_func, is_supported_union
from cattrs.errors import StructureHandlerNotFoundError
from cattrs.gen import make_dict_structure_fn, override
from cattrs.gen import make_dict_structure_fn, make_dict_unstructure_fn, override

from .untyped import simple_classes

Expand Down Expand Up @@ -377,6 +377,96 @@ class B:
assert converter.structure({"b": 1}, Union[A, B]) == B(1)


@pytest.mark.parametrize("decorator", [define, dataclass])
@pytest.mark.parametrize("rename", ["type", ""])
def test_literal_field_renaming(converter, decorator, rename):
"""Renamed literal discriminators work when round-tripping unions."""

@decorator
class A:
kind: Literal["a"]

@decorator
class B:
kind: Literal["b"]

for cl in (A, B):
overrides = {"kind": override(rename=rename)}
converter.register_structure_hook(
cl, make_dict_structure_fn(cl, converter, **overrides)
)
converter.register_unstructure_hook(
cl, make_dict_unstructure_fn(cl, converter, **overrides)
)

for instance in (A("a"), B("b")):
payload = converter.unstructure(instance)
assert payload == {rename: instance.kind}
assert converter.structure(payload, Union[A, B]) == instance


def test_literal_field_renaming_different_attributes(converter):
"""Different attribute names can share a serialized discriminator key."""

@define
class A:
a: Literal["a"]

@define
class B:
b: Literal["b"]

converter.register_structure_hook(
A, make_dict_structure_fn(A, converter, a=override(rename="type"))
)
converter.register_structure_hook(
B, make_dict_structure_fn(B, converter, b=override(rename="type"))
)

assert converter.structure({"type": "a"}, Union[A, B]) == A("a")
assert converter.structure({"type": "b"}, Union[A, B]) == B("b")


def test_literal_field_renaming_different_keys(converter):
"""Literal fields with different serialized names use key disambiguation."""

@define
class A:
kind: Literal["a"]

@define
class B:
kind: Literal["b"]

converter.register_structure_hook(
A, make_dict_structure_fn(A, converter, kind=override(rename="a"))
)
converter.register_structure_hook(
B, make_dict_structure_fn(B, converter, kind=override(rename="b"))
)

assert converter.structure({"a": "a"}, Union[A, B]) == A("a")
assert converter.structure({"b": "b"}, Union[A, B]) == B("b")


def test_literal_field_explicit_overrides():
"""Explicit discriminator overrides apply to literal fields too."""

@define
class A:
kind: Literal["a"]

@define
class B:
kind: Literal["b"]

fn = create_default_dis_func(
Converter(), A, B, overrides={"kind": override(rename="type")}
)
assert fn({"type": "a"}) is A
assert fn({"type": "b"}) is B


def test_dataclasses(converter):
"""The default strategy works for dataclasses too."""

Expand Down