moonlit 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. moonlit-0.1.0/.gitignore +12 -0
  2. moonlit-0.1.0/CLAUDE.md +169 -0
  3. moonlit-0.1.0/IMPLEMENTATION_PLAN.md +262 -0
  4. moonlit-0.1.0/LICENSE +21 -0
  5. moonlit-0.1.0/PKG-INFO +163 -0
  6. moonlit-0.1.0/README.md +137 -0
  7. moonlit-0.1.0/docs/assets/javascripts/moonlit-landing.js +63 -0
  8. moonlit-0.1.0/docs/assets/stylesheets/colors_and_type.css +270 -0
  9. moonlit-0.1.0/docs/assets/stylesheets/moonlit-landing.css +709 -0
  10. moonlit-0.1.0/docs/cli-reference.md +131 -0
  11. moonlit-0.1.0/docs/getting-started.md +166 -0
  12. moonlit-0.1.0/docs/index.md +16 -0
  13. moonlit-0.1.0/docs/runtime.md +169 -0
  14. moonlit-0.1.0/overrides/home.html +203 -0
  15. moonlit-0.1.0/pyproject.toml +90 -0
  16. moonlit-0.1.0/specs/00-architecture.md +170 -0
  17. moonlit-0.1.0/specs/01-cli.md +159 -0
  18. moonlit-0.1.0/specs/02-build-pipeline.md +161 -0
  19. moonlit-0.1.0/specs/03-bootstrap-runtime.md +215 -0
  20. moonlit-0.1.0/specs/04-cache-layout.md +145 -0
  21. moonlit-0.1.0/specs/05-env-json-schema.md +141 -0
  22. moonlit-0.1.0/specs/06-workspace-integration.md +101 -0
  23. moonlit-0.1.0/specs/CROSS_CUTTING_DECISIONS.md +224 -0
  24. moonlit-0.1.0/specs/README.md +34 -0
  25. moonlit-0.1.0/src/moonlit/__init__.py +1 -0
  26. moonlit-0.1.0/src/moonlit/__main__.py +6 -0
  27. moonlit-0.1.0/src/moonlit/_bootstrap/__init__.py +78 -0
  28. moonlit-0.1.0/src/moonlit/_bootstrap/environment.py +170 -0
  29. moonlit-0.1.0/src/moonlit/_bootstrap/errors.py +48 -0
  30. moonlit-0.1.0/src/moonlit/_bootstrap/extract.py +202 -0
  31. moonlit-0.1.0/src/moonlit/_bootstrap/locking.py +64 -0
  32. moonlit-0.1.0/src/moonlit/_bootstrap/runner.py +84 -0
  33. moonlit-0.1.0/src/moonlit/_templates/__init__.py +1 -0
  34. moonlit-0.1.0/src/moonlit/_templates/main_py.tmpl +3 -0
  35. moonlit-0.1.0/src/moonlit/builder.py +415 -0
  36. moonlit-0.1.0/src/moonlit/cli.py +246 -0
  37. moonlit-0.1.0/src/moonlit/errors.py +69 -0
  38. moonlit-0.1.0/src/moonlit/hashing.py +34 -0
  39. moonlit-0.1.0/src/moonlit/resolver.py +141 -0
  40. moonlit-0.1.0/src/moonlit/workspace.py +158 -0
  41. moonlit-0.1.0/tests/e2e/test_bootstrap_e2e.py +462 -0
  42. moonlit-0.1.0/tests/e2e/test_workspace_demo.py +226 -0
  43. moonlit-0.1.0/tests/unit/test_bootstrap_orchestrator.py +416 -0
  44. moonlit-0.1.0/tests/unit/test_bootstrap_stdlib_only.py +183 -0
  45. moonlit-0.1.0/tests/unit/test_builder_archive.py +473 -0
  46. moonlit-0.1.0/tests/unit/test_builder_pipeline.py +656 -0
  47. moonlit-0.1.0/tests/unit/test_cli.py +737 -0
  48. moonlit-0.1.0/tests/unit/test_environment.py +348 -0
  49. moonlit-0.1.0/tests/unit/test_errors.py +65 -0
  50. moonlit-0.1.0/tests/unit/test_extract.py +372 -0
  51. moonlit-0.1.0/tests/unit/test_hashing.py +113 -0
  52. moonlit-0.1.0/tests/unit/test_locking.py +195 -0
  53. moonlit-0.1.0/tests/unit/test_resolver.py +327 -0
  54. moonlit-0.1.0/tests/unit/test_runner.py +312 -0
  55. moonlit-0.1.0/tests/unit/test_workspace.py +289 -0
  56. moonlit-0.1.0/zensical.toml +99 -0
