Packaging Rust CLI Apps to PyPI¶
2026-06-16
I distribute a couple of Rust command-line tools as standalone binaries on
GitHub Releases. That works, but it asks something of the user: download the
right file for their platform, unzip it, put it on PATH. People who live in
the Python ecosystem already have a one-liner for installing CLI tools — pip
install or uv tool install — and they'd rather use it.
The good news: you can ship a Rust binary through PyPI without writing a single
line of Python or any FFI bindings. The wheel just carries your compiled
executable and drops it on PATH at install time. This is exactly how tools like
ruff and uv are distributed. The tool that makes it work is
maturin.
The key idea: bindings = "bin"¶
maturin is best known for building Python extension modules from Rust (via
PyO3). But it has a second mode, bindings = "bin", that does something much
simpler: it takes your crate's [[bin]] target and packages the compiled
executable into a wheel as a script. No PyO3, no cdylib, no library API — the
wheel just installs the binary into the environment's bin/ (or Scripts/ on
Windows). If you only want to distribute the executable, this is all you need.
Add a pyproject.toml¶
For a binary-only crate, this is the entire Python-side configuration:
[build-system]
requires = ["maturin>=1.7,<2"]
build-backend = "maturin"
[project]
name = "mytool"
description = "What it does"
requires-python = ">=3.10"
readme = "README.md"
dynamic = ["version"]
[tool.maturin]
bindings = "bin"
dynamic = ["version"] lets maturin read the version straight from
Cargo.toml. requires-python barely matters for a binary wheel — there's no
Python code, so the binary runs identically under any interpreter; it only gates
which environments will install it. Your existing [lib] and [[bin]] stay
untouched. Build and test locally with:
Publishing is easy with the ready-made actions¶
A binary wheel is platform-specific (it contains a native executable) but Python-version-agnostic, so the CI matrix is over OS/arch, not Python versions. Two off-the-shelf GitHub Actions do all the work:
PyO3/maturin-actionbuilds the wheel — just hand it the Rusttargetandmanylinux: auto.pypa/gh-action-pypi-publishuploads it. Give that jobpermissions: id-token: writeand a GitHubenvironmentand you get token-free Trusted Publishing (OIDC).
Both READMEs have copy-pasteable jobs. These slot in alongside your existing GitHub-Release binary jobs — I kept shipping standalone binaries and added PyPI as a parallel channel.
The payoff: the right binary, picked automatically¶
This is the real win over GitHub Releases. There, the user has to know their own
platform and pick the matching download — the right OS, the right arch, gnu vs
musl. With wheels, the installer does that for them. They run pip install
mytool (or uv tool install) on a Mac, a Linux box, or Windows, and the
correct binary is fetched and put on PATH. No choosing, no wrong-arch
mistakes — the selection is built into how wheels work.
Updates come for free too: pip install -U mytool or uv tool upgrade mytool
pulls the latest release, no re-downloading the right file by hand. And with
uvx you don't install the tool onto PATH at all — uvx mytool downloads the
right wheel into an isolated environment in uv's disposable cache and runs the
binary. The cached environment may remain to make later invocations faster, and
uv cache clean removes it. This is useful for a one-off invocation or trying a
tool before committing to it.
The mechanism is just the wheel filename. A wheel like
mytool-2.0.0-py3-none-manylinux_2_17_x86_64.whl encodes its target platform in
the tag (derived from the Rust target triple maturin built for). At install
time, pip/uv compute the set of tags the current machine supports — OS,
arch, and on Linux the glibc version — and download the wheel whose tag matches.
No Python code does this; it falls out of how the packaging tools already work.
So: a ~10-line pyproject.toml, two stock actions, and your binaries on GitHub
Releases keep working unchanged — while everyone who thinks in pip/uv gets a
one-liner.
Related writing¶
- What Cloudflare's Free Tier Gives Vibe Coders follows a similarly pragmatic path from a small tool to something people can install and use.
- A Tiny HTTP Server with Just the Python Standard Library is another example of choosing the smallest useful implementation.