From 1bdcc47419a4192e1bde53cd5f6ec0ced503bb9f Mon Sep 17 00:00:00 2001 From: Vincent Gao Date: Wed, 24 Jun 2026 22:03:17 +0200 Subject: [PATCH 1/2] fix: unstructure enum values through the converter recursively MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When an enum's members have other enums (or complex types) as their values—and the enum has no `_value_` type annotation—`enum_unstructure_factory` returned `e.value` directly instead of running it through the converter. This caused enum-valued enums to produce non-JSON-serializable objects (the raw nested Enum) instead of the fully unstructured result. The typed-enum branch already called `converter.unstructure(e.value)`. Apply the same approach unconditionally: the distinction between typed and untyped enum is not needed for unstructuring, since both paths call `converter.unstructure`. Fixes #679. --- src/cattrs/enums.py | 8 +++----- tests/test_enums.py | 17 +++++++++++++++++ 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/src/cattrs/enums.py b/src/cattrs/enums.py index b1ab5040..702cdf66 100644 --- a/src/cattrs/enums.py +++ b/src/cattrs/enums.py @@ -12,12 +12,10 @@ def enum_unstructure_factory( """A factory for generating enum unstructure hooks. If the enum is a typed enum (has `_value_`), we use the underlying value's hook. - Otherwise, we use the value directly. + Otherwise, we unstructure the value through the converter so that enum members + whose values are themselves enums (or other complex types) are handled correctly. """ - if "_value_" in type.__annotations__: - return lambda e: converter.unstructure(e.value) - - return lambda e: e.value + return lambda e: converter.unstructure(e.value) def enum_structure_factory( diff --git a/tests/test_enums.py b/tests/test_enums.py index bdf591f1..6e219afc 100644 --- a/tests/test_enums.py +++ b/tests/test_enums.py @@ -68,3 +68,20 @@ def test_structure_complex_enum() -> None: assert converter.structure(0, SimpleEnum) == SimpleEnum.A assert converter.structure("E", SimpleEnumWithTypeHint) == SimpleEnumWithTypeHint.E assert converter.structure((0, "D"), ComplexEnum) == ComplexEnum.AD + + +class EnumValuedEnum(Enum): + """Enum whose members have other Enum instances as values (no type annotation).""" + + X = SimpleEnum.A + Y = SimpleEnum.B + + +def test_unstructure_enum_with_enum_values() -> None: + """Enum members whose values are themselves Enums are unstructured recursively. + + Regression test for https://github.com/python-attrs/cattrs/issues/679. + """ + converter = BaseConverter() + assert converter.unstructure(EnumValuedEnum.X) == 0 + assert converter.unstructure(EnumValuedEnum.Y) == 1 From 213013a920801dd42940d8f7ab1094b91eb90430 Mon Sep 17 00:00:00 2001 From: Vincent Gao Date: Thu, 25 Jun 2026 17:35:57 +0200 Subject: [PATCH 2/2] Avoid recursive unstructure for simple enum values --- src/cattrs/enums.py | 27 +++++++++++++++++++++++---- tests/test_enums.py | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 4 deletions(-) diff --git a/src/cattrs/enums.py b/src/cattrs/enums.py index 702cdf66..94d73e29 100644 --- a/src/cattrs/enums.py +++ b/src/cattrs/enums.py @@ -1,21 +1,40 @@ -from collections.abc import Callable +from collections.abc import Callable, Mapping from enum import Enum from typing import TYPE_CHECKING, Any +from ._compat import has + if TYPE_CHECKING: from .converters import BaseConverter +def _needs_recursive_unstructure(value: Any) -> bool: + if isinstance(value, Enum) or has(value.__class__): + return True + if isinstance(value, tuple | list | set | frozenset): + return any(_needs_recursive_unstructure(v) for v in value) + if isinstance(value, Mapping): + return any( + _needs_recursive_unstructure(k) or _needs_recursive_unstructure(v) + for k, v in value.items() + ) + return False + + def enum_unstructure_factory( type: type[Enum], converter: "BaseConverter" ) -> Callable[[Enum], Any]: """A factory for generating enum unstructure hooks. If the enum is a typed enum (has `_value_`), we use the underlying value's hook. - Otherwise, we unstructure the value through the converter so that enum members - whose values are themselves enums (or other complex types) are handled correctly. + Otherwise, we only use the converter when the values are known to need it. """ - return lambda e: converter.unstructure(e.value) + if "_value_" in type.__annotations__ or any( + _needs_recursive_unstructure(member.value) for member in type + ): + return lambda e: converter.unstructure(e.value) + + return lambda e: e.value def enum_structure_factory( diff --git a/tests/test_enums.py b/tests/test_enums.py index 6e219afc..5a58f2ae 100644 --- a/tests/test_enums.py +++ b/tests/test_enums.py @@ -2,6 +2,7 @@ from enum import Enum +from attrs import define from hypothesis import given from hypothesis.strategies import data, sampled_from from pytest import raises @@ -77,6 +78,31 @@ class EnumValuedEnum(Enum): Y = SimpleEnum.B +class TupleValuedEnum(Enum): + """Enum whose members have tuples with Enum instances (no type annotation).""" + + X = (SimpleEnum.A, 1) + + +@define +class AnAttrsClass: + a: int + + +class AttrsValuedEnum(Enum): + """Enum whose members have attrs instances as values (no type annotation).""" + + X = AnAttrsClass(1) + + +def test_unstructure_simple_enum_uses_value_directly() -> None: + """Simple enum values do not recurse through the converter.""" + converter = BaseConverter() + converter.register_unstructure_hook(int, lambda _: "overridden") + + assert converter.unstructure(SimpleEnum.A) == 0 + + def test_unstructure_enum_with_enum_values() -> None: """Enum members whose values are themselves Enums are unstructured recursively. @@ -85,3 +111,15 @@ def test_unstructure_enum_with_enum_values() -> None: converter = BaseConverter() assert converter.unstructure(EnumValuedEnum.X) == 0 assert converter.unstructure(EnumValuedEnum.Y) == 1 + + +def test_unstructure_enum_with_tuple_values() -> None: + """Enum member tuples containing Enums are unstructured recursively.""" + converter = BaseConverter() + assert converter.unstructure(TupleValuedEnum.X) == (0, 1) + + +def test_unstructure_enum_with_attrs_values() -> None: + """Enum members whose values are attrs classes are unstructured recursively.""" + converter = BaseConverter() + assert converter.unstructure(AttrsValuedEnum.X) == {"a": 1}