Skip to main content

moonlit

moonlit is a CLI that bundles a uv-managed Python project (and optionally a uv workspace) into a single self-contained zipapp per PEP 441. The produced .pyz ships every transitive dependency from uv.lock; on the end user's machine it extracts to a per-build cache on first run, then dispatches the configured entry point.

It is similar to LinkedIn's shiv, with two differences:

  • Built on uv, not pip. Resolution is done by uv export --frozen against uv.lock; staging is done by uv pip install --target (no virtualenv); the target's wheel is built by uv build --wheel.
  • uv workspaces are first-class. --package <member> selects a workspace target; transitive workspace deps are bundled automatically via uv build --all-packages.

Status

Beta (0.x). The build pipeline, runtime bootstrap, .pyz/.exe output shape, env.json schema, cache layout, and locking protocol are stable and pinned by the design specs under specs/. Remaining flag work (--reproducible, --compile-pyc, --no-modify, --python-platform) is additive — no breaking changes to shipped contracts are anticipated before 1.0.

Install

uv tool install moonlit

Or with pipx / pip:

pipx install moonlit
# or
pip install --user moonlit

From source:

git clone https://github.com/OpenAfterHours/moonlit.git
cd moonlit
uv sync
uv run moonlit --help

Quick start

In a uv-managed project (pyproject.toml + uv.lock):

uv run moonlit build -e myapp.cli:main -o myapp.pyz
python ./myapp.pyz

In a uv workspace:

uv run moonlit build --package shouter -e shouter.cli:main -o shouter.pyz
python ./shouter.pyz

The produced .pyz is self-contained in the dependency sense: uv.lock's entire dependency closure is bundled, plus the target's own wheel. On first run it extracts site-packages/ to a per-build cache (%LOCALAPPDATA%\moonlit on Windows, ~/.moonlit on POSIX); subsequent runs hit the cache directly without unpacking. Like shiv, the .pyz/.exe does not bundle the Python interpreter itself — recipients still need a Python on PATH (or a py.exe-discoverable install on Windows) whose major.minor matches the build's target ABI. See Cross-interpreter builds for how to control that target.

Pack PyPI packages directly (no project)

No local project? Bundle straight from PyPI with moonlit pack — the analogue of uvx --with <extra> <tool>. Hand it package names; get back a .pyz a recipient can run with neither uv nor PyPI access:

# Bundle the `mooring` tool plus `polars`, then run it offline.
uv run moonlit pack mooring --with polars -o mooring.pyz
python ./mooring.pyz        # runs mooring's console script; polars is bundled in

With no -e/-c, pack defaults to the console script named after the primary package (just like uvx mooring). Pass -e module:callable to choose a different entry point, or bundle from an existing requirements file:

uv run moonlit pack --with-requirements requirements.txt --name mooring -c mooring -o mooring.pyz

pack needs no pyproject.toml or uv.lock: it resolves the full closure with uv pip compile at build time (that resolution is the lock for the artifact) and installs it with --no-deps. It accepts the same output-shape flags as build — --python-version, --windows-exe, --bundle-python, --gc*. Because there's no lockfile, repeated packs can pick newer pins as the index moves; pin exact versions (or pass a fully-pinned --with-requirements file) when you need reproducible inputs. See the moonlit pack reference.

Cross-interpreter builds

Native-extension wheels (e.g. msgspec, numpy, pydantic-core) carry cp<X><Y> ABI tags and only load on the matching Python major.minor. By default moonlit build targets the build host's interpreter; pass --python-version <X.Y> to target a different one — useful when the dev box runs a different Python than the recipients:

# Build for Python 3.12 from a Python 3.13 dev box.
# uv auto-fetches a managed standalone CPython 3.12 if one isn't installed.
uv run moonlit build --python-version 3.12 -e myapp:main -o myapp-py312.pyz

The flag threads through every uv invocation (export, pip install --target, build) and is stamped into env.json.python_version. At runtime the bootstrap compares this against the recipient's sys.version_info.major.minor and exits 1 with a clear message on mismatch (no more cryptic ModuleNotFoundError: No module named '<pkg>._core'):

moonlit: this archive was built for Python 3.12, but you are running Python 3.13;
install a Python 3.12 interpreter or rebuild with `moonlit build --python <python-3.12>`

For --windows-exe builds, combining --python-version <X.Y> with the default shebang automatically pivots the launcher's interpreter selection to py -<X.Y> so the Windows PEP 397 launcher pins to the matching Python on the recipient's machine. Pass --python explicitly to override.

Multi-version-in-one-artifact (one .pyz that runs on multiple Pythons) is not supported; build one artifact per target version.

