diff --git a/CLAUDE.md b/CLAUDE.md index ce76bf7..9ed570f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -74,17 +74,20 @@ is about to run, write it as a hook. - Grow `template-core` first; expose through `template-cli` and `template-py`. Don't let them diverge on core behavior. -- The wheel exposes `template_python_rust_cmd._native` (PyO3) AND a - packaged `template-cli` binary under - `src/template_python_rust_cmd/_bin/`. `ci/build_wheel.py` enforces - both are present. +- The wheel exposes `template_python_rust_cmd._native` (PyO3) AND + ships the cargo-built `template-cli[.exe]` as a raw wheel script at + `template_python_rust_cmd-.data/scripts/`. Pip extracts that + directly into the venv's `Scripts/` / `bin/` on install — no Python + shim sits in front of the binary. `ci/build_wheel.py::verify_artifacts()` + enforces both deliverables are present in the wheel. - When changing user-visible commands, update [README.md](./README.md), [UPDATE.md](./UPDATE.md), [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md), and any affected `ci/gates/.py`. -- The release pipeline stages the compiled `template-cli` binary into - `src/template_python_rust_cmd/_bin/` during the build, verifies the - resulting wheel contains both native deliverables, and removes the - staged binary afterward so the worktree stays clean. +- The release pipeline builds `template-cli` via cargo into the pinned + `CARGO_TARGET_DIR`, then `ci/build_wheel.py` post-processes the + maturin wheel to inject the binary at `.data/scripts/` with a fresh + RECORD row. There is no `_bin/` staging step under the package source + tree anymore; see #7 for the rationale (Windows `os.execv` race). ## Where to ask questions diff --git a/ENHANCE.md b/ENHANCE.md index bcb53e6..25c7b0f 100644 --- a/ENHANCE.md +++ b/ENHANCE.md @@ -34,12 +34,14 @@ When growing the scaffold, the load-bearing decisions are: ## Invariants to preserve - `template-cli` and `template-py` never diverge on core behavior. -- The Python CLI shim in `src/template_python_rust_cmd/cli.py` stays - thin: it locates the packaged binary and delegates. No business - logic. +- There is no Python CLI shim. `template-cli[.exe]` ships as a raw + wheel script at `template_python_rust_cmd-.data/scripts/`; pip + drops it straight into the venv's `Scripts/` / `bin/` on install with + no Python wrapper. Adding `[project.scripts]` back would re-introduce + the Windows `os.execv` race fixed in #7 — don't. - The wheel always contains both deliverables (PyO3 extension AND - staged native binary). `ci/build_wheel.py::verify_artifacts()` - enforces this — don't bypass it. + the raw `template-cli[.exe]` wheel script). `ci/build_wheel.py:: + verify_artifacts()` enforces this — don't bypass it. - The composite `action.yml` only references subcommands that exist in `template-cli --help`. `ci/gates/action_surface.py` checks this. diff --git a/README.md b/README.md index 2214ecd..63b8d4e 100644 --- a/README.md +++ b/README.md @@ -42,9 +42,11 @@ the rules and where they're implemented are also covered in ├── src/template_python_rust_cmd/ │ ├── __init__.py # package version + public imports │ ├── _native.pyi # typing stub for the PyO3 surface -│ ├── bindings.py # Python wrapper around the extension -│ ├── cli.py # Python entry that delegates to the native binary -│ └── _bin/ # packaged native executable location (gitignored) +│ └── bindings.py # Python wrapper around the extension +│ # `template-cli[.exe]` is NOT under the package — it's injected into +│ # the wheel's -.data/scripts/ directory by ci/build_wheel.py +│ # and pip drops it straight into the venv's Scripts/ (Win) or bin/ +│ # (POSIX) on install. See src/template_python_rust_cmd/README.md. ├── tests/ # pytest fixtures + gate contract tests └── docs/ ├── ARCHITECTURE.md @@ -91,9 +93,13 @@ gate produce noise instead of signal. See [zccache#835 rule 7](https://github.co The wheel contains: - the PyO3 extension module at `template_python_rust_cmd._native`, and -- the packaged native executable used by the `template-python-rust-cmd` - console script, staged into `src/template_python_rust_cmd/_bin/` - during build and removed afterward (gitignored). +- the cargo-built `template-cli[.exe]` binary at + `template_python_rust_cmd-.data/scripts/` — pip extracts this + straight into the venv's `Scripts/` (Windows) or `bin/` (POSIX) + directory on install, with no Python wrapper in front of it. See + [#7](https://github.com/zackees/template-python-rust-cmd/pull/7) for + why we avoid `[project.scripts]` (Windows `os.execv` is emulated and + races the shell prompt ahead of the child's stdout). `./build_wheel.py` orchestrates the maturin build, verifies the wheel contains both deliverables, and cleans up. `./publish.py` is the diff --git a/crates/template-cli/README.md b/crates/template-cli/README.md index b1db1b7..5e37884 100644 --- a/crates/template-cli/README.md +++ b/crates/template-cli/README.md @@ -1,8 +1,13 @@ # `template-cli` The bare Rust binary shipped with the Python package. Built into -`target/release/template-cli{,.exe}`, then staged into -`src/template_python_rust_cmd/_bin/` by `ci/build_wheel.py`. +`target/release/template-cli{,.exe}` (or `$CARGO_TARGET_DIR/release/` +when the wheel-build path runs — see `ci/build_wheel.py` for the pin), +then **injected directly into the wheel** at +`template_python_rust_cmd-.data/scripts/` by +`ci/build_wheel.py::inject_cli_into_wheel()`. Pip extracts the binary +straight into the venv's `Scripts/` (Win) or `bin/` (POSIX) on install +— no Python launcher in front of it. See #7 / #2 for why. ## Responsibilities @@ -31,11 +36,12 @@ you remove one, the cleanup step is the same in reverse. ## Why the binary is packaged into the wheel A Python user doing `pip install template-python-rust-cmd` gets the -`template-cli` binary on PATH (via the entry-point script). They -don't need to install Rust, and they don't need a separate -distribution channel for the binary. The wheel is one artifact for -both deliverables; `ci/build_wheel.py` enforces that the staged -binary is present. +`template-cli` binary on PATH (the wheel ships it as a raw script in +`.data/scripts/`; pip handles the extraction). They don't need to +install Rust, and they don't need a separate distribution channel for +the binary. The wheel is one artifact for both deliverables; +`ci/build_wheel.py::verify_artifacts()` enforces that the script entry +is present alongside the PyO3 extension. ## Cross-compilation diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 5278354..f8677d4 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -6,7 +6,10 @@ Ship one Python wheel that exposes: - a Python import surface backed by `PyO3` (`template_python_rust_cmd._native`) - a command surface backed by a compiled Rust executable (`template-cli`, - packaged at `template_python_rust_cmd._bin/`) + shipped as a raw wheel script at + `template_python_rust_cmd-.data/scripts/` — pip extracts it + straight into the venv's `Scripts/`/`bin/` on install, no Python + launcher; see #7 for the Windows `os.execv` race that motivated this) A consumer installs a single distribution and gets both deliverables. A maintainer maintains one workspace and one CI matrix to feed both. @@ -63,9 +66,14 @@ Five load-bearing pieces: - package version and re-exports (`__init__.py`) - Python wrapper around the extension (`bindings.py`) -- CLI shim that finds and execs the packaged native binary (`cli.py`) - typing stub for the PyO3 surface (`_native.pyi`) +The `template-cli` binary is intentionally NOT under the package — it +ships as a raw wheel script (see Goal above). A Python caller that +wants to invoke the CLI from code should do +`subprocess.run([shutil.which("template-cli"), ...])`, not import +anything from this package. + ## Hook / Gate Split | Concern | Home | diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 7014a37..b63e769 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -14,11 +14,14 @@ The canonical release sequence and the publish-script contract. ./ci.sh all uv run python ci/build_wheel.py ``` - `build_wheel.py` stages the compiled CLI binary into - `src/template_python_rust_cmd/_bin/`, drives maturin to produce - wheel + sdist, asserts both the PyO3 extension module and the - staged binary are present in the wheel, then removes the staged - binary. + `build_wheel.py` builds the CLI via cargo into the pinned + `CARGO_TARGET_DIR`, drives maturin to produce the wheel + sdist + (PyO3 extension only), then post-processes the wheel to inject the + cargo-built `template-cli[.exe]` at + `template_python_rust_cmd-.data/scripts/` with a fresh RECORD + row. `verify_artifacts()` asserts both the PyO3 extension module + and the raw `template-cli` wheel script are present. There is no + `_bin/` staging step under the package source tree — see #7. 4. Verify wheel and sdist by hand: `uv run --with twine twine check dist/*`. 5. Set `_ENABLED = True` in `ci/publish.py` (deliberately not a CLI