pyenum
Expose Rust enums to Python as real enum.Enum subclasses — via PyO3.
pyenum provides a #[derive(PyEnum)] macro that turns a Rust enum into a
genuine Python enum class. The resulting type passes isinstance(x, enum.Enum),
iterates in declaration order, and works wherever the standard enum.Enum
protocol is expected — with zero hand-written conversion code.
Why
PyO3's #[pyclass] gives you a Python class, but not a Python enum.Enum.
Any downstream code that branches on isinstance(x, enum.Enum) — match/
case patterns, framework validators, ORM enum columns — rejects the result.
The common workaround is hand-written FromPyObject / IntoPyObject shims
plus a mirror class on the Python side.
pyenum eliminates that boilerplate:
- The derive generates the PyO3 conversion traits automatically
(
IntoPyObject<'py>forTand&T, plusFromPyObject<'a, 'py>). - The Python class is constructed once per interpreter via a cached
pyo3::sync::PyOnceLock, so the boundary cost is negligible after the first call. - Ill-formed Rust input (field-carrying variants, generics, base/value mismatches) is rejected at compile time with a variant-level diagnostic.
Features
- Full
enum.Enumprotocol: iteration order, name/value lookup, hashing, equality, and base-specific operations (bitwise ops onFlag/IntFlag,int/strmixins forIntEnum/StrEnum). - Supports all five standard Python enum bases —
Enum(default),IntEnum,StrEnum,Flag,IntFlag— selectable via a derive attribute argument. - Automatic bidirectional conversion for
#[pyfunction],#[pymethods], and#[pyclass]field signatures. - Per-interpreter class cache: constructed once, shared by identity across all call sites.
- Compile-time validation via
trybuild-style negative tests.
Quick start
Add the crate to your PyO3 extension (the crates.io badge above shows the current version):
pyenum pins PyO3 to 0.28 — see Compatibility for the
rationale.
Declare a Rust enum and derive PyEnum:
use ;
use *;
On the Python side the exposed class behaves exactly like a native enum.Enum:
assert
assert ==
assert is
assert is
Targeting a different base
Every standard Python enum base is one attribute argument away:
assert == 200 and
assert + ==
assert | in
StrEnum values
StrEnum variants without an explicit #[pyenum(value = "...")] defer to
Python's enum.auto(), which — per the StrEnum contract introduced in
Python 3.11 — lowercases the variant name:
Attach an explicit value whenever you need to preserve case or pick a
label that differs from the variant identifier.
Compatibility
| Surface | Supported |
|---|---|
| PyO3 | 0.28 only |
| Python | 3.10, 3.11, 3.12, 3.13, 3.14 (CPython; abi3-py310 limited API; StrEnum requires 3.11+) |
| Rust | stable, edition 2024, MSRV 1.94 |
| Platforms | Linux (x86_64 / aarch64), macOS (x86_64 / arm64), Windows (x64) |
Why PyO3 0.28 only
Cargo's pyo3-ffi links = "python" rule forbids two pyo3 versions
coexisting in the same dependency graph, so a pyo3-0_2X feature matrix
cannot actually be built. pyenum therefore tracks a single PyO3 minor
line and will bump in lockstep with upstream.
Python 3.10 and StrEnum
enum.StrEnum landed in Python 3.11. On a 3.10 interpreter, using
#[pyenum(base = "StrEnum")] raises RuntimeError the first time the
class is constructed — every other base (Enum, IntEnum, Flag,
IntFlag) works normally. pyenum does not polyfill StrEnum, because
mixing str into enum.Enum changes the runtime base class and breaks
the isinstance(x, StrEnum) guarantee. Keep a 3.11+ floor if you need
StrEnum.
Compile-time rejections
The derive will not let an invalid Rust enum reach the Python boundary. Each of these fails the build with a variant-level diagnostic:
- Tuple-struct or struct variants (
Variant(u8),Variant { x: u8 }) - Generics or lifetime parameters
- Zero-variant enums
- Base/value mismatches: integer discriminants on
StrEnum, string#[pyenum(value = "...")]onIntEnum/Flag/IntFlag - Both a Rust discriminant and
#[pyenum(value = "...")]on the same variant - Name collisions with Python dunder names or
enum-reserved members - Duplicate Python values across variants — including
StrEnumauto collisions where two Rust variant names lowercase to the same string. The library refuses to create Python-side aliases because they would break Rust-side round-trip identity
Every case is covered by a trybuild snapshot test.
Development
Prerequisites: Rust stable (edition 2024, MSRV 1.94), Python 3.10+
(3.11+ if you need StrEnum), uv,
maturin.
# Rust checks
# Python integration — conftest.py rebuilds the pyenum-test cdylib
# on every pytest run via `maturin develop`, so no manual build step.
CI runs, on every PR: cargo fmt/clippy/test, the trybuild suite,
and the Python integration tests against the supported Python versions
on Linux / macOS / Windows.
License
MIT