diff --git a/HISTORY.md b/HISTORY.md index f81acc7a..8b45f3b6 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -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 ` 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..451d5768 100644 --- a/src/cattrs/disambiguators.py +++ b/src/cattrs/disambiguators.py @@ -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.") @@ -71,17 +73,17 @@ 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 @@ -89,10 +91,8 @@ def create_default_dis_func( # 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( @@ -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 = { diff --git a/tests/test_disambiguators.py b/tests/test_disambiguators.py index 2ae5090f..66f366d8 100644 --- a/tests/test_disambiguators.py +++ b/tests/test_disambiguators.py @@ -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 @@ -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."""