From 2e0a35ce1f142960c77832d984dc6ca21e476152 Mon Sep 17 00:00:00 2001 From: Sohel2309 Date: Fri, 14 Aug 2026 19:43:07 +0530 Subject: [PATCH] fix: improve enum misuse error --- HISTORY.md | 2 ++ src/cattrs/enums.py | 29 ++++++++++++++++++++++-- tests/test_enums.py | 54 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 83 insertions(+), 2 deletions(-) diff --git a/HISTORY.md b/HISTORY.md index e7b91f71..f06f9d71 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -13,6 +13,8 @@ Our backwards-compatibility policy can be found [here](https://github.com/python ## NEXT (UNRELEASED) +- Fix unstructuring an enum-typed value that isn't actually an instance of that enum (for example, a raw value assigned directly to an attrs attribute) raising an opaque `AttributeError` instead of a clear, actionable `TypeError`. + ([#601](https://github.com/python-attrs/cattrs/issues/601)) - Fix `Counter` keys not being unstructured with the key type's own hook; the single-type-arg branch passed the whole type-args tuple to the key hook lookup instead of the key type. ([#768](https://github.com/python-attrs/cattrs/pull/768)) - Fix `create_default_dis_func ` (aka `create_uniq_field_dis_func`) failing to disambiguate valid unions depending on the order of the member classes; unique fields are now resolved iteratively to a fixpoint. diff --git a/src/cattrs/enums.py b/src/cattrs/enums.py index b1ab5040..6a7442a8 100644 --- a/src/cattrs/enums.py +++ b/src/cattrs/enums.py @@ -1,11 +1,23 @@ from collections.abc import Callable from enum import Enum from typing import TYPE_CHECKING, Any +from typing import Type as _Type if TYPE_CHECKING: from .converters import BaseConverter +def _enum_misuse_message(expected: type[Enum], got: Any) -> str: + return ( + f"Expected an instance of {expected!r} to unstructure, got " + f"{got!r} of type {got.__class__!r} instead. This usually means a " + f"raw value (e.g. {expected.__name__}.MEMBER.value) or some other " + f"non-enum value was assigned to an attribute or variable that is " + f"typed as this enum, instead of an actual {expected.__name__} " + f"member." + ) + + def enum_unstructure_factory( type: type[Enum], converter: "BaseConverter" ) -> Callable[[Enum], Any]: @@ -15,9 +27,22 @@ def enum_unstructure_factory( Otherwise, we use the value directly. """ if "_value_" in type.__annotations__: - return lambda e: converter.unstructure(e.value) - return lambda e: e.value + def unstructure_typed_enum( + e: Enum, _cl: _Type[Enum] = type, _converter: "BaseConverter" = converter + ) -> Any: + if not isinstance(e, _cl): + raise TypeError(_enum_misuse_message(_cl, e)) + return _converter.unstructure(e.value) + + return unstructure_typed_enum + + def unstructure_enum(e: Enum, _cl: _Type[Enum] = type) -> Any: + if not isinstance(e, _cl): + raise TypeError(_enum_misuse_message(_cl, e)) + return e.value + + return unstructure_enum def enum_structure_factory( diff --git a/tests/test_enums.py b/tests/test_enums.py index bdf591f1..f903f7cc 100644 --- a/tests/test_enums.py +++ b/tests/test_enums.py @@ -2,6 +2,7 @@ from enum import Enum +import attrs from hypothesis import given from hypothesis.strategies import data, sampled_from from pytest import raises @@ -68,3 +69,56 @@ 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 + + +def test_unstructure_enum_misuse_raises_clear_error() -> None: + """Regression test for #601. + + Unstructuring a value that isn't actually an instance of the expected + enum (e.g. because the enum's raw value was assigned directly to an + attribute typed as the enum, bypassing any validation) must raise a + clear, actionable ``TypeError`` instead of an opaque ``AttributeError`` + like ``'str' object has no attribute 'value'``. + """ + converter = BaseConverter() + + with raises(TypeError) as exc_info: + converter.unstructure("A", unstructure_as=SimpleEnum) + + msg = str(exc_info.value) + assert "SimpleEnum" in msg + assert "'A'" in msg + + +def test_unstructure_typed_enum_misuse_raises_clear_error() -> None: + """Regression test for #601, typed-enum branch (has `_value_`).""" + converter = BaseConverter() + + with raises(TypeError) as exc_info: + converter.unstructure("D", unstructure_as=SimpleEnumWithTypeHint) + + msg = str(exc_info.value) + assert "SimpleEnumWithTypeHint" in msg + assert "'D'" in msg + + +def test_unstructure_attrs_class_with_misused_enum_field() -> None: + """End-to-end regression test for #601, matching the original report. + + Assigning a plain string default (instead of an actual enum member) to + an attrs attribute typed as an ``Enum`` used to blow up with an + unhelpful ``AttributeError`` deep inside generated code. + """ + + @attrs.define + class Site: + flavor: SimpleEnumWithTypeHint = "D" # intentionally not an enum member + + converter = BaseConverter() + + with raises(TypeError) as exc_info: + converter.unstructure(Site()) + + msg = str(exc_info.value) + assert "SimpleEnumWithTypeHint" in msg + assert "'D'" in msg