@@ -0,0 +1,12 @@
1
+ .idea/
2
+ .venv/
3
+ __pycache__/
4
+ *.pyc
5
+ .pytest_cache/
6
+ dist/
7
+ build/
8
+ *.egg-info/
9
+
10
+ # zensical docs site build output
11
+ site/
12
+ .zensical/
@@ -0,0 +1,169 @@
1
+ # CLAUDE.md
2
+
3
+ Operating notes for future Claude instances working in this repo.
4
+
5
+ **For design contracts, read `specs/`** — the foundational specifications that drive implementation. Start with `specs/README.md`, then `specs/00-architecture.md` for the system view, then the component specs. `specs/CROSS_CUTTING_DECISIONS.md` is binding when any spec disagrees with it.
6
+
7
+ `IMPLEMENTATION_PLAN.md` (in the repo root) captures the original design rationale and is preserved for context, but the specs in `specs/` are the canonical contract.
8
+
9
+ This file captures rules and conventions that aren't obvious from the code or the specs.
10
+
11
+ ## What this project is
12
+
13
+ **moonlit** is a CLI that builds self-contained Python zipapps (PEP 441) — analogous to LinkedIn's [shiv](https://github.com/linkedin/shiv), but built on **uv** instead of pip and aware of **uv workspaces**.
14
+
15
+ A produced `.pyz` bundles the application + all dependencies. On first run, it extracts site-packages to a per-build cache directory and invokes the configured entry point. Subsequent runs hit the cache.
16
+
17
+ ## Architecture (two halves, hard boundary)
18
+
19
+ There are two distinct codebases in `src/moonlit/`, and they have **different rules**:
20
+
21
+ ### Build-time code (`cli.py`, `builder.py`, `resolver.py`, `workspace.py`, `hashing.py`, `errors.py`)
22
+ - Runs as the user's `moonlit` CLI on their dev machine.
23
+ - May depend on third-party packages (currently just `click`).
24
+ - Shells out to `uv` via `subprocess` — there is no Python API for uv. The shell-out is in `resolver.py`; do not call `subprocess.run(["uv", ...])` from anywhere else.
25
+
26
+ ### Bootstrap code (`src/moonlit/_bootstrap/`)
27
+ - Gets copied verbatim into every `.pyz` produced.
28
+ - Runs **before** the bundled site-packages is on `sys.path`.
29
+ - **Must be stdlib-only.** No `click`, no anything-else. If you need a third-party feature, reimplement enough of it on stdlib or rethink the design.
30
+ - No relative imports outside the `_bootstrap` package.
31
+ - Compatible with the *target* Python the user will run the .pyz with — currently the project pins `requires-python >=3.13` and the bootstrap can assume that.
32
+
33
+ If you find yourself adding a dep to `_bootstrap`, stop. That's a design break, not a tweak.
34
+
35
+ ## The build pipeline at a glance
36
+
37
+ `cli.build` → `builder.build(BuildConfig)`:
38
+
39
+ 1. `workspace.detect(project_root)` — parse `[tool.uv.workspace]`.
40
+ 2. Pick target package (workspace member via `--package`, or the project itself).
41
+ 3. `uv export --frozen --no-dev --no-emit-workspace --format requirements-txt [--package <name>]` → requirements file.
42
+ 4. `uv pip install --target <staging>/site-packages --no-deps -r <reqs> --python <sys.executable>`.
43
+ 5. `uv build --wheel [--package <name>] --out-dir <tmp>/dist`.
44
+ 6. `uv pip install --target <staging>/site-packages --no-deps --reinstall-package <name> <wheel>`.
45
+ 7. Resolve `-c` (console script) by reading `*.dist-info/entry_points.txt` from staging.
46
+ 8. `hashing.compute_build_id(staging)` — sorted SHA-256.
47
+ 9. `builder.create_archive` — write shebang prefix → ZipFile → site-packages → `_bootstrap/` → generated `__main__.py` → `env.json`.
48
+ 10. POSIX `chmod 0o755`; Windows no-op.
49
+
50
+ The bootstrap `__main__.py` template is just:
51
+ ```python
52
+ import sys
53
+ from _bootstrap import bootstrap
54
+ sys.exit(bootstrap())
55
+ ```
56
+
57
+ ## Development practices
58
+
59
+ **Test-driven development.** The specs in `specs/` already enumerate falsifiable invariants, and many name explicit test files (e.g. `tests/unit/test_bootstrap_stdlib_only.py`, `tests/unit/test_builder_preflight.py::test_parent_missing_exits_7`). Workflow per change:
60
+
61
+ 1. Pick the spec invariant or named test ID you're implementing.
62
+ 2. Write the failing test first. Run it. Confirm it fails for the expected reason.
63
+ 3. Make it pass with the smallest change that works.
64
+ 4. Refactor with the test green.
65
+ 5. Commit the test and production code together.
66
+
67
+ Don't write production code without a failing test. Don't write speculative tests for behavior the specs don't require. When a critique surfaces a missing edge case, add the test first, then fix.
68
+
69
+ **Stepdown rule — modules read like a book.** Every module is laid out top-to-bottom in order of decreasing abstraction. A reader doing `from moonlit.builder import build` and scrolling top-to-bottom should understand what `build` does without ever scrolling backward.
70
+
71
+ - Module docstring at the top.
72
+ - Public API immediately below — the names another module would import.
73
+ - The highest-level function defined first; the functions it calls appear below, in the order they're called.
74
+ - Private helpers at the bottom.
75
+
76
+ If you find yourself jumping up the file to understand a downstream function, the module is in the wrong order — reorder before merging. Same rule applies inside `_bootstrap/`: `bootstrap()` at the top of `__init__.py`; the helpers it dispatches to follow in call order across `environment.py`, `extract.py`, `runner.py`.
77
+
78
+ **Clean-code defaults** (beyond the system prompt's guidance):
79
+
80
+ - Small functions, one job each. If a function does two things, split it.
81
+ - Names describe intent, not implementation: `compute_build_id`, not `do_sha256`. `materialize`, not `do_extract_step`.
82
+ - Guard clauses over deep nesting. Return early on the error path; keep the happy path unindented.
83
+ - Pure functions wherever possible — especially in `hashing.py`, `workspace.py`, and `_bootstrap/extract.py`. Pure functions test trivially and don't need fixtures.
84
+ - Errors are part of the design, not afterthoughts. Every `MoonlitError` subclass has a stable `exit_code`; raise the right one as early as the spec's preflight order allows (CLI spec §4 is authoritative).
85
+ - No dead code, no commented-out blocks, no `_unused` variables left as breadcrumbs. If it's not called, delete it.
86
+ - Side effects live at the boundary (CLI layer, `resolver.py` subprocess calls, file I/O). Pure logic in the middle.
87
+
88
+ ## Invariants — don't break these
89
+
90
+ - **Build-id determinism**: `hashing.compute_build_id` hashes sorted relative paths (forward-slash, regardless of platform) interleaved with file content, separated by `\0`. Cache correctness depends on this being deterministic across runs of the same staging dir.
91
+ - **`env.json` is not part of the build_id input**. Compute the id first, then write env.json.
92
+ - **Shebang prefix goes before the zip header**, not as a zip entry. This is what makes `.pyz` files Unix-executable. `zipapp` and `zipfile` both tolerate a leading `#!...\n` line.
93
+ - **`--no-deps` on every `uv pip install`** in the build pipeline. The lockfile is the single source of truth for resolution; we do not want uv re-resolving and disagreeing with `uv.lock`.
94
+ - **`--reinstall-package <name>`** in step 6 — defensive against future changes to `uv export` semantics.
95
+ - **Cache root on Windows is `%LOCALAPPDATA%\moonlit`**, not `~/.moonlit`. Roaming profiles must not bloat.
96
+ - **`os.replace()` for atomic rename** — works on both POSIX and Windows since Python 3.3. Don't use `os.rename()` (Windows fails if the target exists).
97
+ - **Locking is `O_CREAT|O_EXCL` sentinels**, not `fcntl` (POSIX-only) or `msvcrt.locking` (Windows byte-range, different semantics). Documented limitation: stale lock on crash, recovered via `MOONLIT_FORCE_EXTRACT=1`.
98
+
99
+ ## Environment variables (runtime, read by bootstrap)
100
+
101
+ - `MOONLIT_ROOT` — override cache root.
102
+ - `MOONLIT_FORCE_EXTRACT` — re-extract even if cache exists.
103
+ - `MOONLIT_ENTRY_POINT` — override the entry point baked into env.json.
104
+
105
+ When adding new env vars, prefix with `MOONLIT_` and document them in this section.
106
+
107
+ ## Error handling conventions
108
+
109
+ - All user-facing failures raise a subclass of `MoonlitError` (in `errors.py`), each with a stable `exit_code` attribute.
110
+ - Top-level CLI catches `MoonlitError` → prints message, exits with the class's exit code.
111
+ - `KeyboardInterrupt` → exit 130, no traceback.
112
+ - Tracebacks are only shown with `--verbose`.
113
+ - Exit-code map lives in `IMPLEMENTATION_PLAN.md`. Don't reuse codes for unrelated conditions.
114
+
115
+ ## Testing
116
+
117
+ - `tests/unit/` — pure-Python unit tests, no real subprocess calls. Mock `subprocess.run` in resolver tests.
118
+ - `tests/e2e/` — runs the real `moonlit build` against a fixture workspace. Slower, hits real `uv`. Skip if `uv` isn't on PATH (don't fail).
119
+ - The bootstrap is tested by building a fixture .pyz and running it as a subprocess, then asserting on stdout/exit code.
120
+
121
+ ## Documentation
122
+
123
+ Project docs are built with **[zensical](https://zensical.org)** — the modern static-site generator from the Material for MkDocs team (Rust + Python core, differential builds, configured via `zensical.toml`).
124
+
125
+ - Markdown source lives under `docs/`. Configuration lives in `zensical.toml` at the repo root.
126
+ - Add zensical as a dev/docs dependency: `uv add --group docs zensical`. Build with `uv run zensical build`; local preview with `uv run zensical serve`.
127
+ - **Do not introduce MkDocs, Sphinx, Read the Docs Sphinx theme, or any other docs framework.** Zensical is the chosen tool; switching is not in scope.
128
+
129
+ ## Common commands
130
+
131
+ ```powershell
132
+ # Run the CLI from source during development
133
+ uv run moonlit build --help
134
+
135
+ # Build moonlit's own wheel
136
+ uv build --wheel
137
+
138
+ # Run tests
139
+ uv run pytest
140
+
141
+ # Build the docs site (zensical)
142
+ uv run zensical build
143
+ uv run zensical serve # local preview at http://127.0.0.1:8000
144
+
145
+ # Build a .pyz from a workspace member (the canonical demo)
146
+ cd C:\tmp\moonlit-demo
147
+ uv lock
148
+ uv run moonlit build --package shouter -e shouter.cli:main -o shouter.pyz
149
+ python .\shouter.pyz
150
+ ```
151
+
152
+ ## Out of scope (don't implement without asking)
153
+
154
+ The following are intentionally deferred from the MVP. If a task touches one of these, surface it before doing the work:
155
+
156
+ - `--reproducible` builds (zeroed mtimes, sorted entries, `SOURCE_DATE_EPOCH`)
157
+ - `--compile-pyc`
158
+ - `--no-modify` hash verification at runtime
159
+ - `--preamble` script
160
+ - `--extend-pythonpath` for subprocesses
161
+ - `--site-packages` extra-dirs flag
162
+ - `moonlit info <pyz>` subcommand
163
+ - `--windows-exe` launcher (distlib-style native .exe wrapping)
164
+ - Real `fcntl.flock` / `msvcrt.LK_NBLCK` locking (replacement for the sentinel approach)
165
+ - Cross-interpreter builds (`--python-version` / `--platform` pass-through to uv)
166
+
167
+ ## Platform note
168
+
169
+ Primary dev environment is **Windows 11**. PowerShell syntax for any commands surfaced to the user. Code paths that touch the filesystem, locking, or paths must be tested mentally on both Windows and POSIX before being committed.
@@ -0,0 +1,262 @@
1
+ # moonlit — uv-powered zipapp builder (MVP)
2
+
3
+ ## Context
4
+
5
+ The repo is a clean slate (just a stub `pyproject.toml` for `moonlit`, `requires-python >=3.13`, no deps, no source). The goal is a CLI tool that builds fully self-contained Python zipapps per PEP 441 — like LinkedIn's **shiv**, but using **uv** for dependency resolution/installation and supporting **uv workspaces** as a first-class concept.
6
+
7
+ User decisions locked in:
8
+ - CLI command: `moonlit` (e.g. `moonlit build -e pkg.cli:main -o app.pyz`)
9
+ - Scope: **MVP only** — build pipeline + runtime bootstrap. Defer `--reproducible`, `--compile-pyc`, `--no-modify` hash verification, `--preamble`, `--extend-pythonpath`, the `info` subcommand.
10
+ - Workspace UX: `moonlit build --package <member>` mirroring `uv`'s own flag.
11
+
12
+ Constraint: the user is on Windows 11. Cross-platform locking and the `.pyz` vs `.exe` question both have to be answered, even if the answer is "defer the Windows launcher."
13
+
14
+ ## Approach
15
+
16
+ A two-piece tool:
17
+
18
+ 1. **Build-time** (`moonlit` CLI): shell out to `uv` to resolve deps for the target package against `uv.lock`, install into a staging dir, build the target's own wheel and install it too, then assemble a `.pyz` via stdlib `zipapp`/`zipfile` semantics.
19
+ 2. **Run-time** (`_bootstrap` package baked into every .pyz): on first execution, extract site-packages to a per-build cache dir under `%LOCALAPPDATA%\moonlit` (Win) or `~/.moonlit` (POSIX), set up `sys.path` via `site.addsitedir`, then import + invoke the entry point. Subsequent runs reuse the cache.
20
+
21
+ The bootstrap is **stdlib-only** — it runs before the staged site-packages is on `sys.path`, so it cannot depend on third-party code (including click).
22
+
23
+ ## Package layout
24
+
25
+ ```
26
+ src/moonlit/
27
+ ├── __init__.py # __version__
28
+ ├── __main__.py # `python -m moonlit` -> cli.main
29
+ ├── cli.py # click command group + `build` subcommand
30
+ ├── builder.py # zipapp assembly, shebang prefix, env.json, __main__ template
31
+ ├── resolver.py # subprocess wrapper around uv (export, build, pip install --target)
32
+ ├── workspace.py # parse [tool.uv.workspace], validate --package
33
+ ├── hashing.py # sorted SHA-256 over (relpath, content) -> build_id
34
+ ├── errors.py # MoonlitError hierarchy
35
+ ├── _templates/
36
+ │ └── main_py.tmpl # source for generated __main__.py inside the .pyz
37
+ └── _bootstrap/ # SHIPPED INSIDE THE .PYZ — stdlib-only
38
+ ├── __init__.py # bootstrap() entry
39
+ ├── environment.py # parse env.json (dataclass)
40
+ ├── extract.py # cache-dir resolution, atomic extract
41
+ ├── locking.py # cross-platform file lock
42
+ └── runner.py # sys.path setup + entry-point invocation
43
+
44
+ tests/
45
+ ├── unit/ # workspace, hashing, resolver (mock subprocess), builder
46
+ └── e2e/fixtures/ # tiny demo workspace for the smoke test
47
+ ```
48
+
49
+ CLI lib choice: **click** (`>=8.1`). Justified by the planned growth of the CLI surface (info subcommand, more flags) — argparse would just get refactored away.
50
+
51
+ ## CLI surface (MVP)
52
+
53
+ ```
54
+ moonlit build [PROJECT] # positional, default = cwd
55
+ -e, --entry-point TEXT # "pkg.module:callable"
56
+ -c, --console-script TEXT # mutually exclusive with -e; resolved post-stage
57
+ -o, --output-file PATH [required]
58
+ -p, --python TEXT # shebang; default "/usr/bin/env python3"
59
+ --package TEXT # workspace member; required iff project is a workspace
60
+ --no-dev # default ON
61
+ --force # overwrite OUTPUT
62
+ -q/-v
63
+ ```
64
+
65
+ Rules:
66
+ - `-e` xor `-c` is required (exit 2 otherwise).
67
+ - `--package` is **required** when `[tool.uv.workspace]` exists, **forbidden** when it doesn't (mirrors uv).
68
+ - `-c` resolution happens after Step 6 of the build pipeline by reading `*.dist-info/entry_points.txt` from the staging dir.
69
+
70
+ ## Build pipeline
71
+
72
+ Driven from `cli.build` calling `builder.build(BuildConfig)`:
73
+
74
+ 1. **Workspace detection** (`workspace.detect(project_root)`). Parse `pyproject.toml` with `tomllib`. Expand `members` globs, apply `exclude`, return `Workspace(root, members)` or `None`. Validate `--package`.
75
+ 2. **Pick target package.** Workspace + `--package foo` → `members["foo"]`. Else → project root, name read from `[project].name`.
76
+ 3. **`uv export`** (`resolver.export`):
77
+ ```
78
+ uv export --frozen --no-dev --no-emit-workspace --format requirements-txt
79
+ [--package <name>] --output-file <tmp>/requirements.txt
80
+ ```
81
+ Run from project root. `--no-emit-workspace` strips `-e file://` self-refs.
82
+ 4. **Stage third-party deps** (`resolver.pip_install_target`):
83
+ ```
84
+ uv pip install --target <staging>/site-packages --no-deps
85
+ --requirement <tmp>/requirements.txt
86
+ --python <sys.executable>
87
+ ```
88
+ `--no-deps` because the lockfile is already complete. `--python <sys.executable>` works around uv's "needs a venv" quirk.
89
+ 5. **Build target wheel** (`resolver.build_wheel`):
90
+ ```
91
+ uv build --wheel [--package <name>] --out-dir <tmp>/dist
92
+ ```
93
+ 6. **Install target wheel into staging:**
94
+ ```
95
+ uv pip install --target <staging>/site-packages --no-deps
96
+ --reinstall-package <name> <tmp>/dist/<wheel>
97
+ ```
98
+ `--reinstall-package` is cheap insurance against future `uv export` semantics changes.
99
+ 7. **Resolve `-c`** if used. Walk `*.dist-info/entry_points.txt`, parse with `configparser`, look up `[console_scripts][name]`. Convert to `module:function`. If missing, error with the discovered list.
100
+ 8. **Compute build_id** (see "Build ID" below).
101
+ 9. **Assemble zipapp** (`builder.create_archive`):
102
+ 1. Open output as `ZipFile(path, "w", ZIP_DEFLATED)`.
103
+ 2. **Before** opening the zip, write `b"#!" + python.encode() + b"\n"` directly to the file (zipapp tolerates a prefix — same trick shiv uses).
104
+ 3. Walk `staging/site-packages/`, write each file with arcname relative to staging.
105
+ 4. Copy the `_bootstrap/` package from the installed `moonlit` location via `importlib.resources.files("moonlit") / "_bootstrap"`.
106
+ 5. Render `__main__.py` from the template:
107
+ ```python
108
+ import sys
109
+ from _bootstrap import bootstrap
110
+ sys.exit(bootstrap())
111
+ ```
112
+ 6. Write `env.json` at zip root.
113
+ 10. **Finalize.** POSIX: `os.chmod(output, 0o755)`. Windows: no-op. Tempdir cleanup.
114
+
115
+ ### Why `uv pip install --target`, not `uv add`
116
+
117
+ `uv add` is project-management: it mutates `[project.dependencies]` in `pyproject.toml`, updates `uv.lock`, and installs into the project's `.venv`. We need the opposite — a stateless "install these wheels into this staging dir, don't touch anything else." `uv pip install --target` is the right primitive. The "pip" in the name is the surface (pip-compatible flags); the implementation is native uv (Rust resolver/installer, no pip shell-out). `uv sync` and `uv tool install` are similarly project- or user-scoped and have no `--target`.
118
+
119
+ ## Bootstrap runtime
120
+
121
+ `_bootstrap/__init__.py::bootstrap()`, stdlib-only:
122
+
123
+ 1. Locate the running zipapp via `os.path.abspath(sys.argv[0])`.
124
+ 2. Read `env.json` via `zipfile.ZipFile(archive).read("env.json")`, hydrate `Environment`.
125
+ 3. Resolve cache root: `MOONLIT_ROOT` env var, else `%LOCALAPPDATA%\moonlit` (Win) / `~/.moonlit` (POSIX).
126
+ 4. `site_dir = cache_root / f"{name}_{build_id}" / "site-packages"`.
127
+ 5. Skip extraction if `site_dir` exists and `MOONLIT_FORCE_EXTRACT` is unset.
128
+ 6. Otherwise: acquire lock → re-check (TOCTOU) → extract to `cache_root / f".{name}_{build_id}.tmp.{pid}"` → `os.replace()` to final path (atomic on POSIX and Windows since Python 3.3) → release lock.
129
+ 7. `site.addsitedir(str(site_dir))` — handles `.pth` files correctly.
130
+ 8. Resolve entry point: `MOONLIT_ENTRY_POINT` env var overrides `env.entry_point`. Split on `:`, `importlib.import_module`, `getattr` walking dots.
131
+ 9. Invoke; return `0` if `None`, else `int(result)`.
132
+
133
+ **Cross-platform locking** (`_bootstrap/locking.py`): use `os.open(..., O_CREAT | O_EXCL | O_RDWR)` on a `.lock` sentinel file with a polling retry loop (50ms, 60s timeout). Portable, no `fcntl`/`msvcrt` divergence. Trade-off: stale lock on crashed extraction; recovered manually or via `MOONLIT_FORCE_EXTRACT=1`. A real `flock`/`msvcrt.LK_NBLCK` implementation is a v0.2 follow-up — document the limitation in README.
134
+
135
+ Env vars honored: `MOONLIT_ROOT`, `MOONLIT_FORCE_EXTRACT`, `MOONLIT_ENTRY_POINT`.
136
+
137
+ ## env.json schema
138
+
139
+ ```json
140
+ {
141
+ "schema_version": 1,
142
+ "name": "myapp",
143
+ "build_id": "<64hex>",
144
+ "entry_point": "myapp.cli:main",
145
+ "built_at": "2026-05-08T15:23:01Z",
146
+ "moonlit_version": "0.1.0",
147
+ "python_shebang": "/usr/bin/env python3"
148
+ }
149
+ ```
150
+
151
+ Bootstrap reads `name`, `build_id`, `entry_point`. Rest is for human inspection / future `info` subcommand.
152
+
153
+ ## Build ID
154
+
155
+ `hashing.compute_build_id(staging_root)`:
156
+
157
+ ```python
158
+ h = hashlib.sha256()
159
+ for relpath in sorted(all_files_under(staging_root)):
160
+ h.update(relpath.encode("utf-8")); h.update(b"\0")
161
+ h.update((staging_root / relpath).read_bytes()); h.update(b"\0")
162
+ return h.hexdigest()
163
+ ```
164
+
165
+ Forward-slash relpaths regardless of platform. Computed before `env.json` is written, so env.json contents don't feed into the id.
166
+
167
+ ## Windows considerations
168
+
169
+ **MVP outputs `.pyz` only.** Run via `python app.pyz` or `py app.pyz`. The shebang is harmless on Windows.
170
+
171
+ **Deferred to v0.2:** a `--windows-exe` flag that prepends a distlib launcher .exe to the zipapp to produce a native-runnable `app.exe`. Document this gap in README so users aren't surprised.
172
+
173
+ Cache root on Windows is `%LOCALAPPDATA%\moonlit` (not `~/.moonlit`) to avoid bloating roaming profiles.
174
+
175
+ ## Error cases (MVP)
176
+
177
+ | Condition | Class | Exit |
178
+ |---|---|---|
179
+ | `uv` not on PATH | `UvNotFoundError` | 3 |
180
+ | `uv.lock` missing | `NoLockfileError` | 4 |
181
+ | `--package` set, no `[tool.uv.workspace]` | `NotAWorkspaceError` | 5 |
182
+ | `--package foo` not a member | `UnknownPackageError` | 5 |
183
+ | Entry-point string unparseable | `BadEntryPointError` | 6 |
184
+ | `-c name` not in any installed dist | `ConsoleScriptNotFoundError` | 6 |
185
+ | Output exists, no `--force` | `OutputExistsError` | 7 |
186
+
187
+ `KeyboardInterrupt` → exit 130, no traceback. Tracebacks only with `--verbose`.
188
+
189
+ ## Critical files to create
190
+
191
+ - `pyproject.toml` (extend: add `click>=8.1`, `[project.scripts] moonlit = "moonlit.cli:main"`, build-system, `[tool.hatch.build.targets.wheel] packages = ["src/moonlit"]` or equivalent)
192
+ - `src/moonlit/cli.py`
193
+ - `src/moonlit/builder.py`
194
+ - `src/moonlit/resolver.py`
195
+ - `src/moonlit/workspace.py`
196
+ - `src/moonlit/hashing.py`
197
+ - `src/moonlit/errors.py`
198
+ - `src/moonlit/_templates/main_py.tmpl`
199
+ - `src/moonlit/_bootstrap/__init__.py`
200
+ - `src/moonlit/_bootstrap/environment.py`
201
+ - `src/moonlit/_bootstrap/extract.py`
202
+ - `src/moonlit/_bootstrap/locking.py`
203
+ - `src/moonlit/_bootstrap/runner.py`
204
+
205
+ Reuse from stdlib (no new deps for these): `tomllib`, `zipfile`, `zipapp`, `site`, `importlib.resources`, `importlib.import_module`, `configparser` (for entry_points.txt), `hashlib`, `subprocess`, `tempfile`.
206
+
207
+ ## Documentation
208
+
209
+ Docs are built with **[zensical](https://zensical.org)** — the modern static-site generator from the Material for MkDocs team. Rust + Python core, differential builds, configured via `zensical.toml`. Requires Python ≥3.10 (well within the project's `requires-python >=3.13`).
210
+
211
+ - Markdown source lives under `docs/`; site config in `zensical.toml` at the repo root.
212
+ - Install via a `docs` dependency group: `uv add --group docs zensical`. The group keeps it out of the runtime install closure for the `moonlit` package itself.
213
+ - Build / preview: `uv run zensical build`, `uv run zensical serve`.
214
+ - Initial docs scope (MVP-aligned): `index.md` (intro + install), `getting-started.md` (single-package and workspace examples mirroring the verification commands below), `cli-reference.md` (auto or hand-written from `moonlit build --help`), `runtime.md` (env vars, cache layout). Defer the full reference site until v0.2.
215
+ - **Do not introduce MkDocs, Sphinx, or any other docs framework** — zensical is the chosen tool.
216
+
217
+ ## Verification (end-to-end)
218
+
219
+ Create demo workspace at `C:\tmp\moonlit-demo\`:
220
+
221
+ ```
222
+ moonlit-demo/
223
+ ├── pyproject.toml # [tool.uv.workspace] members = ["packages/*"]
224
+ ├── uv.lock
225
+ └── packages/
226
+ ├── greeter/ (name="greeter", deps=["click>=8.1"], cli:main prints "hello from greeter")
227
+ └── shouter/ (name="shouter", deps=["greeter"], [tool.uv.sources] greeter={workspace=true};
228
+ cli:main imports greeter and uppercases its output)
229
+ ```
230
+
231
+ PowerShell smoke test:
232
+
233
+ ```powershell
234
+ cd C:\tmp\moonlit-demo
235
+ uv lock
236
+ uv run moonlit build --package shouter -e shouter.cli:main -o shouter.pyz
237
+ python .\shouter.pyz # expect "HELLO FROM GREETER"
238
+ $env:MOONLIT_FORCE_EXTRACT="1"; python .\shouter.pyz # re-extracts; same output
239
+ ls $env:LOCALAPPDATA\moonlit # confirm cache dir
240
+ python .\shouter.pyz # cache hit; near-instant
241
+ Remove-Item Env:MOONLIT_FORCE_EXTRACT
242
+ ```
243
+
244
+ Negative paths to spot-check:
245
+ - `moonlit build --package nonexistent` → exit 5
246
+ - `moonlit build` (no `-e`/`-c`) → exit 2
247
+ - Build twice without `--force` → exit 7
248
+ - Delete `uv.lock` and rebuild → exit 4
249
+
250
+ Unit tests cover: workspace parsing/validation, build_id determinism, resolver subprocess argv assembly (mocked), `-c` resolution against synthetic `entry_points.txt`.
251
+
252
+ ## Open risks
253
+
254
+ - **uv `--target` without active venv**: passing `--python <sys.executable>` should work, but uv's behavior here has been a moving target — verify on first implementation pass. If it breaks, fall back to creating a throwaway venv in the tempdir.
255
+ - **Build interpreter == run interpreter**: native-extension wheels are tagged for a specific Python. MVP documents "build on the same major.minor as you run." Cross-interpreter builds (`--python-version`/`--platform` pass-through to uv) deferred to v0.3.
256
+ - **Lock file robustness**: `O_CREAT|O_EXCL` sentinels leak on crash. Acceptable for MVP; real `flock`/`LK_NBLCK` is v0.2.
257
+ - **Workspace root that itself has `[project]`**: uv allows it. The `--package` validator should accept the root's name. Add a unit test.
258
+ - **`.pth` files in staged deps**: `site.addsitedir` handles them, but absolute-path `.pth` files from editable installs would point outside the staging dir. Going through wheel install (Step 6) avoids this; verify with a test that `uv export --no-emit-workspace` truly excludes editable workspace members from the requirements file.
259
+
260
+ ## Follow-ups (post-MVP, explicitly out of scope)
261
+
262
+ `--reproducible` (zeroed mtimes, sorted entries, `SOURCE_DATE_EPOCH`) · `--compile-pyc` · `--no-modify` hash verification · `--preamble` · `--extend-pythonpath` · `--site-packages` (extra dirs to bundle) · `moonlit info <pyz>` subcommand · `--windows-exe` launcher · real `flock`/`msvcrt` locking · cross-interpreter builds.
moonlit-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Phil Harrison
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
moonlit-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,163 @@
1
+ Metadata-Version: 2.4
2
+ Name: moonlit
3
+ Version: 0.1.0
4
+ Summary: uv-powered Python zipapp builder.
5
+ Project-URL: Homepage, https://github.com/OpenAfterHours/moonlit
6
+ Project-URL: Documentation, https://openafterhours.github.io/moonlit/
7
+ Project-URL: Repository, https://github.com/OpenAfterHours/moonlit
8
+ Project-URL: Issues, https://github.com/OpenAfterHours/moonlit/issues
9
+ Author: Phil Harrison
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: bundler,packaging,pep441,pyz,shiv,uv,zipapp
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Environment :: Console
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Operating System :: Microsoft :: Windows
18
+ Classifier: Operating System :: POSIX
19
+ Classifier: Programming Language :: Python :: 3
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Software Development :: Build Tools
22
+ Classifier: Topic :: System :: Archiving :: Packaging
23
+ Requires-Python: >=3.13
24
+ Requires-Dist: click>=8.1
25
+ Description-Content-Type: text/markdown
26
+
27
+ # moonlit
28
+
29
+ `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](https://peps.python.org/pep-0441/). 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.
30
+
31
+ It is similar to LinkedIn's [shiv](https://github.com/linkedin/shiv), with two differences:
32
+
33
+ - **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`.
34
+ - **uv workspaces are first-class.** `--package <member>` selects a workspace target; transitive workspace deps are bundled automatically via `uv build --all-packages`.
35
+
36
+ ## Status
37
+
38
+ Pre-release (0.x). API and CLI surface are stabilizing toward 1.0; the produced `.pyz` runtime contract is pinned by the design specs under [`specs/`](specs/).
39
+
40
+ ## Install
41
+
42
+ `moonlit` is not yet on PyPI. From source:
43
+
44
+ ```sh
45
+ git clone <repo> moonlit
46
+ cd moonlit
47
+ uv sync
48
+ uv run moonlit --help
49
+ ```
50
+
51
+ ## Quick start
52
+
53
+ In a uv-managed project (`pyproject.toml` + `uv.lock`):
54
+
55
+ ```sh
56
+ uv run moonlit build -e myapp.cli:main -o myapp.pyz
57
+ python ./myapp.pyz
58
+ ```
59
+
60
+ In a uv workspace:
61
+
62
+ ```sh
63
+ uv run moonlit build --package shouter -e shouter.cli:main -o shouter.pyz
64
+ python ./shouter.pyz
65
+ ```
66
+
67
+ The produced `.pyz` is self-contained: `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.
68
+
69
+ ## How it works
70
+
71
+ The `moonlit build` pipeline runs ten ordered steps:
72
+
73
+ 1. **Workspace detection.** Parse `[tool.uv.workspace]` from `pyproject.toml`; expand `members` globs; apply `exclude`; PEP 503 normalize.
74
+ 2. **Target selection.** Workspace + `--package <name>` → matched member; non-workspace → project root.
75
+ 3. **`uv export`** writes a frozen requirements file from `uv.lock`.
76
+ 4. **`uv pip install --target`** stages the third-party closure under a temp `site-packages/` (no venv).
77
+ 5. **`uv build --wheel`** (or `--all-packages` for workspaces) builds the target's wheel.
78
+ 6. Each produced wheel is installed into the same `site-packages/`.
79
+ 7. If `-c <script>` was used, the entry point is resolved from staged `*.dist-info/entry_points.txt`.
80
+ 8. **`build_id`** is computed: a sorted SHA-256 over every file in `site-packages/` (excluding `__pycache__`/`.pyc`).
81
+ 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`.
82
+ 10. **Atomic finalize:** temp-then-rename to the output path; POSIX `chmod 0o755`.
83
+
84
+ 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.
85
+
86
+ ## Documentation
87
+
88
+ | | |
89
+ |---|---|
90
+ | [`docs/index.md`](docs/index.md) | Overview and at-a-glance example. |
91
+ | [`docs/getting-started.md`](docs/getting-started.md) | Walkthroughs for single-package projects and uv workspaces. |
92
+ | [`docs/cli-reference.md`](docs/cli-reference.md) | Every flag, every exit code, preflight order, stdout/stderr semantics. |
93
+ | [`docs/runtime.md`](docs/runtime.md) | What runs inside the `.pyz`: cache layout, env vars, runtime exit codes, stale-lock recovery. |
94
+
95
+ The docs are built with [zensical](https://zensical.org):
96
+
97
+ ```sh
98
+ uv sync --group docs
99
+ uv run zensical serve # http://127.0.0.1:8000
100
+ ```
101
+
102
+ ## Project layout
103
+
104
+ ```
105
+ src/moonlit/
106
+ ├── cli.py # Click frontend
107
+ ├── builder.py # 10-step build pipeline orchestrator
108
+ ├── resolver.py # the only module that calls `uv` subprocesses
109
+ ├── workspace.py # parses [tool.uv.workspace]
110
+ ├── hashing.py # deterministic build_id
111
+ ├── errors.py # MoonlitError hierarchy with stable exit codes
112
+ ├── _templates/main_py.tmpl
113
+ └── _bootstrap/ # SHIPPED INSIDE EVERY .pyz — stdlib-only
114
+ ├── __init__.py # bootstrap() orchestrator
115
+ ├── environment.py # env.json validation
116
+ ├── extract.py # D4 atomic-replace, D14 fast path
117
+ ├── locking.py # O_CREAT|O_EXCL sentinel lock
118
+ ├── runner.py # site.addsitedir, entry-point resolution
119
+ └── errors.py
120
+
121
+ specs/ # Foundational design contracts (start here for hacking)
122
+ tests/
123
+ ├── unit/ # 451 unit tests
124
+ └── e2e/ # 25 contract tests via subprocess
125
+ ```
126
+
127
+ ## Status of features
128
+
129
+ | Feature | State |
130
+ |---|---|
131
+ | Build single-package projects | done |
132
+ | Build uv workspaces with transitive deps | done |
133
+ | `--entry-point` (`-e`) and `--console-script` (`-c`) | done |
134
+ | Atomic `.pyz` output (temp-then-rename) | done |
135
+ | First-run extraction + cache-hit fast path | done |
136
+ | Cross-platform caching (`%LOCALAPPDATA%`, `~/.moonlit`) | done |
137
+ | `MOONLIT_ROOT`, `MOONLIT_FORCE_EXTRACT`, `MOONLIT_ENTRY_POINT`, `MOONLIT_DEBUG` | done |
138
+ | `--reproducible` builds (zeroed mtimes, sorted entries) | deferred to v0.2 |
139
+ | `--compile-pyc` | deferred to v0.2 |
140
+ | `--no-modify` integrity verification | deferred to v0.2 |
141
+ | `--windows-exe` native launcher | deferred to v0.2 |
142
+ | Real `flock`/`msvcrt` locking | deferred to v0.2 |
143
+ | `moonlit info <pyz>` subcommand | deferred to v0.2 |
144
+
145
+ ## Contributing
146
+
147
+ Read [`CLAUDE.md`](CLAUDE.md) for development conventions and [`specs/`](specs/) for the design contracts (start with `specs/README.md`, then `specs/00-architecture.md`).
148
+
149
+ ```sh
150
+ uv run pytest # 476 tests, ~10s with e2e
151
+ uv run pytest tests/unit # unit only, <2s
152
+ uv run ruff format --check . # format check (CI gate)
153
+ uv run ruff check . # lints (CI gate)
154
+ uv run zensical build --strict # docs build (CI gate)
155
+ ```
156
+
157
+ The e2e suite (`tests/e2e/`) shells out to real `uv` and produces real `.pyz` files; it skips automatically if `uv` is not on `PATH`.
158
+
159
+ CI runs all four gates on every pull request via [`.github/workflows/ci.yml`](.github/workflows/ci.yml).
160
+
161
+ ## License
162
+
163
+ [MIT](LICENSE).