Bundling Python in a folder (Windows)

If your recipients don't have Python installed at all, add --bundle-python. moonlit asks uv python install for the matching CPython (python-build-standalone) and produces a folder bundle containing a thin launcher .exe, the application zipapp, and the managed CPython tree side-by-side. With --bundle-python set, -o names the output directory and MUST NOT end in .exe or .pyz:

uv run moonlit build --bundle-python -e myapp:main -o dist/myapp

The bundle layout:

dist/myapp/
├── myapp.exe       # thin launcher (runs ./_python/python.exe ./myapp.pyz)
├── myapp.pyz       # the application zipapp
└── _python/        # bundled CPython tree (~30 MiB)

Distribute the folder (typically zipped) and run myapp\myapp.exe. The launcher spawns the sibling _python\python.exe directly with -I (isolated mode) — nothing is extracted at runtime, so nothing on the recipient's PATH or user-site can bleed in. This folder shape replaces the v0.3.0 single-.exe --bundle-python output, which tripped Windows Defender's ML heuristics for self-extracting archives. --windows-exe may be passed alongside --bundle-python but is a no-op (the folder always contains a launcher .exe). Phase 1 is Windows-only; POSIX bundles are out of scope.

Build output

Default mode shows per-step progress on stderr — a Braille spinner per step on TTYs (⠋ freezing dependencies (uv export) → ✓ frozen · 87 packages · 0.7s), or plain →/✓ lines when stderr is not a TTY (CI logs, file redirect, pipe). The spec-frozen success line wrote <path> (<size>, <N> entries) always lands on stdout. -q/--quiet suppresses stderr; -v/--verbose additionally echoes + uv <argv> (POSIX-shlex format) before each uv call.

How it works

The moonlit build pipeline runs ten ordered steps:

  1. Workspace detection. Parse [tool.uv.workspace] from pyproject.toml; expand members globs; apply exclude; PEP 503 normalize.
  2. Target selection. Workspace + --package <name> → matched member; non-workspace → project root.
  3. uv export writes a frozen requirements file from uv.lock.
  4. uv pip install --target stages the third-party closure under a temp site-packages/ (no venv).
  5. uv build --wheel (or --all-packages for workspaces) builds the target's wheel.
  6. Each produced wheel is installed into the same site-packages/.
  7. If -c <script> was used, the entry point is resolved from staged *.dist-info/entry_points.txt.
  8. build_id is computed: a sorted SHA-256 over every file in site-packages/ (excluding __pycache__/.pyc).
  9. Archive assembly: shebang prefix, then a ZIP_DEFLATED archive containing site-packages/, the stdlib-only _bootstrap/ package, the rendered __main__.py, and env.json.
  10. Atomic finalize: temp-then-rename to the output path; POSIX chmod 0o755.

At runtime, the _bootstrap package reads env.json, derives a cache key from (name, build_id), takes either the lock-free fast path (cache hit) or the locked slow path (extract under O_CREAT|O_EXCL sentinel, atomic-replace into the cache via os.rename + os.replace), then calls site.addsitedir() and invokes the entry point.

Documentation

docs/index.md Marketing landing page (rendered via the standalone overrides/home.html template — markdown body intentionally empty).
docs/getting-started.md Walkthroughs for single-package projects and uv workspaces.
docs/cli-reference.md Every flag, every exit code, preflight order, stdout/stderr semantics.
docs/runtime.md What runs inside the .pyz: cache layout, env vars, runtime exit codes, stale-lock recovery.

The docs are built with zensical:

uv sync --group docs
uv run zensical serve   # http://127.0.0.1:8000

Project layout

src/moonlit/
├── __init__.py         # __version__
├── __main__.py         # `python -m moonlit` entry
├── cli.py              # Click frontend
├── builder.py          # 10-step build pipeline orchestrator
├── resolver.py         # the only module that calls `uv` subprocesses
├── workspace.py        # parses [tool.uv.workspace]
├── hashing.py          # deterministic build_id
├── errors.py           # MoonlitError hierarchy with stable exit codes
├── _progress.py        # spinner + step-line progress reporter (build-time)
├── _templates/
│   ├── __init__.py
│   └── main_py.tmpl    # rendered into every .pyz as __main__.py
└── _bootstrap/         # SHIPPED INSIDE EVERY .pyz — stdlib-only
    ├── __init__.py     # bootstrap() orchestrator
    ├── environment.py  # env.json validation
    ├── extract.py      # D4 atomic-replace, D14 fast path
    ├── locking.py      # O_CREAT|O_EXCL sentinel lock
    ├── runner.py       # site.addsitedir, entry-point resolution
    └── errors.py

