Bundle any uv project into a single runnable .py file.
Project description
zuv
Pack your uv project into one .py file. Hand it over, drop it on a server, email it — uv run app.zuv.py and it works.
Why
- One file, no setup. No zip + README +
requirements.txt+ venv dance. Recipient runs one command. - Cross-platform, cross-Python. Same file works on Windows / Linux / macOS and any Python minor version (bytecode is built on the target).
- Tiny. ~10 KB even for a FastAPI app — deps install at first run, they're not embedded (unless you ask with
--deps). - Just uv. No bespoke runtime, no PyInstaller-style freezing. The bundle is a PEP 723 script;
uv runis the entrypoint.
Install
uv tool install zuv
Quick start
zuv build # -> ./dist/<name>.zuv.py
uv run dist/<name>.zuv.py # run it
dist/ is wiped on every build. Bundles ship .py sources (no bytecode), so the same file works on any OS and any Python minor version. First run extracts into dist/.zuv/<name>_<hash>/, installs deps into a local .venv, runs your entry. Later runs skip extraction.
Commands
zuv build [project] [flags]
| arg / flag | what it does |
|---|---|
project |
Path to the uv project (containing pyproject.toml). Default: current directory. |
-o, --output |
Output file path. Default: ./dist/<project-name>.zuv.py (or .zuv.zip with --zip). |
-e, --entry |
Entry script relative to project root. Default: [tool.zuv].entry, then src/main.py, then main.py. |
--zip |
Wrap the .zuv.py in a .zuv.zip with run.bat (Windows) + run.sh (Unix/macOS) launchers. The launchers install uv from https://astral.sh/uv if missing, then run the bundle. For recipients with neither uv nor Python. |
--deps [LIST] |
Embed wheels for the locked deps so the bundle runs offline. Bare = current OS only. all = every supported platform. Comma list = pick from windows, linux, linux-arm, macos, macos-arm. Wheels are tied to the Python minor you build with. Note: wheels can't carry OS libs (libGL, libpq, …); on bare Linux, apt install what ImportError names. Prefer -headless variants. |
--no-compile |
Tell the loader to skip the first-run .py → .pyc compile. Cache stays as plain .py; per-import startup is slower. |
--update-repo REPO |
Make the bundle self-update from a GitHub or GitLab repo's Releases. Accepts a URL (https://github.com/user/repo, https://gitlab.com/user/repo) or shorthand (user/repo → GitHub, gitlab:user/repo → GitLab). On every startup it asks the Releases API for the named asset and the release's tag_name. If the bundled [project] version from pyproject and the remote tag both parse as dotted-int versions, the loader skips when local ≥ remote (no prompt for older or equal). Otherwise (e.g. rolling latest tag) it falls back to comparing the asset's id; prompts install latest version? [Y/n] when something changed. Declined versions are remembered. Non-TTY runs skip silently. Private repos: set $GH_TOKEN (GitHub) or $GITLAB_TOKEN (GitLab). ZUV_NO_UPDATE=1 disables. |
--update-tag TAG |
Release tag to fetch the asset from. Default: latest (special value: hits the provider's /releases/latest endpoint). Use a fixed tag to pin recipients to one release. |
--update-file FILENAME |
Asset filename inside the release. Default: <output-stem>.zuv.py. Lets one release host several bundles (e.g. fastapi.zuv.py, dashboard.zuv.py). |
zuv run <file> [-- script-args...]
Thin wrapper around uv run <file>. Identical to running the bundle directly with uv.
zuv inspect <file>
Print the entry, build hash, sha256, PEP 723 metadata, and a summary of the embedded loader bytecode. Payload is elided.
zuv clean [target]
Remove every .zuv/ extraction cache under target (default: cwd). target can be a directory or a built .zuv.py (its parent is used). Persistent volumes (see below) are kept by default; pass --data to also wipe them.
zuv volume <locate|wipe|backup> <file>
Developer helpers for a bundle's persistent volume (see Persistent storage).
locate <file>- print the on-disk host path of the volume.wipe <file> [-y]- delete the volume (prompts unless-y).backup <file> [-o OUT]- write atar.gzof the volume contents.
Runtime env vars
| var | default | purpose |
|---|---|---|
ZUV_CACHE_DIR |
next to script | Override where the bundle extracts. |
ZUV_MAX_EXTRACT_BYTES |
2 GiB | Decompression-bomb cap. |
ZUV_NO_UPDATE |
(unset) | If set, disables the --update-repo self-update check at startup. |
GH_TOKEN / GITHUB_TOKEN |
(unset) | Sent as Authorization: Bearer … on GitHub update checks. Required for private GitHub repos. |
GITLAB_TOKEN |
(unset) | Sent as PRIVATE-TOKEN: … on GitLab update checks. Required for private GitLab repos. |
If the script's directory isn't writable, the loader falls back to $XDG_CACHE_HOME/zuv / %LOCALAPPDATA%\zuv / ~/.cache/zuv. Persistent volumes follow the same root.
How it works (1 paragraph)
The output is a PEP 723 script: shebang, metadata, a base85-encoded tar.xz of your project (_ZUV_PAYLOAD), and a tiny loader (_ZUV_LOADER) that verifies the sha256, extracts into .zuv/<stem>_<hash>/, and runs uv run --project <extracted> <entry>. Deps install at first run, so the bundle stays small.
Persistent storage (volumes)
Standard project layout
The recommended layout for a zuv-friendly project keeps source, persistent
data, and project metadata in clearly separated places:
myproject/
pyproject.toml # project metadata + [tool.zuv]
.python-version
src/ # all source code
main.py
api/
frontend/
data/ # persistent storage (declared as the volume)
With [tool.zuv] volume = "data", the data/ directory is mounted as a
persistent host folder at runtime, so anything the app writes there survives
rebuilds and version upgrades. Everything else under the project (sources,
lockfile, .venv) is treated as ephemeral and may be re-extracted between
versions.
Without a volume, every new build of a bundle extracts into a fresh .zuv/<stem>_<build_id>/ directory and the previous cache is garbage-collected on first run, so anything the app wrote inside its project tree is lost on upgrade.
Declare a volume in your project's pyproject.toml:
[tool.zuv]
volume = "data"
volumeis a relative path inside the project (no.., no absolute paths). One per project.- At runtime the loader mounts
<script_dir>/.zuv/<volume_path>/at<extracted>/<volume>via a symlink (POSIX) or junction (Windows, no admin / no Dev Mode required). - The host folder is persistent: it is not part of any
<stem>_<build_id>/cache, sozuv cleanand version upgrades leave it untouched. - App code keeps using the same relative path (e.g.
Path("data/app.db")); mounting is transparent.
Seeding (Docker-style): if your project ships content under <volume>/ (e.g. a default SQLite file), the first extraction promotes that content into the persistent volume. Subsequent versions never re-seed, so user data is preserved.
Cleanup:
zuv cleankeeps volumes.zuv clean --dataalso wipes volume directories.zuv volume wipe app.zuv.pywipes one bundle's volume.
Caveat
Don't name your project the same as one of its dependencies (fastapi depending on fastapi confuses uv). Use fastapi-example or similar.
Examples
zuv build examples/bigtest && uv run dist/bigtest.zuv.py
zuv build examples/fastapi && uv run dist/fastapi.zuv.py
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file zuv-0.5.0.tar.gz.
File metadata
- Download URL: zuv-0.5.0.tar.gz
- Upload date:
- Size: 23.7 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
cb5f0137a4d1dba09536f7548410d761b8a1b967332064694f2055bac35bb749
|
|
| MD5 |
a71d191ad9875e05d1c145ed0d9d8de9
|
|
| BLAKE2b-256 |
9c4c2ff1e16fbe27b9795179eed70ad2af35645e8309218548c1ad2825f3839a
|
Provenance
The following attestation bundles were made for zuv-0.5.0.tar.gz:
Publisher:
pypi.yml on HamzaYslmn/zuv
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
zuv-0.5.0.tar.gz -
Subject digest:
cb5f0137a4d1dba09536f7548410d761b8a1b967332064694f2055bac35bb749 - Sigstore transparency entry: 1570396133
- Sigstore integration time:
-
Permalink:
HamzaYslmn/zuv@7be4a0b5daa9c714ed8bb0ea49da258517513fb6 -
Branch / Tag:
refs/tags/v0.5.0 - Owner: https://github.com/HamzaYslmn
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
pypi.yml@7be4a0b5daa9c714ed8bb0ea49da258517513fb6 -
Trigger Event:
push
-
Statement type:
File details
Details for the file zuv-0.5.0-py3-none-any.whl.
File metadata
- Download URL: zuv-0.5.0-py3-none-any.whl
- Upload date:
- Size: 29.8 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
6e447605bbc5795e3f1e266f3251a57bc840ed2ecd829039338a3a5e27af1ba4
|
|
| MD5 |
ab972cdfd69e32df54d23621e76184f8
|
|
| BLAKE2b-256 |
d401b049ac3243339e736def4db596e7b000c65332c7494b590afa9ddaaee341
|
Provenance
The following attestation bundles were made for zuv-0.5.0-py3-none-any.whl:
Publisher:
pypi.yml on HamzaYslmn/zuv
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
zuv-0.5.0-py3-none-any.whl -
Subject digest:
6e447605bbc5795e3f1e266f3251a57bc840ed2ecd829039338a3a5e27af1ba4 - Sigstore transparency entry: 1570396716
- Sigstore integration time:
-
Permalink:
HamzaYslmn/zuv@7be4a0b5daa9c714ed8bb0ea49da258517513fb6 -
Branch / Tag:
refs/tags/v0.5.0 - Owner: https://github.com/HamzaYslmn
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
pypi.yml@7be4a0b5daa9c714ed8bb0ea49da258517513fb6 -
Trigger Event:
push
-
Statement type: