pyo3-gated
Write your Rust types once. Use them natively in Rust and expose them to Python via PyO3. No duplicate definitions.
Quick start
# Cargo.toml
[]
= "^0.1"
= { = "0.28", = true }
= { = "0.6", = true }
[]
= []
= ["dep:pyo3", "dep:pyo3-stub-gen"]
= ["python", "pyo3/extension-module", "pyo3/generate-import-lib"]
use ;
// When a `#[py_compat_fn]` returns a `Result<T, E>`, the error type `E` must be
// convertible to a Python error when building with the Python feature (i.e. it
// must implement a conversion into `PyErr`). For convenience and to keep the
// function usable from both Rust and Python without extra boilerplate, the
// recommended approach is to return `anyhow::Result<T>` and enable pyo3's
// `anyhow` feature in your `Cargo.toml` so `anyhow::Error` converts to `PyErr`.
cargo build compiles plain Rust. cargo build --features python generates a fully annotated PyO3 extension. That's it.
Why this crate exists
When a type lives in both your Rust API and your Python bindings, the usual approach starts small and gets painful fast:
- A plain Rust
structorenumfor your internal code. - A separate
#[pyclass]version for Python. - Duplicate
implblocks, or heavycfg(feature = "python")branching. - Constant drift between the Rust-native and Python-exposed versions.
That duplication has real costs:
- Fields get added in one place and forgotten in the other.
- Methods diverge.
- Python-only items like
#[new]or__repr__leak into code that should stay Rust-only. - Your non-Python builds still have to tiptoe around PyO3-specific code paths.
pyo3-gated fixes that by generating two cfg-gated views from one definition:
- A Python build with
#[pyclass]/#[pymethods]. - A plain Rust build with PyO3 attributes stripped out completely.
The result is simple: write your type once, keep your logic in one place, and compile the right shape for the target you're building.
What you get
Single-source definitions
Use one struct, enum, or impl block instead of parallel Rust and PyO3 versions.
Zero PyO3 dependency in plain builds
When your Python feature is off, the expanded plain version removes PyO3 attributes so the crate can compile without PyO3.
Python-only items where they belong
Mark constructors, protocol methods, and similar items with #[py_only] so they exist only in Python-enabled builds.
Shared methods without annotation noise
Use #[py_attrs] for items that should exist in both builds, but keep Python-specific attributes only in the Python version.
Automatic .pyi registration
When enabled, the macros emit the matching pyo3-stub-gen derive automatically, including the correct enum variant for simple vs. complex enums.
Feature-driven integration
The crate is designed around one obvious feature flag, so Rust-only consumers and Python-extension builds can coexist cleanly.
Before and after
Before: duplicated types
This works, but it does not scale. Every change has to be made twice, and every impl block becomes a maintenance trap.
After: one definition
use ;
Same type. Same logic. One place to evolve.
How it works
pyo3-gated provides four attribute macros:
| Macro | Applies to |
|---|---|
[py_compat_struct] |
struct definitions |
[py_compat_enum] |
enum definitions (simple and complex) |
[py_compat_methods] |
impl blocks |
[py_compat_fn] |
free function definitions |
py_compat_fn exposes a free function to Python via PyO3 while emitting
an identical plain-Rust version with PyO3 attributes stripped for non-Python
builds. This keeps a single implementation that is callable from Rust and,
when the Python feature is enabled, registered as a #[pyfunction].
Example:
// With a PyO3 signature (kept in Python build, stripped in plain Rust):
Error handling for py_compat_fn
Cargo.toml snippet:
[]
= { = "0.28", = true, = ["anyhow"] }
= "1.0"
[]
= ["dep:pyo3", "dep:pyo3-stub-gen"]
Example using anyhow::Result:
This pattern keeps the Rust API ergonomic (returning anyhow::Result in
Rust-only builds) while letting PyO3 raise a suitable PyErr in Python
builds.
Inside #[py_compat_methods], you can use two sentinels:
| Attribute | Meaning |
|---|---|
#[py_only] |
Item exists only in Python builds |
#[py_attrs] |
Item exists in both builds, but its attributes are stripped in plain builds |
This gives you a nice split:
- Shared Rust/Python business logic stays shared.
- Python protocol glue stays Python-only.
- Getter/setter-style annotations can stay attached to the same method without polluting Rust-only builds.
Feature model
By default, the crate assumes a feature named python.
That means a typical setup looks like this:
[]
= []
= ["dep:pyo3", "dep:pyo3-stub-gen"]
= ["python", "pyo3/extension-module", "pyo3/generate-import-lib"]
= ["python"]
[]
= { = "0.28.0", = true }
= { = "0.6", = true }
[[]]
= "stub_gen"
= "src/bin/stub_gen.rs"
= ["stub-gen"]
This layout keeps the crate ergonomic:
cargo buildstays Rust-only.cargo build --features pythonenables PyO3 bindings and stub registration.cargo build --features python-extensionis a natural extension-module build.stub-genexists only to gate the stub binary, not your core library.
Macro arguments
All three macros accept the same optional arguments:
| Argument | Values | Default | Purpose |
|---|---|---|---|
feature |
"feature-name" |
"python" |
Which Cargo feature enables the Python build |
stub_gen |
false, true, or "feature-name" |
false |
Controls automatic stub-registration derive emission |
pyclass_args |
token tree | none | Forwarded into #[pyclass(...)] |
Custom Python feature
Disable stub generation for one type
Forward #[pyclass(...)] options
This keeps the macro lightweight: it handles the cfg split, while you still retain control over PyO3 class configuration.
Stub generation
When stub generation is enabled, pyo3-gated emits the matching pyo3-stub-gen derive for you automatically.
You do not need to manually pick the enum stub kind:
struct→gen_stub_pyclass- simple enum →
gen_stub_pyclass_enum - complex enum →
gen_stub_pyclass_complex_enum - methods →
gen_stub_pymethods
You still need to define the stub info gatherer once in your library crate:
// lib.rs
define_stub_info_gatherer!;
And then your stub-generation binary can gather and write the .pyi output in the usual way for your project.
A realistic pattern
A good mental model is:
- Use normal Rust types and methods for your real domain logic.
- Add PyO3 field or method attributes where Python exposure matters.
- Mark Python-only glue with
#[py_only]. - Let
pyo3-gatedgenerate the split for you.
For example:
use ;
That gives you:
- A clean Rust type in normal builds.
- A Python class in Python-enabled builds.
- No duplicated methods.
- No parallel maintenance burden.
When this crate is a good fit
pyo3-gated is especially useful when:
- Your crate is primarily a Rust library, but optionally exposes Python bindings.
- You publish both a Rust API and a Python extension from the same codebase.
- You want to keep domain types free of hand-written cfg duplication.
- You use
pyo3-stub-genand want stub registration to happen automatically. - You care about keeping non-Python builds lean and easy to compile.
It is less useful if your Python layer is intentionally a completely separate API surface from your Rust one. In that case, explicit wrapper types may still be the better design.
Installation
[]
= "0.1"
For optional Python bindings:
[]
= { = "0.28", = true }
= { = "0.6", = true }
[]
= []
= ["dep:pyo3", "dep:pyo3-stub-gen"]
Design goals
This crate aims to be:
- Minimal — it should remove boilerplate, not impose a framework.
- Predictable — the expanded code should match what you would have written by hand.
- Rust-first — your plain build should remain a first-class path.
- PyO3-friendly — Python-specific annotations should still feel native where they belong.
The crate is best thought of as a code-generation convenience layer, not a replacement for understanding PyO3 itself.