specs/                  # Foundational design contracts (start here for hacking)
overrides/home.html     # Standalone landing template (docs homepage)
scripts/release.py      # Version-bump + tag helper (run before publishing)
tests/
├── unit/               # 556 unit tests
└── e2e/                # 25 contract tests via subprocess

Status of features

Feature State
Build single-package projects done
Build uv workspaces with transitive deps done
moonlit pack — bundle PyPI packages with no local project (uvx/shiv-style) done
--entry-point (-e) and --console-script (-c) done
Atomic .pyz output (temp-then-rename) done
First-run extraction + cache-hit fast path done
Cross-platform caching (%LOCALAPPDATA%, ~/.moonlit) done
Automatic same-app cache self-GC (keep newest N, default on) done
MOONLIT_ROOT, MOONLIT_FORCE_EXTRACT, MOONLIT_ENTRY_POINT, MOONLIT_DEBUG, MOONLIT_NO_GC, MOONLIT_GC_KEEP_LATEST, MOONLIT_GC_GRACE done
--windows-exe native launcher done
--bundle-python (ship CPython next to a launcher .exe as a folder bundle, Windows phase 1) done
Real flock/msvcrt locking done
moonlit info <pyz> subcommand done
--python-version cross-interpreter builds done
Bootstrap Python-version mismatch check (clear error vs cryptic ModuleNotFoundError) done
--reproducible builds (zeroed mtimes, sorted entries) deferred
--compile-pyc deferred
--no-modify integrity verification deferred
--python-platform (cross-OS / cross-arch builds) deferred
--bundle-python on POSIX (Linux/macOS launcher binaries) deferred
Multi-version-in-one-artifact (single .pyz that runs on multiple Pythons) deferred

Contributing

Read CLAUDE.md for development conventions and specs/ for the design contracts (start with specs/README.md, then specs/00-architecture.md).

uv run pytest                       # 581 tests, ~11s with e2e
uv run pytest tests/unit            # unit only, ~6s
uv run ruff format --check .        # format check (CI gate)
uv run ruff check .                 # lints (CI gate)
uv run zensical build --strict      # docs build (CI gate)

The e2e suite (tests/e2e/) shells out to real uv and produces real .pyz files; it skips automatically if uv is not on PATH.

CI runs all four gates on every pull request via .github/workflows/ci.yml.

Cutting a release

scripts/release.py is the release helper. It enforces a clean working tree, that you're on the release branch, and that the target tag doesn't already exist; runs pytest + ruff + uv build against the current code; bumps the version in pyproject.toml, src/moonlit/__init__.py, and overrides/home.html; runs uv lock; commits as chore: release vX.Y.Z; and creates an annotated tag. Pushing and uv publish are deliberately left to you.

uv run python scripts/release.py patch        # 0.1.0 -> 0.1.1
uv run python scripts/release.py minor        # 0.1.0 -> 0.2.0
uv run python scripts/release.py 0.2.3        # explicit (must be strictly greater)
uv run python scripts/release.py patch --dry-run

License

MIT.

Release files for moonlit 0.5.1

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for moonlit 0.5.1
File Size Uploaded
moonlit-0.5.1.tar.gz 369.1 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for moonlit 0.5.1
File Interpreter ABI Platform
moonlit-0.5.1-py3-none-any.whl Python 3 none any Details

Total release size: 569.0 kB

Release files / moonlit-0.5.1.tar.gz

Download URL moonlit-0.5.1.tar.gz
Size 369.1 kB
Tags Source
SHA-256 checksum
How to use checksums
f57a38cb7d5dfb854f43c99762f457c855b3a1c1686a103b7caf10cb1dccfc81
BLAKE2b-256 checksum
How to use checksums
bf888bcf7f65cd3cb9ed67441843d26c8fdea9151ca49076c87a06d631b2c642
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/6.1.0 CPython/3.13.12

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Jun 24, 2026.

Transparency log

Release files / moonlit-0.5.1-py3-none-any.whl

Download URL moonlit-0.5.1-py3-none-any.whl
Size 199.9 kB
Tags Python 3
SHA-256 checksum
How to use checksums
204b18068217c01e0c37d6bfb96c81ec660d029d823aa0bcc01954a0e4c93244
BLAKE2b-256 checksum
How to use checksums
f769c994748dd8d12277518dbcea1f9de87f6ef68be16b3336cd24604156c355
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/6.1.0 CPython/3.13.12

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Jun 24, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.5.1 This release

2 release files

0.5.0

2 release files

0.4.2

2 release files

0.4.1

2 release files

0.4.0

2 release files

0.2.0

2 release files

0.1.5

2 release files

0.1.3

2 release files

0.1.1

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page