Generate Python .pyi stub files from PyO3-annotated Rust source code — statically, without compilation.
If you're writing a Python extension in Rust with PyO3, Rylai reads your #[pymodule], #[pyfunction], and #[pyclass] annotations and produces type stubs that IDEs and type checkers (mypy, Pyright, ty, etc.) can understand — so your Rust-backed module gets proper autocomplete, type hints, and inline documentation in Python.
Write Rust with PyO3 annotations:
#[pymodule]
mod my_crate {
use pyo3::prelude::*;
/// Formats the sum of two numbers as string.
#[pyfunction]
fn sum_as_string(a: usize, b: usize) -> PyResult<String> {
Ok((a + b).to_string())
}
#[pyclass]
pub struct Counter { value: u64 }
#[pymethods]
impl Counter {
#[new]
fn new() -> Self { Self { value: 0 } }
}
}Run rylai:
rylai path/to/my_crateGet a .pyi stub:
import typing as t
__all__ = ["sum_as_string", "Counter"]
def sum_as_string(a: int, b: int) -> str:
"""Formats the sum of two numbers as string."""
@t.final
class Counter:
def __init__(self) -> None: ...Rust types are mapped to Python types automatically — usize → int, PyResult<String> → str, and so on. Doc comments become Python docstrings.
# 1. Run on your PyO3 crate (no install needed if you have uv)
uvx rylai path/to/my_crate
# Or install first:
# cargo install rylai
# rylai path/to/my_crate
# 2. Stubs are written next to Cargo.toml by default.
# Use --output to put them somewhere else:
uvx rylai path/to/my_crate --output path/to/stubs/No configuration required. Point it at a crate and it works.
To try it with the bundled example:
git clone https://github.com/monchin/Rylai.git
cd Rylai
cargo run -- examples/basic_function_sample --output examples/basic_function_sample/python/basic_function_sampleCompared with other tools that generate .pyi stubs for PyO3 projects, Rylai offers:
- No compilation — Rylai parses Rust source code directly (via syn). You don't need to build the crate or depend on compiled artifacts, so stub generation is fast and works even when the project doesn't compile (e.g. missing native deps or wrong toolchain).
- No code changes — No need to add build scripts,
#[cfg]blocks, or extra annotations to your Rust code. Point Rylai at your crate root and it reads existing#[pymodule]/#[pyfunction]/#[pyclass]andcreate_exception!macros as-is. - No Python version lock-in — Stubs are plain text. You generate them once and use them with any Python version; there's no dependency on a specific Python interpreter or ABI, so you avoid "built for Python 3.x" issues and cross-version workflows stay simple.
Together, this makes Rylai easy to integrate into CI, docs, or local dev without touching your PyO3 code or your Python environment.
Core:
- Parses
#[pymodule],#[pyfunction], and#[pyclass]annotations directly from Rust source - Maps Rust types to Python types automatically (
i32→int,Vec<T>→list[T],Option<T>→T | None, etc.) - Extracts doc comments and emits them as Python docstrings
- Zero-config by default; optionally configured via
rylai.toml
Advanced:
- Python-version-aware output (
T | Nonefor ≥ 3.10,t.Optional[T]for older; PEP 585 built-in generics for ≥ 3.9;t.Selffor ≥ 3.11) create_exception!/pyo3::create_exception!support — emits matchingclass Name(Base): ...stubs- Generates
__all__in every stub; configurable globally and per file - Multi-module stubs via
#[pyclass(module = "...")]with automatic cross-module imports [[override]]to replace or tweak specific generated signatures[[add_content]]to inject custom Python into generated.pyifiles[[macro_expand]]to expandmacro_rules!invocations before parsing- Custom Rust → Python type mapping via
[type_map] - Post-generation formatting via
formatcommands (e.g. ruff)
Choose one of the following:
| Method | Command | Notes |
|---|---|---|
| uvx | uvx rylai |
Run without installing. Recommended if you have uv |
| uv | uv tool install rylai |
Install to uv tools dir; requires uv |
| Cargo | cargo install rylai |
Build from source and install to ~/.cargo/bin |
| crgx | crgx rylai |
Run pre-built binary without compiling; requires crgx |
For local development:
cargo install --path .The path you pass is the project root — the folder that contains Cargo.toml (and usually a src/ directory). Rylai scans all .rs files under that project's src/ and uses the root for rylai.toml, pyproject.toml, etc.
# Run in the current directory (must be the project root with Cargo.toml)
rylai
# Specify the project root explicitly
rylai path/to/my_crate
# Write stubs to a custom output directory
rylai path/to/my_crate --output path/to/out/
# Use a custom config file
rylai --config path/to/rylai.tomlThe examples/ directory contains several self-contained sample projects, each demonstrating different Rylai features:
| Example | What it demonstrates | Try it |
|---|---|---|
basic_function_sample |
#[pyfunction], #[pyo3(name = "...")] rename, #[pyclass], create_exception! |
cargo run -- examples/basic_function_sample --output examples/basic_function_sample/python/basic_function_sample |
cross_module_sample |
#[pyclass(module = "...")] with cross-module imports, [[add_content]] |
cargo run -- examples/cross_module_sample --output examples/cross_module_sample/python/cross_module_sample |
override_sample |
[[override]] via stub and via param_types / return_type, [[add_content]] |
cargo run -- examples/override_sample --output examples/override_sample/python/override_sample |
add_content_sample |
[[add_content]] with tail location and location = "file" to create standalone .pyi files |
cargo run -- examples/add_content_sample --output examples/add_content_sample/python/add_content_sample |
macro_expand_sample |
[[macro_expand]] auto-discover and explicit modes |
cargo run -- examples/macro_expand_sample --output examples/macro_expand_sample/python/macro_expand_sample |
Install just, then run:
just gen-pyi-examplesThis regenerates .pyi files for every example. CI checks that the committed stubs match the generated output; if they differ, CI fails.
You don't need to install the binary. Use cargo run and pass arguments after --:
cargo run -- examples/basic_function_sample --output examples/basic_function_sample/python/basic_function_sample
cargo run -- --helpAnything after -- is forwarded to the rylai binary.
When you use PyO3's module attribute on a class, Rylai emits multiple .pyi files instead of a single flat stub. Here's how it works:
Given this Rust code:
#[pymodule]
mod my_pkg {
#[pyclass(module = "my_pkg.sub_a")]
pub struct ClassA { ... }
#[pyclass(module = "my_pkg.sub_b")]
pub struct ClassB { ... }
#[pyfunction]
fn hello() -> String { ... }
}Rylai generates this file structure (with -o stubs/):
stubs/
├── my_pkg.pyi # hello() + imports for sub-modules
├── sub_a.pyi # ClassA
└── sub_b.pyi # ClassB
-o is treated as the first segment of the module path (the top-level #[pymodule] name). So for -o stubs/ and pymodule my_pkg, submodule my_pkg.sub_a is written to stubs/sub_a.pyi, not stubs/my_pkg/sub_a.pyi.
When a stub references a type from another submodule (e.g. a method in ClassA returns ClassB), Rylai automatically adds the correct import:
# stubs/sub_a.pyi
from my_pkg.sub_b import ClassBAdvanced: -o layouts and edge cases
Pointing -o at a package directory. You can point -o either at a parent directory (stubs/ → stubs/my_pkg.pyi, stubs/sub_a.pyi) or directly at the package directory (-o python/my_pkg → python/my_pkg/my_pkg.pyi, python/my_pkg/sub_a.pyi). If two paths resolve to the same output file, Rylai reports a duplicate-path error; use a parent directory instead.
Pymodule name differs from public package. When #[pyclass(module = "...")] does not start with {pymodule}. (e.g. pymodule _pkg but module = "pkg.abc"), Rylai emits those classes by dropping the first dotted segment and mirroring the rest under -o — e.g. stubs/abc.pyi. #[pyfunction] and classes without module stay in the pymodule stub (e.g. stubs/_pkg.pyi).
Classes in the root stub. #[pyfunction] and m.add symbols are emitted into the top-level #[pymodule] stub (my_pkg.pyi), together with any #[pyclass] that has no module = "..." attribute. Only classes with an explicit #[pyclass(module = "...")] are written to submodule stubs.
Merging into root. If #[pyclass(module = "pkg.pkg")] resolves to the same file as the root stub (pkg.pyi), those classes are merged into pkg.pyi. If everything is routed to submodules so the root stub would be empty, Rylai still writes it (possibly empty, but carrying the pymodule docstring when present).
Rylai works out of the box with no configuration. All options below are optional — only set them when you need to customize behavior.
Configure via rylai.toml (crate root) or [tool.rylai] in pyproject.toml. When both exist, duplicate keys in rylai.toml take precedence; array tables ([[override]], [[add_content]], [[all]], [[macro_expand]]) are replaced as a whole, not merged item-by-item.
Root-level keys (e.g. format) should appear before any [section] or [[array]] to avoid being parsed as part of a table.
Run commands on generated .pyi files. Generated file paths are appended to each command.
format = ["ruff format", "ruff check --select I --fix"]Each command must be executable (on PATH or use a full path). Empty entries are ignored. You may need "uvx ruff" or "uv run ruff" instead of "ruff" depending on your setup.
See any examples/*/rylai.toml for a working example.
| Key | Default | Description |
|---|---|---|
python_version |
"3.10" |
Target Python version. Affects t.Optional[T] vs T | None (3.10+), PEP 585 built-in generics (3.9+), t.Self vs class name (3.11+) |
add_header |
true |
Prepend # Auto-generated by rylai… banner |
all_include_private |
false |
Include _-prefixed names in __all__. Overridable per file via [[all]] |
Override how specific Rust types map to Python types. Keys must be Rust path types — a single identifier (e.g. PyBbox) or a qualified path (e.g. crate::types::MyHandle).
[type_map]
"numpy::PyReadonlyArray1" = "numpy.ndarray"
"PyColor" = "Color"Limitations:
- Anonymous tuple types (e.g.
(u8, u8, u8, u8)) cannot be keys. Define atypealias in Rust, use it in signatures, then map the alias name. - A Rust
typealias listed here is preserved during expansion (not resolved), so nested uses likeVec<ThatAlias>still map correctly. - Two keys sharing the same last path segment but different Python types cause a warning; the short-name lookup is skipped.
What to emit when a type cannot be resolved statically:
| Strategy | Behavior |
|---|---|
"any" |
Emit t.Any and print a warning (default) |
"error" |
Abort with an error |
"skip" |
Silently omit the item |
When Rylai can't produce the right signature from static analysis alone, you can override specific items.
Full override with stub:
[[override]]
item = "my_module::complex_function"
stub = "def complex_function(x: t.Any, **kwargs: t.Any) -> dict[str, t.Any]:"Partial override with param_types / return_type (mutually exclusive with stub):
Rylai still builds def ... from Rust and #[pyo3(signature)]; you only override the specified parts.
[[override]]
item = "my_module::f"
param_types = { "**kwargs" = "Unpack[KwargsItems]" }
return_type = "dict[str, t.Any]"item path format:
- Module-level:
{module}::{function} - Class method:
{module}::{class}::{method}
{module} is the logical Python module of the stub file — the top-level #[pymodule] name for the root .pyi, or the full #[pyclass(module = "...")] string for submodule stubs (e.g. pkg.abc::MyClass::method). class may be the Rust struct name or #[pyclass(name = "...")]. method is the Rust fn ident or #[pyo3(name = "...")]. For #[new], use ...::__init__ as the method segment.
See examples/override_sample/ for a working example.
Add extra Python code (type aliases, imports, version branches) into generated .pyi files. file is relative to -o, use /.
location |
Behavior |
|---|---|
head |
After the auto-generated banner (or file start if add_header = false) |
after-import-typing |
After the import typing as t line |
tail |
End of file |
file |
Write content as the complete file — no banner or imports added. Only one entry per file; can create standalone .pyi files |
[[add_content]]
file = "my_package/sub.pyi"
location = "after-import-typing"
content = """
from my_package._internal import KwargsItems
"""For head, after-import-typing, and tail: file must match a .pyi path produced in that run. If content doesn't end with a newline, Rylai appends one.
See examples/add_content_sample/ (tail + file), examples/override_sample/ (after-import-typing), and examples/cross_module_sample/ for working examples.
Customize which names appear in __all__ for specific files. Paths relative to -o, same convention as [[add_content]].
[[all]]
file = "my_package.pyi"
include_private = true
include = ["_special_export"]
exclude = ["InternalHelper"]Priority (highest first):
- Per-file
exclude— always removed from__all__ - Per-file
include— force-included even if_-prefixed (only for symbols Rylai actually generated) - Per-file
include_private— overrides global setting - Global
output.all_include_private - Default (
false):_-prefixed names excluded
Names added via [[add_content]] are not automatically included in __all__. Multiple entries for the same file are merged.
If your project wraps PyO3 registration calls in custom macros, Rylai can expand them before parsing so wrapped add_class / add_function calls are collected.
Mode A — explicit pattern/transcription:
[[macro_expand]]
name = "add_pymodule"
from = '$py:expr, $parent:expr, $name:expr, [$($cls:ty),* $(,)?]'
to = '{ let sub = pyo3::types::PyModule::new($py, $name)?; $(sub.add_class::<$cls>()?;)* $parent.add_submodule(&sub)?; Ok::<_, pyo3::PyErr>(()) }'Mode B — auto-discover from Rust source:
[[macro_expand]]
name = "register_classes"Mode B searches source files for macro_rules! {name} and extracts the pattern/body automatically. Best-effort: duplicate macro names across files use the first match; unparsable .rs files are skipped.
See examples/macro_expand_sample/ for a working example.
cfg features to treat as active during parsing.
[features]
enabled = ["some_feature"]All options are available under [tool.rylai]:
rylai.toml |
pyproject.toml |
|---|---|
format |
[tool.rylai] format |
[output] |
[tool.rylai.output] |
[fallback] |
[tool.rylai.fallback] |
[type_map] |
[tool.rylai.type_map] |
[[override]] |
[[tool.rylai.override]] |
[[add_content]] |
[[tool.rylai.add_content]] |
[[macro_expand]] |
[[tool.rylai.macro_expand]] |
[[all]] |
[[tool.rylai.all]] |
[tool.rylai]
format = ["ruff format", "ruff check --select I --fix"]
[tool.rylai.output]
python_version = "3.10"
[tool.rylai.type_map]
"numpy::PyReadonlyArray1" = "numpy.ndarray"
[[tool.rylai.override]]
item = "my_module::complex_function"
stub = "def complex_function(x: t.Any, **kwargs: t.Any) -> dict[str, t.Any]:"
[[tool.rylai.macro_expand]]
name = "register_classes"
[[tool.rylai.add_content]]
file = "mymod.pyi"
location = "tail"
content = "X: t.TypeAlias = int"
[[tool.rylai.all]]
file = "mymod.pyi"
exclude = ["InternalHelper"]For [output] python_version: generic containers follow PEP 585 only on Python ≥ 3.9 (list[T], dict[K, V], tuple[...], set[T]). On 3.8, Rylai emits the equivalent typing forms: t.List[T], t.Dict[K, V], t.Tuple[...], t.Set[T] (stubs use import typing as t). Bare list / dict / set without type parameters stay as built-in names.
| Rust type | Python type |
|---|---|
| Scalars | |
i8 … i128, u8 … u128, isize, usize |
int |
f32, f64 |
float |
bool |
bool |
str, String, char |
str |
() |
None |
| Bytes | |
&[u8], [u8] |
bytes |
Vec<u8> |
bytes |
| Path-like | |
Path, PathBuf (incl. std::path::*) |
Path | str / t.Union[Path, str] |
| Containers | |
Option<T> |
T | None / t.Optional[T] |
Vec<T> |
list[T] (3.9+) / t.List[T] (3.8) |
(T1, T2, ...) (non-empty tuple) |
tuple[...] (3.9+) / t.Tuple[...] (3.8) |
HashMap<K,V>, BTreeMap<K,V>, IndexMap<K,V> |
dict[K, V] (3.9+) / t.Dict[K, V] (3.8) |
HashSet<T>, BTreeSet<T> |
set[T] (3.9+) / t.Set[T] (3.8) |
| PyO3 types | |
PyResult<T>, Result<T, E> |
T (errors become Python exceptions) |
Py<T>, Bound<T>, Borrowed<T> |
recurse into T |
PyRef<T>, PyRefMut<T> |
recurse into T |
PyBytes |
bytes |
PyByteArray |
bytearray |
PyString |
str |
PyDict, PyList, PyTuple, PySet |
dict, list, tuple, set |
PyAny, PyObject |
t.Any |
| Other | |
Self (in #[pymethods]) |
t.Self (py ≥ 3.11) or class name |
#[pyclass] structs/enums |
Python class name (from crate) |
| Unknown types | t.Any (configurable via [fallback]) |
Rylai is purely static: it parses Rust source for #[pymodule], #[pyfunction], #[pyclass], etc., and does not run the compiler. It is therefore not a good fit for cases where concrete information is only known after compilation (e.g. procedural macros that generate Python bindings at compile time, or types/signatures that only exist in compiled artifacts).
- Same-name items. Rylai keys most internal lookups by bare identifier. If two items in different modules share the same Rust name (e.g. two
struct Foo), the last one parsed silently wins. Use unique names or#[pyclass(name = "...")]to disambiguate. use … as …renamed imports. Rylai does not resolve import aliases. Code likeuse pyo3::prelude::PyResult as MyResult;followed by-> MyResult<T>will causeMyResultto be treated as unknown (falling back tot.Any). Use the original name or add atypealias +[type_map]entry instead.create_exception!parsing. Parsing expects exactly three comma-separated macro arguments; if the exception base path contains generics (<...>), comma splitting may fail.[[macro_expand]]repetition blocks. Due to amacro_rules_rtlimitation,$(...)*blocks can only contain repeating variables (e.g.$cls). Non-repeating metavariables inside a repetition are not expanded. Bind non-repeating variables outside the repetition with alet(see themacro_expand_sampleexample).
For more complex projects, or when you rely on type/signature information that only exists after a build, a build-based approach is a better choice — for example pyo3-stub-gen, which compiles the extension first and then generates stubs from runtime/compilation artifacts.
For relatively simple projects where PyO3 bindings are mostly hand-annotated with straightforward types, Rylai's speed, no-compile workflow, and zero intrusion are strong advantages. This is especially true when you have Python version requirements (e.g. supporting versions below 3.10 which pyo3-stub-gen does not support). For declarative macros that wrap binding registration calls, you can use [[macro_expand]] to expand specific macros before parsing.
Related support is planned, but Rylai cannot generate stubs for all possible PyO3 code.
Before committing, run the pre-commit checks with prek. See CONTRIBUTING.md for details.