diff --git a/.cargo/README.md b/.cargo/README.md new file mode 100644 index 0000000..2d1525c --- /dev/null +++ b/.cargo/README.md @@ -0,0 +1,60 @@ +# `.cargo/` + +Per-repo cargo configuration. The single file here is `config.toml`, +which sets workspace-wide build settings cargo picks up without +needing a CLI flag every time. + +## Why this directory exists at all + +`Cargo.toml` declares dependencies and crate metadata. **`.cargo/ +config.toml`** is where you put settings that affect *the build +process itself* — target-specific linker flags, build-script +environment, registry overrides, rustflags for the whole workspace. + +The two files have different audiences: + +- `Cargo.toml` is the contract for consumers of this workspace. +- `.cargo/config.toml` is build-system policy for this checkout. + +## Common contents + +Typical entries you might see here, depending on what the template +ends up needing: + +```toml +[build] +# Workspace-wide rustflags. Be careful — this affects every crate. + +[target.x86_64-pc-windows-msvc] +# Per-target linker settings. + +[net] +# Registry/git fetch policy. Useful for CI but not generally. + +[profile.release] +# Override the default release profile across the workspace. +``` + +## What does NOT belong here + +- **Per-user paths.** Anything user-specific lives in `~/.cargo/ + config.toml`. Checked-in config should be reproducible across + contributors. +- **Secrets / tokens.** Never. The CI registry token comes from a + GitHub Actions secret, not from this file. +- **Toolchain pinning.** That's `rust-toolchain.toml` at the repo + root. + +## Interaction with the workflow + +The CI workflow's cache key (in `.github/workflows/ci.yml`) hashes +both `Cargo.lock` and `rust-toolchain.toml`. If you add a build flag +here that changes ABI / artifact shape, also tweak the cache key so +stale caches don't bleed in across config changes. + +## Reference + +The full schema for `config.toml` is in the +[cargo book](https://doc.rust-lang.org/cargo/reference/config.html). +Stick to what you actively need — every extra knob here is a +configuration drift surface. diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..fcb07c1 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,12 @@ +[profile.dev] +debug = "line-tables-only" +incremental = true + +[profile.debug] +inherits = "dev" +debug = "full" +incremental = true + +[profile.test] +debug = "line-tables-only" +incremental = false diff --git a/.claude/README.md b/.claude/README.md new file mode 100644 index 0000000..5ded6a2 --- /dev/null +++ b/.claude/README.md @@ -0,0 +1,58 @@ +# `.claude/` + +Per-project configuration for Claude Code. Right now this directory +just holds `settings.json`, which wires the four canonical hooks +defined in `ci/hooks/`. + +## What's wired + +`settings.json` binds each hook to its event: + +| Event | Hook | Matcher | +|-------------|-----------------------------------|----------------| +| PreToolUse | `ci/hooks/tool_guard.py` | Bash/Shell/PS | +| PostToolUse | `ci/hooks/readme_guard.py` | Edit/Write | +| PostToolUse | `ci/hooks/loc_guard.py` | Edit/Write | +| SessionStart| `ci/hooks/check-on-start.py` | any | + +All hooks are invoked through `uv run --no-project --script`, the +same discipline the rest of the repo follows. This avoids the maturin +auto-build trap that would otherwise fire on every hook execution +(see `ci.sh` for the rationale). + +## Why hooks live in the repo, not user settings + +Two reasons: + +1. **Consistency.** Every contributor sees the same gates fire. The + hook bodies are version-controlled, reviewed in PRs, and evolve + with the project. User-level hook configuration is fine for + personal helpers but doesn't survive a fresh checkout or a + teammate joining. + +2. **Hooks ARE the contract.** A bare `cargo build` triggering soldr + discovery rules, a `python` invocation routing through `uv`, the + LOC budget — these are project policy, not user preference. They + belong with the source they protect. + +## What's intentionally NOT here + +- **Slash commands / skills.** Those live in `~/.claude/skills/` or + `~/.claude/commands/` per user. If a project ever needs a skill + bundled, this directory is a fine place to put it (`.claude/skills/` + is picked up by Claude Code automatically). + +- **Custom agents.** Same as skills — user-level by default. + +- **API keys or secrets.** Never. Use env vars or a secret manager. + +## Settings.json discipline + +If you add a new hook to `ci/hooks/.py`, register it here. If +you remove one, remove the binding. The +[`fewer-permission-prompts` skill](https://docs.claude.com/en/docs/claude-code/skills) +can help if local development hits permission churn — keep additions +under PreToolUse-allowlist, not under hook bypass. + +For the full hook schema, see the [Claude Code hooks +documentation](https://docs.claude.com/en/docs/claude-code/hooks). diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..7316b0a --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,42 @@ +{ + "$schema": "https://raw.githubusercontent.com/anthropics/claude-code/main/schemas/settings.json", + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash|Shell|PowerShell", + "hooks": [ + { + "type": "command", + "command": "uv run --no-project --script ci/hooks/tool_guard.py" + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "Edit|Write|MultiEdit", + "hooks": [ + { + "type": "command", + "command": "uv run --no-project --script ci/hooks/readme_guard.py" + }, + { + "type": "command", + "command": "uv run --no-project --script ci/hooks/loc_guard.py" + } + ] + } + ], + "SessionStart": [ + { + "matcher": ".*", + "hooks": [ + { + "type": "command", + "command": "uv run --no-project --script ci/hooks/check-on-start.py" + } + ] + } + ] + } +} diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..fdc2c54 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,27 @@ +# Normalize line endings to LF for all text files. Without this the +# Linux ruff format check fails on CRLF-terminated files committed +# from Windows. +* text=auto eol=lf + +# Shell scripts and Python sources must be LF. +*.sh text eol=lf +*.py text eol=lf +*.yml text eol=lf +*.yaml text eol=lf +*.md text eol=lf +*.toml text eol=lf +*.json text eol=lf + +# Treat these as binary so git doesn't try to convert line endings. +*.png binary +*.jpg binary +*.gif binary +*.pdf binary +*.so binary +*.dylib binary +*.pyd binary +*.exe binary + +# Lockfiles stay as-is. +Cargo.lock text eol=lf +uv.lock text eol=lf diff --git a/.github/README.md b/.github/README.md new file mode 100644 index 0000000..f1e2d57 --- /dev/null +++ b/.github/README.md @@ -0,0 +1,56 @@ +# `.github/` + +GitHub-specific metadata: the CI workflow that calls `./ci.sh` per +platform, issue and PR templates (if any), and CODEOWNERS hooks. Kept +deliberately thin per [zackees/zccache#835 rule 6](https://github.com/zackees/zccache/issues/835): +every CI step is a single `run: ./ci.sh ` line. The actual logic +lives in `ci/gates/*.py` so the same bytes run on a developer laptop. + +## Layout + +``` +.github/ +└── workflows/ + └── ci.yml # 8-platform matrix; every step is `./ci.sh ` +``` + +## Workflow shape + +`workflows/ci.yml` declares one matrix entry per platform target: + +- `linux-x86` (ubuntu-latest, x86_64-unknown-linux-gnu) +- `linux-x86-musl` (ubuntu-latest, x86_64-unknown-linux-musl) +- `linux-arm` (ubuntu-24.04-arm, aarch64-unknown-linux-gnu) +- `linux-arm-musl` (ubuntu-24.04-arm, aarch64-unknown-linux-musl) +- `mac-x86` (macos-13, x86_64-apple-darwin) +- `mac-arm` (macos-14, aarch64-apple-darwin) +- `windows-x86` (windows-latest, x86_64-pc-windows-msvc) +- `windows-arm` (windows-11-arm, aarch64-pc-windows-msvc) + +Each runner sets up uv + rustup, then runs each gate as a separate +named step with `continue-on-error: true` — except `build`, which is +fatal (a failing build short-circuits downstream gates because they'd +produce noise against an uncompiled tree). A final reporting step +collects step outcomes and exits non-zero with the list of failed +gates so the PR check surface is "exactly these gates need attention," +not "the run failed, hunt through logs." + +## Why this folder is intentionally small + +The historic anti-pattern is multi-line shell embedded in YAML — +unlintable, untestable, only validates when CI runs. Pushing logic +into `ci/gates/.py` makes each gate `import ci.gates.fmt; +ci.gates.fmt.run()` from a future `tests/test_gates.py`. The +workflow file only has to know which gates exist (it lists them by +name), not what they do. + +## Adding a new gate to CI + +1. Write the gate at `ci/gates/.py` with `def run() -> int`. +2. Register it in `ci.py::GATE_ORDER`. +3. Add a row under the matrix step list in `workflows/ci.yml` that + runs `./ci.sh ` with the appropriate `continue-on-error` + policy. + +No multi-line shell — if you find yourself reaching for +`run: |\n ...`, the logic belongs in the gate, not the workflow. diff --git a/.github/workflows/README.md b/.github/workflows/README.md new file mode 100644 index 0000000..7e19b98 --- /dev/null +++ b/.github/workflows/README.md @@ -0,0 +1,66 @@ +# `.github/workflows/` + +GitHub Actions workflows. Currently just `ci.yml`. Other workflows +(release-auto, build-dist, etc.) get added here as the template evolves. + +## `ci.yml` + +The canonical CI workflow. One runner per platform, every step is a +`run: ./ci.sh ` line — no multi-line shell. The 8-platform matrix +covers: + +- linux-x86, linux-x86-musl +- linux-arm, linux-arm-musl +- mac-x86, mac-arm +- windows-x86, windows-arm + +### Step contract + +Every gate step has the same shape: + +```yaml +- name: + id: + continue-on-error: true # except `build` + run: ./ci.sh +``` + +A final `report-failures` step inspects `steps..outcome` for each +gate and exits 1 with a summary if anything failed. The exception is +`build`: it's the only fatal step. A failing build halts the matrix +job because every downstream gate would emit noise against an +uncompiled tree (see [zccache#835 rule 7](https://github.com/zackees/zccache/issues/835)). + +### Caching policy + +- `actions/setup-python` with `uv` cache key keyed off `uv.lock`. +- Rust toolchain cache keyed off `rust-toolchain.toml`. +- `target/` cache keyed off `Cargo.lock` + the toolchain version. + +The cargo cache is shared across `fmt`, `clippy`, `build`, and `test` +within a single runner because they all live on the same matrix entry — +splitting them across separate matrix entries would defeat that +amortization (and is the historical anti-pattern this workflow shape +fixes). + +### Local reproducibility + +Anything you can do here, you can do locally: + +``` +./ci.sh fmt +./ci.sh clippy +./ci.sh all +``` + +The bytes are identical because both paths call the same +`ci/gates/.run()`. + +### Adding a workflow + +New workflows belong here as `.github/workflows/.yml`. Keep them +thin: orchestration only, logic in Python under `ci/`. If a workflow +needs a script that isn't a gate (e.g., a release pipeline step), put +it under `ci/` with a `def main() -> int` entry point and call it the +same way: `run: ./ci.sh` is fine for gates, or a dedicated +`run: uv run --no-project --script ci/.py` for one-offs. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d8b6e50 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,165 @@ +# Canonical CI workflow for hybrid Rust+Python repos. +# +# Shape (per zackees/zccache#835): +# - One matrix entry per platform target. +# - Every gate is a separate step calling `./ci.sh ` — no +# multi-line shell embedded in this file. Logic lives in +# `ci/gates/.py`. +# - continue-on-error: true on every gate except `build` (fatal). +# - Final `report-failures` step collects step outcomes and exits +# non-zero with the list of failed gates. + +name: ci + +on: + push: + branches: [main] + pull_request: + +# Cancel superseded runs of the same ref so concurrent pushes don't pile +# up runner time on PR-amend flurries. +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + ci: + name: ${{ matrix.target }} + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + include: + - target: linux-x86 + runner: ubuntu-latest + rust_target: x86_64-unknown-linux-gnu + - target: linux-x86-musl + runner: ubuntu-latest + rust_target: x86_64-unknown-linux-musl + - target: linux-arm + runner: ubuntu-24.04-arm + rust_target: aarch64-unknown-linux-gnu + - target: linux-arm-musl + runner: ubuntu-24.04-arm + rust_target: aarch64-unknown-linux-musl + - target: mac-x86 + runner: macos-13 + rust_target: x86_64-apple-darwin + - target: mac-arm + runner: macos-14 + rust_target: aarch64-apple-darwin + - target: windows-x86 + runner: windows-latest + rust_target: x86_64-pc-windows-msvc + - target: windows-arm + runner: windows-11-arm + rust_target: aarch64-pc-windows-msvc + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + python-version: "3.13" + + # PyO3 0.23 supports up to Python 3.13. Some runners (notably + # macos-14) default to Python 3.14, which fails pyo3-ffi's build + # script with "configured Python interpreter version (3.14) is + # newer than PyO3's maximum supported version (3.13)". Pin + # python3 -> 3.13 on PATH so pyo3's build.rs picks the right + # interpreter. Drop this once pyo3 is bumped past 0.24. + - name: Pin Python on PATH + shell: bash + run: | + uv python install 3.13 + py="$(uv python find 3.13)" + echo "$(dirname "$py")" >> "$GITHUB_PATH" + echo "PYO3_PYTHON=$py" >> "$GITHUB_ENV" + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.rust_target }} + components: clippy, rustfmt + + - name: Cache cargo target + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: cargo-${{ matrix.target }}-${{ hashFiles('Cargo.lock', 'rust-toolchain.toml') }} + restore-keys: | + cargo-${{ matrix.target }}- + + - name: loc + id: loc + continue-on-error: true + shell: bash + run: ./ci.sh loc + + - name: fmt + id: fmt + continue-on-error: true + shell: bash + run: ./ci.sh fmt + + - name: clippy + id: clippy + continue-on-error: true + shell: bash + run: ./ci.sh clippy + + - name: ruff + id: ruff + continue-on-error: true + shell: bash + run: ./ci.sh ruff + + # build is fatal: every later gate produces noise against an + # uncompiled tree, so we don't continue-on-error here. + - name: build + id: build + shell: bash + run: ./ci.sh build + + - name: test + id: test + continue-on-error: true + shell: bash + run: ./ci.sh test + + - name: action-yaml + id: action_yaml + continue-on-error: true + shell: bash + run: ./ci.sh action_yaml + + - name: action-surface + id: action_surface + continue-on-error: true + shell: bash + run: ./ci.sh action_surface + + - name: report-failures + if: always() + shell: bash + run: | + fail=() + [ "${{ steps.loc.outcome }}" = "failure" ] && fail+=("loc") || true + [ "${{ steps.fmt.outcome }}" = "failure" ] && fail+=("fmt") || true + [ "${{ steps.clippy.outcome }}" = "failure" ] && fail+=("clippy") || true + [ "${{ steps.ruff.outcome }}" = "failure" ] && fail+=("ruff") || true + [ "${{ steps.test.outcome }}" = "failure" ] && fail+=("test") || true + [ "${{ steps.action_yaml.outcome }}" = "failure" ] && fail+=("action_yaml") || true + [ "${{ steps.action_surface.outcome }}" = "failure" ] && fail+=("action_surface") || true + if [ ${#fail[@]} -gt 0 ]; then + echo "FAILED: ${fail[*]}" + exit 1 + fi + echo "All gates passed." diff --git a/.gitignore b/.gitignore index ad67955..9d89f45 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,26 @@ target # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ + +# Python build / venv / cache +__pycache__/ +*.py[cod] +.venv/ +dist/ +build/ +*.egg-info/ +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ + +# Maturin staged native binary (build_wheel.py copies it in, then removes it) +src/template_python_rust_cmd/_bin/* +!src/template_python_rust_cmd/_bin/.gitkeep + +# Maturin extension module built in-place by `maturin develop` +src/template_python_rust_cmd/_native*.pyd +src/template_python_rust_cmd/_native*.so +src/template_python_rust_cmd/_native*.dylib + +# Cache used by ci/hooks/check-on-start.py +.cache/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..ce76bf7 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,94 @@ +# CLAUDE.md + +Guidance for Claude Code (and any agent) working in this repository. + +This is the **canonical hybrid Rust + Python template**. Practices that +land here propagate to every downstream consumer seeded by +`gh repo create --template zackees/template-python-rust-cmd`. Bias +toward keeping things tight and load-bearing; the leverage is high. + +## Essential Rules + +1. **Always run gates through `./ci.sh `.** Never paste + `uv run python ci/gates/...` or `cargo clippy` directly into a + command. The `ci/hooks/tool_guard.py` PreToolUse hook blocks bare + forms and tells you why. +2. **Reserve full `uv run` (without `--no-project --script`) for named + build entry points: `./test`, `./build`, `ci/build_wheel.py`, + `./publish`, `./install`.** Everything else needs the protective + flags — see `ci.sh` for the rationale. +3. **Every directory must have a `README.md` of ≥ 50 lines.** Enforced + by `ci/hooks/readme_guard.py` on every edit. +4. **Source files ≤ 1000 lines (warn) / ≤ 1500 (fail).** Enforced both + on every CI run (`ci/gates/loc.py`) and per-edit + (`ci/hooks/loc_guard.py`). Split convention: + `foo.rs` → `foo/mod.rs` + per-domain submodules with `pub use` + re-exports in `mod.rs`. +5. **Logic lives in Python under `ci/`; YAML stays thin.** Every CI + step is `run: ./ci.sh `. No multi-line shell embedded in + `.github/workflows/ci.yml`. +6. **`build` is the only fatal gate.** A failing build halts the rest + of the run because every downstream gate would produce noise + against an uncompiled tree. + +## Commands + +```bash +./install # verify toolchain shape (no maturin build) +./ci.sh fmt # one gate +./ci.sh all # every gate, continue past failures +./ci.sh --list # show registered gates +./test # cargo test + maturin develop + pytest +./lint # convenience: fmt + clippy + ruff +``` + +For the full design rationale see +[zackees/zccache#835](https://github.com/zackees/zccache/issues/835). + +## Hooks vs Gates + +| Concern | Home | +|----------------------------------------|----------------------------| +| Repo-state checks (`git push` catches it) | `ci/gates/*.py` | +| Agent-intent checks (only during sessions)| `ci/hooks/*.py` | +| LOC budget across the workspace | `ci/gates/loc.py` | +| LOC growth on this edit | `ci/hooks/loc_guard.py` | +| README presence + size | `ci/hooks/readme_guard.py` | +| Bare cargo / unsafe `uv run` shape | `ci/hooks/tool_guard.py` | + +If a rule would fire equally well on a `git push` from a terminal as +from a Claude edit, write it as a gate. If it needs to know what tool +is about to run, write it as a hook. + +## Repo Shape + +- `crates/template-core` — reusable Rust library logic +- `crates/template-cli` — bare Rust binary (the packaged CLI) +- `crates/template-py` — `PyO3` wrapper crate +- `src/template_python_rust_cmd/` — thin Python surface, packaging glue, + Python CLI shim +- `ci/` — automation (see `ci/README.md`) +- `action.yml`, `action/cleanup/action.yml` — composite action contract + +## Working Rules + +- 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. +- 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. + +## Where to ask questions + +- Design rationale → [zackees/zccache#835](https://github.com/zackees/zccache/issues/835) +- Architecture → [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md) +- Release flow → [docs/RELEASE.md](./docs/RELEASE.md) +- Linting policy → [LINTING.md](./LINTING.md) diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..ca8eb94 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,202 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "libc" +version = "0.2.184" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pyo3" +version = "0.23.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7778bffd85cf38175ac1f545509665d0b9b92a198ca7941f131f85f7a4f9a872" +dependencies = [ + "cfg-if", + "indoc", + "libc", + "memoffset", + "once_cell", + "portable-atomic", + "pyo3-build-config", + "pyo3-ffi", + "pyo3-macros", + "unindent", +] + +[[package]] +name = "pyo3-build-config" +version = "0.23.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f6cbe86ef3bf18998d9df6e0f3fc1050a8c5efa409bf712e661a4366e010fb" +dependencies = [ + "once_cell", + "target-lexicon", +] + +[[package]] +name = "pyo3-ffi" +version = "0.23.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f1b4c431c0bb1c8fb0a338709859eed0d030ff6daa34368d3b152a63dfdd8d" +dependencies = [ + "libc", + "pyo3-build-config", +] + +[[package]] +name = "pyo3-macros" +version = "0.23.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbc2201328f63c4710f68abdf653c89d8dbc2858b88c5d88b0ff38a75288a9da" +dependencies = [ + "proc-macro2", + "pyo3-macros-backend", + "quote", + "syn", +] + +[[package]] +name = "pyo3-macros-backend" +version = "0.23.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fca6726ad0f3da9c9de093d6f116a93c1a38e417ed73bf138472cf4064f72028" +dependencies = [ + "heck", + "proc-macro2", + "pyo3-build-config", + "quote", + "syn", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "template-cli" +version = "0.1.0" +dependencies = [ + "anyhow", + "template-core", +] + +[[package]] +name = "template-core" +version = "0.1.0" +dependencies = [ + "anyhow", +] + +[[package]] +name = "template-py" +version = "0.1.0" +dependencies = [ + "pyo3", + "template-core", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unindent" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..50543cb --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,19 @@ +[workspace] +resolver = "2" +members = [ + "crates/template-core", + "crates/template-cli", + "crates/template-py", +] + +[workspace.package] +version = "0.1.0" +edition = "2021" +rust-version = "1.85" +license = "MIT" +repository = "https://github.com/example/template-python-rust-cmd" +homepage = "https://github.com/example/template-python-rust-cmd" + +[workspace.dependencies] +anyhow = "1" +pyo3 = { version = "0.23", features = ["extension-module"] } diff --git a/ENHANCE.md b/ENHANCE.md new file mode 100644 index 0000000..bcb53e6 --- /dev/null +++ b/ENHANCE.md @@ -0,0 +1,50 @@ +# Enhancement Notes + +When growing the scaffold, the load-bearing decisions are: + +## Where new logic goes + +- **Reusable behavior** → `crates/template-core`. Both the CLI binary + and the PyO3 bindings depend on this crate; growth here propagates + to both consumers for free. +- **CLI commands** → `crates/template-cli`. Subcommands here become + part of the composite action's surface contract — adding a + subcommand means updating `action.yml`'s shell snippets and letting + `ci/gates/action_surface.py` verify the binary still exports it. +- **Public Python API** → `src/template_python_rust_cmd/bindings.py`. + Keep the wrapper thin: each function should be a near-1:1 reflection + of the underlying `_native` call, with type annotations and a + one-line docstring. +- **Native Rust boundary** → `crates/template-py/src/lib.rs`. PyO3 + decorators belong here, not in `template-core`. +- **CI logic** → `ci/gates/.py`. Never in the workflow YAML. + +## Where new infrastructure goes + +- **New gate** → `ci/gates/.py` exposing `def run() -> int`, + registered in `ci.py::GATE_ORDER`. +- **New hook** → `ci/hooks/.py` reading JSON from stdin, + wired in `.claude/settings.json`. +- **New named build entry point** → script at repo root with shebang + `#!/usr/bin/env bash` (route to `./ci.sh`) or + `#!/usr/bin/env -S uv run --script` for inline Python; add its name + to `ci/hooks/tool_guard.py::BUILD_ENTRY_POINTS` so the hook knows + it's allowed to use full `uv run`. + +## 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. +- 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 composite `action.yml` only references subcommands that exist + in `template-cli --help`. `ci/gates/action_surface.py` checks this. + +## When in doubt + +Read [CLAUDE.md](./CLAUDE.md) for the essential rules, then +[UPDATE.md](./UPDATE.md) for the change checklist, then the relevant +gate's docstring for why the check exists. diff --git a/LINTING.md b/LINTING.md new file mode 100644 index 0000000..1cbef48 --- /dev/null +++ b/LINTING.md @@ -0,0 +1,62 @@ +# Linting Policy + +The CI gates that lint the repo, in canonical order. + +## Rust + +Two gates, both via `./ci.sh`: + +| Gate | Command | +|-------------------|------------------------------------------------------------------------| +| `./ci.sh fmt` | `cargo fmt --all -- --check` | +| `./ci.sh clippy` | `cargo clippy --workspace --all-targets -- -D warnings` | + +Both run on every push and locally. Failing format is fixable with +`cargo fmt --all`; failing clippy points at a real issue (a denied +warning, an unused-result, etc.). + +## Python + +One gate covering lint + format: + +| Gate | Command | +|-------------------|------------------------------------------------------------------------| +| `./ci.sh ruff` | `ruff check src tests ci ci.py` + `ruff format --check src tests ci ci.py` | + +`ruff` is provisioned at script-time via `uv run --no-project --with +ruff>=0.8`, so it never triggers a maturin build to lint a few `.py` +files. + +## LOC Budget + +| Gate | Threshold | +|-------------------|------------------------------------| +| `./ci.sh loc` | warn > 1000, fail > 1500 (per file)| + +Split convention printed on every failure: +`foo.rs` → `foo/mod.rs` + per-domain submodules, with `pub use` +re-exports in `mod.rs` so the public path is unchanged. The +per-edit half lives in `ci/hooks/loc_guard.py`. + +## Agent Maintenance Rule + +If you add new tooling: + +- write the gate at `ci/gates/.py` with `def run() -> int` +- register it in `ci.py::GATE_ORDER` +- add a step to `.github/workflows/ci.yml` +- update [README.md](./README.md) and this file +- explain in the gate's docstring why this gate belongs here instead + of the language-native default + +## What's intentionally NOT here + +- **`mypy` / `pyright`.** Type-checking the thin Python surface adds + more noise than signal for a template; downstream consumers that + grow real Python should add a gate. Pattern: `ci/gates/pyright.py` + with `subprocess.run(["uv", "run", "--no-project", "--with", + "pyright", "pyright", "src", "tests"])`. +- **`dylint`.** Rust dylints are heavy (large dependency graph) and + the canonical template doesn't depend on any. Downstream consumers + add `ci/gates/dylint.py` if they want it; the Docker-for-Rust gate + shape (zccache#835 rule 11) is the right amplifier when they do. diff --git a/README.md b/README.md index b2c4000..2214ecd 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,116 @@ # template-python-rust-cmd -Python bindings to a rust binary / libary + +Canonical scaffold for a hybrid Rust + Python package with two native +deliverables: + +- a bare Rust command shipped into the Python install as the CLI backend +- a `PyO3` extension module exposed to Python as a `.pyd` / `.so` + +This repo is a **template** — every future hybrid Rust+Python project +seeded from `gh repo create --template zackees/template-python-rust-cmd` +inherits its CI gates, hooks, and uv-run discipline by construction. If +you're auditing the CI shape of a downstream consumer, the source of +truth is here. + +The design rationale lives in [`zackees/zccache#835`](https://github.com/zackees/zccache/issues/835). +Each rule (1–10) has a one-line summary in [CLAUDE.md](./CLAUDE.md); +the rules and where they're implemented are also covered in +[`docs/ARCHITECTURE.md`](./docs/ARCHITECTURE.md). + +## Repo Layout + +```text +. +├── Cargo.toml # Rust workspace root +├── pyproject.toml # Python package + maturin build config +├── rust-toolchain.toml # pinned Rust toolchain +├── action.yml # composite GitHub Action (root entry) +├── action/cleanup/action.yml # paired post-job cleanup action +├── ci.sh # canonical CI dispatcher (bash wrapper) +├── ci.py # PEP 723 dispatcher (called by ci.sh) +├── ci/ +│ ├── gates/ # repo-state checks (run on every push) +│ ├── hooks/ # agent-intent guards (Claude Code only) +│ ├── build_wheel.py # release-flow: stage CLI + maturin build +│ └── publish.py # release-flow: twine upload (guarded) +├── .github/workflows/ci.yml # 8-platform matrix; every step is ./ci.sh +├── .claude/settings.json # hook wiring for Claude Code +├── crates/ +│ ├── template-core/ # reusable Rust library logic +│ ├── template-cli/ # bare Rust binary +│ └── template-py/ # PyO3 bindings crate +├── 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) +├── tests/ # pytest fixtures + gate contract tests +└── docs/ + ├── ARCHITECTURE.md + └── RELEASE.md +``` + +## Development Flow + +```bash +./install # verify uv, rustup, and pinned toolchain +./ci.sh fmt # one gate +./ci.sh all # every gate, continue past failures +./test # cargo test + maturin develop + pytest (full build) +./publish # guarded twine upload (must set _ENABLED first) +``` + +The dispatcher's flag discipline (`uv run --no-project --script`) is +load-bearing — see [`ci.sh`](./ci.sh) for the rationale. Bare `uv run` +on a maturin-backed project walks up to `pyproject.toml` and triggers a +full wheel build *before* your script starts, blowing up a 200 ms gate +into a 5+ minute cold compile. The wrapper exists to keep that flag +combo in one place. + +## CI Surface + +`./ci.sh all` runs every gate registered in `ci.py::GATE_ORDER`: + +| Gate | What it does | +|-------------------|---------------------------------------------------------------------------| +| `loc` | Workspace LOC budget (warn > 1000, fail > 1500). | +| `fmt` | `cargo fmt --all -- --check`. | +| `clippy` | `cargo clippy --workspace --all-targets -D warnings`. | +| `ruff` | `ruff check` + `ruff format --check` over src / tests / ci. | +| `build` | `cargo check --workspace --all-targets`. **Fatal** — halts `all` on fail. | +| `test` | `cargo test --workspace` + `maturin develop` + `pytest`. | +| `action_yaml` | Structural check of `action.yml` + `action/cleanup/action.yml`. | +| `action_surface` | Subcommands referenced from `action.yml` exist in `template-cli --help`. | + +`build` is the only fatal gate: a failing build would make every later +gate produce noise instead of signal. See [zccache#835 rule 7](https://github.com/zackees/zccache/issues/835). + +## Packaging Intent + +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). + +`./build_wheel.py` orchestrates the maturin build, verifies the wheel +contains both deliverables, and cleans up. `./publish.py` is the +guarded upload — it exits until `_ENABLED = True` is set. + +## Composite Action + +Downstream consumers can pin this repo as a composite action: + +```yaml +- uses: zackees/template-python-rust-cmd@v1 + with: + version: "0.1.0" +- uses: zackees/template-python-rust-cmd/action/cleanup@v1 + if: always() +``` + +The action installs the package via `uv tool install`, exposes +`template-cli` on PATH, and emits `binary-path` as an output. The +cleanup sibling step removes the install and prunes the uv cache. diff --git a/UPDATE.md b/UPDATE.md new file mode 100644 index 0000000..2750c64 --- /dev/null +++ b/UPDATE.md @@ -0,0 +1,49 @@ +# Update Procedure + +When changing repo structure, CI gates, release flow, or agent +guidance, walk this checklist so nothing rots out of sync. + +## Repo structure / CI + +1. Update the relevant manifest, gate, or script. +2. If you added a gate: + - `ci/gates/.py` with `def run() -> int`. + - Register in `ci.py::GATE_ORDER`. + - Add a step to `.github/workflows/ci.yml`. + - Add a row to `tests/test_gates.py` covering the happy path. +3. If you added a hook: + - `ci/hooks/.py` reading JSON from stdin. + - Wire in `.claude/settings.json` under the right event. +4. Update [README.md](./README.md) if the user-visible workflow changed. + +## Architecture + +5. Update [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md) if crate or + package responsibilities changed. + +## Release flow + +6. Update [docs/RELEASE.md](./docs/RELEASE.md) if build or publish + behavior changed. +7. If the composite action's surface changed, update `action.yml` (and + `action/cleanup/action.yml` if the cleanup contract changed), and + re-run `./ci.sh action_yaml action_surface` locally to confirm the + gates still pass. + +## Agent guidance + +8. Update [CLAUDE.md](./CLAUDE.md) if agent-facing rules changed (the + essential-rules list, hooks vs gates split, etc.). +9. Update [LINTING.md](./LINTING.md) if the lint surface changed. + +## Versioning + +10. For version bumps, keep Python (`pyproject.toml::project.version`) + and Rust (`Cargo.toml::workspace.package.version`) aligned. The + release pipeline assumes they match. + +## Dropping the requirement + +If a step on this list stops applying (e.g., gates aren't keyed by +GATE_ORDER anymore), update this file too. An update procedure that +references removed concepts is worse than no procedure. diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..db55b4d --- /dev/null +++ b/action.yml @@ -0,0 +1,68 @@ +name: "template-python-rust-cmd" +description: >- + Composite action that installs the `template-cli` binary shipped with + the template-python-rust-cmd Python package, then exposes it on PATH + for downstream steps. Pair with `./cleanup` for a clean + post-job state. + +branding: + icon: "package" + color: "purple" + +inputs: + version: + description: >- + Version of the template-python-rust-cmd Python package to install. + Defaults to the version declared in pyproject.toml of the consuming + repo (resolved by uv). + required: false + default: "" + python-version: + description: >- + Python version uv should provision the install venv with. + Defaults to a recent CPython compatible with the package's + requires-python lower bound. + required: false + default: "3.11" + +outputs: + binary-path: + description: >- + Absolute path to the installed `template-cli` binary. + value: ${{ steps.expose.outputs.binary-path }} + +runs: + using: "composite" + steps: + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + + - name: Provision Python + shell: bash + run: | + uv python install ${{ inputs.python-version }} + + - name: Install template-python-rust-cmd via uv tool + shell: bash + run: | + if [ -n "${{ inputs.version }}" ]; then + uv tool install "template-python-rust-cmd==${{ inputs.version }}" + else + uv tool install template-python-rust-cmd + fi + + - name: Expose binary path + id: expose + shell: bash + run: | + bin_path="$(command -v template-cli)" + echo "binary-path=$bin_path" >> "$GITHUB_OUTPUT" + echo "$(dirname "$bin_path")" >> "$GITHUB_PATH" + + - name: Smoke-test the surface + shell: bash + run: | + template-cli --version + template-cli --help diff --git a/action/README.md b/action/README.md new file mode 100644 index 0000000..e0a418c --- /dev/null +++ b/action/README.md @@ -0,0 +1,54 @@ +# `action/` + +GitHub composite-action support files. The top-level `action.yml` lives +at the repo root (that's where `uses: zackees/template-python-rust-cmd@v1` +looks for it), but secondary actions — most importantly the `cleanup` +post-job step — live here so the root doesn't accumulate. + +## Layout + +``` +action/ +└── cleanup/ + └── action.yml # post-job uninstall + cache trim +``` + +## Why this split + +GitHub Actions resolves `uses: /@` to the repo +root's `action.yml`. Any *additional* composite actions consumed by +the main action (via `uses: //action/cleanup@`, +etc.) need to live in subdirectories with their own `action.yml`. +Putting them under `action/` keeps the repo root focused and matches +the convention you'll find in [`actions/cache`](https://github.com/actions/cache) +and [`actions/setup-node`](https://github.com/actions/setup-node). + +## Surface validation + +Two gates in `ci/gates/` keep the action contract honest: + +- `ci/gates/action_yaml.py` — static parse of every `action.yml` + under this tree. Asserts `runs.using == composite`, every input has + a description, every step has either `uses` or `run`, and every + `run:` step declares `shell:`. + +- `ci/gates/action_surface.py` — runtime check that subcommands + referenced by the action's shell snippets exist in + `template-cli --help`. Cheap — under a second against the built + binary — and catches the typo class of contract regressions + without paying for a real `uses: ./` end-to-end build per matrix + entry. + +See [zackees/zccache#835 rule 10](https://github.com/zackees/zccache/issues/835) +for the rationale: the contract that downstream consumers actually +care about is "does the binary's surface match what action.yml's +shell snippets call against?" That's <5 s against the already-built +binary, not minutes per platform. + +## Adding a new composite step + +1. Create `action//action.yml` with the composite shape. +2. Reference it from consumers as + `uses: /template-python-rust-cmd/action/@`. +3. The `action_yaml` gate will pick it up automatically because it + walks every `action.yml` under this tree. diff --git a/action/cleanup/README.md b/action/cleanup/README.md new file mode 100644 index 0000000..9a5aa5f --- /dev/null +++ b/action/cleanup/README.md @@ -0,0 +1,48 @@ +# `action/cleanup/` + +Post-job composite step that pairs with the root `action.yml`. Removes +the `template-python-rust-cmd` uv tool install and trims the uv cache +so the runner leaves the same shape it had before this action ran. + +Pin it explicitly with: + +```yaml +- uses: zackees/template-python-rust-cmd/action/cleanup@v1 + if: always() +``` + +The `if: always()` matters — you want cleanup to run even if an +earlier step failed, otherwise you leak the tool install into the +next job's cache restore. + +## Why this is split out (not folded into the main action's post step) + +Composite actions in GitHub Actions support a single `runs.using: +composite` with a list of steps. There's no built-in `post:` hook the +way JavaScript actions have. Consumers who want cleanup behavior have +to opt in explicitly by adding the cleanup step themselves. Splitting +it into a sibling composite action makes the opt-in mechanical: one +line, no copy-paste of the cleanup logic itself. + +## What it actually does + +1. `uv tool uninstall template-python-rust-cmd` (no-op if not installed). +2. `uv cache prune --ci` to drop entries the next job won't reuse. +3. Removes the `_bin/` staging directory if the main action created one. + +The shell snippets are short by design — anything more complex would +move into `ci/gates/cleanup.py` and be invoked via `./ci.sh cleanup`. + +## Surface contract + +Validated by `ci/gates/action_yaml.py` alongside the root `action.yml`. +Both files must satisfy: + +- top-level `name`, `description`, `runs` present; +- `runs.using == composite`; +- every input has a `description`; +- every `run:` step declares `shell:`; +- `runs.steps` is a non-empty list. + +A break in any of these fails the `action_yaml` gate, and the PR check +surface points at exactly which file went wrong. diff --git a/action/cleanup/action.yml b/action/cleanup/action.yml new file mode 100644 index 0000000..d395ba9 --- /dev/null +++ b/action/cleanup/action.yml @@ -0,0 +1,34 @@ +name: "template-python-rust-cmd: cleanup" +description: >- + Post-job step that pairs with the root action.yml. Uninstalls the + template-python-rust-cmd uv tool and trims the uv cache so the + runner leaves the same shape it had before the action ran. Add with + `if: always()` so it runs even if an earlier step failed. + +inputs: + keep-cache: + description: >- + When 'true', skip `uv cache prune --ci` and leave the uv cache + untouched. Useful for self-hosted runners where the cache is + managed externally. + required: false + default: "false" + +runs: + using: "composite" + steps: + - name: Uninstall tool + shell: bash + run: | + uv tool uninstall template-python-rust-cmd 2>/dev/null || true + + - name: Prune uv cache + if: inputs.keep-cache != 'true' + shell: bash + run: | + uv cache prune --ci 2>/dev/null || true + + - name: Remove staged binary + shell: bash + run: | + rm -rf "src/template_python_rust_cmd/_bin" 2>/dev/null || true diff --git a/ci.py b/ci.py new file mode 100755 index 0000000..046ffd7 --- /dev/null +++ b/ci.py @@ -0,0 +1,130 @@ +#!/usr/bin/env -S uv run --no-project --script +# /// script +# requires-python = ">=3.11" +# dependencies = ["pyyaml>=6"] +# /// +"""Canonical CI gate dispatcher. + +Usage: + ./ci.sh # run one gate + ./ci.sh all # run every gate, continue past failures + ./ci.sh --list # show registered gates in order + +Every gate is `ci/gates/.py` exposing `def run() -> int`. +GATE_ORDER below is the canonical sequence; `all` runs them in that +order with continue-past-failure semantics, except that a failing +`build` gate halts the run (downstream gates against an uncompiled +tree only produce noise). + +See zackees/zccache#835 rule 6 for the rationale: keep ci.yml thin, +push logic to Python, lock the contract so `./ci.sh fmt` on a laptop +runs the exact same bytes as the GHA step. +""" + +from __future__ import annotations + +import argparse +import importlib +import sys +import traceback +from pathlib import Path + +ROOT = Path(__file__).resolve().parent + +# Order matters: cheap workspace-state checks first, then language-level +# linters, then the build, then tests, then artifact-shape gates. +# `build` is the only fatal gate — see the loop below. +GATE_ORDER: list[str] = [ + "loc", + "fmt", + "clippy", + "ruff", + "build", + "test", + "action_yaml", + "action_surface", +] + +FATAL_GATES = {"build"} + + +def _load_gate(name: str): + sys.path.insert(0, str(ROOT)) + return importlib.import_module(f"ci.gates.{name}") + + +def run_one(name: str) -> int: + print(f"==> [{name}]", flush=True) + try: + mod = _load_gate(name) + except ModuleNotFoundError as exc: + print(f"[{name}] unknown gate: {exc}", file=sys.stderr) + return 1 + try: + rc = mod.run() + except Exception: + print(f"[{name}] crashed:", file=sys.stderr) + traceback.print_exc() + return 1 + if rc != 0: + print(f"[{name}] FAILED (rc={rc})", file=sys.stderr) + else: + print(f"[{name}] ok", flush=True) + return rc + + +def run_all() -> int: + failures: list[str] = [] + for name in GATE_ORDER: + rc = run_one(name) + if rc != 0: + failures.append(name) + if name in FATAL_GATES: + print( + f"\n[{name}] is fatal; halting (downstream gates would produce noise against an uncompiled tree).", + file=sys.stderr, + ) + break + print("") + if failures: + print(f"FAILED: {', '.join(failures)}", file=sys.stderr) + return 1 + print("ALL GATES PASSED") + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(prog="ci.sh", description=__doc__) + parser.add_argument( + "gate", + nargs="?", + default="all", + help="gate name (see --list) or 'all' (default)", + ) + parser.add_argument( + "--list", + action="store_true", + help="print registered gates in run order and exit", + ) + args = parser.parse_args() + + if args.list: + for name in GATE_ORDER: + print(name) + return 0 + + if args.gate == "all": + return run_all() + + if args.gate not in GATE_ORDER: + print( + f"Unknown gate: {args.gate}. Run `./ci.sh --list` for the registered gates.", + file=sys.stderr, + ) + return 2 + + return run_one(args.gate) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/ci.sh b/ci.sh new file mode 100755 index 0000000..b55ee0b --- /dev/null +++ b/ci.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# Canonical CI dispatcher for this hybrid Rust+Python repo. +# +# WHY this exists (and not a bare `uv run python ci.py`): +# With a maturin-backed pyproject.toml at the repo root, a bare `uv run` +# walks up the tree, discovers pyproject.toml, and triggers a full maturin +# wheel build *before* running anything. A 200 ms fmt-check blows up to a +# 5+ minute cold build. `--no-project` suppresses that discovery, and +# `--script` reads PEP 723 inline deps from ci.py so the gate runs in an +# isolated venv with just what it needs. +# +# Both flags are load-bearing. See zackees/zccache#835 rules 2-3 for the +# full rationale. +# +# Reserve full `uv run` (without these flags) for named build entry points +# like `./test`, `./build`, `ci/build_wheel.py` — places that genuinely +# need the maturin wheel + extension module materialized. + +set -euo pipefail +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +exec uv run --no-project --script "$script_dir/ci.py" "$@" diff --git a/ci/README.md b/ci/README.md new file mode 100644 index 0000000..150a0dd --- /dev/null +++ b/ci/README.md @@ -0,0 +1,69 @@ +# `ci/` + +Repo automation. Two structured sub-packages plus a small set of +named release-flow scripts. + +## Layout + +``` +ci/ +├── gates/ # workspace-state checks (run by ./ci.sh) +│ ├── __init__.py +│ ├── loc.py # LOC budget gate (warn 1000 / fail 1500) +│ ├── fmt.py # cargo fmt --check +│ ├── clippy.py # cargo clippy -D warnings +│ ├── ruff.py # ruff check + format --check +│ ├── build.py # cargo check --workspace (FATAL in `all`) +│ ├── test.py # cargo test + maturin develop + pytest +│ ├── action_yaml.py # composite-action structural check +│ └── action_surface.py # subcommand-vs-binary surface check +├── hooks/ # agent-intent guards (run by Claude Code) +│ ├── tool_guard.py +│ ├── readme_guard.py +│ ├── loc_guard.py +│ └── check-on-start.py +├── build_wheel.py # release-flow: stage binary + maturin build +└── publish.py # release-flow: twine upload (guarded) +``` + +## Two halves: gates vs. hooks + +| Concern | Home | +|----------------------------------------|----------------------------| +| Runs on every CI cycle | `ci/gates/*.py` | +| Runs only during a Claude/Codex session| `ci/hooks/*.py` | +| Workspace-wide LOC budget | `ci/gates/loc.py` | +| Per-edit LOC budget | `ci/hooks/loc_guard.py` | +| README presence + size | `ci/hooks/readme_guard.py` | +| Bare cargo/uv shape ban | `ci/hooks/tool_guard.py` | + +If a rule would fire on a `git push` from a terminal the same way it +would fire on a Claude edit, write it as a gate. If it needs to see +what tool is about to run, write it as a hook. See [zccache#835 rule 9](https://github.com/zackees/zccache/issues/835). + +## Release-flow scripts + +`build_wheel.py` and `publish.py` are NOT gates. They're named entry +points that opt INTO the full maturin context — see [zccache#835 rule +5](https://github.com/zackees/zccache/issues/835). The `tool_guard` +hook recognizes them by name and allows `uv run` without +`--no-project --script` from inside them. + +## Conventions + +- Every gate file exposes a single `def run() -> int`. +- Every hook file is invoked as `uv run --no-project --script + ci/hooks/.py`. +- No multi-line shell in `.github/workflows/ci.yml` — if you can't fit + a CI step on one line as `./ci.sh `, the logic belongs as a + gate. +- Release-flow scripts use shebang `#!/usr/bin/env -S uv run --script` + with PEP 723 inline-deps; gates and hooks use `import` from the + dispatcher's venv (no shebang needed). + +## Where the dispatcher lives + +`ci.py` at the repo root is the PEP 723 dispatcher; `ci.sh` is the +thin bash wrapper that calls it with `--no-project --script`. Don't +duplicate that flag combination in CI snippets — always route through +`./ci.sh `. diff --git a/ci/__init__.py b/ci/__init__.py new file mode 100644 index 0000000..d293afb --- /dev/null +++ b/ci/__init__.py @@ -0,0 +1,15 @@ +"""CI helper package. + +Two structured sub-packages: + + - `ci.gates.*` — workspace-state checks invoked by `./ci.sh `; + runs on every CI cycle and on developer laptops. + - `ci.hooks.*` — agent-intent guards wired through `.claude/settings.json`; + runs only during a Claude/Codex session. + +Release-flow helpers (`build_wheel.py`, `publish.py`) live alongside +the gates package and are the documented opt-in to the full maturin +context. See `ci/README.md` for the directory map and +[zackees/zccache#835](https://github.com/zackees/zccache/issues/835) +for the design rationale. +""" diff --git a/ci/build_wheel.py b/ci/build_wheel.py new file mode 100644 index 0000000..c9f7e48 --- /dev/null +++ b/ci/build_wheel.py @@ -0,0 +1,103 @@ +#!/usr/bin/env -S uv run --script +# /// script +# requires-python = ">=3.11" +# /// +from __future__ import annotations + +import shutil +import platform +import subprocess +import sys +import zipfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +PACKAGE_BIN_DIR = ROOT / "src" / "template_python_rust_cmd" / "_bin" +CLI_TARGET_NAME = ( + "template-cli.exe" if platform.system() == "Windows" else "template-cli" +) + + +def run(cmd: list[str]) -> int: + return subprocess.run(cmd, cwd=ROOT, check=False).returncode + + +def build_cli_binary() -> Path: + if run(["cargo", "build", "--release", "-p", "template-cli"]) != 0: + raise SystemExit(1) + binary = ROOT / "target" / "release" / CLI_TARGET_NAME + if not binary.exists(): + raise SystemExit(f"expected native CLI binary at {binary}") + return binary + + +def stage_cli_binary(binary: Path) -> Path: + PACKAGE_BIN_DIR.mkdir(parents=True, exist_ok=True) + staged = PACKAGE_BIN_DIR / CLI_TARGET_NAME + shutil.copy2(binary, staged) + return staged + + +def remove_staged_binary(staged: Path) -> None: + if staged.exists(): + staged.unlink() + + +def build_python_artifacts() -> int: + cmd = ["uv", "run"] + if platform.system() == "Linux": + cmd.extend(["--with", "ziglang"]) + cmd.extend( + [ + "maturin", + "build", + "--release", + "--sdist", + "--interpreter", + sys.executable, + "--out", + str(ROOT / "dist"), + ] + ) + return run(cmd) + + +def verify_artifacts() -> int: + dist_dir = ROOT / "dist" + wheels = sorted(dist_dir.glob("template_python_rust_cmd-*.whl")) + sdists = sorted(dist_dir.glob("template_python_rust_cmd-*.tar.gz")) + if not wheels: + print("expected at least one wheel in dist/") + return 1 + if not sdists: + print("expected an sdist in dist/") + return 1 + + expected_binary_suffix = ( + "template-cli.exe" if platform.system() == "Windows" else "template-cli" + ) + expected_entries = [ + "template_python_rust_cmd/_native", + f"template_python_rust_cmd/_bin/{expected_binary_suffix}", + ] + with zipfile.ZipFile(wheels[-1]) as archive: + names = archive.namelist() + for entry in expected_entries: + if not any(name.startswith(entry) for name in names): + print(f"wheel is missing expected entry: {entry}") + return 1 + return 0 + + +def main() -> int: + staged = stage_cli_binary(build_cli_binary()) + try: + if build_python_artifacts() != 0: + return 1 + return verify_artifacts() + finally: + remove_staged_binary(staged) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/ci/gates/README.md b/ci/gates/README.md new file mode 100644 index 0000000..5dafb13 --- /dev/null +++ b/ci/gates/README.md @@ -0,0 +1,64 @@ +# `ci/gates/` + +Workspace-state checks that run on every CI cycle and on developer +laptops via `./ci.sh `. One Python file per gate; each file +exposes a single `def run() -> int` returning the conventional Unix +exit code (0 = pass, non-zero = fail). + +## Why gates live here (and not as inline YAML) + +Embedded YAML shell snippets are untestable, unlintable, and +unreviewable. Pulling each step into `ci/gates/.py` makes the +contract: + +- **Lintable** — ruff and pyright understand `def run() -> int` + directly. Embedded `run:` blocks are opaque. +- **Testable** — `tests/test_gates.py` can `import ci.gates.fmt; + ci.gates.fmt.run()` against a worktree fixture and assert the exit + code. +- **Locally reproducible** — `./ci.sh fmt` on a dev laptop runs the + *exact* same bytes as the GHA step that calls `./ci.sh fmt`. +- **Replaceable in isolation** — downstream consumers (forks of this + template) can swap one gate without forking the whole workflow file. + +See [`zackees/zccache#835` rule 6](https://github.com/zackees/zccache/issues/835) +for the full rationale. + +## Registered gates and ordering + +The canonical run order is owned by `ci.py::GATE_ORDER`, not by the +filesystem order of this directory. The order roughly reflects cost: + +1. `loc` — workspace LOC budget (cheap directory walk). +2. `fmt` — `cargo fmt --check`. +3. `clippy` — `cargo clippy --workspace --all-targets -D warnings`. +4. `ruff` — Python linting + format check. +5. `build` — `cargo check --workspace` (**FATAL**: a failing build + short-circuits the rest of the run since downstream gates against + an uncompiled tree only emit noise). +6. `test` — `cargo test --workspace` + `pytest`. +7. `action_yaml` — static parse of the composite action contract. +8. `action_surface` — runtime check that subcommands referenced from + `action.yml` exist in the built binary. + +## Gates vs. hooks + +| Concern | Home | +|----------------------------------------|--------------------| +| File size budget (workspace-wide) | `ci/gates/loc.py` | +| README presence (per-edit reaction) | `ci/hooks/readme_guard.py` | +| Tool command shape (per-edit guard) | `ci/hooks/tool_guard.py` | +| `fmt` / `clippy` / `ruff` / `build` | `ci/gates/*.py` | + +If a rule would catch the same failure from a `git push` as from a +Claude edit, it's a gate. If it needs to see which tool is about to +run, write a hook. + +## Adding a gate + +1. Create `ci/gates/.py` exposing `def run() -> int`. +2. Insert `` into `ci.py::GATE_ORDER` at the right position. +3. Add a row to `tests/test_gates.py` covering the happy path. +4. The workflow picks it up automatically because `./ci.sh all` walks + `GATE_ORDER`; no `.github/workflows/ci.yml` edit needed unless the + gate has a different platform affinity. diff --git a/ci/gates/__init__.py b/ci/gates/__init__.py new file mode 100644 index 0000000..bfefda6 --- /dev/null +++ b/ci/gates/__init__.py @@ -0,0 +1,18 @@ +"""CI gates: one `run() -> int` per file. + +Each module under `ci.gates.*` exposes a single zero-arg `run()` returning +the conventional Unix exit code (0 = pass, non-zero = fail). The canonical +ordering is owned by `ci.py::GATE_ORDER`, NOT by alphabetical name. + +Hooks vs gates — repo-state vs agent-intent: + - Gates here run on every push (GHA `./ci.sh all`) and on developer + laptops. They check repo state. + - Hooks under `ci/hooks/` only fire during a Claude/Codex session + (PreToolUse / PostToolUse / SessionStart). They check agent intent + at the moment of an action. + +If a rule would fire equally well on a `git push` from a terminal as +from a Claude edit, write it as a gate here. If it needs to know what +tool is about to run, what file is being written, or what session just +started, write it under `ci/hooks/`. +""" diff --git a/ci/gates/action_surface.py b/ci/gates/action_surface.py new file mode 100644 index 0000000..95e32e9 --- /dev/null +++ b/ci/gates/action_surface.py @@ -0,0 +1,155 @@ +"""Runtime check that the composite action's surface matches the built binary. + +The contract a downstream consumer cares about when they pin +`uses: zackees/template-python-rust-cmd@v1` is: + + - The binary the action points at exists and is executable. + - Every subcommand the action's shell snippets call against shows up + in `--help`. + +That contract is independent of the full release pipeline — once the +binary is built, this gate is a <1 s subprocess sweep. Way cheaper than +a real `uses: ./` end-to-end build per matrix entry. See [zccache#835 +rule 10](https://github.com/zackees/zccache/issues/835). + +Heuristic: we look at every `run:` shell step in `action.yml`, extract +tokens that look like `template-cli ` invocations, and +verify each `` is mentioned in `template-cli --help`. False +negatives are possible (e.g., a step that builds the subcommand name +dynamically) but the simple grep covers the common case and stops the +typo class of regressions. +""" + +from __future__ import annotations + +import re +import shutil +import subprocess +import sys +from pathlib import Path + +# `yaml` is guarded so the contract test in `tests/test_gates.py` can +# import this module without pyyaml installed in the pytest venv. The +# PEP 723 inline-deps path on `ci.py` always provides pyyaml when this +# gate actually runs via `./ci.sh action_surface`. +try: + import yaml # type: ignore[import-not-found] +except ImportError: # pragma: no cover + yaml = None # type: ignore[assignment] + +ROOT = Path(__file__).resolve().parents[2] +ACTION = ROOT / "action.yml" +BINARY_NAME = "template-cli.exe" if sys.platform == "win32" else "template-cli" + + +def _binary_path() -> Path | None: + # Prefer the staged location used by the wheel; fall back to debug + # for local dev (`cargo build`). + candidates = [ + ROOT / "src" / "template_python_rust_cmd" / "_bin" / BINARY_NAME, + ROOT / "target" / "release" / BINARY_NAME, + ROOT / "target" / "debug" / BINARY_NAME, + ] + for path in candidates: + if path.is_file(): + return path + return None + + +def _subcommands_from_action(path: Path) -> list[str]: + assert yaml is not None # guarded by run(); narrow for type-checkers + if not path.is_file(): + return [] + data = yaml.safe_load(path.read_text(encoding="utf-8")) + steps = ((data or {}).get("runs") or {}).get("steps") or [] + pattern = re.compile(r"\btemplate-cli\s+([A-Za-z][A-Za-z0-9_-]*)") + found: list[str] = [] + for step in steps: + run_block = (step or {}).get("run") + if not isinstance(run_block, str): + continue + for match in pattern.finditer(run_block): + sub = match.group(1) + # Skip flags accidentally captured (shouldn't happen with the + # pattern above, but defensive). + if sub.startswith("-"): + continue + found.append(sub) + return sorted(set(found)) + + +def run() -> int: + if yaml is None: + print( + "action_surface: PyYAML not available. Run via `./ci.sh action_surface` so the PEP 723 inline-deps path provides it.", + file=sys.stderr, + ) + return 1 + + if not ACTION.is_file(): + # No action.yml means nothing to check. action_yaml gate will + # have failed first if there's supposed to be one. + print("action_surface: no action.yml; skipping") + return 0 + + binary = _binary_path() + if binary is None: + # The `build` gate runs `cargo check` (not `cargo build`), so the + # template-cli binary may not be materialized in CI. Skip with a + # warning rather than fail — the structural contract is already + # covered by action_yaml, and runtime surface validation is + # best-effort against whatever build is available locally. + print( + "action_surface: no template-cli binary found " + "(src/template_python_rust_cmd/_bin/, target/release/, " + "target/debug/). Skipping runtime surface check. Run " + "`cargo build -p template-cli` or `./test` to materialize " + "the binary and re-run." + ) + return 0 + if not shutil.which(str(binary)) and not binary.exists(): + print(f"action_surface: {binary} not executable", file=sys.stderr) + return 1 + + try: + proc = subprocess.run( + [str(binary), "--help"], + capture_output=True, + text=True, + check=False, + timeout=10, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + print(f"action_surface: failed to run {binary} --help: {exc}", file=sys.stderr) + return 1 + + if proc.returncode != 0: + print( + f"action_surface: {binary} --help exited {proc.returncode}\nstderr: {proc.stderr.strip()}", + file=sys.stderr, + ) + return 1 + + help_text = (proc.stdout + "\n" + proc.stderr).lower() + subs = _subcommands_from_action(ACTION) + + if not subs: + # action.yml exists but doesn't shell out to template-cli — nothing + # to check at the runtime level. action_yaml has the static checks. + print("action_surface: no template-cli subcommands referenced in action.yml") + return 0 + + missing = [s for s in subs if s.lower() not in help_text] + if missing: + print( + f"action_surface: subcommand(s) referenced in action.yml but not in `{binary.name} --help`: {', '.join(missing)}", + file=sys.stderr, + ) + return 1 + + print(f"action_surface: ok ({len(subs)} subcommand(s) verified)") + return 0 + + +if __name__ == "__main__": + raise SystemExit(run()) diff --git a/ci/gates/action_yaml.py b/ci/gates/action_yaml.py new file mode 100644 index 0000000..fb85e4b --- /dev/null +++ b/ci/gates/action_yaml.py @@ -0,0 +1,127 @@ +"""Static parse + structural check of the composite-action contract. + +The repo ships a top-level `action.yml` (used when consumers do +`uses: zackees/template-python-rust-cmd@v1`) and an `action/cleanup/ +action.yml` for the post-run step. This gate parses both files with +PyYAML and asserts: + + - Required top-level keys (`name`, `description`, `runs`) present. + - `runs.using` is `composite` (the contract this template targets). + - Every input declared in `inputs:` has a `description`. + - `runs.steps` is a non-empty list and every step has either `uses` + or `run`. + +That's enough to catch the silly failure modes (typo in a key, missing +description) without paying for a real `uses: ./` end-to-end run. The +runtime check that subcommands referenced by the shell snippets exist +in the built binary lives in `action_surface.py` — see [zccache#835 +rule 10](https://github.com/zackees/zccache/issues/835). +""" + +from __future__ import annotations + +import sys +from pathlib import Path +from typing import Any + +# `yaml` (PyYAML) is the only third-party dep this gate needs. It's +# declared as a PEP 723 inline dep on `ci.py`, so `./ci.sh action_yaml` +# always has it. The try/except keeps the module importable in the +# pytest venv (which doesn't install pyyaml) so the contract test in +# `tests/test_gates.py` can introspect `run()`'s signature; `run()` +# fails fast with a clear message if pyyaml is unavailable at call time. +try: + import yaml # type: ignore[import-not-found] +except ImportError: # pragma: no cover + yaml = None # type: ignore[assignment] + +ROOT = Path(__file__).resolve().parents[2] + +ACTION_FILES = [ + ROOT / "action.yml", + ROOT / "action" / "cleanup" / "action.yml", +] + +REQUIRED_TOP = ("name", "description", "runs") + + +def _check_one(path: Path) -> list[str]: + assert yaml is not None # guarded by run(); narrow for type-checkers + errs: list[str] = [] + if not path.is_file(): + return [f"{path.relative_to(ROOT)}: missing"] + + try: + data: Any = yaml.safe_load(path.read_text(encoding="utf-8")) + except yaml.YAMLError as exc: + return [f"{path.relative_to(ROOT)}: invalid YAML: {exc}"] + + if not isinstance(data, dict): + return [f"{path.relative_to(ROOT)}: top level must be a mapping"] + + for key in REQUIRED_TOP: + if key not in data: + errs.append(f"{path.relative_to(ROOT)}: missing top-level `{key}`") + + runs = data.get("runs") + if isinstance(runs, dict): + using = runs.get("using") + if using != "composite": + errs.append( + f"{path.relative_to(ROOT)}: runs.using must be 'composite' (got {using!r})" + ) + steps = runs.get("steps") + if not isinstance(steps, list) or not steps: + errs.append( + f"{path.relative_to(ROOT)}: runs.steps must be a non-empty list" + ) + else: + for i, step in enumerate(steps): + if not isinstance(step, dict): + errs.append( + f"{path.relative_to(ROOT)}: runs.steps[{i}] not a mapping" + ) + continue + if "uses" not in step and "run" not in step: + errs.append( + f"{path.relative_to(ROOT)}: runs.steps[{i}] must have `uses` or `run`" + ) + # Composite shell steps must declare `shell:`. + if "run" in step and "shell" not in step: + errs.append( + f"{path.relative_to(ROOT)}: runs.steps[{i}] is a `run:` step but is missing `shell:`" + ) + + inputs = data.get("inputs") + if isinstance(inputs, dict): + for name, spec in inputs.items(): + if not isinstance(spec, dict): + errs.append(f"{path.relative_to(ROOT)}: inputs.{name} not a mapping") + continue + if not spec.get("description"): + errs.append( + f"{path.relative_to(ROOT)}: inputs.{name} missing description" + ) + + return errs + + +def run() -> int: + if yaml is None: + print( + "action_yaml: PyYAML not available. Run via `./ci.sh action_yaml` so the PEP 723 inline-deps path provides it.", + file=sys.stderr, + ) + return 1 + all_errs: list[str] = [] + for path in ACTION_FILES: + all_errs.extend(_check_one(path)) + if all_errs: + for e in all_errs: + print(f"action-yaml: {e}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(run()) diff --git a/ci/gates/build.py b/ci/gates/build.py new file mode 100644 index 0000000..651cea2 --- /dev/null +++ b/ci/gates/build.py @@ -0,0 +1,37 @@ +"""`cargo check --workspace` gate. + +The cheapest gate that proves the Rust workspace still compiles. We use +`check` instead of `build` because the downstream `test` gate already +does a real build, so paying for two builds is wasted work. + +**Fatal in `./ci.sh all`.** If this gate fails, every later gate +(`test`, `action_surface`, anything that touches the compiled binaries) +will produce noise instead of signal. `ci.py` sees `build` in +`FATAL_GATES` and halts the run, reporting only the build failure in +the final summary. See [zccache#835 rule 7](https://github.com/zackees/zccache/issues/835). +""" + +from __future__ import annotations + +import shutil +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] + + +def run() -> int: + if shutil.which("cargo") is None: + print("cargo not on PATH; cannot run build gate", file=sys.stderr) + return 1 + proc = subprocess.run( + ["cargo", "check", "--workspace", "--all-targets"], + cwd=ROOT, + check=False, + ) + return proc.returncode + + +if __name__ == "__main__": + raise SystemExit(run()) diff --git a/ci/gates/clippy.py b/ci/gates/clippy.py new file mode 100644 index 0000000..2fbcd28 --- /dev/null +++ b/ci/gates/clippy.py @@ -0,0 +1,44 @@ +"""`cargo clippy` gate with `-D warnings`. + +Workspace-wide clippy run that treats every warning as an error. Targets +the entire crate graph (`--workspace --all-targets`) so tests and +examples are linted alongside the library / binary targets. + +This is heavier than fmt (it compiles for analysis) but still cheaper +than a full release build because it shares the cargo cache with the +`build` and `test` gates. CI runs all three on the same runner so the +cache amortizes across them — see [zccache#835 rule 7](https://github.com/zackees/zccache/issues/835). +""" + +from __future__ import annotations + +import shutil +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] + + +def run() -> int: + if shutil.which("cargo") is None: + print("cargo not on PATH; cannot run clippy gate", file=sys.stderr) + return 1 + proc = subprocess.run( + [ + "cargo", + "clippy", + "--workspace", + "--all-targets", + "--", + "-D", + "warnings", + ], + cwd=ROOT, + check=False, + ) + return proc.returncode + + +if __name__ == "__main__": + raise SystemExit(run()) diff --git a/ci/gates/fmt.py b/ci/gates/fmt.py new file mode 100644 index 0000000..5c94da7 --- /dev/null +++ b/ci/gates/fmt.py @@ -0,0 +1,36 @@ +"""`cargo fmt --check` gate. + +Runs `cargo fmt --all -- --check` against the Rust workspace. Does NOT +write changes; failing is the signal to run `cargo fmt --all` locally. + +This gate is fast (no compilation, no target/ writes), so it runs before +the heavier clippy / build gates in `ci.py::GATE_ORDER`. Crucially the +process is invoked through plain `cargo` — the caller (`./ci.sh`) has +already protected itself from a maturin rebuild via `--no-project +--script` on the dispatcher, so adding `uv run` here would be pure cost. +""" + +from __future__ import annotations + +import shutil +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] + + +def run() -> int: + if shutil.which("cargo") is None: + print("cargo not on PATH; cannot run fmt gate", file=sys.stderr) + return 1 + proc = subprocess.run( + ["cargo", "fmt", "--all", "--", "--check"], + cwd=ROOT, + check=False, + ) + return proc.returncode + + +if __name__ == "__main__": + raise SystemExit(run()) diff --git a/ci/gates/loc.py b/ci/gates/loc.py new file mode 100644 index 0000000..5610cec --- /dev/null +++ b/ci/gates/loc.py @@ -0,0 +1,118 @@ +"""Workspace LOC budget gate. + +Walks every tracked source file under the repo (excluding generated / +vendored / build-output trees) and enforces: + LOC > 1000 -> warning (gate still passes) + LOC > 1500 -> failure (gate returns 1) + +Refactor convention printed on every offender: + foo.rs -> foo/mod.rs + per-domain submodules, `pub use` re-exports + in mod.rs so the public path is unchanged. + +This is a gate, not a hook — it walks the whole tree on every CI run, so +a file that grew past budget via `git push` (no agent in the loop) is +caught the same way an agent edit would be. +""" + +from __future__ import annotations + +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] + +WARN = 1000 +ERROR = 1500 + +SOURCE_EXTS = { + ".rs", + ".py", + ".ts", + ".tsx", + ".js", + ".jsx", + ".go", + ".java", + ".kt", + ".swift", + ".c", + ".cc", + ".cpp", + ".cxx", + ".h", + ".hh", + ".hpp", +} + +EXCLUDED = { + ".git", + "target", + ".cargo", + ".rustup", + ".venv", + "node_modules", + "__pycache__", + "dist", + "build", + ".claude", + ".pytest_cache", + ".ruff_cache", + ".mypy_cache", + ".cache", +} + + +def _line_count(path: Path) -> int: + with path.open("rb") as fh: + return sum(1 for _ in fh) + + +def _iter_source_files() -> list[Path]: + out: list[Path] = [] + for path in ROOT.rglob("*"): + if not path.is_file(): + continue + if path.suffix.lower() not in SOURCE_EXTS: + continue + if any(part in EXCLUDED for part in path.parts): + continue + out.append(path) + return out + + +def run() -> int: + warns: list[tuple[Path, int]] = [] + fails: list[tuple[Path, int]] = [] + for path in _iter_source_files(): + try: + loc = _line_count(path) + except OSError: + continue + if loc > ERROR: + fails.append((path, loc)) + elif loc > WARN: + warns.append((path, loc)) + + if warns: + print(f"LOC warnings (>{WARN}):") + for path, loc in warns: + rel = path.relative_to(ROOT) + print(f" WARN {rel} {loc} lines") + + if fails: + print(f"LOC failures (>{ERROR}):") + for path, loc in fails: + rel = path.relative_to(ROOT) + print(f" FAIL {rel} {loc} lines") + print( + "\nRefactor convention: foo.rs -> foo/mod.rs + per-domain " + "submodules, with `pub use` re-exports in mod.rs so the public " + "path is unchanged. Target < 1000 lines per file so future edits " + "have headroom." + ) + return 1 + + return 0 + + +if __name__ == "__main__": + raise SystemExit(run()) diff --git a/ci/gates/ruff.py b/ci/gates/ruff.py new file mode 100644 index 0000000..f172f07 --- /dev/null +++ b/ci/gates/ruff.py @@ -0,0 +1,62 @@ +"""Python lint + format check via ruff. + +Runs `ruff check` and `ruff format --check` over the Python tree (src, +tests, ci). Failing format is fixable with `ruff format`; failing lint +points at a real issue (unused imports, shadowed names, etc.). + +Invoked via `uv run --no-project --with ruff` so we get a hermetic ruff +without triggering the maturin build the surrounding pyproject.toml +otherwise demands. `--with ruff` provisions the dep at script-time even +when the script itself has no PEP 723 deps declared for it. + +Ruff is pinned exactly — `>=0.8` resolved to different patch versions +between local and CI, producing different format-check verdicts. The +pin is the cheapest way to make the gate deterministic. +""" + +from __future__ import annotations + +import shutil +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +TARGETS = ["src", "tests", "ci", "ci.py"] + +# Bump deliberately when you want to adopt a newer ruff. Don't loosen +# this to a range — see the docstring. +RUFF_PIN = "ruff==0.15.18" + + +def _run(args: list[str]) -> int: + if shutil.which("uv") is None: + print("uv not on PATH; cannot run ruff gate", file=sys.stderr) + return 1 + # `--isolated` keeps ruff from walking up to a parent pyproject.toml + # (e.g., a workspace-level `[tool.ruff]` block that doesn't belong to + # this repo). Without it, a developer with the repo checked out under + # `~/dev/` sees green locally while CI fails — the two evaluate the + # same file under different effective config. + cmd = [ + "uv", + "run", + "--no-project", + "--with", + RUFF_PIN, + "ruff", + *args, + "--isolated", + ] + proc = subprocess.run(cmd, cwd=ROOT, check=False) + return proc.returncode + + +def run() -> int: + rc1 = _run(["check", *TARGETS]) + rc2 = _run(["format", "--check", *TARGETS]) + return rc1 or rc2 + + +if __name__ == "__main__": + raise SystemExit(run()) diff --git a/ci/gates/test.py b/ci/gates/test.py new file mode 100644 index 0000000..9510251 --- /dev/null +++ b/ci/gates/test.py @@ -0,0 +1,63 @@ +"""Test gate: `cargo test --workspace` + `pytest`. + +This is one of the named entry points that legitimately *needs* the +maturin wheel built (pytest imports `template_python_rust_cmd._native`). +We invoke it via plain `uv run` (project context kept on purpose) so +maturin develop materializes the extension module before pytest runs. + +Reserve this opt-in to the build for named entry points — see [zccache#835 +rule 5](https://github.com/zackees/zccache/issues/835). Other gates use +`./ci.sh`'s `--no-project --script` discipline so they don't pay the +maturin cost. +""" + +from __future__ import annotations + +import shutil +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] + + +def _run(cmd: list[str]) -> int: + proc = subprocess.run(cmd, cwd=ROOT, check=False) + return proc.returncode + + +def run() -> int: + if shutil.which("cargo") is None: + print("cargo not on PATH; cannot run test gate", file=sys.stderr) + return 1 + if shutil.which("uv") is None: + print("uv not on PATH; cannot run test gate", file=sys.stderr) + return 1 + + rc = _run(["cargo", "test", "--workspace"]) + if rc != 0: + return rc + + # `maturin develop` needs the project context, so we do NOT pass + # --no-project here. This is the documented opt-in to the full + # build (see [zccache#835] rule 5). + rc = _run( + [ + "uv", + "run", + "maturin", + "develop", + "--uv", + "--profile", + "dev", + ] + ) + if rc != 0: + return rc + + rc = _run(["uv", "run", "pytest"]) + return rc + + +if __name__ == "__main__": + raise SystemExit(run()) diff --git a/ci/hooks/README.md b/ci/hooks/README.md new file mode 100644 index 0000000..9a51025 --- /dev/null +++ b/ci/hooks/README.md @@ -0,0 +1,64 @@ +# `ci/hooks/` + +Agent-intent guards that fire **only during a Claude/Codex session** — +PreToolUse blocks a forbidden command shape before it runs, PostToolUse +reacts to a file edit, SessionStart captures a fingerprint. None of +them run as part of CI on a `git push`. + +## Why these are hooks and not gates + +Repo-state checks belong in `ci/gates/` because they fire equally on +every CI cycle regardless of where the change came from. Hooks here +need information that only exists at the moment the agent is acting — +which tool is about to run, which file just got edited, when the +session opened. See [zccache#835 rule 9](https://github.com/zackees/zccache/issues/835). + +| Concern | Home | +|----------------------------------------|------| +| File size budget across the workspace | `ci/gates/loc.py` | +| File size growth on this edit | `ci/hooks/loc_guard.py` | +| README presence after a new file lands | `ci/hooks/readme_guard.py` | +| Bare `cargo` / `python` / unsafe `uv run` | `ci/hooks/tool_guard.py` | +| Git fingerprint at session start | `ci/hooks/check-on-start.py` | + +## Hooks in this directory + +- `tool_guard.py` — **PreToolUse**. Reads the about-to-run Bash command + payload, rejects bare `cargo`/`rustc`/`rustfmt`/`python`/`pip`, and + rejects `uv run` without `--no-project --script` outside the named + build entry points (`./test`, `./build`, `ci/build_wheel.py`). Returns + exit 2 with a structured deny payload so Claude Code surfaces the + reason to the agent. + +- `readme_guard.py` — **PostToolUse on Edit|Write**. After any file + edit, checks the containing directory for a `README.md` of at least + 50 lines. Missing or too short → exit 2 with a prescription + ("expand it with what's in this directory + why + key entry points"). + +- `loc_guard.py` — **PostToolUse on Edit|Write**. After any source-file + edit, counts the resulting LOC. Warns over 1000, hard-blocks over + 1500 with the canonical split convention. + +- `check-on-start.py` — **SessionStart**. Captures an MD5 fingerprint of + `git status --porcelain` into `.cache/session_fingerprint.json` so a + future Stop hook can decide whether anything changed during the + session. + +## Wiring + +`.claude/settings.json` at the repo root binds each hook to its event. +The full list is canonical; downstream forks should keep the binding +shape and only customize the hook body. See the +[Claude Code hooks docs](https://docs.claude.com/en/docs/claude-code/hooks) +for the schema. + +## Hook contract + +Each hook reads a JSON payload from stdin and exits: + +- `0`: pass / not applicable. +- `2`: block; the stderr message is fed back to Claude so the agent + sees *why* and can self-correct without the user intervening. + +Stdout is reserved for the structured permission decision (PreToolUse +only) — see `tool_guard.py::deny()` for the shape. diff --git a/ci/hooks/check-on-start.py b/ci/hooks/check-on-start.py new file mode 100644 index 0000000..835ef70 --- /dev/null +++ b/ci/hooks/check-on-start.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +"""SessionStart hook: captures repo state fingerprint. + +Saves an MD5 fingerprint of the current `git status --porcelain` so a +matching Stop hook (or follow-up analysis) can detect whether anything +in the worktree changed during the session. + +Always exits 0; this hook is observation only, never blocking. +""" + +from __future__ import annotations + +import hashlib +import json +import subprocess +import sys +from pathlib import Path + +SCRIPT_DIR = Path(__file__).resolve().parent +PROJECT_ROOT = SCRIPT_DIR.parent.parent +SESSION_FINGERPRINT_FILE = PROJECT_ROOT / ".cache" / "session_fingerprint.json" + + +def _run_cmd(cmd: list[str]) -> subprocess.CompletedProcess[str]: + return subprocess.run( + cmd, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + cwd=str(PROJECT_ROOT), + check=False, + ) + + +def _current_fingerprint() -> str | None: + result = _run_cmd(["git", "status", "--porcelain"]) + if result.returncode != 0 or not result.stdout.strip(): + return None + return hashlib.md5(result.stdout.encode()).hexdigest() + + +def _save(fingerprint: str) -> None: + SESSION_FINGERPRINT_FILE.parent.mkdir(parents=True, exist_ok=True) + SESSION_FINGERPRINT_FILE.write_text( + json.dumps( + { + "fingerprint": fingerprint, + "description": "Captured at session start", + } + ) + ) + + +def main() -> int: + fingerprint = _current_fingerprint() + if fingerprint is None: + if SESSION_FINGERPRINT_FILE.exists(): + SESSION_FINGERPRINT_FILE.unlink() + return 0 + _save(fingerprint) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/ci/hooks/loc_guard.py b/ci/hooks/loc_guard.py new file mode 100644 index 0000000..5031d69 --- /dev/null +++ b/ci/hooks/loc_guard.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +"""PostToolUse hook: enforces per-file LOC budget on source edits. + +After any Edit/Write to a source file, counts the resulting line count +and: + - emits a warning (exit 0 + stderr) when LOC > WARN_THRESHOLD + - hard-errors (exit 2 + stderr) when LOC > ERROR_THRESHOLD + +This is the per-edit half of the LOC story; the per-CI half lives in +`ci/gates/loc.py`. Together they catch growth from agent edits (hook) +and from manual pushes / rebases (gate). + +Exit codes: + 0 - file is fine, missing, not a tracked source extension, or only warned + 2 - file exceeds ERROR_THRESHOLD (stderr fed back to Claude as a block) +""" + +from __future__ import annotations + +import json +import os +import sys +from pathlib import Path + +WARN_THRESHOLD = 1000 +ERROR_THRESHOLD = 1500 + +SOURCE_EXTS = { + ".rs", + ".py", + ".ts", + ".tsx", + ".js", + ".jsx", + ".go", + ".java", + ".kt", + ".swift", + ".c", + ".cc", + ".cpp", + ".cxx", + ".h", + ".hh", + ".hpp", +} + +EXCLUDED_DIRS = { + ".git", + "target", + ".cargo", + ".rustup", + ".venv", + "node_modules", + "__pycache__", + "dist", + "build", + ".claude", + ".pytest_cache", + ".ruff_cache", + ".mypy_cache", + ".cache", +} + +REFRAIN = "Convention: `foo.rs` -> `foo/mod.rs` + per-domain submodules, with `pub use` re-exports in `mod.rs` so the public path is unchanged." + + +def main() -> int: + try: + data = json.load(sys.stdin) + except json.JSONDecodeError: + return 0 + + file_path = data.get("tool_input", {}).get("file_path", "") + if not file_path: + return 0 + + norm = file_path.replace("\\", "/") + ext = os.path.splitext(norm)[1].lower() + if ext not in SOURCE_EXTS: + return 0 + + parts = Path(norm).parts + if any(p in EXCLUDED_DIRS for p in parts): + return 0 + + if not os.path.isfile(file_path): + return 0 + + try: + with open(file_path, "rb") as fh: + loc = sum(1 for _ in fh) + except OSError: + return 0 + + if loc > ERROR_THRESHOLD: + print( + f"LOC guard: {file_path} is {loc} lines (> {ERROR_THRESHOLD}). " + f"Split into focused submodules before continuing. " + f"Refactor target: < {WARN_THRESHOLD} lines so future edits have " + f"headroom. {REFRAIN}", + file=sys.stderr, + ) + return 2 + + if loc > WARN_THRESHOLD: + print( + f"LOC guard warning: {file_path} is {loc} lines " + f"(> {WARN_THRESHOLD}). Refactor down to < {WARN_THRESHOLD} so " + f"future edits can land without crossing {ERROR_THRESHOLD} and " + f"hard-blocking. {REFRAIN}", + file=sys.stderr, + ) + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/ci/hooks/readme_guard.py b/ci/hooks/readme_guard.py new file mode 100644 index 0000000..af0c950 --- /dev/null +++ b/ci/hooks/readme_guard.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +"""PostToolUse hook: every directory must have a README.md of >= 50 lines. + +After any Edit/Write to a file, this hook checks the containing +directory for a `README.md` and asserts a minimum line count. The +floor exists to force enough prose that a reader actually learns what +the directory is for, not just "Title\\n" placeholders. + +Exit codes: + 0 - README.md exists and meets the floor, or check not applicable + 2 - README.md missing or too short (stderr fed back to Claude) + +See [zackees/zccache#835 rule 8](https://github.com/zackees/zccache/issues/835) +for the rationale. This is a hook (not a gate) because it fires at the +moment a new directory gets its first file — which is the right +moment to demand the README. A workspace walk would have to know which +directories were newly-populated, which is exactly what the agent +context provides for free. +""" + +from __future__ import annotations + +import json +import os +import sys +from pathlib import Path + +EXCLUDED_DIRS = { + ".git", + ".github", + "target", + ".loop", + "__pycache__", + ".venv", + "node_modules", + "dist", + "build", + ".pytest_cache", + ".ruff_cache", + ".mypy_cache", + ".cache", +} + +MIN_LINES = 50 + + +def main() -> int: + try: + data = json.load(sys.stdin) + except json.JSONDecodeError: + return 0 + + file_path = data.get("tool_input", {}).get("file_path", "") + if not file_path: + return 0 + + norm = file_path.replace("\\", "/") + filename = os.path.basename(norm) + directory = os.path.dirname(norm) + + # If the file being written IS a README.md, defer the floor check + # to the next edit in that directory; the agent may still be in the + # middle of expanding it. + if filename == "README.md": + return 0 + + parts = Path(norm).parts + if any(p in EXCLUDED_DIRS for p in parts): + return 0 + + readme = os.path.join(directory, "README.md") + if not os.path.isfile(readme): + # Try original (un-normalized) path too. + orig_dir = os.path.dirname(file_path) + readme = os.path.join(orig_dir, "README.md") if orig_dir else readme + if not os.path.isfile(readme): + print(f"Missing README.md in directory: {directory}", file=sys.stderr) + print( + "Every directory must have a README.md (>= 50 lines). Expand it with what's in this directory, why, and the key entry points.", + file=sys.stderr, + ) + return 2 + + try: + with open(readme, "rb") as fh: + line_count = sum(1 for _ in fh) + except OSError: + return 0 + + if line_count < MIN_LINES: + print( + f"README.md in {directory} is {line_count} lines (< {MIN_LINES}). Expand it with what's in this directory, why, and the key entry points.", + file=sys.stderr, + ) + return 2 + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/ci/hooks/tool_guard.py b/ci/hooks/tool_guard.py new file mode 100644 index 0000000..5a11782 --- /dev/null +++ b/ci/hooks/tool_guard.py @@ -0,0 +1,373 @@ +#!/usr/bin/env python3 +"""PreToolUse hook: blocks unsafe command shapes. + +Rejects: + - Bare `cargo` / `rustc` / `rustfmt` / `clippy-driver` / `rustup` / + `rustdoc` — must be invoked through a build entry point or `uv run` + with explicit project context (i.e., from `./test` or `./build`). + - Bare `python` / `python3` — must go through `uv run`. + - Bare `pip` / `pip3` — must go through `uv pip`. + - `uv run` *without* `--no-project --script` for invocations outside + the named build entry points. The whole point of `./ci.sh`'s + `--no-project --script` discipline (rule 2 of zackees/zccache#835) + is to avoid the maturin auto-build trap when running gates; an + agent that pastes a snippet from chat needs the protective flags + or needs to route through a named opt-in entry point. + +The hook is conservative — it only blocks command *shapes*, never +specific arguments. The named build entry points +(`./test`, `./build`, `ci/build_wheel.py`) are detected by the leading +token of the about-to-run command and are exempt. +""" + +from __future__ import annotations + +import json +import re +import sys + +RUST_TOOLS = { + "cargo", + "rustc", + "rustfmt", + "clippy-driver", + "cargo-clippy", + "cargo-fmt", + "rustup", + "rustdoc", + "rust-gdb", + "rust-lldb", + "rust-analyzer", +} +PYTHON_TOOLS = {"python", "python3", "pip", "pip3"} + +# Routing through one of these is the documented opt-in to the full +# maturin build context (rule 5 of zackees/zccache#835). These are +# detected by the leading shell token after env-stripping; either with +# or without a leading `./` and any extension. +BUILD_ENTRY_POINTS = { + "test", + "build", + "publish", + "install", + "build_wheel.py", + "publish.py", + "test.py", +} + +SHELL_WRAPPERS = {"cmd", "powershell", "pwsh", "bash", "sh", "zsh"} + +# uv-run options that take a value (so we can correctly skip past them +# when scanning for the script positional or the protective flags). +UV_RUN_OPTIONS_WITH_VALUE = { + "--config-file", + "--directory", + "--env-file", + "--exclude-newer", + "--extra", + "--index", + "--index-strategy", + "--keyring-provider", + "--link-mode", + "--module", + "--no-binary", + "--no-binary-package", + "--no-build-isolation-package", + "--no-build-package", + "--no-extra", + "--no-group", + "--only-group", + "--project", + "--python", + "--python-platform", + "--refresh-package", + "--resolution", + "--script", + "--upgrade-package", + "--with", + "--with-editable", + "--with-requirements", +} + +# Flags that, when present on a `uv run` line, are considered to +# satisfy the protection requirement. +PROTECTIVE_FLAGS = {"--no-project", "--script"} + +SHELL_TOOL_NAMES = { + "Bash", + "Shell", + "PowerShell", + "shell_command", + "functions.shell_command", +} + + +def _extract_command(data: dict) -> str: + tool_input = data.get("tool_input") or data.get("toolInput") or {} + if isinstance(tool_input, str): + return tool_input + if not isinstance(tool_input, dict): + return "" + for key in ("command", "script"): + value = tool_input.get(key) + if isinstance(value, str): + return value + argv = tool_input.get("argv") + if isinstance(argv, list): + return " ".join(str(p) for p in argv) + return "" + + +def _is_env_assignment(word: str) -> bool: + return re.match(r"^[A-Za-z_][A-Za-z0-9_]*=", word) is not None + + +def _split_segments(command: str) -> list[str]: + out: list[str] = [] + buf: list[str] = [] + quote: str | None = None + i = 0 + while i < len(command): + ch = command[i] + if quote is not None: + buf.append(ch) + if ch == quote: + quote = None + i += 1 + continue + if ch in {"'", '"'}: + quote = ch + buf.append(ch) + i += 1 + continue + nxt = command[i + 1] if i + 1 < len(command) else "" + if ( + ch in {";", "\r", "\n"} + or (ch == "&" and nxt == "&") + or (ch == "|" and nxt == "|") + or ch == "|" + ): + segment = "".join(buf).strip() + if segment: + out.append(segment) + buf = [] + i += 2 if ((ch == "&" and nxt == "&") or (ch == "|" and nxt == "|")) else 1 + continue + buf.append(ch) + i += 1 + seg = "".join(buf).strip() + if seg: + out.append(seg) + return out + + +def _tokenize(segment: str) -> list[str]: + words: list[str] = [] + buf: list[str] = [] + quote: str | None = None + for ch in segment: + if quote is not None: + if ch == quote: + quote = None + else: + buf.append(ch) + continue + if ch in {"'", '"'}: + quote = ch + continue + if ch.isspace(): + if buf: + words.append("".join(buf)) + buf = [] + continue + buf.append(ch) + if buf: + words.append("".join(buf)) + return words + + +def _program_name(word: str) -> str: + cleaned = word.strip().strip("'\"").replace("\\", "/") + while cleaned.startswith("./"): + cleaned = cleaned[2:] + base = cleaned.rsplit("/", 1)[-1].lower() + for suffix in (".exe", ".cmd", ".bat", ".ps1", ".sh"): + if base.endswith(suffix): + base = base[: -len(suffix)] + break + return base + + +def _strip_env_prefix(words: list[str]) -> list[str]: + while words and words[0] in {"&", "call", "exec", "command"}: + words = words[1:] + if words and _program_name(words[0]) == "env": + words = words[1:] + while words and _is_env_assignment(words[0]): + words = words[1:] + return words + + +def _is_named_build_entry(words: list[str]) -> bool: + if not words: + return False + head = _program_name(words[0]) + if head in BUILD_ENTRY_POINTS: + return True + # `uv run --no-project --script ci/build_wheel.py` — only scoped to + # uv invocations so that bare `cargo build` doesn't pass just because + # `build` is a named entry point. + if head == "uv" and len(words) > 1 and words[1] == "run": + for w in words[2:]: + if _program_name(w) in BUILD_ENTRY_POINTS: + return True + return False + + +def _uv_run_has_protection(words: list[str]) -> bool: + if len(words) < 2 or _program_name(words[0]) != "uv" or words[1] != "run": + return False + seen = set() + i = 2 + while i < len(words): + w = words[i] + if w == "--": + break + if w in PROTECTIVE_FLAGS: + seen.add(w) + if "=" in w: + base = w.split("=", 1)[0] + if base in PROTECTIVE_FLAGS: + seen.add(base) + i += 1 + return PROTECTIVE_FLAGS.issubset(seen) + + +def _nested_shell(words: list[str]) -> str | None: + if not words: + return None + head = _program_name(words[0]) + if head not in SHELL_WRAPPERS: + return None + if head == "cmd": + for i, w in enumerate(words[1:], start=1): + if w.lower() in {"/c", "/r"} and i + 1 < len(words): + return " ".join(words[i + 1 :]) + return None + if head in {"powershell", "pwsh"}: + for i, w in enumerate(words[1:], start=1): + if w.lower() in {"-command", "-c", "/c"} and i + 1 < len(words): + return " ".join(words[i + 1 :]) + return None + for i, w in enumerate(words[1:], start=1): + opt = w.lower().lstrip("-") + if "c" in opt and i + 1 < len(words): + return " ".join(words[i + 1 :]) + return None + + +def _check_segment(seg: str) -> tuple[str, str] | None: + words = _strip_env_prefix(_tokenize(seg)) + if not words: + return None + + nested = _nested_shell(words) + if nested is not None: + for sub in _split_segments(nested): + result = _check_segment(sub) + if result: + return result + return None + + head = _program_name(words[0]) + + # Routing through a named build entry point: full pass — those scripts + # are the documented opt-in to the maturin context. + if _is_named_build_entry(words): + return None + + if head == "soldr": + return None + + if head == "uv": + if len(words) > 1 and words[1] == "run": + if _uv_run_has_protection(words): + return None + return ( + "uv run", + "Use `./ci.sh ` for lint/gate invocations, or run " + "your build through a named entry point (./test, ./build, " + "ci/build_wheel.py). Bare `uv run` walks up to pyproject.toml " + "and triggers the maturin wheel build before your script " + "starts. Add `--no-project --script` to skip discovery and " + "use the PEP 723 inline-deps path.", + ) + # uv pip / uv tool / etc. are fine. + return None + + if head in RUST_TOOLS: + return ( + head, + f"Use `./ci.sh ` (clippy/fmt/build/test) or route through a named build entry point. Bare `{head}` bypasses the workspace's pinned toolchain configuration.", + ) + + if head in PYTHON_TOOLS: + if head.startswith("pip"): + return ( + head, + f"Use `uv pip ...` instead of bare `{head}`. All pip operations must go through uv so the lock file stays authoritative.", + ) + return ( + head, + f"Use `uv run ...` (with `--no-project --script` for gates, or a named build entry point for builds) instead of bare `{head}`. All Python must be executed through uv.", + ) + + return None + + +def check_command(command: str) -> tuple[str, str] | None: + for seg in _split_segments(command): + result = _check_segment(seg) + if result: + return result + return None + + +def deny(reason: str) -> None: + json.dump( + { + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": reason, + } + }, + sys.stdout, + ) + + +def main() -> None: + try: + data = json.load(sys.stdin) + except json.JSONDecodeError: + sys.exit(0) + + if data.get("tool_name", "") not in SHELL_TOOL_NAMES: + sys.exit(0) + + command = _extract_command(data) + if not command: + sys.exit(0) + + result = check_command(command) + if result: + _, reason = result + deny(reason) + print(reason, file=sys.stderr) + sys.exit(2) + + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/ci/publish.py b/ci/publish.py new file mode 100644 index 0000000..19f8c39 --- /dev/null +++ b/ci/publish.py @@ -0,0 +1,69 @@ +#!/usr/bin/env -S uv run --script +# /// script +# requires-python = ">=3.11" +# /// +from __future__ import annotations + +import argparse +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +DIST = ROOT / "dist" +_ENABLED = False + + +def run(cmd: list[str]) -> int: + return subprocess.run(cmd, cwd=ROOT, check=False).returncode + + +def ensure_clean() -> int: + result = subprocess.run( + ["git", "status", "--short"], + cwd=ROOT, + capture_output=True, + text=True, + check=False, + ) + if result.stdout.strip(): + print("publish refuses dirty worktrees") + return 1 + return 0 + + +def ensure_dist() -> int: + artifacts = list(DIST.glob("*.whl")) + list(DIST.glob("*.tar.gz")) + if not artifacts: + print("no artifacts found in dist/") + return 1 + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description="Build and publish release artifacts") + parser.add_argument("--repository-url") + args = parser.parse_args() + + if not _ENABLED: + print( + "publishing is disabled; please manually enable _ENABLED when you are ready" + ) + return 1 + + if ensure_clean() != 0: + return 1 + if run(["uv", "run", "python", "ci/build_wheel.py"]) != 0: + return 1 + if run(["uv", "run", "--with", "twine", "twine", "check", "dist/*"]) != 0: + return 1 + if ensure_dist() != 0: + return 1 + cmd = ["uv", "run", "--with", "twine", "twine", "upload", "--skip-existing"] + if args.repository_url: + cmd.extend(["--repository-url", args.repository_url]) + cmd.extend([str(path) for path in sorted(DIST.glob("*"))]) + return run(cmd) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/crates/README.md b/crates/README.md new file mode 100644 index 0000000..ceb24f1 --- /dev/null +++ b/crates/README.md @@ -0,0 +1,58 @@ +# `crates/` + +The Rust workspace. Three crates share `template-core`'s logic; the +binary and the PyO3 bindings depend on it but never on each other. + +## Layout + +``` +crates/ +├── template-core/ # pure Rust domain logic; no Python concerns +├── template-cli/ # bare Rust binary; the packaged CLI backend +└── template-py/ # PyO3 wrapper; exposes Rust to Python +``` + +## Dependency direction (don't break this) + +``` +template-cli ──┐ + ├──► template-core +template-py ───┘ +``` + +Anything that needs to behave the same in Python AND in the CLI lives +in `template-core`. The other two crates translate domain types into +their respective surfaces — argv parsing + exit codes for the CLI, +PyO3 conversions for the bindings. + +## Adding a new crate + +1. `cargo new --lib crates/` (or `--bin` for an executable). +2. Add `` to `members` in `Cargo.toml` at the repo root. +3. Use `version.workspace = true`, `edition.workspace = true`, and the + other inherited package fields — the workspace owns version + alignment with the Python wheel. +4. Add a README.md (this directory's `readme_guard` requires it). +5. The new crate is automatically picked up by `cargo check + --workspace` (the `build` gate) and `cargo clippy --workspace` (the + `clippy` gate). No CI changes needed. + +## Workspace conventions + +- `Cargo.toml` at the repo root owns `version`, `edition`, + `rust-version`, `license`, `repository`, `homepage`. Member crates + inherit them with `.workspace = true`. +- Shared deps go in `[workspace.dependencies]`; member crates pin + with `{ workspace = true }`. +- Toolchain is pinned by `rust-toolchain.toml` at the repo root; do + not override per-crate. + +## Where new logic goes + +- **Reusable domain logic** → `template-core`. +- **CLI subcommands** → `template-cli`. Adding a subcommand means + updating `action.yml`'s shell snippets too (and + `ci/gates/action_surface.py` will verify the binary surface + matches). +- **Python-callable APIs** → `template-py`. Keep PyO3 decorators + here, not in `template-core`. diff --git a/crates/template-cli/Cargo.toml b/crates/template-cli/Cargo.toml new file mode 100644 index 0000000..199b7b1 --- /dev/null +++ b/crates/template-cli/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "template-cli" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +description = "Bare Rust binary shipped with the Python package" + +[[bin]] +name = "template-cli" +path = "src/main.rs" + +[dependencies] +anyhow = { workspace = true } +template_core = { package = "template-core", path = "../template-core" } diff --git a/crates/template-cli/README.md b/crates/template-cli/README.md new file mode 100644 index 0000000..b1db1b7 --- /dev/null +++ b/crates/template-cli/README.md @@ -0,0 +1,53 @@ +# `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`. + +## Responsibilities + +- argv parsing +- subcommand dispatch +- exit-code policy (0 ok, 1 user error, 2 unexpected) +- stdout for primary output, stderr for diagnostics +- thin translation of domain results into render-ready output + +That's the whole list. Everything else (the actual logic) lives in +`template-core`. + +## Surface contract + +The subcommands this binary exposes are part of the **composite action +contract** — `action.yml` at the repo root shells out to them. Two CI +gates enforce this: + +- `ci/gates/action_yaml.py` checks the action file's structure. +- `ci/gates/action_surface.py` checks that every subcommand + referenced by `action.yml` shows up in `template-cli --help`. + +Add a subcommand → update `action.yml` → both gates re-validate. If +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. + +## Cross-compilation + +CI builds on each platform's native runner (see the matrix in +`.github/workflows/ci.yml`). For local cross-builds, install the +target with `rustup target add ` and pass `--target` to +`cargo build`. The `linux-x86-musl` and `linux-arm-musl` matrix +entries need `ziglang` (provisioned via `uv run --with ziglang` +inside `ci/build_wheel.py`). + +## Linting + +Both `cargo clippy -D warnings` and `cargo fmt --check` run via +`./ci.sh clippy` and `./ci.sh fmt`. Failing either fails the gate; +no `#[allow(...)]` without a justification comment. diff --git a/crates/template-cli/src/main.rs b/crates/template-cli/src/main.rs new file mode 100644 index 0000000..d1f9451 --- /dev/null +++ b/crates/template-cli/src/main.rs @@ -0,0 +1,3 @@ +fn main() -> anyhow::Result<()> { + template_core::run_cli() +} diff --git a/crates/template-core/Cargo.toml b/crates/template-core/Cargo.toml new file mode 100644 index 0000000..0c75d74 --- /dev/null +++ b/crates/template-core/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "template-core" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +description = "Shared Rust library for the CLI and PyO3 layer" + +[lib] +name = "template_core" + +[dependencies] +anyhow = { workspace = true } diff --git a/crates/template-core/README.md b/crates/template-core/README.md new file mode 100644 index 0000000..78825ca --- /dev/null +++ b/crates/template-core/README.md @@ -0,0 +1,48 @@ +# `template-core` + +Pure Rust domain logic. No Python, no CLI surface, no I/O policy. This +is the crate the other two depend on — keep it clean. + +## What belongs here + +- Types that model the problem domain. +- Algorithms over those types. +- Pure functions and value-semantics structs. +- `Result` (or a domain-specific error enum) as the + error contract — never `panic!` on recoverable conditions. + +## What does NOT belong here + +- `clap` or any other argv parser — that's `template-cli`'s job. +- `pyo3` decorators or `PyResult` — that's `template-py`'s job. +- `println!` / `eprintln!` for user output — domain code returns + values; the binary decides how to render them. +- Process exits, signal handling, locale negotiation. +- Direct filesystem or network I/O unless it's the actual subject of + the domain. + +## Why this crate is load-bearing + +`template-cli` and `template-py` translate domain types into their +respective surfaces. If domain behavior lives anywhere else, the two +surfaces will drift — the CLI ships one bug fix, the bindings ship a +different one, and consumers see inconsistency. The whole point of the +workspace shape is to make divergence structurally hard. + +## Public surface + +Everything exported from `lib.rs` is the contract the other two crates +see. Be conservative: + +- Mark items `pub` only when a consumer needs them. +- Prefer `pub use` re-exports over inline `pub mod` so the surface is + greppable in one place. +- Match the `template-py` Python surface name-for-name where possible + — `do_thing()` in `template-core` should be `do_thing()` in both + the CLI and the bindings. + +## Testing + +Unit tests live next to their code under `#[cfg(test)] mod tests`. +Integration tests live in `crates/template-core/tests/`. Both run as +part of `./ci.sh test` (which calls `cargo test --workspace`). diff --git a/crates/template-core/src/lib.rs b/crates/template-core/src/lib.rs new file mode 100644 index 0000000..777c509 --- /dev/null +++ b/crates/template-core/src/lib.rs @@ -0,0 +1,10 @@ +//! Shared domain layer for the template scaffold. + +pub fn version_banner() -> &'static str { + concat!("template-core ", env!("CARGO_PKG_VERSION")) +} + +pub fn run_cli() -> anyhow::Result<()> { + println!("{}", version_banner()); + Ok(()) +} diff --git a/crates/template-py/Cargo.toml b/crates/template-py/Cargo.toml new file mode 100644 index 0000000..b334a9c --- /dev/null +++ b/crates/template-py/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "template-py" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +description = "PyO3 bindings for template-python-rust-cmd" + +[lib] +name = "_native" +crate-type = ["cdylib"] + +[dependencies] +template_core = { package = "template-core", path = "../template-core" } +pyo3 = { workspace = true } diff --git a/crates/template-py/README.md b/crates/template-py/README.md new file mode 100644 index 0000000..6fd38fa --- /dev/null +++ b/crates/template-py/README.md @@ -0,0 +1,61 @@ +# `template-py` + +PyO3 wrapper that turns `template-core`'s Rust API into a Python +extension module. Built as a `cdylib` named `_native`, loaded into +Python as `template_python_rust_cmd._native`. + +## Responsibilities + +- `#[pymodule]` / `#[pyfunction]` / `#[pyclass]` decorators. +- Python-friendly value conversion (Rust `String` ↔ Python `str`, + Rust `Result` ↔ Python exceptions, etc.). +- Holding the GIL boundary where it matters. +- Thin translation over `template-core` — no domain logic. + +If a behavior change needs to land in both the CLI and the Python API, +it goes in `template-core`. This crate just exposes it. + +## Build + +Maturin handles the heavy lifting: + +``` +uv run maturin develop --uv --profile dev # in-place build for tests +uv run python ci/build_wheel.py # release wheel +``` + +`pyproject.toml` declares `maturin` as the build backend and pins this +crate's manifest path: + +```toml +[tool.maturin] +manifest-path = "crates/template-py/Cargo.toml" +module-name = "template_python_rust_cmd._native" +python-source = "src" +features = ["pyo3/extension-module"] +``` + +That `features` entry is crucial — `pyo3/extension-module` is the +build flag that lets the dylib not link against libpython at compile +time (it loads symbols at runtime instead). Without it, the wheel +breaks on any Python interpreter that wasn't compiled with the same +ABI. + +## Module name vs. crate name + +- Crate: `template-py` (workspace member, name in `Cargo.toml`) +- Library: `_native` (`[lib].name`, becomes the dylib filename) +- Python import: `template_python_rust_cmd._native` (set by + `module-name` in `[tool.maturin]`) + +The three are intentionally different because the Rust crate name, +the dylib filename, and the Python import path serve different +audiences. + +## Surface contract + +The public Python API lives in +`src/template_python_rust_cmd/bindings.py` and wraps this crate's +extension module. Don't expose `_native` directly to package +consumers; wrap each function so the package can evolve its surface +independently of PyO3 decorators. diff --git a/crates/template-py/src/lib.rs b/crates/template-py/src/lib.rs new file mode 100644 index 0000000..bcbcb37 --- /dev/null +++ b/crates/template-py/src/lib.rs @@ -0,0 +1,12 @@ +use pyo3::prelude::*; + +#[pyfunction] +fn version_banner() -> &'static str { + template_core::version_banner() +} + +#[pymodule] +fn _native(_py: Python<'_>, module: &Bound<'_, PyModule>) -> PyResult<()> { + module.add_function(wrap_pyfunction!(version_banner, module)?)?; + Ok(()) +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..5278354 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,87 @@ +# Architecture + +## Goal + +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/`) + +A consumer installs a single distribution and gets both deliverables. +A maintainer maintains one workspace and one CI matrix to feed both. + +## CI Architecture + +This template implements the canonical Rust+Python CI shape from +[`zackees/zccache#835`](https://github.com/zackees/zccache/issues/835). +Five load-bearing pieces: + +1. **`./ci.sh` + `ci.py`.** Bash wrapper + PEP 723 dispatcher. Every + gate invocation goes through here so the `--no-project --script` + flag combo (which suppresses the maturin auto-build trap) lives in + one place. +2. **`ci/gates/`.** Workspace-state checks. Each file exposes + `def run() -> int`. Canonical ordering in `ci.py::GATE_ORDER`. +3. **`ci/hooks/`.** Agent-intent guards wired through + `.claude/settings.json`. Only fires during Claude/Codex sessions. +4. **`.github/workflows/ci.yml`.** Thin orchestration — one runner per + platform, every step is `./ci.sh `. Final `report-failures` + step collects step outcomes. +5. **`action.yml` + `action/cleanup/action.yml`.** Composite action + contract. Validated by `ci/gates/action_yaml.py` (structural) + + `ci/gates/action_surface.py` (runtime binary surface match). + +## Crate Responsibilities + +### `crates/template-core` + +- pure Rust domain logic +- no Python-specific concerns +- reusable from both the binary and the bindings crate +- the unit of behavior consistency between CLI and Python + +### `crates/template-cli` + +- argument parsing +- command execution +- stdout/stderr policy +- exit-code policy +- thin translation layer over `template-core` +- **subcommand surface is part of the composite-action contract** — + `ci/gates/action_surface.py` verifies `action.yml`'s shell snippets + only reference subcommands actually present in `--help` + +### `crates/template-py` + +- `PyO3` module definitions (`#[pymodule]`, `#[pyfunction]`) +- Python-friendly value conversion +- GIL boundary management +- thin translation layer over `template-core` + +### `src/template_python_rust_cmd` + +- 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`) + +## Hook / Gate Split + +| Concern | Home | +|----------------------------------------|----------------------------| +| Repo-state checks (every CI cycle) | `ci/gates/*.py` | +| Agent-intent checks (Claude sessions) | `ci/hooks/*.py` | +| File size budget (workspace-wide) | `ci/gates/loc.py` | +| File size budget (per-edit) | `ci/hooks/loc_guard.py` | +| README presence + size | `ci/hooks/readme_guard.py` | +| Bare cargo / unsafe `uv run` shape | `ci/hooks/tool_guard.py` | + +## Non-goals + +- duplicating core logic in Python +- embedding CLI-only behavior inside the `PyO3` module without an API + use case +- maintaining separate CI infrastructure for the Python and Rust sides +- inline shell logic in `.github/workflows/ci.yml` +- gates that hard-couple to the agent's session (those are hooks) diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..854eb67 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,51 @@ +# `docs/` + +Architecture and release documentation. Two files today; more get added +here as the template grows. + +## Index + +| File | Purpose | +|-------------------|------------------------------------------------------------------------| +| `ARCHITECTURE.md` | Workspace shape: which crate owns what, public surfaces, non-goals. | +| `RELEASE.md` | Release sequence + publish-script contract. | + +## Where new docs go + +- **A new subsystem doc** (e.g., the CLI's command dispatch + architecture) → here, as `docs/.md`. Add a row to the + index above so future readers can find it. + +- **CI / gate / hook docs** → keep concise prose in the relevant + `ci/README.md` and let the gate's docstring cover the why. If the + topic outgrows the README, lift it to `docs/CI.md`. + +- **Design decisions** → consider an ADR pattern (`docs/adr/0001-*.md`) + if the project grows enough to need one. For now, the rationale for + the canonical CI shape lives in + [zackees/zccache#835](https://github.com/zackees/zccache/issues/835) + and isn't worth duplicating here. + +- **API references** → autogenerated. Don't hand-write them. + Rust → `cargo doc`; Python → `pdoc` if it ever becomes load-bearing. + +## What doesn't belong here + +- **Source code.** Even build helpers, scripts, etc. — those live under + `ci/`. +- **Generated reports** (HTML coverage, benchmark output). Those land + under `target/` or `dist/` and are gitignored. +- **Issue / PR templates.** Those go under `.github/`. +- **Anything that duplicates README.md.** Keep doc topics scoped: + README is the orientation; `docs/` is the detail. If a section in + README starts feeling too long, move it here and link. + +## Style + +- Markdown, GitHub-flavored. +- Code blocks with language tags. +- Relative links to other repo files (`../ci/README.md`), not absolute + URLs. +- Sections marked with `##` and `###`; no deeper than `####`. +- Prefer tables over long bulleted lists when the items have parallel + structure. diff --git a/docs/RELEASE.md b/docs/RELEASE.md new file mode 100644 index 0000000..7014a37 --- /dev/null +++ b/docs/RELEASE.md @@ -0,0 +1,86 @@ +# Release Flow + +The canonical release sequence and the publish-script contract. + +## Sequence + +1. Confirm versions match in `pyproject.toml::project.version` and + `Cargo.toml::workspace.package.version`. The publish guard rejects + a mismatch. +2. Land a clean main: `./ci.sh all` passes locally, all CI gates + green on the PR, branch merged. +3. Locally on a clean checkout of the release tag, run: + ``` + ./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. +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 + flag — the file edit is the audit trail). +6. Publish: + ``` + ./publish + ``` + Which runs `twine upload --skip-existing` against the artifacts in + `dist/`. The `--skip-existing` flag is load-bearing: it lets a + reattempted release skip files that already landed without failing + the whole upload. +7. Tag and push: `git tag vX.Y.Z && git push origin vX.Y.Z`. +8. Reset `_ENABLED = False` in `ci/publish.py` and commit so the + guard stays on for the next maintainer. + +## `ci/publish.py` Contract + +The publish script: + +- **exits with code 1** while `_ENABLED = False` and tells the + operator to enable it manually. +- **fails on a dirty worktree** — uses `git status --short` as the + predicate. +- **builds release artifacts** by invoking `ci/build_wheel.py` (the + named entry point that opts INTO the full maturin context). +- **runs `twine check`** before upload so a malformed wheel is + caught locally. +- **uploads only missing artifacts when rerunning** via + `twine upload --skip-existing`. +- **supports an explicit `--repository-url`** for staging uploads to + TestPyPI. + +## Why this is gated, not automated + +A release is a human-in-the-loop decision: version bump, changelog, +deprecation policy. Wrapping it in a script that won't run unless the +operator flipped a constant by hand is a deliberate friction step — +the file edit is the "I really mean it" signal. CI doesn't release; +operators release. + +If the release flow ever becomes a workflow (`release-auto.yml`), +move the guard into a manual-approval gate, not into a CLI flag. The +edit-in-source-file pattern only works at human cadence. + +## Cross-platform wheels + +`ci/build_wheel.py` runs on each native runner in the CI matrix; per +platform you get one wheel and (on Linux) one sdist. Upload all of +them at once at the end — `twine upload dist/*` handles the mix +correctly because each wheel's tag identifies its target platform. + +## Surface validation + +Before tagging, run the two action gates to confirm the composite +action contract still holds: + +``` +./ci.sh action_yaml +./ci.sh action_surface +``` + +These are fast (<5 s) and catch the typo class of regressions where +`action.yml`'s shell snippets drift from the binary's real +subcommand surface. diff --git a/install b/install new file mode 100755 index 0000000..417002d --- /dev/null +++ b/install @@ -0,0 +1,61 @@ +#!/usr/bin/env -S uv run --script +# /// script +# requires-python = ">=3.11" +# /// +"""Local bootstrap. + +Verifies the toolchain shape this repo expects: + + - `uv` is on PATH (the only Python entry point). + - The pinned Rust toolchain (rust-toolchain.toml) is resolvable. + - The dev dependency group is materialized. + +Does NOT trigger a maturin build — that's reserved for `./test` / +`./build`. Use `./install` after a fresh clone to confirm the +environment is sane. +""" + +from __future__ import annotations + +import shutil +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent + + +def _check(cmd: list[str], label: str) -> int: + print(f" {label:14} ...", end=" ", flush=True) + proc = subprocess.run(cmd, cwd=ROOT, capture_output=True, text=True, check=False) + if proc.returncode != 0: + print("FAIL") + sys.stderr.write(proc.stderr) + return proc.returncode + print("ok") + return 0 + + +def main() -> int: + print(f"bootstrap {ROOT.name}") + + if shutil.which("uv") is None: + print("uv not on PATH. Install it: https://docs.astral.sh/uv/", file=sys.stderr) + return 1 + + rc = 0 + rc |= _check(["uv", "--version"], "uv") + rc |= _check(["uv", "sync", "--frozen", "--no-install-project"], "uv sync") + rc |= _check(["rustup", "show", "active-toolchain"], "rustup") + if rc != 0: + return rc + + print("\nNext steps:") + print(" ./ci.sh all # run every gate") + print(" ./test # cargo test + maturin develop + pytest") + print(" ./ci.sh --list # show registered gates") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/lint b/lint new file mode 100755 index 0000000..2e45a1c --- /dev/null +++ b/lint @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +# Thin wrapper: route to the canonical CI dispatcher. +# +# `./lint` is preserved as muscle memory; the actual implementation is +# `./ci.sh fmt && ./ci.sh clippy && ./ci.sh ruff`. Anything new you'd +# want under `./lint` belongs as a gate under `ci/gates/`. +set -euo pipefail +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +"$script_dir/ci.sh" fmt +"$script_dir/ci.sh" clippy +"$script_dir/ci.sh" ruff diff --git a/publish b/publish new file mode 100755 index 0000000..e74f5c7 --- /dev/null +++ b/publish @@ -0,0 +1,9 @@ +#!/usr/bin/env -S uv run --script +# /// script +# requires-python = ">=3.11" +# /// +from ci.publish import main + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..c9695d4 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,36 @@ +[build-system] +requires = ["maturin>=1.7,<2"] +build-backend = "maturin" + +[project] +name = "template-python-rust-cmd" +version = "0.1.0" +description = "Skeleton for a Python package backed by a Rust CLI and PyO3 bindings" +readme = "README.md" +requires-python = ">=3.11" +license = { text = "MIT" } +license-files = ["LICENSE"] +authors = [{ name = "Maintainer", email = "maintainer@example.com" }] +dependencies = [] + +[project.scripts] +template-python-rust-cmd = "template_python_rust_cmd.cli:main" + +[dependency-groups] +dev = [ + "maturin>=1.7,<2", + "pytest>=8.0.0", + "ruff>=0.8.0", +] + +[tool.maturin] +manifest-path = "crates/template-py/Cargo.toml" +module-name = "template_python_rust_cmd._native" +python-source = "src" +features = ["pyo3/extension-module"] + +[tool.pytest.ini_options] +minversion = "8.0" +addopts = ["-ra", "--strict-markers"] +testpaths = ["tests"] +pythonpath = ["src"] diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..3f06a64 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,3 @@ +[toolchain] +channel = "1.85.0" +components = ["cargo", "clippy", "rustfmt"] diff --git a/src/README.md b/src/README.md new file mode 100644 index 0000000..92a9570 --- /dev/null +++ b/src/README.md @@ -0,0 +1,52 @@ +# `src/` + +The Python source layout for the wheel. Single package: +`template_python_rust_cmd`, declared in `pyproject.toml` as the +maturin `python-source`. + +## Layout + +``` +src/template_python_rust_cmd/ +├── __init__.py # package version + re-exports +├── _native.pyi # typing stub for the PyO3 surface +├── bindings.py # Python wrapper around the extension module +├── cli.py # Python entrypoint; delegates to the native binary +└── _bin/ # staged native executable (gitignored except .gitkeep) +``` + +## Why `src/`-layout instead of flat + +Standard PEP 517 src-layout avoids the "accidentally importing +half-built package" failure mode where the working directory shadows +the installed package. Tools that read `pyproject.toml` (pytest, uv, +maturin) all support src-layout out of the box, so there's no friction +for it. + +## What you can edit here + +- `bindings.py` — the public Python API. Each function should be a + near-1:1 reflection of an underlying `_native` call, with type + annotations and a one-line docstring. +- `cli.py` — the Python CLI shim. Should stay tiny: locate the + packaged binary, exec it, pass through the exit code. No business + logic. +- `__init__.py` — package version, public re-exports. Don't import + `_native` directly here; route through `bindings.py`. +- `_native.pyi` — optional typing stub mirroring the PyO3 surface. + Keep it in sync with `crates/template-py/src/lib.rs`. + +## What you should NOT edit here + +- `_native*.pyd` / `_native*.so` / `_native*.dylib` — built by + maturin, gitignored. +- `_bin/` — staged by `ci/build_wheel.py`, gitignored. +- Anything implementing domain logic — that belongs in `template-core`. + +## Build modes + +| Mode | Command | What's materialized | +|--------------------|----------------------------------------------------|------------------------------------| +| In-place (dev) | `uv run maturin develop --uv --profile dev` | `_native*.pyd` next to `bindings.py`| +| Release wheel | `uv run python ci/build_wheel.py` | wheel under `dist/` | +| Sdist | (included in `build_wheel.py`) | tarball under `dist/` | diff --git a/src/template_python_rust_cmd/README.md b/src/template_python_rust_cmd/README.md new file mode 100644 index 0000000..feaabd2 --- /dev/null +++ b/src/template_python_rust_cmd/README.md @@ -0,0 +1,60 @@ +# `template_python_rust_cmd` + +The actual Python package. Imported as +`import template_python_rust_cmd` after a `pip install`. + +## Public modules + +| Module | Purpose | +|---------------|-------------------------------------------------------------------------| +| `bindings` | Python wrapper around the PyO3 extension. Public API surface. | +| `cli` | Thin shim; locates `_bin/template-cli` and execs it. Used by the script entry point in `pyproject.toml`. | +| `__init__` | Package version (`__version__`) and re-exports. | + +## Internal modules + +| Module | Purpose | +|---------------|-------------------------------------------------------------------------| +| `_native` | Built by maturin from `crates/template-py`. Never import directly from outside this package — go through `bindings`. | +| `_native.pyi` | Optional typing stub for IDEs. | +| `_bin/` | Holds the packaged `template-cli{,.exe}` binary at install time. Staged by `ci/build_wheel.py` and removed afterward. | + +## Wrapping the extension + +```python +from template_python_rust_cmd._native import version_banner as _vb + +def version_banner() -> str: + """Return the human-readable version banner from the Rust core.""" + return _vb() +``` + +Why wrap instead of re-export? So the Python surface stays stable +even if the underlying PyO3 decorator's signature changes (e.g., a +new keyword argument added at the Rust layer). The wrapper is the +unit of API compatibility. + +## CLI delegation + +```python +def main() -> int: + binary = packaged_binary_path() + return subprocess.call([str(binary), *sys.argv[1:]]) +``` + +`cli.main` is the entry point declared in +`pyproject.toml::project.scripts`. It's deliberately tiny — argv +parsing, exit codes, and behavior all live in the native binary; the +Python shim just hands control over. + +## Why both an extension AND a binary? + +Two different consumption stories: + +- **In-process API.** A Python program wants to call into Rust + without the cost of spawning a subprocess. → Use `bindings.py`. +- **Command surface.** A user (or shell script, or CI step) wants to + run `template-cli foo --bar`. → Use the `template-python-rust-cmd` + console script that delegates to the binary. + +`template-core` makes both surfaces honor the same domain behavior. diff --git a/src/template_python_rust_cmd/__init__.py b/src/template_python_rust_cmd/__init__.py new file mode 100644 index 0000000..f8e6b07 --- /dev/null +++ b/src/template_python_rust_cmd/__init__.py @@ -0,0 +1,3 @@ +"""Python package skeleton for the mixed Rust/Python distribution.""" + +__version__ = "0.1.0" diff --git a/src/template_python_rust_cmd/_bin/.gitkeep b/src/template_python_rust_cmd/_bin/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/template_python_rust_cmd/_bin/.gitkeep @@ -0,0 +1 @@ + diff --git a/src/template_python_rust_cmd/_native.pyi b/src/template_python_rust_cmd/_native.pyi new file mode 100644 index 0000000..447f321 --- /dev/null +++ b/src/template_python_rust_cmd/_native.pyi @@ -0,0 +1 @@ +def version_banner() -> str: ... diff --git a/src/template_python_rust_cmd/bindings.py b/src/template_python_rust_cmd/bindings.py new file mode 100644 index 0000000..5b8237d --- /dev/null +++ b/src/template_python_rust_cmd/bindings.py @@ -0,0 +1,8 @@ +"""Thin Python wrappers around the PyO3 extension module.""" + +from template_python_rust_cmd import _native + + +def version_banner() -> str: + """Return a version string from the native extension.""" + return _native.version_banner() diff --git a/src/template_python_rust_cmd/cli.py b/src/template_python_rust_cmd/cli.py new file mode 100644 index 0000000..dbf9c92 --- /dev/null +++ b/src/template_python_rust_cmd/cli.py @@ -0,0 +1,20 @@ +"""Python console entrypoint that delegates to the packaged Rust binary.""" + +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path + + +def packaged_binary_path() -> Path: + """Return where the packaged Rust executable is expected to live.""" + suffix = ".exe" if os.name == "nt" else "" + return Path(__file__).resolve().parent / "_bin" / f"template-cli{suffix}" + + +def main() -> int: + binary = packaged_binary_path() + completed = subprocess.run([str(binary), *sys.argv[1:]], check=False) + return completed.returncode diff --git a/test b/test new file mode 100755 index 0000000..a85c923 --- /dev/null +++ b/test @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +# Thin wrapper: route to the canonical CI dispatcher's test gate. +# +# `./test` is a named build entry point (one of the documented places +# where the maturin context is opted INTO — see ci/hooks/tool_guard.py's +# BUILD_ENTRY_POINTS). The test gate runs `cargo test` + `maturin +# develop` + `pytest`. +set -euo pipefail +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +"$script_dir/ci.sh" test diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..ca5aec8 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,60 @@ +# `tests/` + +Python-side test suite. Picked up by `pytest` per the configuration in +`pyproject.toml::[tool.pytest.ini_options]`. + +## What lives here + +| File | What it tests | +|-------------------|------------------------------------------------------------------------| +| `test_bindings.py`| The PyO3 extension surface via `template_python_rust_cmd.bindings`. | +| `test_cli.py` | The Python CLI shim's binary-discovery logic. | +| `test_version.py` | Package `__version__` is non-empty and matches the manifest. | +| `test_gates.py` | Each gate registered in `ci.py::GATE_ORDER` is importable and exposes `def run() -> int`. The contract test for the gates infra itself. | + +## What lives in `crates/*/tests/` instead + +- Pure Rust unit tests live alongside the code under + `#[cfg(test)] mod tests`. +- Rust integration tests live under `crates//tests/`. + +Both run as part of `./ci.sh test` (which calls `cargo test +--workspace` then `pytest`). + +## Conventions + +- **No mocks of the extension module.** Test the real `_native` build. + Mocking PyO3 functions defeats the point of having an extension. +- **Use `pytest.fixture` for binary discovery / temp dirs** rather + than hardcoding paths. The CI matrix runs on 8 platforms; paths + differ. +- **Mark slow tests with `@pytest.mark.slow`** and skip by default. + The gate target should be sub-30s on a developer laptop; longer + scenarios go in a separate workflow. +- **Async tests use `pytest-asyncio`** if the binding ever grows an + async surface (not currently — but reserve the marker). + +## Why the gate contract test exists + +`tests/test_gates.py` asserts that: + +1. Every name in `ci.py::GATE_ORDER` resolves to a module under + `ci/gates/`. +2. Each module exposes `def run() -> int`. +3. Calling `run()` doesn't `raise` (it may return non-zero — that's + fine; the test just verifies the contract shape). + +This is what the issue's actionable TODO list calls for explicitly — +once the contract is locked, future gate additions can't accidentally +ship a broken signature. + +## Running just the Python tests + +```bash +uv run pytest # all +uv run pytest tests/test_cli.py # one file +uv run pytest -k version # by name +``` + +These need the maturin extension materialized first (`uv run maturin +develop --uv --profile dev`). The `test` gate handles that for you. diff --git a/tests/test_bindings.py b/tests/test_bindings.py new file mode 100644 index 0000000..ff8e47c --- /dev/null +++ b/tests/test_bindings.py @@ -0,0 +1,5 @@ +from template_python_rust_cmd.bindings import version_banner + + +def test_version_banner_returns_string() -> None: + assert isinstance(version_banner(), str) diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..1f9dba8 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,9 @@ +import os + +from template_python_rust_cmd.cli import packaged_binary_path + + +def test_packaged_binary_path_points_into_package() -> None: + expected = "template-cli.exe" if os.name == "nt" else "template-cli" + assert packaged_binary_path().name == expected + assert "_bin" in str(packaged_binary_path()) diff --git a/tests/test_gates.py b/tests/test_gates.py new file mode 100644 index 0000000..bbe26d4 --- /dev/null +++ b/tests/test_gates.py @@ -0,0 +1,80 @@ +"""Contract test for the gates infrastructure. + +Asserts that every gate registered in `ci.py::GATE_ORDER`: + + 1. resolves to a module under `ci/gates/`, + 2. exposes a zero-arg `run()`, + 3. has `run()` annotated to return an int. + +This does NOT execute the gates — that's what `./ci.sh all` is for. The +contract test exists so future gate additions can't accidentally ship a +broken signature; a developer who registers a new gate but forgets the +`def run() -> int` shape sees the failure here instead of mid-CI. +""" + +from __future__ import annotations + +import importlib +import importlib.util +import inspect +import sys +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO_ROOT)) + + +def _load_gate_order() -> list[str]: + # Import the dispatcher's GATE_ORDER without executing the script + # body (it has argparse / SystemExit in main()). + spec = importlib.util.spec_from_file_location("_ci_dispatcher", REPO_ROOT / "ci.py") + assert spec is not None and spec.loader is not None + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return list(mod.GATE_ORDER) + + +GATE_NAMES = _load_gate_order() + + +@pytest.mark.parametrize("name", GATE_NAMES) +def test_gate_module_is_importable(name: str) -> None: + mod = importlib.import_module(f"ci.gates.{name}") + assert mod is not None + + +@pytest.mark.parametrize("name", GATE_NAMES) +def test_gate_exposes_run(name: str) -> None: + mod = importlib.import_module(f"ci.gates.{name}") + assert hasattr(mod, "run"), f"ci.gates.{name} must expose `def run()`" + fn = mod.run + assert callable(fn), f"ci.gates.{name}.run must be callable" + + +@pytest.mark.parametrize("name", GATE_NAMES) +def test_gate_run_signature_is_zero_arg(name: str) -> None: + mod = importlib.import_module(f"ci.gates.{name}") + sig = inspect.signature(mod.run) + required = [ + p + for p in sig.parameters.values() + if p.default is inspect.Parameter.empty + and p.kind + not in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD) + ] + assert not required, ( + f"ci.gates.{name}.run must take no required arguments (found: {[p.name for p in required]})" + ) + + +@pytest.mark.parametrize("name", GATE_NAMES) +def test_gate_run_returns_int_annotation(name: str) -> None: + mod = importlib.import_module(f"ci.gates.{name}") + sig = inspect.signature(mod.run) + ann = sig.return_annotation + # Accept either `int` directly or the string "int" (PEP 563 future-annotations). + assert ann in (int, "int"), ( + f"ci.gates.{name}.run should be annotated `-> int` (got {ann!r})" + ) diff --git a/tests/test_version.py b/tests/test_version.py new file mode 100644 index 0000000..4983504 --- /dev/null +++ b/tests/test_version.py @@ -0,0 +1,5 @@ +from template_python_rust_cmd import __version__ + + +def test_version_is_set() -> None: + assert __version__ == "0.1.0" diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..1140eaf --- /dev/null +++ b/uv.lock @@ -0,0 +1,131 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "maturin" +version = "1.12.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0c/18/8b2eebd3ea086a5ec73d7081f95ec64918ceda1900075902fc296ea3ad55/maturin-1.12.6.tar.gz", hash = "sha256:d37be3a811a7f2ee28a0fa0964187efa50e90f21da0c6135c27787fa0b6a89db", size = 269165, upload-time = "2026-03-01T14:54:04.21Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/8b/9ddfde8a485489e3ebdc50ee3042ef1c854f00dfea776b951068f6ffe451/maturin-1.12.6-py3-none-linux_armv6l.whl", hash = "sha256:6892b4176992fcc143f9d1c1c874a816e9a041248eef46433db87b0f0aff4278", size = 9789847, upload-time = "2026-03-01T14:54:09.172Z" }, + { url = "https://files.pythonhosted.org/packages/ef/e8/5f7fd3763f214a77ac0388dbcc71cc30aec5490016bd0c8e6bd729fc7b0a/maturin-1.12.6-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:c0c742beeeef7fb93b6a81bd53e75507887e396fd1003c45117658d063812dad", size = 19023833, upload-time = "2026-03-01T14:53:46.743Z" }, + { url = "https://files.pythonhosted.org/packages/e0/7f/706ff3839c8b2046436d4c2bc97596c558728264d18abc298a1ad862a4be/maturin-1.12.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:2cb41139295eed6411d3cdafc7430738094c2721f34b7eeb44f33cac516115dc", size = 9821620, upload-time = "2026-03-01T14:54:12.04Z" }, + { url = "https://files.pythonhosted.org/packages/0e/9c/70917fb123c8dd6b595e913616c9c72d730cbf4a2b6cac8077dc02a12586/maturin-1.12.6-py3-none-manylinux_2_12_i686.manylinux2010_i686.musllinux_1_1_i686.whl", hash = "sha256:351f3af1488a7cbdcff3b6d8482c17164273ac981378a13a4a9937a49aec7d71", size = 9849107, upload-time = "2026-03-01T14:53:48.971Z" }, + { url = "https://files.pythonhosted.org/packages/59/ea/f1d6ad95c0a12fbe761a7c28a57540341f188564dbe8ad730a4d1788cd32/maturin-1.12.6-py3-none-manylinux_2_12_x86_64.manylinux2010_x86_64.musllinux_1_1_x86_64.whl", hash = "sha256:6dbddfe4dc7ddee60bbac854870bd7cfec660acb54d015d24597d59a1c828f61", size = 10242855, upload-time = "2026-03-01T14:53:44.605Z" }, + { url = "https://files.pythonhosted.org/packages/93/1b/2419843a4f1d2fb4747f3dc3d9c4a2881cd97a3274dd94738fcdf0835e79/maturin-1.12.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:8fdb0f63e77ee3df0f027a120e9af78dbc31edf0eb0f263d55783c250c33b728", size = 9674972, upload-time = "2026-03-01T14:53:52.763Z" }, + { url = "https://files.pythonhosted.org/packages/71/46/b60ab2fc996d904b40e55bd475599dcdccd8f7ad3e649bf95e87970df466/maturin-1.12.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:fa84b7493a2e80759cacc2e668fa5b444d55b9994e90707c42904f55d6322c1e", size = 9645755, upload-time = "2026-03-01T14:53:58.497Z" }, + { url = "https://files.pythonhosted.org/packages/a4/96/03f2b55a8c226805115232fc23c4a4f33f0c9d39e11efab8166dc440f80d/maturin-1.12.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.musllinux_1_1_ppc64le.whl", hash = "sha256:e90dc12bc6a38e9495692a36c9e231c4d7e0c9bfde60719468ab7d8673db3c45", size = 12737612, upload-time = "2026-03-01T14:54:05.393Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c2/648667022c5b53cdccefa67c245e8a984970f3045820f00c2e23bdb2aff4/maturin-1.12.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:06fc8d089f98623ce924c669b70911dfed30f9a29956c362945f727f9abc546b", size = 10455028, upload-time = "2026-03-01T14:54:07.349Z" }, + { url = "https://files.pythonhosted.org/packages/63/d6/5b5efe3ca0c043357ed3f8d2b2d556169fdbf1ff75e50e8e597708a359d2/maturin-1.12.6-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:75133e56274d43b9227fd49dca9a86e32f1fd56a7b55544910c4ce978c2bb5aa", size = 10014531, upload-time = "2026-03-01T14:53:54.548Z" }, + { url = "https://files.pythonhosted.org/packages/68/d5/39c594c27b1a8b32a0cb95fff9ad60b888c4352d1d1c389ac1bd20dc1e16/maturin-1.12.6-py3-none-win32.whl", hash = "sha256:3f32e0a3720b81423c9d35c14e728cb1f954678124749776dc72d533ea1115e8", size = 8553012, upload-time = "2026-03-01T14:53:50.706Z" }, + { url = "https://files.pythonhosted.org/packages/94/66/b262832a91747e04051e21f986bd01a8af81fbffafacc7d66a11e79aab5f/maturin-1.12.6-py3-none-win_amd64.whl", hash = "sha256:977290159d252db946054a0555263c59b3d0c7957135c69e690f4b1558ee9983", size = 9890470, upload-time = "2026-03-01T14:53:56.659Z" }, + { url = "https://files.pythonhosted.org/packages/e3/47/76b8ca470ddc8d7d36aa8c15f5a6aed1841806bb93a0f4ead8ee61e9a088/maturin-1.12.6-py3-none-win_arm64.whl", hash = "sha256:bae91976cdc8148038e13c881e1e844e5c63e58e026e8b9945aa2d19b3b4ae89", size = 8606158, upload-time = "2026-03-01T14:54:02.423Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/97/e9f1ca355108ef7194e38c812ef40ba98c7208f47b13ad78d023caa583da/ruff-0.15.9.tar.gz", hash = "sha256:29cbb1255a9797903f6dde5ba0188c707907ff44a9006eb273b5a17bfa0739a2", size = 4617361, upload-time = "2026-04-02T18:17:20.829Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/1f/9cdfd0ac4b9d1e5a6cf09bedabdf0b56306ab5e333c85c87281273e7b041/ruff-0.15.9-py3-none-linux_armv6l.whl", hash = "sha256:6efbe303983441c51975c243e26dff328aca11f94b70992f35b093c2e71801e1", size = 10511206, upload-time = "2026-04-02T18:16:41.574Z" }, + { url = "https://files.pythonhosted.org/packages/3d/f6/32bfe3e9c136b35f02e489778d94384118bb80fd92c6d92e7ccd97db12ce/ruff-0.15.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:4965bac6ac9ea86772f4e23587746f0b7a395eccabb823eb8bfacc3fa06069f7", size = 10923307, upload-time = "2026-04-02T18:17:08.645Z" }, + { url = "https://files.pythonhosted.org/packages/ca/25/de55f52ab5535d12e7aaba1de37a84be6179fb20bddcbe71ec091b4a3243/ruff-0.15.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:eaf05aad70ca5b5a0a4b0e080df3a6b699803916d88f006efd1f5b46302daab8", size = 10316722, upload-time = "2026-04-02T18:16:44.206Z" }, + { url = "https://files.pythonhosted.org/packages/48/11/690d75f3fd6278fe55fff7c9eb429c92d207e14b25d1cae4064a32677029/ruff-0.15.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9439a342adb8725f32f92732e2bafb6d5246bd7a5021101166b223d312e8fc59", size = 10623674, upload-time = "2026-04-02T18:16:50.951Z" }, + { url = "https://files.pythonhosted.org/packages/bd/ec/176f6987be248fc5404199255522f57af1b4a5a1b57727e942479fec98ad/ruff-0.15.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9c5e6faf9d97c8edc43877c3f406f47446fc48c40e1442d58cfcdaba2acea745", size = 10351516, upload-time = "2026-04-02T18:16:57.206Z" }, + { url = "https://files.pythonhosted.org/packages/b2/fc/51cffbd2b3f240accc380171d51446a32aa2ea43a40d4a45ada67368fbd2/ruff-0.15.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7b34a9766aeec27a222373d0b055722900fbc0582b24f39661aa96f3fe6ad901", size = 11150202, upload-time = "2026-04-02T18:17:06.452Z" }, + { url = "https://files.pythonhosted.org/packages/d6/d4/25292a6dfc125f6b6528fe6af31f5e996e19bf73ca8e3ce6eb7fa5b95885/ruff-0.15.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:89dd695bc72ae76ff484ae54b7e8b0f6b50f49046e198355e44ea656e521fef9", size = 11988891, upload-time = "2026-04-02T18:17:18.575Z" }, + { url = "https://files.pythonhosted.org/packages/13/e1/1eebcb885c10e19f969dcb93d8413dfee8172578709d7ee933640f5e7147/ruff-0.15.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ce187224ef1de1bd225bc9a152ac7102a6171107f026e81f317e4257052916d5", size = 11480576, upload-time = "2026-04-02T18:16:52.986Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6b/a1548ac378a78332a4c3dcf4a134c2475a36d2a22ddfa272acd574140b50/ruff-0.15.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2b0c7c341f68adb01c488c3b7d4b49aa8ea97409eae6462d860a79cf55f431b6", size = 11254525, upload-time = "2026-04-02T18:17:02.041Z" }, + { url = "https://files.pythonhosted.org/packages/42/aa/4bb3af8e61acd9b1281db2ab77e8b2c3c5e5599bf2a29d4a942f1c62b8d6/ruff-0.15.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:55cc15eee27dc0eebdfcb0d185a6153420efbedc15eb1d38fe5e685657b0f840", size = 11204072, upload-time = "2026-04-02T18:17:13.581Z" }, + { url = "https://files.pythonhosted.org/packages/69/48/d550dc2aa6e423ea0bcc1d0ff0699325ffe8a811e2dba156bd80750b86dc/ruff-0.15.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a6537f6eed5cda688c81073d46ffdfb962a5f29ecb6f7e770b2dc920598997ed", size = 10594998, upload-time = "2026-04-02T18:16:46.369Z" }, + { url = "https://files.pythonhosted.org/packages/63/47/321167e17f5344ed5ec6b0aa2cff64efef5f9e985af8f5622cfa6536043f/ruff-0.15.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:6d3fcbca7388b066139c523bda744c822258ebdcfbba7d24410c3f454cc9af71", size = 10359769, upload-time = "2026-04-02T18:17:10.994Z" }, + { url = "https://files.pythonhosted.org/packages/67/5e/074f00b9785d1d2c6f8c22a21e023d0c2c1817838cfca4c8243200a1fa87/ruff-0.15.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:058d8e99e1bfe79d8a0def0b481c56059ee6716214f7e425d8e737e412d69677", size = 10850236, upload-time = "2026-04-02T18:16:48.749Z" }, + { url = "https://files.pythonhosted.org/packages/76/37/804c4135a2a2caf042925d30d5f68181bdbd4461fd0d7739da28305df593/ruff-0.15.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8e1ddb11dbd61d5983fa2d7d6370ef3eb210951e443cace19594c01c72abab4c", size = 11358343, upload-time = "2026-04-02T18:16:55.068Z" }, + { url = "https://files.pythonhosted.org/packages/88/3d/1364fcde8656962782aa9ea93c92d98682b1ecec2f184e625a965ad3b4a6/ruff-0.15.9-py3-none-win32.whl", hash = "sha256:bde6ff36eaf72b700f32b7196088970bf8fdb2b917b7accd8c371bfc0fd573ec", size = 10583382, upload-time = "2026-04-02T18:17:04.261Z" }, + { url = "https://files.pythonhosted.org/packages/4c/56/5c7084299bd2cacaa07ae63a91c6f4ba66edc08bf28f356b24f6b717c799/ruff-0.15.9-py3-none-win_amd64.whl", hash = "sha256:45a70921b80e1c10cf0b734ef09421f71b5aa11d27404edc89d7e8a69505e43d", size = 11744969, upload-time = "2026-04-02T18:16:59.611Z" }, + { url = "https://files.pythonhosted.org/packages/03/36/76704c4f312257d6dbaae3c959add2a622f63fcca9d864659ce6d8d97d3d/ruff-0.15.9-py3-none-win_arm64.whl", hash = "sha256:0694e601c028fd97dc5c6ee244675bc241aeefced7ef80cd9c6935a871078f53", size = 11005870, upload-time = "2026-04-02T18:17:15.773Z" }, +] + +[[package]] +name = "template-python-rust-cmd" +version = "0.1.0" +source = { editable = "." } + +[package.dev-dependencies] +dev = [ + { name = "maturin" }, + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] + +[package.metadata.requires-dev] +dev = [ + { name = "maturin", specifier = ">=1.7,<2" }, + { name = "pytest", specifier = ">=8.0.0" }, + { name = "ruff", specifier = ">=0.8.0" }, +]