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 13f63ee1..c4dc33f8 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), ] ) @@ -631,8 +631,8 @@ 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.""" - return obj.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]: """Convert a sequence to primitive equivalents.""" @@ -713,6 +713,16 @@ 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: + """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_"]) + 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..c17760fd 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,31 @@ def test_enum_failure(enum): converter.structure("", type) assert exc_info.value.args[0] == f" not in literal {type!r}" + + +class SimpleEnum(Enum): + _value_: int + A = 0 + B = 1 + C = 2 + + +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(SimpleEnum.A) == 0 + assert converter.unstructure(ComplexEnum.A1) == (0, 1) + + +def test_structure_complex_enum() -> None: + converter = BaseConverter() + assert converter.structure(0, SimpleEnum) == SimpleEnum.A + assert converter.structure((0, 1), ComplexEnum) == ComplexEnum.A1