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.