Skip to content

Commit 2e0a35c

Browse files
committed
fix: improve enum misuse error
1 parent f2e42f3 commit 2e0a35c

3 files changed

Lines changed: 83 additions & 2 deletions

File tree

HISTORY.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ Our backwards-compatibility policy can be found [here](https://github.com/python
1313

1414
## NEXT (UNRELEASED)
1515

16+
- 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`.
17+
([#601](https://github.com/python-attrs/cattrs/issues/601))
1618
- 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.
1719
([#768](https://github.com/python-attrs/cattrs/pull/768))
1820
- Fix `create_default_dis_func <cattrs.disambiguators.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.

src/cattrs/enums.py

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,23 @@
11
from collections.abc import Callable
22
from enum import Enum
33
from typing import TYPE_CHECKING, Any
4+
from typing import Type as _Type
45

56
if TYPE_CHECKING:
67
from .converters import BaseConverter
78

89

10+
def _enum_misuse_message(expected: type[Enum], got: Any) -> str:
11+
return (
12+
f"Expected an instance of {expected!r} to unstructure, got "
13+
f"{got!r} of type {got.__class__!r} instead. This usually means a "
14+
f"raw value (e.g. {expected.__name__}.MEMBER.value) or some other "
15+
f"non-enum value was assigned to an attribute or variable that is "
16+
f"typed as this enum, instead of an actual {expected.__name__} "
17+
f"member."
18+
)
19+
20+
921
def enum_unstructure_factory(
1022
type: type[Enum], converter: "BaseConverter"
1123
) -> Callable[[Enum], Any]:
@@ -15,9 +27,22 @@ def enum_unstructure_factory(
1527
Otherwise, we use the value directly.
1628
"""
1729
if "_value_" in type.__annotations__:
18-
return lambda e: converter.unstructure(e.value)
1930

20-
return lambda e: e.value
31+
def unstructure_typed_enum(
32+
e: Enum, _cl: _Type[Enum] = type, _converter: "BaseConverter" = converter
33+
) -> Any:
34+
if not isinstance(e, _cl):
35+
raise TypeError(_enum_misuse_message(_cl, e))
36+
return _converter.unstructure(e.value)
37+
38+
return unstructure_typed_enum
39+
40+
def unstructure_enum(e: Enum, _cl: _Type[Enum] = type) -> Any:
41+
if not isinstance(e, _cl):
42+
raise TypeError(_enum_misuse_message(_cl, e))
43+
return e.value
44+
45+
return unstructure_enum
2146

2247

2348
def enum_structure_factory(

tests/test_enums.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from enum import Enum
44

5+
import attrs
56
from hypothesis import given
67
from hypothesis.strategies import data, sampled_from
78
from pytest import raises
@@ -68,3 +69,56 @@ def test_structure_complex_enum() -> None:
6869
assert converter.structure(0, SimpleEnum) == SimpleEnum.A
6970
assert converter.structure("E", SimpleEnumWithTypeHint) == SimpleEnumWithTypeHint.E
7071
assert converter.structure((0, "D"), ComplexEnum) == ComplexEnum.AD
72+
73+
74+
def test_unstructure_enum_misuse_raises_clear_error() -> None:
75+
"""Regression test for #601.
76+
77+
Unstructuring a value that isn't actually an instance of the expected
78+
enum (e.g. because the enum's raw value was assigned directly to an
79+
attribute typed as the enum, bypassing any validation) must raise a
80+
clear, actionable ``TypeError`` instead of an opaque ``AttributeError``
81+
like ``'str' object has no attribute 'value'``.
82+
"""
83+
converter = BaseConverter()
84+
85+
with raises(TypeError) as exc_info:
86+
converter.unstructure("A", unstructure_as=SimpleEnum)
87+
88+
msg = str(exc_info.value)
89+
assert "SimpleEnum" in msg
90+
assert "'A'" in msg
91+
92+
93+
def test_unstructure_typed_enum_misuse_raises_clear_error() -> None:
94+
"""Regression test for #601, typed-enum branch (has `_value_`)."""
95+
converter = BaseConverter()
96+
97+
with raises(TypeError) as exc_info:
98+
converter.unstructure("D", unstructure_as=SimpleEnumWithTypeHint)
99+
100+
msg = str(exc_info.value)
101+
assert "SimpleEnumWithTypeHint" in msg
102+
assert "'D'" in msg
103+
104+
105+
def test_unstructure_attrs_class_with_misused_enum_field() -> None:
106+
"""End-to-end regression test for #601, matching the original report.
107+
108+
Assigning a plain string default (instead of an actual enum member) to
109+
an attrs attribute typed as an ``Enum`` used to blow up with an
110+
unhelpful ``AttributeError`` deep inside generated code.
111+
"""
112+
113+
@attrs.define
114+
class Site:
115+
flavor: SimpleEnumWithTypeHint = "D" # intentionally not an enum member
116+
117+
converter = BaseConverter()
118+
119+
with raises(TypeError) as exc_info:
120+
converter.unstructure(Site())
121+
122+
msg = str(exc_info.value)
123+
assert "SimpleEnumWithTypeHint" in msg
124+
assert "'D'" in msg

0 commit comments

Comments
 (0)