Skip to content

fix: unstructure enum values recursively through the converter#760

Open
gaoflow wants to merge 2 commits into
python-attrs:mainfrom
gaoflow:fix-679-enum-value-enum-unstructure
Open

fix: unstructure enum values recursively through the converter#760
gaoflow wants to merge 2 commits into
python-attrs:mainfrom
gaoflow:fix-679-enum-value-enum-unstructure

Conversation

@gaoflow

@gaoflow gaoflow commented Jun 24, 2026

Copy link
Copy Markdown

Summary

Fixes #679.

When an enum's members have other enums as values (no _value_ annotation),
enum_unstructure_factory returned e.value directly, bypassing the converter.
This meant the inner enum was never unstructured—it was returned as a raw Enum
object, which causes json.dumps (and other serializers) to fail.

Before:

class Color(Enum):
    RED = 1

class Style(Enum):
    NORMAL = Color.RED

c = make_converter()          # json converter
c.unstructure(Style.NORMAL)   # → Color.RED  ← raw Enum, not JSON-serializable

After:

c.unstructure(Style.NORMAL)   # → 1  ✓

Root cause

enum_unstructure_factory had two branches:

  • typed enum (_value_ annotation): called converter.unstructure(e.value) — correct
  • untyped enum: returned lambda e: e.value — skipped the converter entirely

The distinction is unnecessary for unstructuring. The fix collapses both branches to
always call converter.unstructure(e.value).

Primitive enum values (int, str) pass through the converter unchanged, so existing
behavior is preserved.

Test plan

  • Existing enum tests (tests/test_enums.py) all pass
  • New regression test test_unstructure_enum_with_enum_values added and passing
  • All unstructure and disambiguator tests pass (40 total)

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 python-attrs#679.
@codspeed-hq

codspeed-hq Bot commented Jun 25, 2026

Copy link
Copy Markdown

Merging this PR will degrade performance by 14.57%

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

❌ 8 regressed benchmarks
✅ 56 untouched benchmarks

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Benchmark BASE HEAD Efficiency
test_unstructure_attrs_lists[UnstructureStrategy.AS_DICT-Converter] 86.8 µs 107.3 µs -19.1%
test_unstructure_simple_enum[Converter] 15.6 µs 18.9 µs -17.68%
test_unstructure_simple_int_enum[MsgspecJsonConverter] 15.8 µs 18.5 µs -14.75%
test_unstructure_simple_int_enum[OrjsonConverter] 15.8 µs 18.5 µs -14.6%
test_unstructure_simple_enum[MsgspecJsonConverter] 15.6 µs 18.1 µs -14.14%
test_unstructure_simple_enum[OrjsonConverter] 15.6 µs 18 µs -13.26%
test_unstructure_attrs_primitives[UnstructureStrategy.AS_DICT-Converter] 41.2 µs 47 µs -12.35%
test_unstructure_simple_int_enum[Converter] 16 µs 17.9 µs -10.36%

Tip

Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.


Comparing gaoflow:fix-679-enum-value-enum-unstructure (1bdcc47) with main (3d07082)1

Open in CodSpeed

Footnotes

  1. No successful run was found on main (f47bbe0) during the generation of this report, so 3d07082 was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

@Tinche

Tinche commented Jun 25, 2026

Copy link
Copy Markdown
Member

That's what I was afraid of - by always calling unstructure, we tank the very common hot path for simpler enums. I think the factory should be smarter, and default to unstructure only under certain cases.

@gaoflow

gaoflow commented Jun 25, 2026

Copy link
Copy Markdown
Author

Thanks, agreed. I updated the factory so simple enum values keep the direct e.value hook, and only typed enums or enum values that visibly need recursive unstructuring use converter.unstructure(e.value).

The recursive path now covers the cases from #679: enum-valued members, tuples/collections/mappings containing enums, and attrs/dataclass values. I also added a test that registers an overriding int unstructure hook and verifies a simple enum still returns the raw value directly.

Verification run locally:

uv run --group test python -m pytest tests/test_enums.py -q
uv run --group lint ruff check src/cattrs/enums.py tests/test_enums.py
uv run --group lint ruff format --check src/cattrs/enums.py tests/test_enums.py
git diff --check
uv run --group test --all-extras python -m pytest tests/test_preconf.py -q
uv run --group test --all-extras python -m pytest bench/test_enums.py --benchmark-disable -q

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

An enum used as another enum's value is not unstructured by the json converter

2 participants