# pyenum
[](https://crates.io/crates/pyenum)
[](https://github.com/wakita181009/pyenum/actions/workflows/test.yml)
[](https://codecov.io/gh/wakita181009/pyenum)
[](LICENSE)
**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>` for `T` and `&T`, plus `FromPyObject<'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.Enum` protocol: iteration order, name/value lookup, hashing,
equality, and base-specific operations (bitwise ops on `Flag` / `IntFlag`,
`int` / `str` mixins for `IntEnum` / `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](https://crates.io/crates/pyenum)
above shows the current version):
```bash
cargo add pyo3 --features extension-module,abi3-py311
cargo add pyenum
```
`pyenum` pins PyO3 to **0.28** — see [Compatibility](#compatibility) for the
rationale.
Declare a Rust enum and derive `PyEnum`:
```rust
use pyenum::{PyEnum, PyModuleExt};
use pyo3::prelude::*;
#[derive(Clone, Copy, Debug, PartialEq, Eq, PyEnum)]
pub enum Color {
Red,
Green,
Blue,
}
#[pyfunction]
fn invert(c: Color) -> Color {
match c {
Color::Red => Color::Green,
Color::Green => Color::Blue,
Color::Blue => Color::Red,
}
}
#[pymodule]
fn my_ext(m: &Bound<'_, PyModule>) -> PyResult<()> {
// `add_enum::<T>()` comes from `PyModuleExt`. It registers `T` as a real
// Python `enum.Enum` subclass under `T`'s Rust identifier.
m.add_enum::<Color>()?;
m.add_function(wrap_pyfunction!(invert, m)?)?;
Ok(())
}
```
On the Python side the exposed class behaves exactly like a native `enum.Enum`:
```python
from enum import Enum
from my_ext import Color, invert
assert issubclass(Color, Enum)
assert list(Color) == [Color.RED, Color.GREEN, Color.BLUE]
assert Color["RED"] is Color.RED
assert invert(Color.RED) is Color.GREEN
```
### Targeting a different base
Every standard Python enum base is one attribute argument away:
```rust
#[derive(Clone, Copy, PyEnum)]
#[pyenum(base = "IntEnum")]
pub enum Status {
Ok = 200,
NotFound = 404,
ServerError = 500,
}
#[derive(Clone, Copy, PyEnum)]
#[pyenum(base = "StrEnum")]
pub enum Role {
#[pyenum(value = "admin")]
Admin,
#[pyenum(value = "user")]
User,
}
#[derive(Clone, Copy, PyEnum)]
#[pyenum(base = "IntFlag")]
pub enum Permission {
Read = 0b001,
Write = 0b010,
Exec = 0b100,
}
```
```python
assert Status.OK == 200 and isinstance(Status.OK, int)
assert Role.ADMIN + "/panel" == "admin/panel"
### `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**:
```rust
#[derive(Clone, Copy, PyEnum)]
#[pyenum(base = "StrEnum")]
pub enum Greeting {
Hello, // Greeting.HELLO.value == "hello"
#[pyenum(value = "Bye")]
Bye, // Greeting.BYE.value == "Bye"
}
```
Attach an explicit `value` whenever you need to preserve case or pick a
label that differs from the variant identifier.
---
## Compatibility
| **PyO3** | 0.28 only |
| **Python** | 3.11, 3.12, 3.13 (CPython; `abi3-py311` limited API) |
| **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.
### Why Python 3.11+
`enum.StrEnum` landed in Python 3.11. Polyfilling it on 3.10 means mixing
`str` into `enum.Enum`, which changes the runtime base class and breaks
the "pass `isinstance(x, StrEnum)`" guarantee. We chose the strict floor.
---
## 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 = "...")]` on `IntEnum` / `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 `StrEnum` auto
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.11+,
[`uv`](https://github.com/astral-sh/uv), [`maturin`](https://github.com/PyO3/maturin).
```bash
# Rust checks
cargo fmt --all
cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace
cargo test -p pyenum-derive --test trybuild
# Python integration — conftest.py rebuilds the pyenum-test cdylib
# on every pytest run via `maturin develop`, so no manual build step.
cd python
uv venv --python 3.11
uv pip install -e ".[test]" maturin
uv run pytest -q
```
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