From f8fff638e13d5fff8d61a7936c084a4509e7584f Mon Sep 17 00:00:00 2001 From: Julia V Rose Date: Mon, 24 Nov 2025 22:13:08 -0800 Subject: [PATCH 1/7] An initial implementation of unstructuring and structuring enums with complex values --- src/cattrs/converters.py | 12 +++++++++--- tests/test_enums.py | 24 ++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/src/cattrs/converters.py b/src/cattrs/converters.py index 13f63ee1..59de9f27 100644 --- a/src/cattrs/converters.py +++ b/src/cattrs/converters.py @@ -9,7 +9,7 @@ from inspect import Signature from inspect import signature as inspect_signature from pathlib import Path -from typing import Any, Optional, Tuple, TypeVar, overload +from typing import Any, Optional, Tuple, TypeVar, get_type_hints, overload from attrs import Attribute, resolve_types from attrs import has as attrs_has @@ -308,7 +308,7 @@ def __init__( (bytes, self._structure_call), (int, self._structure_call), (float, self._structure_call), - (Enum, self._structure_call), + (Enum, self._structure_enum), (Path, self._structure_call), ] ) @@ -632,7 +632,7 @@ def unstructure_attrs_astuple(self, obj: Any) -> tuple[Any, ...]: def _unstructure_enum(self, obj: Enum) -> Any: """Convert an enum to its value.""" - return obj.value + return self._unstructure_func.dispatch(obj.value.__class__)(obj.value) def _unstructure_seq(self, seq: Sequence[T]) -> Sequence[T]: """Convert a sequence to primitive equivalents.""" @@ -713,6 +713,12 @@ def _structure_simple_literal(val, type): raise Exception(f"{val} not in literal {type}") return val + def _structure_enum(self, val: Any, cl: type[Enum]) -> Enum: + hints = get_type_hints(cl) + if "_value_" in hints: + val = self.structure(val, hints["_value_"]) + return cl(val) + @staticmethod def _structure_enum_literal(val, type): vals = {(x.value if isinstance(x, Enum) else x): x for x in type.__args__} diff --git a/tests/test_enums.py b/tests/test_enums.py index 59ebb6b6..eea2ff8a 100644 --- a/tests/test_enums.py +++ b/tests/test_enums.py @@ -1,5 +1,7 @@ """Tests for enums.""" +from enum import Enum + from hypothesis import given from hypothesis.strategies import data, sampled_from from pytest import raises @@ -29,3 +31,25 @@ def test_enum_failure(enum): converter.structure("", type) assert exc_info.value.args[0] == f" not in literal {type!r}" + + +class E(Enum): + _value_: int + A = 0 + + +class EE(Enum): + _value_: tuple[E, int] + A1 = (E.A, 1) + + +def test_unstructure_complex_enum() -> None: + converter = BaseConverter() + assert converter.unstructure(E.A) == 0 + assert converter.unstructure(EE.A1) == (0, 1) + + +def test_structure_complex_enum() -> None: + converter = BaseConverter() + assert converter.structure(0, E) == E.A + assert converter.structure((0, 1), EE) == EE.A1 From ce51b09392b09bb969ab5bb6afe5f648cfea4349 Mon Sep 17 00:00:00 2001 From: Julia V Rose Date: Tue, 2 Dec 2025 02:09:31 -0800 Subject: [PATCH 2/7] Add docs and history for complex enums update --- HISTORY.md | 2 ++ docs/defaulthooks.md | 25 ++++++++++++++++++++++++- src/cattrs/converters.py | 6 +++++- 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/HISTORY.md b/HISTORY.md index e1b5298d..7a215d32 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -19,6 +19,8 @@ Our backwards-compatibility policy can be found [here](https://github.com/python ([#698](https://github.com/python-attrs/cattrs/pull/698)) - Apply the attrs converter to the default value before checking if it is equal to the attribute's value, when `omit_if_default` is true and an attrs converter is specified. ([#696](https://github.com/python-attrs/cattrs/pull/696)) +- Unstructure enum values and structure enum values if they have a type hinted `_value_` attribute. + ([##699](https://github.com/python-attrs/cattrs/issues/699)) ## 25.3.0 (2025-10-07) diff --git a/docs/defaulthooks.md b/docs/defaulthooks.md index de75c857..1b68ca21 100644 --- a/docs/defaulthooks.md +++ b/docs/defaulthooks.md @@ -53,7 +53,6 @@ When unstructuring, these types are passed through unchanged. ### Enums Enums are structured by their values, and unstructured to their values. -This works even for complex values, like tuples. ```{doctest} @@ -70,6 +69,30 @@ This works even for complex values, like tuples. 'siamese' ``` +Enum structuring and unstructuring even works for complex values, like tuples, but if you have anything but simple literal types in those tuples (`str`, `bool`, `int`, `float`) you should consider defining the Enum value's type via the `_value_` attribute's type hint so that cattrs can properly structure it. + +```{doctest} + +>>> @unique +... class VideoStandard(Enum): +... NTSC = "ntsc" +... PAL = "pal" + +>>> @unique +... class Resolution(Enum): +... _value_: tuple[VideoStandard, int] +... NTSC_0 = (VideoStandard.NTSC, 0) +... PAL_0 = (VideoStandard.PAL, 0) +... NTSC_1 = (VideoStandard.NTSC, 1) +... PAL_1 = (VideoStandard.PAL, 1) + +>>> cattrs.structure(("ntsc", 1), Resolution) +, 1)> + +>>> cattrs.unstructure(Resolution.PAL_0) +['pal', 0] +``` + Again, in case of errors, the expected exceptions are raised. ### `pathlib.Path` diff --git a/src/cattrs/converters.py b/src/cattrs/converters.py index 59de9f27..c4dc33f8 100644 --- a/src/cattrs/converters.py +++ b/src/cattrs/converters.py @@ -631,7 +631,7 @@ def unstructure_attrs_astuple(self, obj: Any) -> tuple[Any, ...]: return tuple(res) def _unstructure_enum(self, obj: Enum) -> Any: - """Convert an enum to its value.""" + """Convert an enum to its unstructured value.""" return self._unstructure_func.dispatch(obj.value.__class__)(obj.value) def _unstructure_seq(self, seq: Sequence[T]) -> Sequence[T]: @@ -714,6 +714,10 @@ def _structure_simple_literal(val, type): return val def _structure_enum(self, val: Any, cl: type[Enum]) -> Enum: + """Structure ``val`` if possible and return the enum it corresponds to. + + Uses type hints for the "_value_" attribute if they exist to structure + the enum values before returning the result.""" hints = get_type_hints(cl) if "_value_" in hints: val = self.structure(val, hints["_value_"]) From eddac2185b88ed7cbe16160032101fb9b01a6525 Mon Sep 17 00:00:00 2001 From: Julia V Rose Date: Tue, 2 Dec 2025 02:18:31 -0800 Subject: [PATCH 3/7] Better class names for complex enum tests --- tests/test_enums.py | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/tests/test_enums.py b/tests/test_enums.py index eea2ff8a..c17760fd 100644 --- a/tests/test_enums.py +++ b/tests/test_enums.py @@ -33,23 +33,29 @@ def test_enum_failure(enum): assert exc_info.value.args[0] == f" not in literal {type!r}" -class E(Enum): +class SimpleEnum(Enum): _value_: int A = 0 + B = 1 + C = 2 -class EE(Enum): - _value_: tuple[E, int] - A1 = (E.A, 1) +class ComplexEnum(Enum): + _value_: tuple[SimpleEnum, int] + A0 = (SimpleEnum.A, 0) + A1 = (SimpleEnum.A, 1) + B1 = (SimpleEnum.B, 1) + B2 = (SimpleEnum.B, 2) + C1 = (SimpleEnum.C, 1) def test_unstructure_complex_enum() -> None: converter = BaseConverter() - assert converter.unstructure(E.A) == 0 - assert converter.unstructure(EE.A1) == (0, 1) + assert converter.unstructure(SimpleEnum.A) == 0 + assert converter.unstructure(ComplexEnum.A1) == (0, 1) def test_structure_complex_enum() -> None: converter = BaseConverter() - assert converter.structure(0, E) == E.A - assert converter.structure((0, 1), EE) == EE.A1 + assert converter.structure(0, SimpleEnum) == SimpleEnum.A + assert converter.structure((0, 1), ComplexEnum) == ComplexEnum.A1 From 98855576133bad2d48188fc349b494ebbce493ae Mon Sep 17 00:00:00 2001 From: Julia V Rose Date: Tue, 9 Dec 2025 17:47:17 -0800 Subject: [PATCH 4/7] Update HISTORY.md with better wording --- HISTORY.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/HISTORY.md b/HISTORY.md index 7a215d32..91280f88 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -19,7 +19,7 @@ Our backwards-compatibility policy can be found [here](https://github.com/python ([#698](https://github.com/python-attrs/cattrs/pull/698)) - Apply the attrs converter to the default value before checking if it is equal to the attribute's value, when `omit_if_default` is true and an attrs converter is specified. ([#696](https://github.com/python-attrs/cattrs/pull/696)) -- Unstructure enum values and structure enum values if they have a type hinted `_value_` attribute. +- Use the optional `_value_` type hint to structure and unstructure enums if present. ([##699](https://github.com/python-attrs/cattrs/issues/699)) ## 25.3.0 (2025-10-07) From 0e386a0f1cd5bb13e665d2ca163f7aa566dbae82 Mon Sep 17 00:00:00 2001 From: Julia V Rose Date: Tue, 9 Dec 2025 17:47:42 -0800 Subject: [PATCH 5/7] Add a link to the Python typing documentation for enums --- docs/defaulthooks.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/defaulthooks.md b/docs/defaulthooks.md index 1b68ca21..6585e7b9 100644 --- a/docs/defaulthooks.md +++ b/docs/defaulthooks.md @@ -69,7 +69,7 @@ Enums are structured by their values, and unstructured to their values. 'siamese' ``` -Enum structuring and unstructuring even works for complex values, like tuples, but if you have anything but simple literal types in those tuples (`str`, `bool`, `int`, `float`) you should consider defining the Enum value's type via the `_value_` attribute's type hint so that cattrs can properly structure it. +Enum structuring and unstructuring even works for complex values, like tuples, but if you have anything but simple literal types in those tuples (`str`, `bool`, `int`, `float`) you should consider defining the Enum value's type via the `_value_` attribute's type hint so that cattrs can properly structure it. See [the Python typing documentation](https://typing.python.org/en/latest/spec/enums.html#member-values) for more information on this type hint. ```{doctest} From c752b32bf65348554f176932261190a047bbbf89 Mon Sep 17 00:00:00 2001 From: Julia V Rose Date: Tue, 9 Dec 2025 17:51:07 -0800 Subject: [PATCH 6/7] Update the enum (un)structuring to only happen if `_value_` is in `__attributes__` --- src/cattrs/converters.py | 9 +++++---- tests/test_enums.py | 25 ++++++++++++++++--------- 2 files changed, 21 insertions(+), 13 deletions(-) diff --git a/src/cattrs/converters.py b/src/cattrs/converters.py index c4dc33f8..44d7e0fa 100644 --- a/src/cattrs/converters.py +++ b/src/cattrs/converters.py @@ -632,7 +632,9 @@ def unstructure_attrs_astuple(self, obj: Any) -> tuple[Any, ...]: def _unstructure_enum(self, obj: Enum) -> Any: """Convert an enum to its unstructured value.""" - return self._unstructure_func.dispatch(obj.value.__class__)(obj.value) + if "_value_" in obj.__class__.__annotations__: + return self._unstructure_func.dispatch(obj.value.__class__)(obj.value) + return obj.value def _unstructure_seq(self, seq: Sequence[T]) -> Sequence[T]: """Convert a sequence to primitive equivalents.""" @@ -718,9 +720,8 @@ def _structure_enum(self, val: Any, cl: type[Enum]) -> Enum: Uses type hints for the "_value_" attribute if they exist to structure the enum values before returning the result.""" - hints = get_type_hints(cl) - if "_value_" in hints: - val = self.structure(val, hints["_value_"]) + if "_value_" in cl.__annotations__: + val = self.structure(val, cl.__annotations__["_value_"]) return cl(val) @staticmethod diff --git a/tests/test_enums.py b/tests/test_enums.py index c17760fd..c600c4f1 100644 --- a/tests/test_enums.py +++ b/tests/test_enums.py @@ -34,28 +34,35 @@ def test_enum_failure(enum): class SimpleEnum(Enum): - _value_: int A = 0 B = 1 C = 2 +class SimpleEnumWithTypeHint(Enum): + _value_: str + D = "D" + E = "E" + F = "F" + class ComplexEnum(Enum): - _value_: tuple[SimpleEnum, int] - A0 = (SimpleEnum.A, 0) - A1 = (SimpleEnum.A, 1) - B1 = (SimpleEnum.B, 1) - B2 = (SimpleEnum.B, 2) - C1 = (SimpleEnum.C, 1) + _value_: tuple[SimpleEnum, SimpleEnumWithTypeHint] + AD = (SimpleEnum.A, SimpleEnumWithTypeHint.D) + AE = (SimpleEnum.A, SimpleEnumWithTypeHint.E) + BE = (SimpleEnum.B, SimpleEnumWithTypeHint.E) + BF = (SimpleEnum.B, SimpleEnumWithTypeHint.F) + CE = (SimpleEnum.C, SimpleEnumWithTypeHint.E) def test_unstructure_complex_enum() -> None: converter = BaseConverter() assert converter.unstructure(SimpleEnum.A) == 0 - assert converter.unstructure(ComplexEnum.A1) == (0, 1) + assert converter.unstructure(SimpleEnumWithTypeHint.F) == "F" + assert converter.unstructure(ComplexEnum.AE) == (0, "E") def test_structure_complex_enum() -> None: converter = BaseConverter() assert converter.structure(0, SimpleEnum) == SimpleEnum.A - assert converter.structure((0, 1), ComplexEnum) == ComplexEnum.A1 + assert converter.structure("E", SimpleEnumWithTypeHint) == SimpleEnumWithTypeHint.E + assert converter.structure((0, "D"), ComplexEnum) == ComplexEnum.AD From 2f07a9587a6478ce17f16f5cd7f985b4f33917b9 Mon Sep 17 00:00:00 2001 From: Julia V Rose Date: Sat, 13 Dec 2025 11:50:42 -0800 Subject: [PATCH 7/7] whoops, fix linting errors --- src/cattrs/converters.py | 2 +- tests/test_enums.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/cattrs/converters.py b/src/cattrs/converters.py index 44d7e0fa..14b2bff6 100644 --- a/src/cattrs/converters.py +++ b/src/cattrs/converters.py @@ -9,7 +9,7 @@ from inspect import Signature from inspect import signature as inspect_signature from pathlib import Path -from typing import Any, Optional, Tuple, TypeVar, get_type_hints, overload +from typing import Any, Optional, Tuple, TypeVar, overload from attrs import Attribute, resolve_types from attrs import has as attrs_has diff --git a/tests/test_enums.py b/tests/test_enums.py index c600c4f1..efb28d51 100644 --- a/tests/test_enums.py +++ b/tests/test_enums.py @@ -38,6 +38,7 @@ class SimpleEnum(Enum): B = 1 C = 2 + class SimpleEnumWithTypeHint(Enum): _value_: str D = "D"