Rust CLIs via uv (Python)
I like to ship Rust CLIs as Python CLIs via uv. The Python CLI requires no Python dependencies and simply passes through arguments to the Rust CLI. I recently did this with Zorto.
install #
Install with uv:
uv tool install --upgrade zorto
or curl and sh (installing uv for you if you don’t have it):
curl -LsSf https://dkdc.sh/zorto/install.sh | sh
The install.sh script:
#!/usr/bin/env sh set -eu if ! command -v uv &>/dev/null; then echo "Installing uv..." curl -LsSf https://astral.sh/uv/install.sh | sh fi echo "Installing zorto..." uv tool install --upgrade zorto
speed #
time zorto --help
A fast static site generator with executable code blocks
Usage: zorto [OPTIONS] <COMMAND>
Commands:
build Build the site
preview Start preview server with live reload
clean Remove output directory
init Initialize a new site
check Check site for errors without building
help Print this message or the help of the given subcommand(s)
Options:
-r, --root <ROOT> Site root directory [default: .]
-h, --help Print help
real 0m0.011s
user 0m0.005s
sys 0m0.006s
time uv tool run zorto --help
A fast static site generator with executable code blocks
Usage: zorto [OPTIONS] <COMMAND>
Commands:
build Build the site
preview Start preview server with live reload
clean Remove output directory
init Initialize a new site
check Check site for errors without building
help Print this message or the help of the given subcommand(s)
Options:
-r, --root <ROOT> Site root directory [default: .]
-h, --help Print help
real 0m0.084s
user 0m0.069s
sys 0m0.013s
time uvx zorto --help
A fast static site generator with executable code blocks
Usage: zorto [OPTIONS] <COMMAND>
Commands:
build Build the site
preview Start preview server with live reload
clean Remove output directory
init Initialize a new site
check Check site for errors without building
help Print this message or the help of the given subcommand(s)
Options:
-r, --root <ROOT> Site root directory [default: .]
-h, --help Print help
real 0m0.047s
user 0m0.036s
sys 0m0.010s
uv definitely adds overhead, but I think it’s worth it for distribution & more.
how it works #
Three layers:
- core: pure Rust library + CLI
- bindings: PyO3 (Rust) wrapper exposing the core library to Python
- wrapper: thin Python layer passing through arguments to the Rust CLI
zorto/
├── zorto/ # core (Rust)
│ ├── Cargo.toml # lib + bin
│ └── src/
│ ├── lib.rs # pub use cli::run;
│ ├── main.rs # standalone binary
│ ├── cli.rs # clap commands
│ └── ... # site, markdown, templates, etc.
├── zorto-py/ # bindings (PyO3)
│ ├── Cargo.toml # depends on zorto, pyo3
│ └── src/
│ └── lib.rs # ~6 lines: expose run() to Python
├── src/zorto/ # wrapper (Python)
│ └── __init__.py # ~15 lines: CLI entry point
└── pyproject.toml # maturin build, zorto:main script
core (Rust) #
// zorto/src/lib.rs pub use cli::run; // zorto/src/main.rs fn main() { if let Err(e) = zorto::run(std::env::args()) { eprintln!("Error: {e}"); std::process::exit(1); } }
bindings (Rust, PyO3) #
// zorto-py/src/lib.rs use pyo3::prelude::*; #[pyfunction] fn run(argv: Vec<String>) -> PyResult<()> { zorto::run(argv.iter().map(|s| s.as_str())) .map_err(|e| PyErr::new::<pyo3::exceptions::PyRuntimeError, _>( e.to_string() )) } #[pymodule] fn core(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(run, m)?)?; Ok(()) }
wrapper (Python) #
# src/zorto/__init__.py import sys from zorto.core import run as _run def run(argv: list[str] | None = None) -> None: if argv is None: argv = sys.argv try: _run(argv) except KeyboardInterrupt: sys.exit(0) def main() -> None: run()
pyproject.toml #
[project] name = "zorto" version = "0.3.0" requires-python = ">=3.11" dependencies = [] [project.scripts] zorto = "zorto:main" [build-system] requires = ["maturin>=1.0,<2.0"] build-backend = "maturin" [tool.maturin] module-name = "zorto.core" python-packages = ["zorto"] python-source = "src" manifest-path = "zorto-py/Cargo.toml"
benefits #
Python dependencies not required; write all logic in Rust using crates & expose to Python with minimal glue code. uv & maturin builds wheels for macOS (x86_64 + arm64) and Linux (x86_64 + aarch64).
uv tool install --upgrade zorto downloads a ~5MB wheel.
cargo install zorto or native binaries still work if you prefer.