Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions HISTORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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.
Expand Down
29 changes: 27 additions & 2 deletions src/cattrs/enums.py
Original file line number Diff line number Diff line change
@@ -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]:
Expand All @@ -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(
Expand Down
54 changes: 54 additions & 0 deletions tests/test_enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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