diff --git a/.github/CONFIG.md b/.github/CONFIG.md new file mode 100644 index 0000000..b7db081 --- /dev/null +++ b/.github/CONFIG.md @@ -0,0 +1,63 @@ +# GitHub Actions Configuration + +This document describes the secrets used by the Rhiza-provided GitHub Actions workflows +(`.github/workflows/rhiza_*.yml`) and how to configure them. + +## PAT_TOKEN + +Some workflows may need to push changes to files under `.github/workflows/`. The +automatic `github.token` **cannot** do that — GitHub rejects such pushes unless +the token carries the `workflow` scope. If you need it, create a Personal Access +Token (PAT) with the `workflow` scope and store it as a repository secret named +`PAT_TOKEN`. + +If `PAT_TOKEN` is not configured, workflows fall back to `github.token`. + +### Creating the token + +**Fine-grained PAT** (recommended): + +1. Go to **Settings → Developer settings → Fine-grained tokens → Generate new token** + (). +2. Restrict **Repository access** to the repository (or repositories) using Rhiza. +3. Under **Repository permissions**, grant: + - **Contents**: Read and write + - **Workflows**: Read and write + - **Pull requests**: Read and write (needed for the scheduled sync-PR mode) +4. Generate the token and copy it. + +**Classic PAT** (alternative): + +1. Go to **Settings → Developer settings → Tokens (classic) → Generate new token**. +2. Select the `repo` and `workflow` scopes. +3. Generate the token and copy it. + +### Storing the secret + +In the repository that consumes Rhiza: + +1. Go to **Settings → Secrets and variables → Actions → New repository secret**. +2. Name: `PAT_TOKEN` +3. Value: the token created above. + +Or with the GitHub CLI: + +```bash +gh secret set PAT_TOKEN +``` + +A PAT expires; when sync pushes start failing with a `refusing to allow ... workflow` error, +regenerate the token and update the secret. + +## Release workflow secrets (optional) + +The release workflow (`.github/workflows/rhiza_release.yml`) supports additional secrets, all +optional depending on which release features you use: + +| Secret | Purpose | +| --- | --- | +| `PYPI_TOKEN` | Publish the built package to PyPI. Not needed when using trusted publishing (OIDC). | +| `GH_PAT` | Git authentication for installing private dependencies during the release build. | +| `UV_EXTRA_INDEX_URL` | Extra package index URL (with credentials) for private dependencies. | + +`GITHUB_TOKEN` is provided automatically by GitHub Actions and needs no configuration. diff --git a/.github/DISCUSSION_TEMPLATE/help-wanted.yml b/.github/DISCUSSION_TEMPLATE/help-wanted.yml new file mode 100644 index 0000000..754369c --- /dev/null +++ b/.github/DISCUSSION_TEMPLATE/help-wanted.yml @@ -0,0 +1,48 @@ +title: "[Help Wanted] " +labels: ["help wanted"] +body: + - type: markdown + attributes: + value: | + Use this template to surface a contribution opportunity. It lets maintainers + describe work that is ready to be picked up — without opening a formal issue. + + - type: input + id: area + attributes: + label: Topic / Area + description: Which part of the project does this involve? + placeholder: e.g. CI workflows, documentation, a specific bundle + validations: + required: true + + - type: textarea + id: help-needed + attributes: + label: What Help Is Needed + description: Describe the task and what "done" looks like. + validations: + required: true + + - type: textarea + id: getting-started + attributes: + label: Getting Started + description: Pointers to relevant files, docs, or prior discussion that help a contributor begin. + placeholder: | + - Relevant files: ... + - Related docs: ... + validations: + required: false + + - type: dropdown + id: skill-level + attributes: + label: Skill Level + description: Roughly how much experience is needed to take this on? + options: + - Good first issue (beginner friendly) + - Intermediate + - Advanced + validations: + required: false diff --git a/.github/DISCUSSION_TEMPLATE/ideas.yml b/.github/DISCUSSION_TEMPLATE/ideas.yml new file mode 100644 index 0000000..3ed9990 --- /dev/null +++ b/.github/DISCUSSION_TEMPLATE/ideas.yml @@ -0,0 +1,40 @@ +title: "[Idea] " +labels: ["enhancement"] +body: + - type: markdown + attributes: + value: | + Propose an idea before implementing it. This lightweight RFC process lets the + community gauge interest and shape the design before any code is written. + + - type: textarea + id: idea + attributes: + label: The Idea + description: What are you proposing? Describe it clearly enough that others can react. + validations: + required: true + + - type: textarea + id: motivation + attributes: + label: Motivation + description: What problem does this solve, and who benefits? + validations: + required: true + + - type: textarea + id: alternatives + attributes: + label: Alternatives Considered + description: Other approaches you weighed, and why this one is preferable. + validations: + required: false + + - type: textarea + id: references + attributes: + label: References + description: Links to prior art, related discussions, issues, or external resources. + validations: + required: false diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 1da7ad1..9c4408e 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -58,7 +58,7 @@ updates: update-types: - "patch" - "minor" - + commit-message: prefix: "chore(deps)" include: "scope" diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 329b071..d9e50ec 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -19,5 +19,6 @@ Closes # ## Checklist - [ ] Commit messages follow the [Conventional Commits](https://www.conventionalcommits.org/) format +- [ ] `CHANGELOG.md` entry added (or not needed for this change) - [ ] Documentation updated if behaviour changed -- [ ] `make deptry` passes (no unused or missing dependencies) +- [ ] `make deps` passes (no unused or missing dependencies) diff --git a/.github/rulesets/main-branch-protection.json b/.github/rulesets/main-branch-protection.json new file mode 100644 index 0000000..f030647 --- /dev/null +++ b/.github/rulesets/main-branch-protection.json @@ -0,0 +1,47 @@ +{ + "name": "main-branch-protection", + "target": "branch", + "enforcement": "active", + "conditions": { + "ref_name": { + "include": ["~DEFAULT_BRANCH"], + "exclude": [] + } + }, + "rules": [ + { "type": "deletion" }, + { "type": "non_fast_forward" }, + { + "type": "pull_request", + "parameters": { + "required_approving_review_count": 1, + "dismiss_stale_reviews_on_push": true, + "require_code_owner_review": true, + "require_last_push_approval": true, + "required_review_thread_resolution": false, + "allowed_merge_methods": ["squash", "merge", "rebase"] + } + }, + { + "type": "required_status_checks", + "parameters": { + "strict_required_status_checks_policy": false, + "required_status_checks": [ + { "context": "ci / Pre-commit hooks", "integration_id": 15368 }, + { "context": "ci / Check dependencies with deptry", "integration_id": 15368 }, + { "context": "ci / docs-coverage", "integration_id": 15368 }, + { "context": "ci / Security scanning", "integration_id": 15368 }, + { "context": "ci / License compliance scan", "integration_id": 15368 }, + { "context": "ci / CI gate", "integration_id": 15368 } + ] + } + } + ], + "bypass_actors": [ + { + "actor_type": "RepositoryRole", + "actor_id": 5, + "bypass_mode": "always" + } + ] +} diff --git a/.github/rulesets/tag-protection.json b/.github/rulesets/tag-protection.json new file mode 100644 index 0000000..1c61dac --- /dev/null +++ b/.github/rulesets/tag-protection.json @@ -0,0 +1,24 @@ +{ + "name": "version-tag-protection", + "target": "tag", + "enforcement": "active", + "conditions": { + "ref_name": { + "include": ["refs/tags/v*"], + "exclude": [] + } + }, + "rules": [ + { "type": "creation" }, + { "type": "update" }, + { "type": "deletion" }, + { "type": "non_fast_forward" } + ], + "bypass_actors": [ + { + "actor_type": "RepositoryRole", + "actor_id": 5, + "bypass_mode": "always" + } + ] +} diff --git a/.github/workflows/rhiza_benchmark.yml b/.github/workflows/rhiza_benchmark.yml index 40ef5fb..d293f86 100644 --- a/.github/workflows/rhiza_benchmark.yml +++ b/.github/workflows/rhiza_benchmark.yml @@ -20,5 +20,5 @@ on: jobs: benchmark: - uses: jebel-quant/rhiza/.github/workflows/rhiza_benchmark.yml@v0.19.9 + uses: jebel-quant/rhiza/.github/workflows/rhiza_benchmark.yml@v1.3.2 secrets: inherit diff --git a/.github/workflows/rhiza_book.yml b/.github/workflows/rhiza_book.yml index e31dac8..5cb4eed 100644 --- a/.github/workflows/rhiza_book.yml +++ b/.github/workflows/rhiza_book.yml @@ -6,7 +6,10 @@ # It combines API documentation, test coverage reports, test results, and # interactive notebooks into a single GitHub Pages site. # -# Trigger: This workflow runs on every push to the main or master branch +# Trigger: This workflow runs on every push (any branch), so every commit +# validates that the book still builds. The reusable workflow deploys +# to GitHub Pages only from the repository's default branch and never +# from a fork; other branches build and upload an artifact only. # # Components: # - 📓 Process Marimo notebooks @@ -19,12 +22,14 @@ name: "(RHIZA) BOOK" on: push: branches: - - main - - master + - '**' + +permissions: + contents: read jobs: book: - uses: jebel-quant/rhiza/.github/workflows/rhiza_book.yml@v0.19.9 + uses: jebel-quant/rhiza/.github/workflows/rhiza_book.yml@v1.3.2 secrets: inherit permissions: contents: read diff --git a/.github/workflows/rhiza_ci.yml b/.github/workflows/rhiza_ci.yml index a54003d..bdb9ce7 100644 --- a/.github/workflows/rhiza_ci.yml +++ b/.github/workflows/rhiza_ci.yml @@ -7,6 +7,11 @@ # pre-commit hooks, verify documentation coverage, validate the # project, run security scans, and check license compliance. # +# Python version matrix source of truth: +# - Implemented in the reusable workflow called below +# - Generated from `Programming Language :: Python :: 3.x` classifiers in pyproject.toml +# - Adding/removing classifiers updates CI Python coverage automatically +# # Trigger: On push and pull_request. name: "(RHIZA) CI" @@ -21,5 +26,5 @@ on: jobs: ci: - uses: jebel-quant/rhiza/.github/workflows/rhiza_ci.yml@v0.19.9 + uses: jebel-quant/rhiza/.github/workflows/rhiza_ci.yml@v1.3.2 secrets: inherit diff --git a/.github/workflows/rhiza_codeql.yml b/.github/workflows/rhiza_codeql.yml index 56b5bdd..7d83f74 100644 --- a/.github/workflows/rhiza_codeql.yml +++ b/.github/workflows/rhiza_codeql.yml @@ -14,9 +14,6 @@ name: "(RHIZA) CODEQL" permissions: - security-events: write - packages: read - actions: read contents: read on: @@ -29,5 +26,10 @@ on: jobs: codeql: - uses: jebel-quant/rhiza/.github/workflows/rhiza_codeql.yml@v0.19.9 + uses: jebel-quant/rhiza/.github/workflows/rhiza_codeql.yml@v1.3.2 secrets: inherit + permissions: + security-events: write # Upload CodeQL results to code scanning + packages: read + actions: read + contents: read diff --git a/.github/workflows/rhiza_fuzzing.yml b/.github/workflows/rhiza_fuzzing.yml new file mode 100644 index 0000000..8a33965 --- /dev/null +++ b/.github/workflows/rhiza_fuzzing.yml @@ -0,0 +1,41 @@ +# This file is part of the jebel-quant/rhiza repository +# (https://github.com/jebel-quant/rhiza). +# +# Workflow: ClusterFuzzLite fuzzing +# +# Purpose: Run coverage-guided fuzzing for the repository's Python security +# parsing utilities. Pull requests run short code-change fuzzing, +# while main-branch pushes and the weekly schedule run batch fuzzing. +# +# Opt-in: fuzzing is OFF by default and very optional. Set the +# repository variable `FUZZING_ENABLED` to 'true' to run it (a +# .clusterfuzzlite/ config must also be present); otherwise the +# reusable workflow skips fuzzing (the run stays green). +# +# Thin stub: the fuzzing logic lives in the reusable workflow in +# jebel-quant/rhiza; this file only wires up the triggers. +# +# Trigger: Pull requests, pushes to main/master, weekly schedule, and manual +# dispatch. + +name: "(RHIZA) FUZZING" + +on: + pull_request: + branches: [ "main", "master" ] + push: + branches: [ "main", "master" ] + schedule: + - cron: '17 3 * * 6' + workflow_dispatch: + +permissions: + contents: read + +jobs: + fuzzing: + uses: jebel-quant/rhiza/.github/workflows/rhiza_fuzzing.yml@v1.3.2 + secrets: inherit + permissions: + contents: read + security-events: write # Upload fuzzing SARIF to code scanning diff --git a/.github/workflows/rhiza_marimo.yml b/.github/workflows/rhiza_marimo.yml index 4fb4866..026003c 100644 --- a/.github/workflows/rhiza_marimo.yml +++ b/.github/workflows/rhiza_marimo.yml @@ -28,5 +28,5 @@ on: jobs: marimo: - uses: jebel-quant/rhiza/.github/workflows/rhiza_marimo.yml@v0.19.9 + uses: jebel-quant/rhiza/.github/workflows/rhiza_marimo.yml@v1.3.2 secrets: inherit diff --git a/.github/workflows/rhiza_mutation.yml b/.github/workflows/rhiza_mutation.yml new file mode 100644 index 0000000..c42946a --- /dev/null +++ b/.github/workflows/rhiza_mutation.yml @@ -0,0 +1,50 @@ +# This file is part of the jebel-quant/rhiza repository +# (https://github.com/jebel-quant/rhiza). +# +# Workflow: Mutation Testing +# +# Purpose: Measure test *assertion strength* with mutmut. 100% line/branch +# coverage proves code is executed, not that a wrong result would be +# caught; surviving mutants reveal assertions that are too weak. +# +# Opt-in: mutation testing is OFF by default and very optional. Set the +# repository variable `MUTATION_ENABLED` to 'true' to run it; otherwise +# the reusable workflow skips the mutation job (the run stays green). +# +# Enforced gate (when enabled): mutation runs are required and fail when +# mutants survive (100% mutation score threshold in the reusable +# workflow). +# +# Thin stub: the mutation logic and the opt-in gate live in the +# reusable workflow in jebel-quant/rhiza; this file only wires up the +# triggers. +# +# Published mutation badge URL (when enabled): +# https://.github.io//mutation-badge.svg +# +# Trigger: Weekly schedule, manual dispatch, and pull_request so mutation +# testing is included in PR CI. + +name: "(RHIZA) MUTATION" + +permissions: + contents: read + +on: + pull_request: + schedule: + - cron: "0 9 * * 1" # Monday 09:00 UTC (after the rhiza weekly job at 08:00) + workflow_dispatch: + +jobs: + mutation: + # Opt-in gate: mutation testing is OFF by default. The job only runs when + # this repo sets the `MUTATION_ENABLED` variable to 'true'. Gating here in + # the caller keeps it optional regardless of the pinned reusable workflow. + if: ${{ vars.MUTATION_ENABLED == 'true' }} + uses: jebel-quant/rhiza/.github/workflows/rhiza_mutation.yml@v1.3.2 + secrets: inherit + permissions: + contents: read + pages: write # publish-mutation-badge deploys the badge to Pages + id-token: write # publish-mutation-badge needs OIDC for the Pages deploy diff --git a/.github/workflows/rhiza_release.yml b/.github/workflows/rhiza_release.yml index 0a34b5d..e255ef4 100644 --- a/.github/workflows/rhiza_release.yml +++ b/.github/workflows/rhiza_release.yml @@ -21,11 +21,15 @@ # 2. 🏗️ Build - Build Python package with Hatch (if [build-system] is defined in pyproject.toml) # 3. 📦 Generate SBOM - Create Software Bill of Materials (CycloneDX format) # 4. 📝 Draft Release - Create draft GitHub release with build artifacts and SBOM -# 5. 📄 Update CHANGELOG - Generate and commit CHANGELOG.md to the default branch -# 6. 🚀 Publish to PyPI - Publish package using OIDC or custom feed -# 7. 📦 Generate Conda Recipe - Generate conda-forge recipe with grayskull (conditional) -# 8. 🐳 Publish Devcontainer - Build and publish devcontainer image (conditional) -# 9. ✅ Finalize Release - Publish the GitHub release with links +# 5. 🚀 Publish to PyPI - Publish package using OIDC or custom feed +# 6. 📦 Generate Conda Recipe - Generate conda-forge recipe with grayskull (conditional) +# 7. 🐳 Publish Devcontainer - Build and publish devcontainer image (conditional) +# 8. ✅ Finalize Release - Publish the GitHub release with links +# +# 📄 CHANGELOG: not updated here. The release process (rhiza-claude `/release`) +# folds a freshly generated CHANGELOG.md into the version-bump commit before the +# tag is pushed, so the tagged commit already carries the changelog — no separate +# post-tag commit. # # 📦 SBOM Generation: # - Generated using CycloneDX format (industry standard for software supply chain security) @@ -102,16 +106,23 @@ on: PYPI_TOKEN: required: false +# Queue runs instead of cancelling: a release or sync must never be +# interrupted mid-publish or mid-push. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +# Least-privilege default: every job below re-declares exactly the write +# scopes it needs (Scorecard Token-Permissions). Top-level stays read-only. permissions: - contents: write # Needed to create releases - id-token: write # Needed for OIDC authentication with PyPI - packages: write # Needed to publish devcontainer image - attestations: write # Needed for SLSA provenance attestations (public repos only) + contents: read jobs: tag: name: Validate Tag runs-on: ubuntu-latest + permissions: + contents: read # Validation only: reads tags and release state outputs: tag: ${{ steps.set_tag.outputs.tag }} steps: @@ -145,6 +156,48 @@ jobs: fi fi + - name: Ensure the tagged commit is reachable from a branch + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ steps.set_tag.outputs.tag }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: | + # Backstop for issue #1454: a release cut on a branch that is then + # squash-merged leaves the tag on the pre-squash commit, and the squash + # puts the same content on the default branch under a new SHA. The tag is + # then permanently orphaned — no branch contains it, `git describe` skips + # the release, and a git-cliff regeneration silently deletes that version's + # CHANGELOG section because it cannot place a boundary at an unreachable + # tag. Publishing from such a tag is never intended, so refuse it. + # The checkout above uses fetch-depth: 0; this fetch only adds the remote + # branch refs, which a tag-push checkout does not need otherwise. + git fetch --no-tags --quiet origin '+refs/heads/*:refs/remotes/origin/*' + + if [ -z "$DEFAULT_BRANCH" ]; then + DEFAULT_BRANCH=$(gh repo view --json defaultBranchRef --jq '.defaultBranchRef.name') + fi + if ! git rev-parse --verify --quiet "refs/remotes/origin/$DEFAULT_BRANCH" >/dev/null; then + echo "::error::Cannot resolve the default branch 'origin/$DEFAULT_BRANCH' — refusing to release without a reachability check." + exit 1 + fi + + COMMIT=$(git rev-parse "$TAG^{commit}") + if git merge-base --is-ancestor "$COMMIT" "refs/remotes/origin/$DEFAULT_BRANCH"; then + echo "✅ $TAG ($COMMIT) is an ancestor of $DEFAULT_BRANCH" + exit 0 + fi + + # Reachable from some other branch: a maintenance/hotfix release. Legitimate, + # but a changelog regenerated from the default branch still cannot see it. + BRANCHES=$(git branch -r --contains "$COMMIT" --format='%(refname:short)') + if [ -n "$BRANCHES" ]; then + echo "::warning::Tag $TAG is not an ancestor of $DEFAULT_BRANCH; it is contained in: $(echo "$BRANCHES" | tr '\n' ' '). A CHANGELOG regenerated from $DEFAULT_BRANCH will not include this release." + exit 0 + fi + + echo "::error::Tag $TAG points at $COMMIT, which no branch contains. It is most likely a pre-squash commit from a squash-merged release branch: re-tag the merged commit on $DEFAULT_BRANCH and delete this tag (issue #1454)." + exit 1 + - name: Install uv uses: astral-sh/setup-uv@v7.6.0 @@ -195,6 +248,10 @@ jobs: name: Build runs-on: ubuntu-latest needs: tag + permissions: + contents: read + id-token: write # OIDC for attestation signing + attestations: write # SLSA provenance + SBOM attestations (public repos only) steps: - name: Checkout Code uses: actions/checkout@v6.1.0 @@ -207,7 +264,7 @@ jobs: version: "0.11.16" - name: Configure git auth for private packages - uses: jebel-quant/rhiza/.github/actions/configure-git-auth@v0.19.9 + uses: jebel-quant/actions/configure-git-auth@6d52725ca371d609489c5b50236965ad70bbe528 # v1 with: token: ${{ secrets.GH_PAT }} @@ -270,12 +327,22 @@ jobs: - name: Attest SBOM # Attest only the JSON format as it's the canonical machine-readable format. # The XML format is provided for compatibility but doesn't need separate attestation. + id: attest-sbom if: hashFiles('pyproject.toml') != '' && github.event.repository.private == false uses: actions/attest@v4.2.0 with: subject-path: sbom.cdx.json sbom-path: sbom.cdx.json + - name: Stage SBOM attestation for the GitHub release + # Attach the SBOM's Sigstore attestation bundle to the release as a + # recognised signature asset (*.sigstore.json). Non-buildable repos + # (no [build-system], so no dist/*.intoto.jsonl provenance) would + # otherwise ship releases without any signature asset, which fails + # OpenSSF Scorecard's Signed-Releases check. + if: hashFiles('pyproject.toml') != '' && github.event.repository.private == false + run: cp "${{ steps.attest-sbom.outputs.bundle-path }}" sbom.cdx.json.sigstore.json + - name: Upload SBOM artifacts if: hashFiles('pyproject.toml') != '' uses: actions/upload-artifact@v7.0.1 @@ -284,13 +351,22 @@ jobs: path: | sbom.cdx.json sbom.cdx.xml + sbom.cdx.json.sigstore.json - name: Generate SLSA provenance attestations + id: provenance if: steps.buildable.outputs.buildable == 'true' && github.event.repository.private == false - uses: actions/attest-build-provenance@v4 + uses: actions/attest-build-provenance@v4.1.0 with: subject-path: dist/* + - name: Stage provenance bundle for the GitHub release + # Attach the SLSA provenance bundle to the release as a recognised + # signature asset (*.intoto.jsonl) so consumers — and OpenSSF + # Scorecard's Signed-Releases check — can verify the artifacts. + if: steps.buildable.outputs.buildable == 'true' && github.event.repository.private == false + run: cp "${{ steps.provenance.outputs.bundle-path }}" dist/provenance.intoto.jsonl + - name: Upload dist artifact if: steps.buildable.outputs.buildable == 'true' uses: actions/upload-artifact@v7.0.1 @@ -303,6 +379,8 @@ jobs: name: Draft GitHub Release runs-on: ubuntu-latest needs: [tag, build] + permissions: + contents: write # Needed to create the GitHub release and upload artifacts steps: - name: Checkout Code @@ -324,6 +402,15 @@ jobs: path: sbom continue-on-error: true + - name: Download dist artifact + # Brings in provenance.intoto.jsonl (and the built distributions) staged + # by the build job, so the SLSA provenance ships as a release asset. + uses: actions/download-artifact@v8.0.1 + with: + name: dist + path: dist + continue-on-error: true + - name: Create GitHub Release with artifacts uses: ncipollo/release-action@v1.21.0 with: @@ -332,35 +419,7 @@ jobs: bodyFile: RELEASE_NOTES.md draft: true allowUpdates: true - artifacts: "sbom/*" - - update-changelog: - name: Update CHANGELOG.md - runs-on: ubuntu-latest - needs: [tag, draft-release] - steps: - - name: Checkout Default Branch - uses: actions/checkout@v6.1.0 - with: - ref: ${{ github.event.repository.default_branch }} - fetch-depth: 0 - token: ${{ secrets.GITHUB_TOKEN }} - - - name: Install uv - uses: astral-sh/setup-uv@v7.6.0 - - - name: Generate CHANGELOG.md with git-cliff - run: uvx git-cliff --output CHANGELOG.md - - - name: Commit and push CHANGELOG.md - env: - TAG: ${{ needs.tag.outputs.tag }} - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add CHANGELOG.md - git diff --staged --quiet || git commit -m "chore: update CHANGELOG.md for $TAG [skip ci]" - git push origin ${{ github.event.repository.default_branch }} + artifacts: "sbom/*,dist/provenance.intoto.jsonl" # Decide at step-level whether to publish pypi: @@ -368,6 +427,9 @@ jobs: runs-on: ubuntu-latest environment: release needs: [tag, build, draft-release] + permissions: + contents: read + id-token: write # OIDC Trusted Publishing to PyPI (no stored credentials) outputs: should_publish: ${{ steps.check_dist.outputs.should_publish }} @@ -400,11 +462,18 @@ jobs: fi cat "$GITHUB_OUTPUT" + - name: Remove non-distribution files before publish + # The dist artifact also carries the SLSA provenance bundle (staged for + # the GitHub release). twine/pypi-publish rejects it as an unknown + # distribution format, so strip it here before publishing. + if: ${{ steps.check_dist.outputs.should_publish == 'true' }} + run: rm -f dist/*.intoto.jsonl + # this should not take place, as "Private :: Do Not Upload" set in pyproject.toml # repository-url and password only used for custom feeds, not for PyPI with OIDC - name: Publish to PyPI if: ${{ steps.check_dist.outputs.should_publish == 'true' }} - uses: pypa/gh-action-pypi-publish@release/v1 + uses: pypa/gh-action-pypi-publish@v1.14.0 with: packages-dir: dist/ skip-existing: true @@ -416,6 +485,8 @@ jobs: name: Generate Conda Recipe runs-on: ubuntu-latest needs: [tag, pypi] + permissions: + contents: read # Generates a recipe and uploads it as a workflow artifact only outputs: should_generate: ${{ steps.check_conda.outputs.should_generate }} @@ -455,7 +526,21 @@ jobs: mkdir -p /tmp/conda-recipe cd /tmp/conda-recipe - grayskull pypi "$PACKAGE_NAME" --strict-conda-forge + # PyPI metadata for a just-published release — especially the first + # release of a new package — can lag the upload by several minutes, + # so retry before giving up. + MAX_ATTEMPTS=5 + for attempt in $(seq 1 "$MAX_ATTEMPTS"); do + if grayskull pypi "$PACKAGE_NAME" --strict-conda-forge; then + break + fi + if [[ "$attempt" -eq "$MAX_ATTEMPTS" ]]; then + echo "::error::grayskull failed after $MAX_ATTEMPTS attempts — PyPI metadata for $PACKAGE_NAME may not be available yet" + exit 1 + fi + echo "grayskull attempt $attempt/$MAX_ATTEMPTS failed — waiting 60s for PyPI metadata to propagate" + sleep 60 + done RECIPE_PATH=$(find . -type f -path "*/meta.yaml" | head -n 1) if [[ -z "$RECIPE_PATH" ]]; then @@ -478,6 +563,9 @@ jobs: runs-on: ubuntu-latest environment: release needs: [tag, build, draft-release] + permissions: + contents: read + packages: write # Needed to push the devcontainer image to the registry outputs: should_publish: ${{ steps.check_publish.outputs.should_publish }} image_name: ${{ steps.image_name.outputs.image_name }} @@ -554,7 +642,7 @@ jobs: - name: Build and Publish Devcontainer Image if: steps.check_publish.outputs.should_publish == 'true' - uses: devcontainers/ci@v0.3 + uses: devcontainers/ci@v0.3.1900000450 with: configFile: .devcontainer/devcontainer.json push: always @@ -566,6 +654,8 @@ jobs: runs-on: ubuntu-latest needs: [tag, pypi, conda, devcontainer] if: needs.pypi.result == 'success' || needs.conda.result == 'success' || needs.devcontainer.result == 'success' + permissions: + contents: write # Needed to undraft/publish the GitHub release steps: - name: Checkout Code uses: actions/checkout@v6.1.0 diff --git a/.github/workflows/rhiza_scorecard.yml b/.github/workflows/rhiza_scorecard.yml new file mode 100644 index 0000000..d1a8461 --- /dev/null +++ b/.github/workflows/rhiza_scorecard.yml @@ -0,0 +1,45 @@ +# This file is part of the jebel-quant/rhiza repository +# (https://github.com/jebel-quant/rhiza). +# +# Workflow: OSSF Scorecard +# +# Purpose: Run the OpenSSF Scorecard supply-chain security analysis and upload +# the results to GitHub code scanning. On public repositories the +# results are also published to the OpenSSF REST API, which powers +# the README badge and lets adopters verify the score independently. +# Set the SCORECARD_ENABLED repository variable to 'true' to +# force-enable on private repos, 'false' to disable, or leave unset +# for auto-detect (public repositories only). +# +# Thin stub: the analysis logic and its enablement/visibility gate +# live in the reusable workflow in jebel-quant/rhiza; this file only +# wires up the triggers and grants the token scopes Scorecard needs. +# +# Trigger: Weekly schedule, pushes to main, branch-protection changes, and +# manual dispatch. + +name: "(RHIZA) SCORECARD" + +on: + # Re-evaluate when branch protection rules change (Scorecard's + # Branch-Protection check reads them). + branch_protection_rule: + schedule: + - cron: '34 2 * * 2' + push: + branches: [ "main", "master" ] + workflow_dispatch: + +# Least privilege by default; the called workflow grants its job only what +# Scorecard needs (see the job-level permissions below). +permissions: read-all + +jobs: + scorecard: + uses: jebel-quant/rhiza/.github/workflows/rhiza_scorecard.yml@v1.3.2 + secrets: inherit + permissions: + security-events: write # Upload the SARIF results to code scanning + id-token: write # Publish results to the OpenSSF REST API (badge) + contents: read + actions: read diff --git a/.github/workflows/rhiza_weekly.yml b/.github/workflows/rhiza_weekly.yml index 7b03886..e226a0e 100644 --- a/.github/workflows/rhiza_weekly.yml +++ b/.github/workflows/rhiza_weekly.yml @@ -28,5 +28,5 @@ on: jobs: weekly: - uses: jebel-quant/rhiza/.github/workflows/rhiza_weekly.yml@v0.19.9 + uses: jebel-quant/rhiza/.github/workflows/rhiza_weekly.yml@v1.3.2 secrets: inherit diff --git a/.gitignore b/.gitignore index 3c08606..1c9d3f0 100644 --- a/.gitignore +++ b/.gitignore @@ -133,3 +133,9 @@ local.mk .bandit-baseline.json + +# Rust (rust-core bundle). Kept in core rather than the language layer because +# .gitignore has one owner and git opens it with O_NOFOLLOW, so it cannot be a +# per-layer file. These entries are inert in a Python repo. +target/ +**/*.rs.bk diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 56689c7..e0ca1c4 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,12 @@ # This file is part of the jebel-quant/rhiza repository # (https://github.com/jebel-quant/rhiza). # +# Pin node so pre-commit provisions a compatible runtime instead of using the +# system node. Some npm-based hooks (markdownlint-cli) pull transitive deps that +# reject odd-numbered current node releases (e.g. v25), failing on EBADENGINE. +default_language_version: + node: "24.12.0" + repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v6.0.0 @@ -25,7 +31,7 @@ repos: pass_filenames: false - repo: https://github.com/astral-sh/ruff-pre-commit - rev: 'v0.15.14' + rev: 'v0.16.1' hooks: - id: ruff args: [ --fix, --exit-non-zero-on-fix, --unsafe-fixes ] @@ -34,13 +40,13 @@ repos: - id: ruff-format - repo: https://github.com/igorshubovych/markdownlint-cli - rev: v0.48.0 + rev: v0.49.0 hooks: - id: markdownlint args: ["--disable", "MD013"] - repo: https://github.com/python-jsonschema/check-jsonschema - rev: 0.37.2 + rev: 0.37.4 hooks: - id: check-renovate args: [ "--verbose" ] @@ -64,8 +70,13 @@ repos: - id: bandit args: ["--ini", ".bandit", "--exclude", ".venv,tests,.rhiza/tests,.git,.pytest_cache"] + - repo: https://github.com/betterleaks/betterleaks + rev: v1.7.3 + hooks: + - id: betterleaks + - repo: https://github.com/astral-sh/uv-pre-commit - rev: 0.11.16 + rev: 0.12.1 hooks: - id: uv-lock @@ -77,7 +88,7 @@ repos: files: ^src/ - repo: https://github.com/Jebel-Quant/rhiza-hooks - rev: v0.4.0 # Use the latest release + rev: v1.2.0 # Use the latest release hooks: # Migrated from rhiza - id: check-rhiza-workflow-names @@ -86,4 +97,14 @@ repos: - id: check-rhiza-config - id: check-makefile-targets - id: check-python-version-consistency + # check-bumpversion-config asserts that bump-my-version can actually discover the + # project's version config (issue #1453) — for this layer, the [tool.bumpversion] + # table in pyproject.toml, since python-core deliberately ships no .bumpversion.toml. + # It ships as of v1.1.0; it stays off because the .rhiza/tests/test_pyproject.py this + # layer also ships enforces the same invariant one gate later. + # - id: check-bumpversion-config + # check-template-bundles validates .rhiza/template.yml against the template repo's + # remote bundle list, so it fires only when that file is staged — and unlike in the + # mother repo (which has no template.yml) it is live in a synced project. Disabled in + # #660 (rhiza-hooks 0.3.0) with no reason recorded. # - id: check-template-bundles diff --git a/.rhiza/.env b/.rhiza/.env index 0fd6a6a..7d92a35 100644 --- a/.rhiza/.env +++ b/.rhiza/.env @@ -1,3 +1,13 @@ +# .rhiza/.env — project-local environment overrides (optional) +# +# This file is optional. If absent, rhiza.mk falls back to built-in defaults: +# SOURCE_FOLDER = src +# MARIMO_FOLDER = docs/notebooks +# RHIZA_CI_OS_MATRIX = ["ubuntu-latest"] +# +# Add or uncomment only the variables you want to override. +# Variables defined here are exported to all Make recipes. + MARIMO_FOLDER=docs/notebooks SOURCE_FOLDER=src RHIZA_CI_OS_MATRIX=["ubuntu-latest","macos-latest","windows-latest"] diff --git a/.rhiza/completions/README.md b/.rhiza/completions/README.md index b37cb15..ff62b4d 100644 --- a/.rhiza/completions/README.md +++ b/.rhiza/completions/README.md @@ -6,12 +6,27 @@ This directory contains shell completion scripts for Bash and Zsh that provide t - ✅ Tab-complete all available make targets - ✅ Show target descriptions in Zsh -- ✅ Complete common make variables (DRY_RUN, BUMP, ENV, etc.) +- ✅ Complete common make variables (DRY_RUN, ENV, etc.) - ✅ Works with any Rhiza-based project - ✅ Auto-discovers targets from Makefile and included .mk files ## Installation +### Quick install (recommended) + +From the project root: + +```bash +make install-completions # install for both bash and zsh +make install-completions SHELL_KIND=zsh # or just one: bash | zsh | both +``` + +This copies the appropriate script into your user completion directory +(`${XDG_DATA_HOME:-~/.local/share}/bash-completion/completions/make` for bash, +`${XDG_DATA_HOME:-~/.local/share}/zsh/site-functions/_make` for zsh) and prints +any follow-up step. Start a new shell afterwards. The manual methods below remain +available if you prefer to wire it up yourself. + ### Bash #### Method 1: Source in your shell config @@ -102,7 +117,7 @@ make make te # Expands to: make test # Complete variables -make BUMP= # Shows: patch, minor, major +make ENV= # Shows: dev, staging, prod # Works with any target make doc # Shows: docs, docker-build, docker-run, etc. @@ -129,7 +144,6 @@ The completion scripts understand these common variables: | Variable | Values | Description | |----------|--------|-------------| | `DRY_RUN` | `1` | Preview mode without making changes | -| `BUMP` | `patch`, `minor`, `major` | Version bump type | | `ENV` | `dev`, `staging`, `prod` | Target environment | | `COVERAGE_FAIL_UNDER` | (number) | Minimum coverage threshold | | `PYTHON_VERSION` | (version) | Override Python version | @@ -141,10 +155,10 @@ Example usage: make DRY_ # Expands to: make DRY_RUN=1 # Tab-complete variable values -make BUMP= # Shows: patch minor major +make ENV= # Shows: dev staging prod # Combine with targets -make bump BUMP= +make deploy ENV= ``` ## Troubleshooting @@ -244,20 +258,20 @@ m te # Expands to: m test 1. **Target Discovery**: Parses `make -qp` output to find all targets 2. **Description Extraction**: Looks for `##` comments after target names 3. **Variable Detection**: Includes common Makefile variables -4. **Dynamic Completion**: Regenerates list each time you tab +4. **Cached Completion**: The target list is cached per directory and refreshed automatically ### Performance -- Completions are generated on-demand (when you press Tab) -- For large Makefiles (100+ targets), there may be a small delay -- Results are not cached to ensure targets are always current +- The target list is cached under `${XDG_CACHE_HOME:-~/.cache}/rhiza/`, keyed per directory +- The cache refreshes automatically whenever the `Makefile`, `local.mk`, + `.rhiza/rhiza.mk`, or any `.rhiza/make.d/*.mk` file changes +- Only the first Tab press after a makefile change pays the full `make -qp` parsing cost +- To force a refresh manually, delete the cache: `rm -rf "${XDG_CACHE_HOME:-$HOME/.cache}/rhiza"` +- If the cache directory cannot be created (e.g. read-only home), completion + falls back to direct parsing on every Tab press ## See Also - [Tools Reference](../../docs/reference/TOOLS_REFERENCE.md) - Complete command reference - [Quick Reference](../../docs/guides/QUICK_REFERENCE.md) - Quick command reference - [Extending Rhiza](../../docs/guides/EXTENDING_RHIZA.md) - How to add custom targets - ---- - -*Last updated: 2026-02-15* diff --git a/.rhiza/completions/rhiza-completion.bash b/.rhiza/completions/rhiza-completion.bash index 0f860da..eca9e8a 100644 --- a/.rhiza/completions/rhiza-completion.bash +++ b/.rhiza/completions/rhiza-completion.bash @@ -9,8 +9,19 @@ # sudo cp .rhiza/completions/rhiza-completion.bash /etc/bash_completion.d/rhiza # +# Return 0 (stale) when the cache file is missing or any makefile source +# changed since it was written. +_rhiza_make_cache_stale() { + local cache_file="$1" src + [[ -f "$cache_file" ]] || return 0 + for src in Makefile local.mk .rhiza/rhiza.mk .rhiza/make.d/*.mk; do + [[ -f "$src" && "$src" -nt "$cache_file" ]] && return 0 + done + return 1 +} + _rhiza_make_completion() { - local cur prev opts + local cur prev opts cache_dir cache_file COMPREPLY=() cur="${COMP_WORDS[COMP_CWORD]}" prev="${COMP_WORDS[COMP_CWORD-1]}" @@ -20,15 +31,32 @@ _rhiza_make_completion() { return 0 fi - # Extract make targets from Makefile and all included .mk files - # Looks for lines like: target: ## description - opts=$(make -qp 2>/dev/null | \ - awk -F':' '/^[a-zA-Z0-9][^$#\/\t=]*:([^=]|$)/ {split($1,A,/ /);for(i in A)print A[i]}' | \ - grep -v '^Makefile$' | \ - sort -u) + # Target extraction parses the full make database (make -qp), which is + # slow on large Makefiles - cache the result per directory and refresh + # only when a makefile source changes. + cache_dir="${XDG_CACHE_HOME:-$HOME/.cache}/rhiza" + cache_file="$cache_dir/targets-$(pwd | cksum | cut -d' ' -f1)" + + if _rhiza_make_cache_stale "$cache_file" && mkdir -p "$cache_dir" 2>/dev/null; then + # Extract make targets from Makefile and all included .mk files + make -qp 2>/dev/null | \ + awk -F':' '/^[a-zA-Z0-9][^$#\/\t=]*:([^=]|$)/ {split($1,A,/ /);for(i in A)print A[i]}' | \ + grep -v '^Makefile$' | \ + sort -u > "$cache_file" + fi + + if [[ -r "$cache_file" ]]; then + opts=$(cat "$cache_file") + else + # Cache unavailable (e.g. unwritable HOME): fall back to direct parsing + opts=$(make -qp 2>/dev/null | \ + awk -F':' '/^[a-zA-Z0-9][^$#\/\t=]*:([^=]|$)/ {split($1,A,/ /);for(i in A)print A[i]}' | \ + grep -v '^Makefile$' | \ + sort -u) + fi # Add common make variables that can be overridden - local vars="DRY_RUN=1 BUMP=patch BUMP=minor BUMP=major ENV=dev ENV=staging ENV=prod" + local vars="DRY_RUN=1 ENV=dev ENV=staging ENV=prod" opts="$opts $vars" # Generate completions diff --git a/.rhiza/completions/rhiza-completion.zsh b/.rhiza/completions/rhiza-completion.zsh index 03931c9..f1c3891 100644 --- a/.rhiza/completions/rhiza-completion.zsh +++ b/.rhiza/completions/rhiza-completion.zsh @@ -19,17 +19,34 @@ # sudo cp .rhiza/completions/rhiza-completion.zsh /usr/local/share/zsh/site-functions/_make # +# Return 0 (stale) when the cache file is missing or any makefile source +# changed since it was written. +_rhiza_make_cache_stale() { + local cache_file="$1" src + [[ -f "$cache_file" ]] || return 0 + for src in Makefile local.mk .rhiza/rhiza.mk .rhiza/make.d/*.mk(N); do + [[ -f "$src" && "$src" -nt "$cache_file" ]] && return 0 + done + return 1 +} + _rhiza_make() { local -a targets variables - + local cache_dir cache_file + # Check if we're in a directory with a Makefile if [[ ! -f "Makefile" ]]; then return 0 fi - # Extract make targets with descriptions - # Format: target:description - targets=(${(f)"$( + # Target extraction parses the full make database (make -qp) twice, which + # is slow on large Makefiles - cache both lists per directory and refresh + # only when a makefile source changes. + cache_dir="${XDG_CACHE_HOME:-$HOME/.cache}/rhiza" + cache_file="$cache_dir/targets-$(pwd | cksum | cut -d' ' -f1)" + + if _rhiza_make_cache_stale "$cache_file.desc" && mkdir -p "$cache_dir" 2>/dev/null; then + # Extract make targets with descriptions (format: target:description) make -qp 2>/dev/null | \ awk -F':' ' /^# Files/,/^# Finished Make data base/ { @@ -43,27 +60,38 @@ _rhiza_make() { } ' | \ grep -v '^Makefile:' | \ - sort -u - )"}) + sort -u > "$cache_file.desc" - # Also get targets without descriptions - local -a plain_targets - plain_targets=(${(f)"$( + # Also get targets without descriptions make -qp 2>/dev/null | \ awk -F':' '/^[a-zA-Z0-9_-]+:([^=]|$)/ { split($1,A,/ /) for(i in A) print A[i] }' | \ grep -v '^Makefile$' | \ - sort -u - )"}) + sort -u > "$cache_file.plain" + fi + + local -a plain_targets + if [[ -r "$cache_file.desc" ]]; then + targets=(${(f)"$(cat "$cache_file.desc")"}) + plain_targets=(${(f)"$(cat "$cache_file.plain" 2>/dev/null)"}) + else + # Cache unavailable (e.g. unwritable HOME): fall back to direct parsing + plain_targets=(${(f)"$( + make -qp 2>/dev/null | \ + awk -F':' '/^[a-zA-Z0-9_-]+:([^=]|$)/ { + split($1,A,/ /) + for(i in A) print A[i] + }' | \ + grep -v '^Makefile$' | \ + sort -u + )"}) + fi # Common make variables variables=( 'DRY_RUN=1:preview mode without making changes' - 'BUMP=patch:bump patch version' - 'BUMP=minor:bump minor version' - 'BUMP=major:bump major version' 'ENV=dev:development environment' 'ENV=staging:staging environment' 'ENV=prod:production environment' diff --git a/.rhiza/make.d/book.mk b/.rhiza/make.d/book.mk index 6323b21..95791bf 100644 --- a/.rhiza/make.d/book.mk +++ b/.rhiza/make.d/book.mk @@ -34,7 +34,7 @@ _book-notebooks: name=$$(basename "$$nb" .py); \ printf "${BLUE}[INFO] Exporting $$nb -> ${ROOT}/docs/notebooks/$$name.html${RESET}\n"; \ abs_output="${ROOT}/docs/notebooks/$$name.html"; \ - (cd "$$(dirname "$$nb")" && ${UV_BIN} run marimo export html --sandbox "$$(basename "$$nb")" -o "$$abs_output"); \ + (cd "$$(dirname "$$nb")" && ${UV_BIN} run --with marimo marimo export html --sandbox "$$(basename "$$nb")" -o "$$abs_output"); \ done; \ else \ printf "${YELLOW}[WARN] MARIMO_FOLDER not set or missing, skipping notebook export${RESET}\n"; \ diff --git a/.rhiza/make.d/bootstrap.mk b/.rhiza/make.d/bootstrap.mk index 2852697..9bf67e2 100644 --- a/.rhiza/make.d/bootstrap.mk +++ b/.rhiza/make.d/bootstrap.mk @@ -1,11 +1,13 @@ ## .rhiza/make.d/bootstrap.mk - Bootstrap and Installation -# This file provides targets for setting up the development environment, -# installing dependencies, and cleaning project artifacts. +# This file provides the language-neutral half of setup: it puts uv/uvx on the +# path (rhiza runs pre-commit, mkdocs and semgrep through uvx whatever the +# project is written in), declares the install hooks, and cleans artifacts. +# +# `install` itself belongs to the language layer, because what it means differs: +# python.mk (bundle python-core) creates a virtualenv and runs `uv sync`. # Declare phony targets (they don't produce files) -.PHONY: install-uv install clean pre-install post-install - -UV_SYNC_ARGS ?= --all-extras --all-groups +.PHONY: install-uv clean pre-install post-install # Hook targets (double-colon rules allow multiple definitions) pre-install:: ; @: @@ -29,62 +31,6 @@ install-uv: ## ensure uv/uvx is installed fi; \ fi -install: pre-install install-uv ## install - # Create the virtual environment only if it doesn't exist - @if [ ! -d "${VENV}" ]; then \ - ${UV_BIN} venv $(if $(PYTHON_VERSION),--python $(PYTHON_VERSION)) ${VENV} || { printf "${RED}[ERROR] Failed to create virtual environment${RESET}\n"; exit 1; }; \ - else \ - printf "${BLUE}[INFO] Using existing virtual environment at ${VENV}, skipping creation${RESET}\n"; \ - fi - - # Install the dependencies from pyproject.toml (if it exists) - @if [ -f "pyproject.toml" ]; then \ - if [ -f "uv.lock" ]; then \ - if ! ${UV_BIN} lock --check >/dev/null 2>&1; then \ - printf "${YELLOW}[WARN] uv.lock is out of sync with pyproject.toml${RESET}\n"; \ - printf "${YELLOW} Run 'uv sync' to update your lock file and environment${RESET}\n"; \ - printf "${YELLOW} Or run 'uv lock' to update only the lock file${RESET}\n"; \ - exit 1; \ - fi; \ - printf "${BLUE}[INFO] Installing dependencies from lock file${RESET}\n"; \ - ${UV_BIN} sync $(UV_SYNC_ARGS) --frozen || { printf "${RED}[ERROR] Failed to install dependencies${RESET}\n"; exit 1; }; \ - else \ - printf "${YELLOW}[WARN] uv.lock not found. Generating lock file and installing dependencies...${RESET}\n"; \ - ${UV_BIN} sync $(UV_SYNC_ARGS) || { printf "${RED}[ERROR] Failed to install dependencies${RESET}\n"; exit 1; }; \ - fi; \ - else \ - printf "${YELLOW}[WARN] No pyproject.toml found, skipping install${RESET}\n"; \ - fi - - # Install dev dependencies from .rhiza/requirements/*.txt files - @if [ -d ".rhiza/requirements" ] && ls .rhiza/requirements/*.txt >/dev/null 2>&1; then \ - for req_file in .rhiza/requirements/*.txt; do \ - if [ -f "$$req_file" ]; then \ - printf "${BLUE}[INFO] Installing requirements from $$req_file${RESET}\n"; \ - ${UV_BIN} pip install -r "$$req_file" || { printf "${RED}[ERROR] Failed to install requirements from $$req_file${RESET}\n"; exit 1; }; \ - fi; \ - done; \ - fi - - # Check if there is requirements.txt file in the tests folder (legacy support) - @if [ -f "tests/requirements.txt" ]; then \ - printf "${BLUE}[INFO] Installing requirements from tests/requirements.txt${RESET}\n"; \ - ${UV_BIN} pip install -r tests/requirements.txt || { printf "${RED}[ERROR] Failed to install test requirements${RESET}\n"; exit 1; }; \ - fi - - # Install pre-commit hooks - @if [ -f ".pre-commit-config.yaml" ]; then \ - printf "${BLUE}[INFO] Installing pre-commit hooks...${RESET}\n"; \ - ${UVX_BIN} -p ${PYTHON_VERSION} pre-commit install || { printf "${YELLOW}[WARN] Failed to install pre-commit hooks${RESET}\n"; }; \ - fi - - @$(MAKE) post-install - - # Display success message with activation instructions - @printf "\n${GREEN}[SUCCESS] Installation complete!${RESET}\n\n" - @printf "${BLUE}To activate the virtual environment, run:${RESET}\n" - @printf "${YELLOW} source ${VENV}/bin/activate${RESET}\n\n" - clean: ## Clean project artifacts and stale local branches @printf "%bCleaning project...%b\n" "$(BLUE)" "$(RESET)" @@ -106,4 +52,4 @@ clean: ## Clean project artifacts and stale local branches @git fetch --prune - @git branch -vv | awk '/: gone]/{print $$1}' | xargs -r git branch -D + @git branch -vv | awk '/: gone]/ && $$1 != "*" && $$1 != "+" {print $$1}' | xargs -r git branch -D diff --git a/.rhiza/make.d/completions.mk b/.rhiza/make.d/completions.mk new file mode 100644 index 0000000..59c2239 --- /dev/null +++ b/.rhiza/make.d/completions.mk @@ -0,0 +1,42 @@ +## .rhiza/make.d/completions.mk - Install shell tab-completion for make targets +# Copies the bundled completion scripts (.rhiza/completions/) into the user's +# completion directory so `make ` completes Rhiza targets. See +# .rhiza/completions/README.md for manual setup, aliases, and troubleshooting. + +.PHONY: install-completions + +# Which shell(s) to install completion for: bash, zsh, or both. +# make cannot reliably detect the login shell (it forces $SHELL to /bin/sh for +# recipes), so this defaults to `both` — installing an unused script is harmless. +# Narrow it with e.g. `make install-completions SHELL_KIND=zsh`. +SHELL_KIND ?= both + +##@ Shell Completion +install-completions: ## install shell tab-completion for make targets (SHELL_KIND=bash|zsh|both) + @src=".rhiza/completions"; \ + if [ ! -d "$$src" ]; then \ + printf "${RED}[❌]${RESET} $$src not found — run from the project root.\n"; \ + exit 1; \ + fi; \ + install_bash() { \ + dest="$${XDG_DATA_HOME:-$$HOME/.local/share}/bash-completion/completions"; \ + mkdir -p "$$dest"; \ + cp "$$src/rhiza-completion.bash" "$$dest/make"; \ + printf "${GREEN}[✓]${RESET} bash: installed to %s\n" "$$dest/make"; \ + printf "${BLUE}[INFO]${RESET} start a new shell, or run: ${BOLD}source %s${RESET}\n" "$$dest/make"; \ + }; \ + install_zsh() { \ + dest="$${XDG_DATA_HOME:-$$HOME/.local/share}/zsh/site-functions"; \ + mkdir -p "$$dest"; \ + cp "$$src/rhiza-completion.zsh" "$$dest/_make"; \ + printf "${GREEN}[✓]${RESET} zsh: installed to %s\n" "$$dest/_make"; \ + printf "${BLUE}[INFO]${RESET} if completion does not activate, add to ~/.zshrc:\n"; \ + printf " ${BOLD}fpath=(%s \$$fpath); autoload -U compinit && compinit${RESET}\n" "$$dest"; \ + }; \ + case "$(SHELL_KIND)" in \ + bash) install_bash ;; \ + zsh) install_zsh ;; \ + both) install_bash; install_zsh ;; \ + *) printf "${YELLOW}[WARN]${RESET} unknown SHELL_KIND='%s'; installing both (use bash|zsh|both).\n" "$(SHELL_KIND)"; \ + install_bash; install_zsh ;; \ + esac diff --git a/.rhiza/make.d/doctor.mk b/.rhiza/make.d/doctor.mk index 398dca4..738db1b 100644 --- a/.rhiza/make.d/doctor.mk +++ b/.rhiza/make.d/doctor.mk @@ -1,9 +1,18 @@ ## .rhiza/make.d/doctor.mk - Developer prerequisite diagnostics +# +# Core checks only the tools it needs whatever the language: uv, make, git. A language +# layer appends its own diagnostics with a second `doctor::` rule — double-colon, like +# `pre-install::` and `test::`, so each rule runs in turn and a layer never has to know +# what core already checked. rust.mk uses this to catch a cargo that is not +# rustup-managed, which no version check would notice. +# +# Each rule runs in its own shell, so `failed` below is local to this one: a layer's rule +# reports and exits on its own account, and `make doctor` fails if any rule does. .PHONY: doctor ##@ Dev -doctor: ## verify local prerequisites and print actionable guidance +doctor:: ## verify local prerequisites and print actionable guidance @failed=0; \ version_ge() { \ awk -v a="$$1" -v b="$$2" 'BEGIN { \ @@ -21,20 +30,20 @@ doctor: ## verify local prerequisites and print actionable guidance check_tool() { \ tool="$$1"; min="$$2"; install_url="$$3"; version_cmd="$$4"; gnu_required="$$5"; \ if ! command -v "$$tool" >/dev/null 2>&1; then \ - printf "${RED}[❌]${RESET} %-9s missing — install: %s\n" "$$tool" "$$install_url"; \ + printf "${RED}[FAIL]${RESET} %-11s missing - install: %s\n" "$$tool" "$$install_url"; \ failed=1; \ return; \ fi; \ version="$$(eval "$$version_cmd" 2>/dev/null)"; \ if [ -z "$$version" ]; then \ - printf "${RED}[❌]${RESET} %-9s unknown version (required ≥ %s)\n" "$$tool" "$$min"; \ + printf "${RED}[FAIL]${RESET} %-11s unknown version (required >= %s)\n" "$$tool" "$$min"; \ failed=1; \ return; \ fi; \ extra=""; \ if [ "$$gnu_required" = "gnu" ] && ! make --version 2>/dev/null | grep -q '^GNU Make'; then \ extra=" (GNU required)"; \ - printf "${RED}[❌]${RESET} %-9s %-8s < %s%s\n" "$$tool" "$$version" "$$min" "$$extra"; \ + printf "${RED}[FAIL]${RESET} %-11s %-8s < %s%s\n" "$$tool" "$$version" "$$min" "$$extra"; \ failed=1; \ return; \ fi; \ @@ -42,12 +51,12 @@ doctor: ## verify local prerequisites and print actionable guidance if [ "$$gnu_required" = "gnu" ]; then \ extra=" (GNU required)"; \ fi; \ - printf "${GREEN}[✅]${RESET} %-9s %-8s ≥ %s%s\n" "$$tool" "$$version" "$$min" "$$extra"; \ + printf "${GREEN}[ OK ]${RESET} %-11s %-8s >= %s%s\n" "$$tool" "$$version" "$$min" "$$extra"; \ else \ if [ "$$gnu_required" = "gnu" ]; then \ extra=" (GNU required)"; \ fi; \ - printf "${RED}[❌]${RESET} %-9s %-8s < %s%s\n" "$$tool" "$$version" "$$min" "$$extra"; \ + printf "${RED}[FAIL]${RESET} %-11s %-8s < %s%s\n" "$$tool" "$$version" "$$min" "$$extra"; \ failed=1; \ fi; \ }; \ diff --git a/.rhiza/make.d/github.mk b/.rhiza/make.d/github.mk new file mode 100644 index 0000000..346b617 --- /dev/null +++ b/.rhiza/make.d/github.mk @@ -0,0 +1,70 @@ +## github.mk - github repo maintenance and helpers +# This file is included by the main Makefile + +# ── Forge Detection ────────────────────────────────────────────────────────── +# FORGE_TYPE is set once and reused by any target that needs to know the forge. +# Priority: .github/workflows/ → .gitlab-ci.yml / .gitlab/ → unknown +FORGE_TYPE := $(if $(wildcard .github/workflows/),github,$(if $(or $(wildcard .gitlab-ci.yml),$(wildcard .gitlab/)),gitlab,unknown)) + +# Declare phony targets +.PHONY: gh-install require-gh view-prs view-issues failed-workflows workflow-status latest-release whoami print-logo + +# ── Internal guard ─────────────────────────────────────────────────────────── +# Require the gh CLI; hard-fail if missing so downstream targets can depend on it. +require-gh: + @if ! command -v gh >/dev/null 2>&1; then \ + printf "${RED}[ERROR] gh cli not found. Install from: https://github.com/cli/cli?tab=readme-ov-file#installation${RESET}\n"; \ + exit 1; \ + fi + +##@ GitHub Helpers +gh-install: ## check for gh cli existence and install extensions + @if ! command -v gh >/dev/null 2>&1; then \ + printf "${YELLOW}[WARN] gh cli not found.${RESET}\n"; \ + printf "${BLUE}[INFO] Please install it from: https://github.com/cli/cli?tab=readme-ov-file#installation${RESET}\n"; \ + else \ + printf "${GREEN}[INFO] gh cli is installed.${RESET}\n"; \ + fi + +view-prs: gh-install ## list open pull requests + @printf "${BLUE}[INFO] Open Pull Requests:${RESET}\n" + @gh pr list --json number,title,author,headRefName,updatedAt --template \ + '{{tablerow (printf "NUM" | color "bold") (printf "TITLE" | color "bold") (printf "AUTHOR" | color "bold") (printf "BRANCH" | color "bold") (printf "UPDATED" | color "bold")}}{{range .}}{{tablerow (printf "#%v" .number | color "green") .title (.author.login | color "cyan") (.headRefName | color "yellow") (timeago .updatedAt | color "white")}}{{end}}' + +view-issues: gh-install ## list open issues + @printf "${BLUE}[INFO] Open Issues:${RESET}\n" + @gh issue list --json number,title,author,labels,updatedAt --template \ + '{{tablerow (printf "NUM" | color "bold") (printf "TITLE" | color "bold") (printf "AUTHOR" | color "bold") (printf "LABELS" | color "bold") (printf "UPDATED" | color "bold")}}{{range .}}{{tablerow (printf "#%v" .number | color "green") .title (.author.login | color "cyan") (pluck "name" .labels | join ", " | color "yellow") (timeago .updatedAt | color "white")}}{{end}}' + +failed-workflows: gh-install ## list recent failing workflow runs + @printf "${BLUE}[INFO] Recent Failing Workflow Runs:${RESET}\n" + @gh run list --limit 10 --status failure --json conclusion,name,headBranch,event,createdAt --template \ + '{{tablerow (printf "STATUS" | color "bold") (printf "NAME" | color "bold") (printf "BRANCH" | color "bold") (printf "EVENT" | color "bold") (printf "TIME" | color "bold")}}{{range .}}{{tablerow (printf "%s" .conclusion | color "red") .name (.headBranch | color "cyan") (.event | color "yellow") (timeago .createdAt | color "white")}}{{end}}' + +whoami: gh-install ## check github auth status + @printf "${BLUE}[INFO] GitHub Authentication Status:${RESET}\n" + @gh auth status --hostname github.com --json hosts --template \ + '{{range $$host, $$accounts := .hosts}}{{range $$accounts}}{{if .active}} {{printf "✓" | color "green"}} Logged in to {{$$host}} account {{.login | color "bold"}} ({{.tokenSource}}){{"\n"}} Active account: {{printf "true" | color "green"}}{{"\n"}} Git operations protocol: {{.gitProtocol | color "yellow"}}{{"\n"}} Token scopes: {{.scopes | color "yellow"}}{{"\n"}}{{end}}{{end}}{{end}}' + +workflow-status: require-gh ## show recent runs for the release workflow + @printf "${BOLD}Release Workflow Status${RESET}\n" + @printf "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}\n" + @RELEASE_WF=$$(gh workflow list --json name,id --jq '.[] | select(.name | test("release";"i")) | .name' 2>/dev/null | head -1); \ + if [ -n "$$RELEASE_WF" ]; then \ + printf "${BLUE}[INFO] Workflow: ${GREEN}$$RELEASE_WF${RESET}\n\n"; \ + gh run list --workflow "$$RELEASE_WF" --limit 5 \ + --json status,conclusion,headBranch,event,createdAt,displayTitle,url \ + --template '{{tablerow (printf "STATUS" | color "bold") (printf "CONCLUSION" | color "bold") (printf "TITLE" | color "bold") (printf "EVENT" | color "bold") (printf "TIME" | color "bold")}}{{range .}}{{tablerow (printf "%s" .status | color "cyan") (printf "%s" (or .conclusion "—") | color (or (and (eq .conclusion "success") "green") (and (eq .conclusion "failure") "red") "yellow")) .displayTitle (.event | color "yellow") (timeago .createdAt | color "white")}}{{end}}'; \ + else \ + printf "${YELLOW}[WARN] No release workflow found in this repository${RESET}\n"; \ + fi + +latest-release: require-gh ## show information about the latest GitHub release + @printf "${BOLD}Latest Release${RESET}\n" + @printf "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}\n" + @if gh release view --json tagName --jq '.tagName' >/dev/null 2>&1; then \ + gh release view --json tagName,name,publishedAt,url,isDraft,isPrerelease,author \ + --template ' Tag: {{.tagName | color "green"}}{{"\n"}} Name: {{.name}}{{"\n"}} Author: {{.author.login}}{{"\n"}} Published: {{timeago .publishedAt}}{{"\n"}} Status: {{if .isDraft}}{{printf "Draft" | color "yellow"}}{{else if .isPrerelease}}{{printf "Pre-release" | color "yellow"}}{{else}}{{printf "Published" | color "green"}}{{end}}{{"\n"}} URL: {{.url}}{{"\n"}}'; \ + else \ + printf "${YELLOW}[WARN] No releases found in this repository${RESET}\n"; \ + fi diff --git a/.rhiza/make.d/marimo.mk b/.rhiza/make.d/marimo.mk index 36ae74c..9fdaace 100644 --- a/.rhiza/make.d/marimo.mk +++ b/.rhiza/make.d/marimo.mk @@ -1,6 +1,14 @@ ## Makefile.marimo - Marimo notebook targets # This file is included by the main Makefile +# Contribute the marimo notebook folder to the shared deptry scan defined in +# quality.mk. DEP004 (misplaced development dependency) is ignored because +# notebooks legitimately import packages declared as development dependencies. +ifneq ($(wildcard $(MARIMO_FOLDER)),) +DEPTRY_FOLDERS += $(MARIMO_FOLDER) +DEPTRY_IGNORE += --ignore DEP004 +endif + # Declare phony targets (they don't produce files) .PHONY: marimo-validate marimo diff --git a/.rhiza/make.d/python.mk b/.rhiza/make.d/python.mk new file mode 100644 index 0000000..8c7c324 --- /dev/null +++ b/.rhiza/make.d/python.mk @@ -0,0 +1,284 @@ +## .rhiza/make.d/python.mk - the Python language layer (bundle: python-core) +# +# Everything in rhiza that only makes sense because the project is written in +# Python. `core` provides the make framework and uv/uvx as a tool runner; this +# file turns that into a Python project: the virtualenv, the `install` that syncs +# it, dependency and licence analysis of the declared dependencies, and the `all` +# aggregate naming the gates. +# +# A sibling language layer (rust.mk, from a rust-core bundle) ships the same +# *target names* — install, all — with different recipes. That contract is the +# reason book.mk and the CI workflows can call `make install` without knowing the +# language. Only one language layer is ever synced into a repo. +# +# Every gate named in `all` below is defined *here*, which is what makes this file +# comparable to rust.mk and go.mk. Until #1475 four of them — test, typecheck, +# security, docs-coverage — lived in the `tests` bundle's test.mk while `all` named +# them, so a project syncing `core + python-core` without `tests` had an `all` that +# could not run. The `tests` bundle now carries only the genuinely optional extras +# (benchmark, hypothesis-test, stress, mutation). + +# Declare phony targets (they don't produce files) +.PHONY: all deps deptry docs-coverage install license security test test-pyproject typecheck + +# The project virtualenv, and the interpreter that fills it. PYTHON_VERSION is +# declared in rhiza.mk (core needs a Python to run its own tooling on); here +# `.python-version` — which this bundle ships — makes it the project's version too. +VENV ?= .venv +UV_SYNC_ARGS ?= --all-extras --all-groups + +export UV_VENV_CLEAR := 1 + +# Configurable list of licenses that fail the compliance scan (semicolon-separated) +LICENSE_FAIL_ON ?= GPL;LGPL;AGPL + +# Default directory for tests. Also read by the `tests` bundle's test.mk, which +# requires this layer, so the two never disagree about where tests live. +TESTS_FOLDER := tests + +# Minimum coverage percent for tests to pass +# (Can be overridden in local.mk or via environment variable) +COVERAGE_FAIL_UNDER ?= 90 + +# Which static type checker(s) the 'typecheck' target runs: ty, mypy, or both. +# Running both is the default for backward compatibility, but ty and mypy +# occasionally disagree (e.g. one accepts a suppression the other still flags), +# forcing duplicate `# type: ignore` / `# ty: ignore` comments. Set this to +# 'ty' or 'mypy' in local.mk or .rhiza/.env to run a single checker instead. +TYPECHECKER ?= both + +##@ Python +install: pre-install install-uv ## install + # Create the virtual environment only if it doesn't exist + @if [ ! -d "${VENV}" ]; then \ + ${UV_BIN} venv $(if $(PYTHON_VERSION),--python $(PYTHON_VERSION)) ${VENV} || { printf "${RED}[ERROR] Failed to create virtual environment${RESET}\n"; exit 1; }; \ + else \ + printf "${BLUE}[INFO] Using existing virtual environment at ${VENV}, skipping creation${RESET}\n"; \ + fi + + # Install the dependencies from pyproject.toml (if it exists). + # --inexact leaves packages uv did not manage in place instead of pruning them each + # run, so repeated 'make' targets don't churn the environment. Per-target tooling + # (pytest, interrogate, mutmut, ...) is provisioned on the fly via `uv run --with` + # in the individual targets, so there is no separate dependency-install step here. + @if [ -f "pyproject.toml" ]; then \ + if [ -f "uv.lock" ]; then \ + if ! ${UV_BIN} lock --check >/dev/null 2>&1; then \ + printf "${YELLOW}[WARN] uv.lock is out of sync with pyproject.toml${RESET}\n"; \ + printf "${YELLOW} Run 'uv sync' to update your lock file and environment${RESET}\n"; \ + printf "${YELLOW} Or run 'uv lock' to update only the lock file${RESET}\n"; \ + exit 1; \ + fi; \ + printf "${BLUE}[INFO] Installing dependencies from lock file${RESET}\n"; \ + ${UV_BIN} sync $(UV_SYNC_ARGS) --inexact --frozen || { printf "${RED}[ERROR] Failed to install dependencies${RESET}\n"; exit 1; }; \ + else \ + printf "${YELLOW}[WARN] uv.lock not found. Generating lock file and installing dependencies...${RESET}\n"; \ + ${UV_BIN} sync $(UV_SYNC_ARGS) --inexact || { printf "${RED}[ERROR] Failed to install dependencies${RESET}\n"; exit 1; }; \ + fi; \ + else \ + printf "${YELLOW}[WARN] No pyproject.toml found, skipping install${RESET}\n"; \ + fi + + # Install pre-commit hooks (skip when core.hooksPath is set, e.g. by an + # external hook manager — pre-commit refuses to install in that case) + # + # `-c` for the reason quality.mk's `fmt` passes `--config`, and it must be repeated + # here: prek bakes the flag into the generated shim, so without it the commit-time + # gate rediscovers nested projects and stops meaning what `make fmt` means. A + # consumer who wants prek's monorepo behaviour drops the flag from both places. + @if [ -f ".pre-commit-config.yaml" ]; then \ + if [ -n "$$(git config --get core.hooksPath 2>/dev/null)" ]; then \ + printf "${BLUE}[INFO] Skipping pre-commit hook install: core.hooksPath is set${RESET}\n"; \ + else \ + printf "${BLUE}[INFO] Installing pre-commit hooks...${RESET}\n"; \ + ${UVX_BIN} prek install -c .pre-commit-config.yaml || { printf "${YELLOW}[WARN] Failed to install pre-commit hooks${RESET}\n"; }; \ + fi; \ + fi + + @$(MAKE) post-install + + # Display success message with activation instructions + @printf "\n${GREEN}[SUCCESS] Installation complete!${RESET}\n\n" + @printf "${BLUE}To activate the virtual environment, run:${RESET}\n" + @printf "${YELLOW} source ${VENV}/bin/activate${RESET}\n\n" + +all: fmt deps test docs-coverage security license typecheck rhiza-test ## run all CI targets locally + +# deptry scans one or more folders for dependency issues. Each feature bundle +# contributes the folders it owns to DEPTRY_FOLDERS (and any per-folder ignores +# to DEPTRY_IGNORE), so this target never needs to know which bundles are +# present. The language layer itself contributes SOURCE_FOLDER when it exists; see e.g. +# marimo.mk for a bundle that appends its own folder. Rhiza's own test folder +# (.rhiza/tests) is deliberately excluded: its tooling is provisioned on the fly +# via `uv run --with` in the individual targets, not declared in the project's +# pyproject, so deptry (which validates against pyproject) would only emit noise +# for it. +DEPTRY_FOLDERS ?= +DEPTRY_IGNORE ?= +ifneq ($(wildcard $(SOURCE_FOLDER)),) +DEPTRY_FOLDERS += $(SOURCE_FOLDER) +endif + +# Named `deps`, matching rust.mk and go.mk. This was the one gate whose *target name* +# differed by language (#1474): `deptry` names the tool, which nothing else in the +# contract does — pytest is `test`, mypy is `typecheck`, bandit is `security`. So +# `make deps` used to fail on a Python project and `make deptry` on the other two, and +# nothing language-neutral could invoke the gate without knowing the language first. +# +# The variables stay `DEPTRY_*`: they name the tool's *arguments*, honestly so — +# marimo.mk appends `--ignore DEP004`, a deptry rule code — and they are the accumulator +# interface a downstream local.mk writes to, so renaming them would break consumers for +# nothing. +deps: install-uv ## Run deptry over the folders contributed by each bundle + @if [ -n "$(strip $(DEPTRY_FOLDERS))" ]; then \ + printf "${BLUE}[INFO] Running deptry on:${RESET} $(strip $(DEPTRY_FOLDERS))\n"; \ + $(UVX_BIN) -p ${PYTHON_VERSION} deptry $(strip $(DEPTRY_FOLDERS) $(DEPTRY_IGNORE)); \ + else \ + printf "${YELLOW}[WARN] no deptry folders found, skipping.${RESET}\n"; \ + fi + +# Deprecated alias. A downstream local.mk, a hand-written CI job or a contributor's +# muscle memory all still call `make deptry`; a hard rename would break them at the +# point where the gate simply stops existing. Warns rather than failing, and can go in +# a future release once consumers have moved. +deptry: ## deprecated alias for `deps` + @printf "${YELLOW}[WARN] \`make deptry\` is deprecated and will be removed; use \`make deps\`.${RESET}\n" + @$(MAKE) --no-print-directory deps + +license: install ## run license compliance scan (fail on GPL, LGPL, AGPL) + @printf "${BLUE}[INFO] Running license compliance scan...${RESET}\n" + @${UV_BIN} run --with pip-licenses pip-licenses --fail-on="${LICENSE_FAIL_ON}" + +##@ Development and Testing + +# The 'test' target runs the complete test suite. +# 1. Cleans up any previous test results in _tests/ and stale coverage data. +# 2. Creates directories for HTML coverage and test reports. +# 3. Invokes pytest via the local virtual environment. +# 4. Generates terminal output, HTML coverage, JSON coverage, and HTML test reports. +# +# Parallel (pytest-xdist) runs occasionally crash *during worker/session +# teardown* even though every test passed — e.g. the xdist +# `worker_workerfinished` KeyError or a pytest-html report-write race. pytest +# signals these runner-internal crashes with exit code 3 (INTERNALERROR), +# which is distinct from real test failures (1), interruptions (2) and usage +# errors (4). We therefore retry the suite once on exit code 3 only, so a +# teardown race no longer flips a green run red, while genuine failures still +# fail immediately. Stale `.coverage*` data is removed before each attempt so a +# previously crashed run cannot leave a corrupt data file that reports a false +# 0% coverage on the next run. +test:: install ## run all tests + @rm -rf _tests + @if [ -z "$$(find ${TESTS_FOLDER} -name 'test_*.py' -o -name '*_test.py' 2>/dev/null)" ]; then \ + printf "${YELLOW}[WARN] No test files found in ${TESTS_FOLDER}, skipping tests.${RESET}\n"; \ + exit 0; \ + fi; \ + if [ -d ${SOURCE_FOLDER} ]; then \ + set -- -n auto \ + --ignore=${TESTS_FOLDER}/benchmarks \ + --ignore=${TESTS_FOLDER}/stress \ + --cov=${SOURCE_FOLDER} \ + --cov-report=term \ + --cov-report=html:_tests/html-coverage \ + --cov-fail-under=$(COVERAGE_FAIL_UNDER) \ + --cov-report=json:_tests/coverage.json \ + --cov-report=xml:_tests/coverage.xml \ + --html=_tests/html-report/report.html; \ + else \ + printf "${YELLOW}[WARN] Source folder ${SOURCE_FOLDER} not found, running tests without coverage${RESET}\n"; \ + set -- -n auto \ + --ignore=${TESTS_FOLDER}/benchmarks \ + --ignore=${TESTS_FOLDER}/stress \ + --html=_tests/html-report/report.html; \ + fi; \ + attempt=1; max_attempts=2; \ + while :; do \ + rm -f .coverage .coverage.* _tests/coverage.xml _tests/coverage.json 2>/dev/null || true; \ + mkdir -p _tests/html-coverage _tests/html-report; \ + ${UV_BIN} run --with pytest --with pytest-cov --with pytest-xdist --with pytest-html --with pytest-timeout --with pytest-mock pytest "$$@"; status=$$?; \ + if [ $$status -ne 3 ]; then exit $$status; fi; \ + if [ $$attempt -ge $$max_attempts ]; then \ + printf "${RED}[ERROR] pytest reported an internal (teardown) error after %s attempts; failing.${RESET}\n" "$$attempt"; \ + exit $$status; \ + fi; \ + printf "${YELLOW}[WARN] pytest exited 3 (xdist/teardown internal error, all tests may have passed); retrying suite (attempt %s/%s)...${RESET}\n" "$$((attempt + 1))" "$$max_attempts"; \ + attempt=$$((attempt + 1)); \ + done + +# The 'typecheck' target runs static type analysis using ty and/or mypy. +# 1. Builds a list of existing Python source folders to check. +# 2. Depending on TYPECHECKER (ty|mypy|both, default: both), runs ty, +# mypy in strict mode, or both in sequence as a cross-check. +typecheck: install ## run ty and/or mypy type checking (TYPECHECKER=ty|mypy|both, default: both) + @typecheck_paths=""; \ + if [ -d "${SOURCE_FOLDER}" ]; then \ + typecheck_paths="${SOURCE_FOLDER}"; \ + fi; \ + if [ -z "$${typecheck_paths}" ]; then \ + printf "${YELLOW}[WARN] No typecheck folders found (SOURCE_FOLDER='${SOURCE_FOLDER}'), skipping typecheck${RESET}\n"; \ + exit 0; \ + fi; \ + case "${TYPECHECKER}" in \ + ty) \ + printf "${BLUE}[INFO] Running ty type checking in:$${typecheck_paths}${RESET}\n"; \ + ${UV_BIN} run --with ty ty check $${typecheck_paths} \ + ;; \ + mypy) \ + printf "${BLUE}[INFO] Running mypy strict type checking in:$${typecheck_paths}${RESET}\n"; \ + ${UV_BIN} run --with mypy mypy --strict $${typecheck_paths} \ + ;; \ + both) \ + printf "${BLUE}[INFO] Running ty type checking in:$${typecheck_paths}${RESET}\n"; \ + ${UV_BIN} run --with ty ty check $${typecheck_paths} && \ + printf "${BLUE}[INFO] Running mypy strict type checking in:$${typecheck_paths}${RESET}\n"; \ + ${UV_BIN} run --with mypy mypy --strict $${typecheck_paths} \ + ;; \ + *) \ + printf "${RED}[ERROR] Invalid TYPECHECKER='${TYPECHECKER}' (expected: ty, mypy, or both)${RESET}\n"; \ + exit 1 \ + ;; \ + esac + +# The 'security' target runs bandit to find common security issues in the +# Python source folders that exist. +security: install ## run security scans (bandit) + @bandit_paths=""; \ + if [ -d "${SOURCE_FOLDER}" ]; then \ + bandit_paths="${SOURCE_FOLDER}"; \ + fi; \ + if [ -n "$${bandit_paths}" ]; then \ + printf "${BLUE}[INFO] Running bandit security scan in:$${bandit_paths}${RESET}\n"; \ + ${UVX_BIN} bandit -r $${bandit_paths} -ll -q --ini .bandit; \ + else \ + printf "${YELLOW}[WARN] No bandit scan folders found (SOURCE_FOLDER='${SOURCE_FOLDER}'), skipping bandit${RESET}\n"; \ + fi + +# The 'docs-coverage' target checks documentation coverage using interrogate. +# 1. Builds a list of existing Python source folders to check. +# 2. Runs interrogate with verbose output against those folders. +docs-coverage: install ## check documentation coverage with interrogate + @docstring_paths=""; \ + if [ -d "${SOURCE_FOLDER}" ]; then \ + docstring_paths="${SOURCE_FOLDER}"; \ + fi; \ + if [ -d "tests" ]; then \ + docstring_paths="$${docstring_paths} tests"; \ + fi; \ + if [ -d ".rhiza/tests" ]; then \ + docstring_paths="$${docstring_paths} .rhiza/tests"; \ + fi; \ + if [ -n "$${docstring_paths}" ]; then \ + printf "${BLUE}[INFO] Checking documentation coverage in:$${docstring_paths}${RESET}\n"; \ + ${UV_BIN} run --with interrogate interrogate -vv --fail-under 100 --ignore-init-method --ignore-magic $${docstring_paths}; \ + else \ + printf "${YELLOW}[WARN] No docs-coverage folders found (SOURCE_FOLDER='${SOURCE_FOLDER}'), skipping docs-coverage${RESET}\n"; \ + fi + +test-pyproject: install ## run pyproject.toml structure tests + @${UV_BIN} run --with pytest pytest .rhiza/tests/test_pyproject.py \ + -v \ + --tb=long \ + --showlocals \ + -rA \ + --durations=0 \ + --no-header diff --git a/.rhiza/make.d/quality.mk b/.rhiza/make.d/quality.mk index 7504828..a3047bd 100644 --- a/.rhiza/make.d/quality.mk +++ b/.rhiza/make.d/quality.mk @@ -1,30 +1,36 @@ ## .rhiza/make.d/quality.mk - Quality and Formatting -# This file provides targets for code quality checks, linting, and formatting. - -# Configurable list of licenses that fail the compliance scan (semicolon-separated) -LICENSE_FAIL_ON ?= GPL;LGPL;AGPL +# The language-neutral gates: pre-commit, the TODO sweep, semgrep, and the runner for +# the template's own test suite. Everything that needs to know how the project declares +# its dependencies — `deptry`, the licence-compliance scan — and the `all` aggregate that +# names the per-language gates live in the language layer (python.mk, from the +# python-core bundle). # Declare phony targets (they don't produce files) -.PHONY: all deptry fmt license todos suppression-audit semgrep +.PHONY: fmt todos semgrep rhiza-test ##@ Quality and Formatting -all: fmt deptry test docs-coverage security license typecheck rhiza-test ## run all CI targets locally - -deptry: install-uv ## Run deptry - @if [ -d ${SOURCE_FOLDER} ]; then \ - $(UVX_BIN) -p ${PYTHON_VERSION} deptry ${SOURCE_FOLDER}; \ - fi - - @if [ -d ${MARIMO_FOLDER} ]; then \ - if [ -d ${SOURCE_FOLDER} ]; then \ - $(UVX_BIN) -p ${PYTHON_VERSION} deptry ${MARIMO_FOLDER} ${SOURCE_FOLDER} --ignore DEP004; \ - else \ - $(UVX_BIN) -p ${PYTHON_VERSION} deptry ${MARIMO_FOLDER} --ignore DEP004; \ - fi \ - fi - +# prek rather than pre-commit: a Rust reimplementation that reads the same +# `.pre-commit-config.yaml` and needs no Python of its own. Two consequences, one +# gained and one that has to be asked for. +# +# Gained: the `-p ${PYTHON_VERSION}` this recipe used to carry is gone. That flag +# existed because `uvx pre-commit` had to choose an interpreter to run pre-commit +# *itself* on, and a Rust or Go project ships no `.python-version` — so the whole +# language-neutral half of the template rested on rhiza.mk's fallback resolving to +# something real. prek is a binary and provisions each hook's toolchain itself, so the +# coupling is removed rather than merely satisfied. +# +# Asked for: `--config`. By default prek treats every directory below the root that +# holds a `.pre-commit-config.yaml` as a separate *project* and runs each one's hooks — +# useful in a monorepo, surprising anywhere else, and wrong in rhiza's own repo, where +# `bundles/{python,rust,go}-core` each ship one as template content. (go-core's hooks +# then run `go vet ./...` in a directory with no `go.mod` and fail.) Naming the config +# explicitly disables that discovery, so `make fmt` means exactly what it meant under +# pre-commit: this repo's config, once. A consumer who wants the monorepo behaviour +# drops the flag. `.prekignore` is documented for the same job but is not honoured by +# prek 0.4.12, so it is not what this relies on. fmt: install-uv ## check the pre-commit hooks and the linting - @${UVX_BIN} -p ${PYTHON_VERSION} pre-commit run --all-files + @${UVX_BIN} prek run --all-files --config .pre-commit-config.yaml todos: ## search and report all TODO/FIXME/HACK comments in the codebase @printf "${BLUE}[INFO] Searching for TODO, FIXME, and HACK comments...${RESET}\n" @@ -42,10 +48,6 @@ todos: ## search and report all TODO/FIXME/HACK comments in the codebase printf "${GREEN}[SUCCESS] No TODO/FIXME/HACK comments found!${RESET}\n" @printf "\n${BLUE}[INFO] Search complete.${RESET}\n" -suppression-audit: ## scan codebase for inline suppressions and report (grade, detail, histogram) - @printf "${BLUE}[INFO] Running suppression audit...${RESET}\n" - @${UV_BIN} run python .rhiza/utils/suppression_audit.py - semgrep: install ## run Semgrep static analysis @printf "${BLUE}[INFO] Running Semgrep...${RESET}\n" @if [ -d ${SOURCE_FOLDER} ]; then \ @@ -54,6 +56,24 @@ semgrep: install ## run Semgrep static analysis printf "${YELLOW}[WARN] SOURCE_FOLDER '${SOURCE_FOLDER}' not found, skipping semgrep.${RESET}\n"; \ fi -license: install ## run license compliance scan (fail on GPL, LGPL, AGPL) - @printf "${BLUE}[INFO] Running license compliance scan...${RESET}\n" - @${UV_BIN} run --with pip-licenses pip-licenses --fail-on="${LICENSE_FAIL_ON}" +# Owned by core, not by a language layer, because nothing about it is language-specific: +# `.rhiza/tests` validates the *template* — READMEs, release config, YAML — and runs +# under pytest whatever the project is written in. `uv` lives in core for the same +# reason. Each layer contributes its own modules to the suite (test_pyproject.py, +# test_cargo_toml.py, test_go_module.py) while the runner stays here, so the recipe +# exists once rather than identically in all three (#1471). +# +# `install` is a deliberate exception to core's rule of never naming a layer-owned +# target. It is a *prerequisite* here, not a definition, and the suite needs it: the +# shipped test_docstrings.py imports the project's own packages to run their doctests, +# which requires the dependencies installed. Make resolves it because every fragment is +# included into one namespace, and every profile selects exactly one language layer — +# `test_a_profile_never_selects_two_bundles_from_one_layer` and +# `test_every_profile_selects_a_language_layer` bracket that from both sides. A +# core-only tree is not a shipped configuration; there, this fails naming `install`. +rhiza-test: install ## run rhiza's own tests (if any) + @if [ -d ".rhiza/tests" ]; then \ + ${UV_BIN} run --with pytest --with pytest-timeout --with python-dotenv --with packaging pytest .rhiza/tests; \ + else \ + printf "${YELLOW}[WARN] No .rhiza/tests directory found, skipping rhiza-tests${RESET}\n"; \ + fi diff --git a/.rhiza/make.d/test.mk b/.rhiza/make.d/test.mk index 3af2847..8792aab 100644 --- a/.rhiza/make.d/test.mk +++ b/.rhiza/make.d/test.mk @@ -1,107 +1,43 @@ -## Makefile.tests - Testing and benchmarking targets +## .rhiza/make.d/test.mk - optional Python testing extras (bundle: tests) # This file is included by the main Makefile. -# It provides targets for running the test suite with coverage and -# executing performance benchmarks. +# +# The gates the language contract names — `test`, `typecheck`, `security`, +# `docs-coverage` — moved into `python.mk` in #1475, because `python.mk`'s own `all` +# named them while this bundle owned them: a project syncing `core + python-core` +# without `tests` got an `all` that could not run. The Rust and Go layers never had +# that split, and now neither does Python. +# +# What is left here is what genuinely *is* optional — four gates no layer's `all` +# depends on, each of which needs its own tool and its own folder convention: +# benchmarks, Hypothesis property tests, stress/load tests and mutation testing. +# Keeping them out of the layer is what lets a project take the Python gate set +# without also declaring an opinion on mutation testing. +# +# `TESTS_FOLDER`, and the shared pytest config in `pytest.ini`, come from +# `python.mk` / `python-core`. This bundle requires `python-core`, so both are always +# present alongside it. # Declare phony targets (they don't produce files) -.PHONY: test benchmark typecheck security docs-coverage hypothesis-test coverage-badge stress test-pyproject mutation +.PHONY: benchmark hypothesis-test stress mutation -# Default directory for tests -TESTS_FOLDER := tests - -# Minimum coverage percent for tests to pass -# (Can be overridden in local.mk or via environment variable) -COVERAGE_FAIL_UNDER ?= 90 - -##@ Development and Testing - -# The 'test' target runs the complete test suite. -# 1. Cleans up any previous test results in _tests/. -# 2. Creates directories for HTML coverage and test reports. -# 3. Invokes pytest via the local virtual environment. -# 4. Generates terminal output, HTML coverage, JSON coverage, and HTML test reports. -test:: install ## run all tests - @rm -rf _tests; - - if [ -z "$$(find ${TESTS_FOLDER} -name 'test_*.py' -o -name '*_test.py' 2>/dev/null)" ]; then \ - printf "${YELLOW}[WARN] No test files found in ${TESTS_FOLDER}, skipping tests.${RESET}\n"; \ - exit 0; \ - fi; \ - mkdir -p _tests/html-coverage _tests/html-report; \ - if [ -d ${SOURCE_FOLDER} ]; then \ - ${UV_BIN} run pytest \ - -n auto \ - --ignore=${TESTS_FOLDER}/benchmarks \ - --ignore=${TESTS_FOLDER}/stress \ - --cov=${SOURCE_FOLDER} \ - --cov-report=term \ - --cov-report=html:_tests/html-coverage \ - --cov-fail-under=$(COVERAGE_FAIL_UNDER) \ - --cov-report=json:_tests/coverage.json \ - --cov-report=xml:_tests/coverage.xml \ - --html=_tests/html-report/report.html; \ - else \ - printf "${YELLOW}[WARN] Source folder ${SOURCE_FOLDER} not found, running tests without coverage${RESET}\n"; \ - ${UV_BIN} run pytest \ - -n auto \ - --ignore=${TESTS_FOLDER}/benchmarks \ - --ignore=${TESTS_FOLDER}/stress \ - --html=_tests/html-report/report.html; \ - fi - -# The 'typecheck' target runs static type analysis using ty. -# 1. Checks if the source directory exists. -# 2. Runs ty on the source folder. -typecheck: install ## run ty type checking - @if [ -d ${SOURCE_FOLDER} ]; then \ - printf "${BLUE}[INFO] Running ty type checking...${RESET}\n"; \ - ${UV_BIN} run ty check ${SOURCE_FOLDER}; \ - else \ - printf "${YELLOW}[WARN] Source folder ${SOURCE_FOLDER} not found, skipping typecheck${RESET}\n"; \ - fi - -# Extra flags forwarded to pip-audit (e.g. --ignore-vuln CVE-XXXX-YYYY) -PIP_AUDIT_ARGS ?= - -# The 'security' target performs security vulnerability scans. -# 1. Runs pip-audit via pip_audit_policy.py: fails on runtime dep CVEs, warns on tooling (pip/setuptools/wheel). -# 2. Runs bandit to find common security issues in the source code. -security: install ## run security scans (pip-audit and bandit) - @printf "${BLUE}[INFO] Running pip-audit for dependency vulnerabilities...${RESET}\n" - @${UV_BIN} run python .rhiza/utils/pip_audit_policy.py ${PIP_AUDIT_ARGS} - @printf "${BLUE}[INFO] Running bandit security scan...${RESET}\n" - @${UVX_BIN} bandit -r ${SOURCE_FOLDER} -ll -q --ini .bandit +##@ Development and Testing (extras) # The 'benchmark' target runs performance benchmarks using pytest-benchmark. # 1. Installs benchmarking dependencies (pytest-benchmark, pygal). # 2. Executes benchmarks found in the benchmarks/ subfolder. # 3. Generates histograms and JSON results. -# 4. Runs a post-analysis script to process the results. benchmark:: install ## run performance benchmarks @if [ -d "${TESTS_FOLDER}/benchmarks" ]; then \ printf "${BLUE}[INFO] Running performance benchmarks...${RESET}\n"; \ - ${UV_BIN} pip install pytest-benchmark==5.2.3 pygal==3.1.0; \ mkdir -p _tests/benchmarks; \ - ${UV_BIN} run pytest "${TESTS_FOLDER}/benchmarks/" \ + ${UV_BIN} run --with pytest --with pytest-benchmark==5.2.3 --with pygal==3.1.0 pytest "${TESTS_FOLDER}/benchmarks/" \ --benchmark-only \ --benchmark-histogram=_tests/benchmarks/histogram \ --benchmark-json=_tests/benchmarks/results.json; \ - ${UVX_BIN} "rhiza-tools>=0.2.3" analyze-benchmarks --benchmarks-json _tests/benchmarks/results.json --output-html _tests/benchmarks/report.html; \ else \ printf "${YELLOW}[WARN] Benchmarks folder not found, skipping benchmarks${RESET}\n"; \ fi -# The 'docs-coverage' target checks documentation coverage using interrogate. -# 1. Checks if SOURCE_FOLDER exists. -# 2. Runs interrogate on the source folder with verbose output. -docs-coverage: install ## check documentation coverage with interrogate - @if [ -d "${SOURCE_FOLDER}" ]; then \ - printf "${BLUE}[INFO] Checking documentation coverage in ${SOURCE_FOLDER}...${RESET}\n"; \ - ${UV_BIN} run interrogate -vv --fail-under 100 --ignore-init-method --ignore-magic ${SOURCE_FOLDER}; \ - else \ - printf "${YELLOW}[WARN] Source folder ${SOURCE_FOLDER} not found, skipping docs-coverage${RESET}\n"; \ - fi - # The 'hypothesis-test' target runs property-based tests using Hypothesis. # 1. Checks if hypothesis tests exist in the tests directory. # 2. Runs pytest with hypothesis-specific settings and statistics. @@ -113,7 +49,7 @@ hypothesis-test:: install ## run property-based tests with Hypothesis fi; \ printf "${BLUE}[INFO] Running Hypothesis property-based tests...${RESET}\n"; \ mkdir -p _tests/hypothesis; \ - PYTEST_HTML_TITLE="Hypothesis tests" ${UV_BIN} run pytest \ + PYTEST_HTML_TITLE="Hypothesis tests" ${UV_BIN} run --with pytest --with hypothesis --with pytest-html pytest \ --ignore=${TESTS_FOLDER}/benchmarks \ -v \ --hypothesis-show-statistics \ @@ -139,7 +75,7 @@ stress:: install ## run stress/load tests fi; \ printf "${BLUE}[INFO] Running stress/load tests...${RESET}\n"; \ mkdir -p _tests/stress; \ - ${UV_BIN} run pytest \ + ${UV_BIN} run --with pytest --with pytest-html pytest \ -v \ -m stress \ --tb=short \ @@ -153,20 +89,11 @@ mutation: install ## run mutation tests with mutmut printf "${BLUE}[INFO] Running mutation tests on ${SOURCE_FOLDER}...${RESET}\n"; \ mkdir -p _tests/mutation; \ run_status=0; \ - ${UV_BIN} run mutmut run \ + ${UV_BIN} run --with mutmut mutmut run \ --paths-to-mutate="${SOURCE_FOLDER}" \ --tests-dir="${TESTS_FOLDER}" || run_status=$$?; \ - ${UV_BIN} run mutmut html || exit $$?; \ + ${UV_BIN} run --with mutmut mutmut html || exit $$?; \ rm -rf _tests/mutation/html; \ mv html _tests/mutation/html || exit $$?; \ - ${UV_BIN} run mutmut results || exit $$?; \ + ${UV_BIN} run --with mutmut mutmut results || exit $$?; \ exit $$run_status - -test-pyproject: install ## run pyproject.toml structure tests - @${UV_BIN} run pytest .rhiza/tests/structure/test_pyproject.py \ - -v \ - --tb=long \ - --showlocals \ - -rA \ - --durations=0 \ - --no-header diff --git a/.rhiza/rhiza.mk b/.rhiza/rhiza.mk index 19c57dd..6d27a3f 100644 --- a/.rhiza/rhiza.mk +++ b/.rhiza/rhiza.mk @@ -10,6 +10,37 @@ ifndef MAKE_VERSION $(error GNU Make is required. macOS ships BSD make — install GNU Make with: brew install make) endif +# Require a working POSIX shell. +# Recipes use POSIX shell syntax (mkdir -p, printf, [ ... ], curl | sh). GNU Make +# runs recipes with sh when it finds one on PATH -- even when make itself was +# launched from PowerShell or cmd -- so e.g. CI on windows-latest (which ships +# Git's sh.exe) works fine. Only when no POSIX shell is found does make fall back +# to cmd.exe, where the recipes fail on the first non-cmd line with errors like: +# process_begin: CreateProcess(NULL, # Ensure ... folder exists, ...) failed. +# So probe the shell make actually uses rather than guessing from the OS: run a +# POSIX command and require its output. Guarded by $(OS) so the probe only runs +# on Windows (no subprocess cost on Linux/macOS/WSL, where the shell is POSIX). +ifeq ($(OS),Windows_NT) +ifneq ($(shell printf POSIX),POSIX) +define RHIZA_WINDOWS_SHELL_ERROR + + This project's Makefile requires a POSIX shell, but make could not find one + and fell back to cmd.exe, which is not supported. + + Run 'make' from an environment that provides a POSIX shell, e.g.: + - WSL (recommended): https://learn.microsoft.com/windows/wsl/install + - Git Bash: bundled with Git for Windows (https://git-scm.com/download/win) + + Tip: if 'sh.exe' (from Git for Windows) is on your PATH, make will use it + automatically even from PowerShell or cmd. + + See README.md > Prerequisites for details. + +endef +$(error $(RHIZA_WINDOWS_SHELL_ERROR)) +endif +endif + # Colours for pretty output in help messages BLUE := \033[36m BOLD := \033[1m @@ -24,53 +55,60 @@ RESET := \033[0m # Declare phony targets (they don't produce files) .PHONY: \ help \ - post-bump \ post-install \ - post-release \ - post-sync \ - post-validate \ - pre-bump \ pre-install \ - pre-release \ - pre-sync \ - pre-validate \ print-logo \ readme \ - summarise-sync \ - sync \ - validate \ version-matrix \ ci-os-matrix +# --------------------------------------------------------------------------- +# uv as a *tool runner*, not as a language choice. +# +# Rhiza reaches for uvx to run pre-commit, mkdocs, semgrep and the rest whatever +# the project is written in, so provisioning it belongs to core. What is Python- +# specific — the project virtualenv, the `install` target that syncs it, and the +# project's own interpreter version — lives in the language layer +# (`bundles/python-core`), which ships `.rhiza/make.d/python.mk`. +# --------------------------------------------------------------------------- + # we need absolute paths! INSTALL_DIR ?= $(abspath ./bin) UV_BIN ?= $(shell command -v uv 2>/dev/null || echo ${INSTALL_DIR}/uv) UVX_BIN ?= $(shell command -v uvx 2>/dev/null || echo ${INSTALL_DIR}/uvx) -VENV ?= .venv -# Read Python version from .python-version (single source of truth) +# The interpreter rhiza's own tooling runs on. A Python project overrides this +# from `.python-version` (see python.mk, which makes it the project's version +# too); anywhere else the fallback is simply the Python uvx provisions. PYTHON_VERSION ?= $(strip $(shell cat .python-version 2>/dev/null || echo "3.13")) export PYTHON_VERSION -# Read Rhiza version from .rhiza/.rhiza-version (single source of truth for rhiza-tools) -RHIZA_VERSION ?= $(shell cat .rhiza/.rhiza-version 2>/dev/null || echo "0.10.2") -export RHIZA_VERSION - -# Default sync schedule (cron expression for GitHub Actions sync workflow) -# Override in your root Makefile to customise when sync runs. -# Example: RHIZA_SYNC_SCHEDULE = 0 9 * * 1-5 (weekdays at 9 AM UTC) -RHIZA_SYNC_SCHEDULE ?= 0 0 * * 1 - export UV_NO_MODIFY_PATH := 1 -export UV_VENV_CLEAR := 1 # Unset VIRTUAL_ENV to prevent uv from warning about path mismatches # when a virtual environment is already activated in the shell unexport VIRTUAL_ENV # Load .rhiza/.env (if present) and export its variables so recipes see them. +# This file is optional — sensible defaults are defined below. -include .rhiza/.env +# --------------------------------------------------------------------------- +# Default values for variables that may be set in .rhiza/.env. +# These ?= assignments are skipped when the variable is already defined by +# the included file, by an environment variable, or by the root Makefile. +# --------------------------------------------------------------------------- + +# Directory that holds the project's Python source package(s). +SOURCE_FOLDER ?= src + +# Directory that holds Marimo notebooks (used by marimo.mk and book.mk). +MARIMO_FOLDER ?= docs/notebooks + +# JSON array of GitHub Actions runner OS labels used by the CI matrix. +# Override in .rhiza/.env or your root Makefile to add more platforms. +RHIZA_CI_OS_MATRIX ?= ["ubuntu-latest"] + # ============================================================================== # Rhiza Core # ============================================================================== @@ -87,15 +125,7 @@ endef export RHIZA_LOGO # Declare phony targets for Rhiza Core -.PHONY: print-logo sync sync-experimental materialize validate readme pre-sync post-sync pre-validate post-validate _apply-sync-schedule - -# Hook targets (double-colon rules allow multiple definitions) -# Note: pre-install/post-install are defined in bootstrap.mk -# Note: pre-bump/post-bump/pre-release/post-release are defined in releasing.mk -pre-sync:: ; @: -post-sync:: ; @: -pre-validate:: ; @: -post-validate:: ; @: +.PHONY: print-logo ##@ Rhiza Workflows @@ -103,68 +133,17 @@ print-logo: @printf "${BLUE}$$RHIZA_LOGO${RESET}\n" -sync: pre-sync ## sync with template repository as defined in .rhiza/template.yml - @if git remote get-url origin 2>/dev/null | grep -iqE 'jebel-quant/rhiza(\.git)?$$'; then \ - printf "${BLUE}[INFO] Skipping sync in rhiza repository (no template.yml by design)${RESET}\n"; \ - else \ - $(MAKE) install-uv && \ - ${UVX_BIN} "rhiza==$(RHIZA_VERSION)" sync . && \ - $(MAKE) _apply-sync-schedule; \ - fi - @$(MAKE) post-sync - -_apply-sync-schedule: ## (internal) apply RHIZA_SYNC_SCHEDULE override to GitHub Actions sync workflow - @if [ "$(RHIZA_SYNC_SCHEDULE)" != "0 0 * * 1" ] && [ -f .github/workflows/rhiza_sync.yml ]; then \ - sed -i.bak "s|cron: '[^']*'|cron: '$(RHIZA_SYNC_SCHEDULE)'|" .github/workflows/rhiza_sync.yml && rm -f .github/workflows/rhiza_sync.yml.bak; \ - printf "${BLUE}[INFO] Applied custom sync schedule: $(RHIZA_SYNC_SCHEDULE)${RESET}\n"; \ - fi - -materialize: ## [DEPRECATED] use 'make sync' instead — materialize --force is now sync - @printf "${YELLOW}[WARN] 'make materialize' is deprecated and will be removed in a future release.${RESET}\n" - @printf "${YELLOW}[WARN] Please use 'make sync' instead (e.g. 'materialize --force' is now 'make sync').${RESET}\n" - @$(MAKE) sync - -summarise-sync: install-uv ## summarise differences created by sync with template repository - @if git remote get-url origin 2>/dev/null | grep -iqE 'jebel-quant/rhiza(\.git)?$$'; then \ - printf "${BLUE}[INFO] Skipping summarise-sync in rhiza repository (no template.yml by design)${RESET}\n"; \ - else \ - $(MAKE) install-uv; \ - ${UVX_BIN} "rhiza==$(RHIZA_VERSION)" summarise .; \ - fi - -rhiza-test: install ## run rhiza's own tests (if any) - @if [ -d ".rhiza/tests" ]; then \ - ${UV_BIN} run pytest .rhiza/tests; \ - else \ - printf "${YELLOW}[WARN] No .rhiza/tests directory found, skipping rhiza-tests${RESET}\n"; \ - fi - -validate: pre-validate rhiza-test ## validate project structure against template repository as defined in .rhiza/template.yml - @if git remote get-url origin 2>/dev/null | grep -iqE 'jebel-quant/rhiza(\.git)?$$'; then \ - printf "${BLUE}[INFO] Skipping validate in rhiza repository (no template.yml by design)${RESET}\n"; \ - else \ - $(MAKE) install-uv; \ - ${UVX_BIN} "rhiza==$(RHIZA_VERSION)" validate .; \ - fi - @$(MAKE) post-validate - -readme: install-uv ## update README.md with current Makefile help output - @${UVX_BIN} "rhiza-tools>=0.2.0" update-readme - ##@ Meta help: print-logo ## Display this help message +@printf "$(BOLD)Usage:$(RESET)\n" +@printf " make $(BLUE)$(RESET)\n\n" +@printf "$(BOLD)Targets:$(RESET)\n" - +@awk 'BEGIN {FS = ":.*##"; printf ""} /^[a-zA-Z_-]+:.*?##/ { printf " $(BLUE)%-20s$(RESET) %s\n", $$1, $$2 } /^##@/ { printf "\n$(BOLD)%s$(RESET)\n", substr($$0, 5) }' $(MAKEFILE_LIST) + +@awk 'BEGIN {FS = ":.*##"; printf ""} /^[a-zA-Z0-9_-]+:.*?##/ { printf " $(BLUE)%-20s$(RESET) %s\n", $$1, $$2 } /^##@/ { printf "\n$(BOLD)%s$(RESET)\n", substr($$0, 5) }' $(MAKEFILE_LIST) +@printf "\n" -version-matrix: install-uv ## Emit the list of supported Python versions from pyproject.toml - @${UVX_BIN} "rhiza-tools>=0.2.2" version-matrix - ci-os-matrix: ## Emit GitHub CI OSes (RHIZA_CI_OS_MATRIX as JSON array, default ["ubuntu-latest"]) - @printf '%s\n' '$(or $(RHIZA_CI_OS_MATRIX),["ubuntu-latest"])' + @$(info $(or $(RHIZA_CI_OS_MATRIX),["ubuntu-latest"])) print-% : ## print the value of a variable (usage: make print-VARIABLE) @printf "${BLUE}[INFO] Printing value of variable '$*':${RESET}\n" diff --git a/.rhiza/template.lock b/.rhiza/template.lock index fb2a01f..2bc154f 100644 --- a/.rhiza/template.lock +++ b/.rhiza/template.lock @@ -1,81 +1,68 @@ -sha: ae79c6f0729b21239655abe390269be9171f907d +sha: 996d5df684d8967907d883e43e0d28376b0ac140 repo: jebel-quant/rhiza host: github -ref: v0.18.8 +ref: v1.3.2 include: [] exclude: [] templates: [] +profiles: +- github-project files: - .bandit - .editorconfig +- .github/CONFIG.md +- .github/DISCUSSION_TEMPLATE/help-wanted.yml +- .github/DISCUSSION_TEMPLATE/ideas.yml - .github/DISCUSSION_TEMPLATE/q-and-a.yml - .github/ISSUE_TEMPLATE/bug_report.yml - .github/ISSUE_TEMPLATE/feature_request.yml - .github/dependabot.yml - .github/pull_request_template.md - .github/release.yml +- .github/rulesets/main-branch-protection.json +- .github/rulesets/tag-protection.json - .github/secret_scanning.yml - .github/workflows/rhiza_benchmark.yml - .github/workflows/rhiza_book.yml - .github/workflows/rhiza_ci.yml - .github/workflows/rhiza_codeql.yml +- .github/workflows/rhiza_fuzzing.yml - .github/workflows/rhiza_marimo.yml +- .github/workflows/rhiza_mutation.yml - .github/workflows/rhiza_release.yml -- .github/workflows/rhiza_sync.yml +- .github/workflows/rhiza_scorecard.yml - .github/workflows/rhiza_weekly.yml - .gitignore - .pre-commit-config.yaml - .python-version -- .rhiza/.cfg.toml - .rhiza/.env - .rhiza/.gitignore -- .rhiza/.rhiza-version - .rhiza/assets/rhiza-logo.svg - .rhiza/completions/README.md - .rhiza/completions/rhiza-completion.bash - .rhiza/completions/rhiza-completion.zsh - .rhiza/make.d/book.mk - .rhiza/make.d/bootstrap.mk +- .rhiza/make.d/completions.mk - .rhiza/make.d/custom-env.mk - .rhiza/make.d/custom-task.mk - .rhiza/make.d/doctor.mk +- .rhiza/make.d/github.mk - .rhiza/make.d/marimo.mk +- .rhiza/make.d/python.mk - .rhiza/make.d/quality.mk -- .rhiza/make.d/releasing.mk - .rhiza/make.d/test.mk -- .rhiza/requirements/README.md -- .rhiza/requirements/docs.txt -- .rhiza/requirements/marimo.txt -- .rhiza/requirements/tests.txt -- .rhiza/requirements/tools.txt - .rhiza/rhiza.mk - .rhiza/semgrep.yml - .rhiza/tests/README.md -- .rhiza/tests/api/conftest.py -- .rhiza/tests/api/test_github_targets.py -- .rhiza/tests/api/test_make_variable_overrides.py -- .rhiza/tests/api/test_makefile_api.py -- .rhiza/tests/api/test_makefile_targets.py - .rhiza/tests/conftest.py -- .rhiza/tests/integration/test_book_targets.py -- .rhiza/tests/integration/test_docs_targets.py -- .rhiza/tests/integration/test_test_mk.py -- .rhiza/tests/integration/test_virtual_env_unexport.py -- .rhiza/tests/shell/test_scripts.sh -- .rhiza/tests/stress/README.md -- .rhiza/tests/stress/__init__.py -- .rhiza/tests/stress/conftest.py -- .rhiza/tests/structure/test_project_layout.py -- .rhiza/tests/structure/test_pyproject.py -- .rhiza/tests/structure/test_requirements.py -- .rhiza/tests/sync/conftest.py -- .rhiza/tests/sync/test_docstrings.py -- .rhiza/tests/sync/test_readme_validation.py -- .rhiza/tests/test_utils.py -- .rhiza/tests/utils/test_git_repo_fixture.py -- .rhiza/utils/pip_audit_policy.py -- .rhiza/utils/suppression_audit.py +- .rhiza/tests/test_docstrings.py +- .rhiza/tests/test_pyproject.py +- .rhiza/tests/test_readme.py +- .rhiza/tests/test_readme_validation.py +- .rhiza/tests/test_release_tags.py - Makefile +- cliff.toml - docs/assets/rhiza-logo.svg - docs/development/MARIMO.md - docs/development/TESTS.md @@ -83,7 +70,6 @@ files: - docs/mkdocs-base.yml - pytest.ini - ruff.toml -profiles: -- github-project -synced_at: '2026-06-13T10:52:58Z' +- tests/test_rhiza_packaging.py +synced_at: '2026-08-05T04:46:26Z' strategy: merge diff --git a/.rhiza/template.yml b/.rhiza/template.yml index 798cfc9..3354bff 100644 --- a/.rhiza/template.yml +++ b/.rhiza/template.yml @@ -1,5 +1,5 @@ repository: "jebel-quant/rhiza" -ref: "v0.18.8" +ref: "v1.3.2" profiles: - github-project diff --git a/.rhiza/tests/README.md b/.rhiza/tests/README.md index 1cf5c26..b79c24b 100644 --- a/.rhiza/tests/README.md +++ b/.rhiza/tests/README.md @@ -1,43 +1,41 @@ -# Rhiza Test Suite +# Rhiza Test Suite (`.rhiza/tests/`) -This directory contains the comprehensive test suite for the Rhiza project. +This directory is **synced from [jebel-quant/rhiza](https://github.com/jebel-quant/rhiza)** +and runs in your project with `make rhiza-test`. Its job is to validate the parts of *your* +repository that Rhiza cares about — the metadata, release config, docs and docstrings that +vary per project — using the shared fixtures below. -## Test Organization +> Tests that only exercise Rhiza's *own* template files (Makefile targets, workflow stubs, +> the project skeleton) live in Rhiza's mother-repo `tests/` suite and are **not** synced +> here — they would be identical in every consumer and can't be changed downstream. Put +> your project's own tests under your `tests/` directory, not here. -Tests are organized into purpose-driven subdirectories: +## Layout -### `structure/` -Static assertions about file and directory presence. These tests verify that the repository contains the expected files, directories, and configuration structure without executing any subprocesses. +The suite is flat — one file per concern — but **which files you get depends on the +bundles you sync**. Each is owned by whichever bundle the assertion belongs to, so a Rust +project gets the Rust manifest checks and none of the Python ones: -- `test_project_layout.py` — Validates root-level files and directories -- `test_requirements.py` — Validates `.rhiza/requirements/` structure +| file | owned by | checks | +| --- | --- | --- | +| `conftest.py` | `core` | shared fixtures (`root`, `logger`, `latest_tag`) | +| `test_release_tags.py` | `core` | the newest tag is reachable from a branch | +| `test_readme.py` | `core` | README exists; every `bash` fence parses | +| `test_pyproject.py` | `python-core` | `pyproject.toml` structure, and its `[tool.bumpversion]` block | +| `test_docstrings.py` | `python-core` | doctests across the modules in your source folder | +| `test_readme_validation.py` | `tests` | executes `python` fences and diffs them against `result` (see below) | +| `test_cargo_toml.py` | `rust-core` | `Cargo.toml` structure and the `.bumpversion.toml` wiring | +| `test_go_module.py` | `go-core` | `go.mod`, the `Version` constant, and the same wiring | -### `api/` -Makefile target validation via dry-runs. These tests verify that Makefile targets are properly defined and would execute the expected commands. +Every profile pairs `core` with exactly one language layer, so `conftest.py` is always +present alongside whichever layer's modules arrived. -- `test_makefile_targets.py` — Core Makefile targets (install, test, fmt, etc.) -- `test_makefile_api.py` — Makefile API (delegation, extension, hooks, overrides) -- `test_github_targets.py` — GitHub-specific Makefile targets +### Skipping README code blocks with `+RHIZA_SKIP` -### `integration/` -Tests requiring sandboxed git repositories or subprocess execution. These tests verify end-to-end workflows. - -- `test_release.py` — Release script functionality -- `test_book_targets.py` — Documentation book build targets - -### `sync/` -Template sync, workflows, versioning, and content validation tests. These tests ensure that template synchronization and content validation work correctly. - -- `test_rhiza_version.py` — Version reading and workflow validation -- `test_readme_validation.py` — README code block execution and validation -- `test_docstrings.py` — Doctest validation across source modules - -#### Skipping README code blocks with `+RHIZA_SKIP` - -By default, every `python` and `bash` code block in `README.md` is executed or -syntax-checked by `test_readme_validation.py`. To mark a block as intentionally -non-runnable (e.g. illustrative snippets or environment-specific commands), add -`+RHIZA_SKIP` to the opening fence line: +By default, every `bash` fence in `README.md` is syntax-checked (`test_readme.py`, any +language) and every `python` fence is executed (`test_readme_validation.py`, Python +projects). To mark a block as intentionally non-runnable — an illustrative snippet, an +environment-specific command — add `+RHIZA_SKIP` to the opening fence line: ~~~markdown ```python +RHIZA_SKIP @@ -56,114 +54,28 @@ Markdown renderers (including GitHub) ignore everything after the first word on a fence line, so the block still renders as a normal highlighted code block. Blocks without `+RHIZA_SKIP` continue to be validated as before. -### `utils/` -Tests for utility code and test infrastructure. These tests validate the testing framework itself and utility scripts. - -- `test_git_repo_fixture.py` — Validates the `git_repo` fixture - -### `deps/` -Dependency validation tests. These tests ensure that project dependencies are correctly specified and healthy. - -- `test_dependency_health.py` — Validates pyproject.toml and requirements files - -### `stress/` -Stress tests that verify Rhiza's stability under heavy load. These tests execute Rhiza-specific operations under concurrent load and repeated execution to detect race conditions, resource leaks, and performance degradation. - -- `test_makefile_stress.py` — Makefile operations under concurrent/repeated load -- `test_git_stress.py` — Git operations under concurrent load - -See [stress/README.md](stress/README.md) for detailed documentation. - ## Running Tests -### Run all tests -```bash -uv run pytest .rhiza/tests/ -# or -make test -``` - -### Run tests from a specific category -```bash -uv run pytest .rhiza/tests/structure/ -uv run pytest .rhiza/tests/api/ -uv run pytest .rhiza/tests/integration/ -uv run pytest .rhiza/tests/sync/ -uv run pytest .rhiza/tests/utils/ -uv run pytest .rhiza/tests/deps/ -uv run pytest .rhiza/tests/stress/ -``` - -### Run stress tests with custom parameters -```bash -# Run all stress tests (default: 100 iterations, 10 workers) -uv run pytest .rhiza/tests/stress/ -v - -# Run with fewer iterations (faster) -uv run pytest .rhiza/tests/stress/ -v --iterations=10 - -# Skip stress tests when running full test suite -uv run pytest .rhiza/tests/ -v -m "not stress" -``` - -### Run a specific test file -```bash -uv run pytest .rhiza/tests/structure/test_project_layout.py -``` - -### Run with verbose output -```bash -uv run pytest .rhiza/tests/ -v -``` - -### Run with coverage ```bash -uv run pytest .rhiza/tests/ --cov +make rhiza-test # run this suite (the usual entry point) +uv run pytest .rhiza/tests/ # equivalent, direct invocation +uv run pytest .rhiza/tests/test_pyproject.py # a single file +uv run pytest .rhiza/tests/ -v # verbose ``` ## Fixtures -### Root-level fixtures (`conftest.py`) -- `root` — Repository root path (session-scoped) -- `logger` — Configured logger instance (session-scoped) -- `git_repo` — Sandboxed git repository (function-scoped) +Defined in `conftest.py` and available to every test without import: -### Category-specific fixtures -- `api/conftest.py` — `setup_tmp_makefile`, `run_make`, `setup_rhiza_git_repo` -- `sync/conftest.py` — `setup_sync_env` +- `root` — repository root path (session-scoped) +- `logger` — configured logger instance (session-scoped) + +`.rhiza/tests` is on `pythonpath` (see `pytest.ini`), so intra-suite imports resolve +without any `sys.path` manipulation. ## Writing Tests -### Conventions - Use descriptive test names that explain what is being tested - Group related tests in classes when appropriate -- Use appropriate fixtures for setup/teardown - Add docstrings to test modules and complex test functions - Use `pytest.mark.skip` for tests that depend on optional features - -### Import Patterns -```python -# Import shared helpers from test_utils -from test_utils import strip_ansi, run_make, setup_rhiza_git_repo - -# Import from local category conftest (for fixtures and category-specific helpers) -from api.conftest import SPLIT_MAKEFILES, setup_tmp_makefile - -# Note: Fixtures defined in conftest.py are automatically available in tests -# and don't need to be explicitly imported -``` - -## Test Coverage - -The test suite aims for high coverage across: -- Configuration validation (structure, dependencies) -- Makefile target correctness (api) -- End-to-end workflows (integration) -- Template synchronization (sync) -- Utility code (utils) - -## Notes - -- Benchmarks are located in `tests/benchmarks/` and run via `make benchmark` -- Integration tests use sandboxed git repositories to avoid affecting the working tree -- All Makefile tests use dry-run mode (`make -n`) to avoid side effects diff --git a/.rhiza/tests/conftest.py b/.rhiza/tests/conftest.py index 419f6d6..7005ded 100644 --- a/.rhiza/tests/conftest.py +++ b/.rhiza/tests/conftest.py @@ -1,129 +1,29 @@ -"""Pytest configuration and fixtures for setting up a mock git repository with versioning. +"""Pytest configuration and fixtures for the rhiza test suite. This file and its associated tests flow down via a SYNC action from the jebel-quant/rhiza repository (https://github.com/jebel-quant/rhiza). -Provides test fixtures for testing git-based workflows and version management. +Provides shared session-scoped fixtures (``root``, ``logger`` and ``latest_tag``) used +across the test modules. + +Owned by ``core`` rather than by a language layer: the fixtures resolve paths and read +git, neither of which depends on what the project is written in. That is what lets the +Rust and Go layers ship their own ``.rhiza/tests`` modules without shipping a conftest +each — every profile pairs ``core`` with exactly one language layer, so this file is +always present alongside them. Security Notes: - S101 (assert usage): Asserts are appropriate in test code for validating conditions -- S603 (subprocess without shell=True): All subprocess calls use lists of known commands (git), - not user input, making them safe from shell injection -- S607 (subprocess with partial path): Using 'git' from PATH is acceptable in test fixtures - as the test environment is controlled and git is a required development dependency """ import logging -import os import pathlib import shutil -import subprocess # nosec B404 - subprocess module needed for git operations in test fixtures -import sys +import subprocess # nosec B404 import pytest -tests_root = pathlib.Path(__file__).resolve().parent -if str(tests_root) not in sys.path: - sys.path.insert(0, str(tests_root)) - -from test_utils import GIT # noqa: E402 - -MOCK_MAKE_SCRIPT = """#!/usr/bin/env python3 -import sys - -if len(sys.argv) > 1 and sys.argv[1] == "help": - print("Mock Makefile Help") - print("target: ## Description") -""" - -MOCK_UV_SCRIPT = """#!/usr/bin/env python3 -import sys -import re - -try: - from packaging.version import parse, InvalidVersion - HAS_PACKAGING = True -except ImportError: - HAS_PACKAGING = False - -def get_version(): - with open("pyproject.toml", "r") as f: - content = f.read() - match = re.search(r'version = "(.*?)"', content) - return match.group(1) if match else "0.0.0" - -def set_version(new_version): - with open("pyproject.toml", "r") as f: - content = f.read() - new_content = re.sub(r'version = ".*?"', f'version = "{new_version}"', content) - with open("pyproject.toml", "w") as f: - f.write(new_content) - -def bump_version(current, bump_type): - major, minor, patch = map(int, current.split('.')) - if bump_type == "major": - return f"{major + 1}.0.0" - elif bump_type == "minor": - return f"{major}.{minor + 1}.0" - elif bump_type == "patch": - return f"{major}.{minor}.{patch + 1}" - return current - -def main(): - args = sys.argv[1:] - if not args: - sys.exit(1) - - if args[0] != "version": - # It might be a uvx call if we use the same script, but let's keep them separate or handle it here. - # For now, let's assume this is only for uv version commands as per original design. - sys.exit(1) - - # uv version --short - if "--short" in args and "--bump" not in args: - print(get_version()) - return - - # uv version --bump --dry-run --short - if "--bump" in args and "--dry-run" in args and "--short" in args: - bump_idx = args.index("--bump") + 1 - bump_type = args[bump_idx] - current = get_version() - print(bump_version(current, bump_type)) - return - - # uv version --bump (actual update) - if "--bump" in args and "--dry-run" not in args: - bump_idx = args.index("--bump") + 1 - bump_type = args[bump_idx] - current = get_version() - new_ver = bump_version(current, bump_type) - set_version(new_ver) - return - - # uv version --dry-run - if len(args) >= 2 and not args[1].startswith("-") and "--dry-run" in args: - version = args[1] - if HAS_PACKAGING: - try: - parse(version) - except InvalidVersion: - sys.exit(1) - else: - # Simple validation: must start with a digit - if not re.match(r"^\\d", version): - sys.exit(1) - # Just exit 0 if valid - return - - # uv version (actual update) - if len(args) == 2 and not args[1].startswith("-"): - set_version(args[1]) - return - -if __name__ == "__main__": - main() -""" +_GIT = shutil.which("git") or "/usr/bin/git" @pytest.fixture(scope="session") @@ -145,73 +45,28 @@ def logger(): return logging.getLogger(__name__) -@pytest.fixture -def git_repo(root, tmp_path, monkeypatch): - """Sets up a remote bare repo and a local clone with necessary files.""" - remote_dir = tmp_path / "remote.git" - local_dir = tmp_path / "local" - - # 1. Create bare remote - remote_dir.mkdir() - subprocess.run([GIT, "init", "--bare", str(remote_dir)], check=True) # nosec B603 - # Ensure the remote's default HEAD points to master for predictable behavior - subprocess.run([GIT, "symbolic-ref", "HEAD", "refs/heads/master"], cwd=remote_dir, check=True) # nosec B603 - - # 2. Clone to local - subprocess.run([GIT, "clone", str(remote_dir), str(local_dir)], check=True) # nosec B603 - - # Use monkeypatch to safely change cwd for the duration of the test - monkeypatch.chdir(local_dir) - - # Ensure local default branch is 'master' to match test expectations - subprocess.run([GIT, "checkout", "-b", "master"], check=True) # nosec B603 - - # Create pyproject.toml - with open("pyproject.toml", "w") as f: - f.write('[project]\nname = "test-project"\nversion = "0.1.0"\n') - - # Create dummy uv.lock - with open("uv.lock", "w") as f: - f.write("") - - # Create bin/uv mock - bin_dir = local_dir / "bin" - bin_dir.mkdir() - - uv_path = bin_dir / "uv" - with open(uv_path, "w") as f: - f.write(MOCK_UV_SCRIPT) - uv_path.chmod(0o755) - - make_path = bin_dir / "make" - with open(make_path, "w") as f: - f.write(MOCK_MAKE_SCRIPT) - make_path.chmod(0o755) - - # Ensure our bin comes first on PATH so 'uv' resolves to mock - monkeypatch.setenv("PATH", f"{bin_dir}:{os.environ.get('PATH', '')}") - - # Copy core Rhiza Makefiles - (local_dir / ".rhiza").mkdir(parents=True, exist_ok=True) - shutil.copy(root / ".rhiza" / "rhiza.mk", local_dir / ".rhiza" / "rhiza.mk") - shutil.copy(root / "Makefile", local_dir / "Makefile") - - # Copy .rhiza/make.d/ directory (contains split makefiles) - make_d_src = root / ".rhiza" / "make.d" - if make_d_src.is_dir(): - make_d_dst = local_dir / ".rhiza" / "make.d" - shutil.copytree(make_d_src, make_d_dst, dirs_exist_ok=True) - - book_src = root / "book" - book_dst = local_dir / "book" - if book_src.is_dir(): - shutil.copytree(book_src, book_dst, dirs_exist_ok=True) - - # Commit and push initial state - subprocess.run([GIT, "config", "user.email", "test@example.com"], check=True) # nosec B603 - subprocess.run([GIT, "config", "user.name", "Test User"], check=True) # nosec B603 - subprocess.run([GIT, "add", "."], check=True) # nosec B603 - subprocess.run([GIT, "commit", "-m", "Initial commit"], check=True) # nosec B603 - subprocess.run([GIT, "push", "origin", "master"], check=True) # nosec B603 - - return local_dir +@pytest.fixture(scope="session") +def latest_tag(root): + """Return the newest ``vX.Y.Z`` git tag, skipping when the repo has none. + + Shared rather than per-module because each language layer asserts the same thing + against a different file — ``[project].version``, ``[package].version``, or Go's + ``Version`` constant — and because every layer's release config derives its current + version from this tag. + + Args: + root: Repository root, from the ``root`` fixture. + + Returns: + str: The highest version tag, e.g. ``v1.3.1``. + """ + result = subprocess.run( # nosec B603 + [_GIT, "tag", "--list", "v*", "--sort=-version:refname"], + capture_output=True, + text=True, + cwd=root, + ) + tags = [line.strip() for line in result.stdout.splitlines() if line.strip()] + if not tags: + pytest.skip("No version tags found in repository") + return tags[0] diff --git a/.rhiza/tests/test_docstrings.py b/.rhiza/tests/test_docstrings.py new file mode 100644 index 0000000..231e57b --- /dev/null +++ b/.rhiza/tests/test_docstrings.py @@ -0,0 +1,123 @@ +"""Tests for module docstrings using doctest. + +This file and its associated tests flow down via a SYNC action from the jebel-quant/rhiza repository +(https://github.com/jebel-quant/rhiza). + +Automatically discovers all packages and runs doctests for each. +""" + +from __future__ import annotations + +import doctest +import importlib +import warnings +from pathlib import Path + +import pytest +from dotenv import dotenv_values + +# Read .rhiza/.env at collection time (no environment side-effects). +RHIZA_ENV_PATH = Path(".rhiza/.env") + + +def _iter_modules_from_path(logger, package_path: Path, src_path: Path): + """Recursively find all Python modules in a directory.""" + for path in package_path.rglob("*.py"): + if path.name == "__init__.py": + module_path = path.parent.relative_to(src_path) + else: + module_path = path.relative_to(src_path).with_suffix("") + + # Convert path to module name in an OS-independent way + module_name = ".".join(module_path.parts) + + try: + yield importlib.import_module(module_name) + except ImportError as e: + warnings.warn(f"Could not import {module_name}: {e}", stacklevel=2) + logger.warning("Could not import module %s: %s", module_name, e) + continue + + +def _find_packages(src_path: Path): + """Find all packages in the source path, including those nested under namespace packages.""" + for init_file in src_path.rglob("__init__.py"): + package_dir = init_file.parent + # Only yield top-level packages (those whose parent doesn't have __init__.py or is src_path) + parent = package_dir.parent + if parent == src_path or not (parent / "__init__.py").exists(): + yield package_dir + + +def test_doctests(logger, root, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]): + """Run doctests for each package directory.""" + values = dotenv_values(root / RHIZA_ENV_PATH) if (root / RHIZA_ENV_PATH).exists() else {} + source_folder = values.get("SOURCE_FOLDER", "src") + src_path = root / source_folder + + logger.info("Starting doctest discovery in: %s", src_path) + if not src_path.exists(): + logger.info("Source directory not found: %s — skipping doctests", src_path) + pytest.skip(f"Source directory not found: {src_path}") + + # Add source path to sys.path with automatic cleanup + monkeypatch.syspath_prepend(str(src_path)) + logger.debug("Prepended to sys.path: %s", src_path) + + total_tests = 0 + total_failures = 0 + failed_modules = [] + + # Find all packages in the source path (supports namespace packages) + for package_dir in _find_packages(src_path): + if package_dir.is_dir() and (package_dir / "__init__.py").exists(): + # Import the package + package_name = package_dir.name + logger.info("Discovered package: %s", package_name) + try: + modules = list(_iter_modules_from_path(logger, package_dir, src_path)) + logger.debug("%d module(s) found in package %s", len(modules), package_name) + + for module in modules: + logger.debug("Running doctests for module: %s", module.__name__) + # Disable pytest's stdout capture during doctest to avoid interference + with capsys.disabled(): + results = doctest.testmod( + module, + verbose=False, + optionflags=(doctest.ELLIPSIS | doctest.NORMALIZE_WHITESPACE), + ) + total_tests += results.attempted + + if results.failed: + logger.warning( + "Doctests failed for %s: %d/%d failed", + module.__name__, + results.failed, + results.attempted, + ) + total_failures += results.failed + failed_modules.append((module.__name__, results.failed, results.attempted)) + else: + logger.debug("Doctests passed for %s (%d test(s))", module.__name__, results.attempted) + + except ImportError as e: + warnings.warn(f"Could not import package {package_name}: {e}", stacklevel=2) + logger.warning("Could not import package %s: %s", package_name, e) + continue + + if failed_modules: + formatted = "\n".join(f" {name}: {failed}/{attempted} failed" for name, failed, attempted in failed_modules) + msg = ( + f"Doctest summary: {total_tests} tests across {len(failed_modules)} module(s)\n" + f"Failures: {total_failures}\n" + f"Failed modules:\n{formatted}" + ) + logger.error("%s", msg) + assert total_failures == 0, msg + else: + logger.info("Doctest summary: %d tests, 0 failures", total_tests) + + if total_tests == 0: + logger.info("No doctests were found in any module — skipping") + pytest.skip("No doctests were found in any module") diff --git a/.rhiza/tests/test_pyproject.py b/.rhiza/tests/test_pyproject.py new file mode 100644 index 0000000..68bf13a --- /dev/null +++ b/.rhiza/tests/test_pyproject.py @@ -0,0 +1,340 @@ +"""Tests for pyproject.toml structure and required fields. + +This file and its associated tests flow down via a SYNC action from the +jebel-quant/rhiza repository (https://github.com/jebel-quant/rhiza). + +Validates that pyproject.toml: +- is syntactically valid TOML +- contains all required [project] fields +- declares a semver-compatible version +- specifies a minimum Python version via requires-python +- lists at least one named author +- provides [project.urls] with Homepage and Repository +- includes at least one Python version classifier +- declares a [dependency-groups] test group containing pytest +- carries a [tool.bumpversion] table bump-my-version can actually discover +- version matches the latest git tag (vX.Y.Z → X.Y.Z) + +Reachability of that tag lives in ``test_release_tags.py``, shipped by ``core``: the +invariant holds for every language layer, not just this one. +""" + +from __future__ import annotations + +import re +import tomllib +from pathlib import Path + +import pytest +from packaging.version import Version + +_SEMVER_RE = re.compile(r"^\d+\.\d+\.\d+") +_REQUIRED_PROJECT_FIELDS = ("name", "version", "description", "readme", "requires-python", "license", "authors") + +# The only filenames bump-my-version auto-discovers. Anything else — including the +# `.rhiza/.cfg.toml` older template versions shipped — is read solely when passed +# with --config-file, which nothing in this template does. +_DISCOVERABLE_CONFIGS = (".bumpversion.toml", ".bumpversion.cfg", "setup.cfg", "pyproject.toml") + + +def _has_bumpversion_section(path: Path) -> bool: + """Report whether a config file carries a bumpversion section at all. + + Args: + path: Candidate config file; a missing or malformed file counts as absent. + + Returns: + True when the file declares ``[tool.bumpversion]`` (TOML) or ``[bumpversion]`` + (INI). ``.bumpversion.toml`` nests the table under ``[tool]`` just as + pyproject.toml does. + """ + if not path.is_file(): + return False + if path.suffix == ".cfg": + return "[bumpversion]" in path.read_text(encoding="utf-8") + try: + with path.open("rb") as handle: + data = tomllib.load(handle) + except tomllib.TOMLDecodeError: + return False + return isinstance(data.get("tool", {}).get("bumpversion"), dict) + + +@pytest.fixture(scope="module") +def pyproject(root: Path) -> dict: + """Load and return pyproject.toml as a parsed dict.""" + path = root / "pyproject.toml" + if not path.exists(): + pytest.skip("pyproject.toml not found") + with path.open("rb") as f: + return tomllib.load(f) + + +@pytest.fixture(scope="module") +def project(pyproject: dict) -> dict: + """Return the [project] table from pyproject.toml.""" + table = pyproject.get("project") + if not isinstance(table, dict): + pytest.fail("pyproject.toml is missing a [project] table") + return table + + +class TestPyprojectToml: + """Tests for basic pyproject.toml existence and validity.""" + + def test_pyproject_toml_exists(self, root: Path) -> None: + """pyproject.toml must exist at the project root.""" + assert (root / "pyproject.toml").is_file(), "pyproject.toml not found at project root" + + def test_pyproject_toml_is_valid_toml(self, root: Path) -> None: + """pyproject.toml must be syntactically valid TOML.""" + with (root / "pyproject.toml").open("rb") as f: + data = tomllib.load(f) + assert isinstance(data, dict), "Parsed pyproject.toml must be a TOML table" + + def test_project_table_present(self, pyproject: dict) -> None: + """pyproject.toml must contain a [project] table.""" + assert "project" in pyproject, "pyproject.toml is missing a [project] table" + assert isinstance(pyproject["project"], dict), "[project] must be a TOML table" + + +class TestProjectFields: + """Tests for required fields within the [project] table.""" + + @pytest.mark.parametrize("field", _REQUIRED_PROJECT_FIELDS) + def test_required_field_present(self, project: dict, field: str) -> None: + """Each required [project] field must be present.""" + assert field in project, f"[project] is missing required field '{field}'" + + def test_name_is_non_empty_string(self, project: dict) -> None: + """[project].name must be a non-empty string.""" + name = project.get("name", "") + assert isinstance(name, str), "[project].name must be a string" + assert name.strip(), "[project].name must be a non-empty string" + + def test_version_follows_semver(self, project: dict) -> None: + """[project].version must follow semver (MAJOR.MINOR.PATCH).""" + version = project.get("version", "") + assert _SEMVER_RE.match(str(version)), ( + f"[project].version {version!r} does not follow semver (expected MAJOR.MINOR.PATCH)" + ) + + def test_requires_python_is_set(self, project: dict) -> None: + """[project].requires-python must be set to a non-empty constraint.""" + rp = project.get("requires-python", "") + assert isinstance(rp, str), "[project].requires-python must be a string" + assert rp.strip(), "[project].requires-python must be a non-empty version constraint" + + def test_authors_have_names(self, project: dict) -> None: + """[project].authors must contain at least one entry with a non-empty 'name'.""" + authors = project.get("authors", []) + assert isinstance(authors, list), "[project].authors must be a list" + assert len(authors) >= 1, "[project].authors must list at least one author" + named = [a for a in authors if isinstance(a, dict) and a.get("name", "").strip()] + assert len(named) >= 1, "At least one entry in [project].authors must have a non-empty 'name'" + + def test_description_is_non_empty_string(self, project: dict) -> None: + """[project].description must be a non-empty string.""" + desc = project.get("description", "") + assert isinstance(desc, str), "[project].description must be a string" + assert desc.strip(), "[project].description must be a non-empty string" + + +class TestProjectUrls: + """Tests for [project.urls] — Homepage and Repository links.""" + + @pytest.fixture + def urls(self, project: dict) -> dict: + """Return the [project.urls] table.""" + table = project.get("urls") + if not isinstance(table, dict): + pytest.skip("[project.urls] not present") + return table + + def test_urls_table_present(self, project: dict) -> None: + """[project.urls] must be present.""" + assert "urls" in project, "pyproject.toml is missing a [project.urls] table" + + def test_homepage_configured(self, urls: dict) -> None: + """[project.urls] must include a Homepage entry.""" + assert "Homepage" in urls, "[project.urls] is missing a 'Homepage' entry" + assert urls["Homepage"].strip(), "[project.urls] 'Homepage' must be non-empty" + + def test_repository_configured(self, urls: dict) -> None: + """[project.urls] must include a Repository entry.""" + assert "Repository" in urls, "[project.urls] is missing a 'Repository' entry" + assert urls["Repository"].strip(), "[project.urls] 'Repository' must be non-empty" + + +class TestProjectClassifiers: + """Tests for [project].classifiers — Python version entries.""" + + @pytest.fixture + def classifiers(self, project: dict) -> list[str]: + """Return the classifiers list.""" + cl = project.get("classifiers", []) + if not cl: + pytest.skip("No classifiers declared in [project]") + return cl + + def test_python_version_classifier_present(self, classifiers: list[str]) -> None: + """At least one 'Programming Language :: Python :: 3.X' classifier must be present.""" + python_classifiers = [c for c in classifiers if re.match(r"Programming Language :: Python :: 3\.\d+", c)] + assert len(python_classifiers) >= 1, ( + "classifiers must include at least one 'Programming Language :: Python :: 3.X' entry" + ) + + def test_no_license_classifier(self, project: dict) -> None: + """No deprecated 'License :: ' classifier may be present. + + PyPI has deprecated the ``License ::`` trove classifiers in favor of the SPDX + ``license`` expression field, so the shipped pyproject must not declare one. + """ + classifiers = project.get("classifiers", []) + license_classifiers = [c for c in classifiers if c.startswith("License ::")] + assert not license_classifiers, ( + f"classifiers must not include any deprecated 'License :: ' entry; found {license_classifiers}" + ) + + +class TestDependencyGroups: + """Tests for [dependency-groups] — ensures required groups are declared. + + Only ``test`` is required, and only because ``make test`` has to have somewhere to + find pytest. There was a ``test_lint_group_present`` here until #1484, and it is + worth saying why it went: rhiza provisions every linter through prek/uvx, so the + group it demanded had nothing legitimate to hold, and the mother repo satisfied it + with a literal ``lint = []``. A required-group check that the reference + implementation can only pass by declaring an empty list is testing a convention + rather than a working project, so a project may still declare ``lint`` — nothing + reads it. + """ + + @pytest.fixture + def dependency_groups(self, pyproject: dict) -> dict: + """Return the [dependency-groups] table.""" + dg = pyproject.get("dependency-groups") + if not isinstance(dg, dict): + pytest.skip("[dependency-groups] not present") + return dg + + def test_test_group_present(self, dependency_groups: dict) -> None: + """A 'test' dependency group must be declared.""" + assert "test" in dependency_groups, "[dependency-groups] must include a 'test' group" + + def test_test_group_includes_pytest(self, dependency_groups: dict) -> None: + """The 'test' dependency group must include pytest.""" + test_deps = dependency_groups.get("test", []) + assert any("pytest" in str(dep).lower() for dep in test_deps), ( + "[dependency-groups.test] must list pytest as a dependency" + ) + + +class TestBumpversionConfigIsDiscoverable: + """The release flow must find a version config, not silently invent one (#1453). + + bump-my-version searches four filenames and stops. When it finds none it does + **not** fail — it falls back to ``git describe`` and reports the last reachable + tag as the current version. Release tooling then computes bump candidates from + that number rather than the project's, which is how a repo at 0.7.0 with a + newest reachable tag of v0.6.4 gets offered "minor → v0.7.0", a version it has + already published. + + Once a ``[tool.bumpversion]`` table exists in pyproject.toml, bump-my-version + reads and rewrites PEP 621 ``[project].version`` natively, so the minimum + workable config is three lines and duplicates the version string nowhere:: + + [tool.bumpversion] + allow_dirty = false + # /rhiza:release commits and tags itself so the changelog lands in the + # bump commit. + commit = false + tag = false + + Add a ``[[tool.bumpversion.files]]`` entry per *additional* location (a plugin + manifest, a self-referencing CI stub pin) — never for ``[project].version`` + itself. + """ + + @pytest.fixture + def declared_version(self, project: dict) -> str: + """The statically declared project version, or skip when it is dynamic.""" + version = project.get("version") + if not isinstance(version, str): + pytest.skip("[project].version is dynamic — no static location to bump") + return version + + def test_a_discoverable_config_exists(self, root: Path, pyproject: dict, declared_version: str) -> None: + """A bumpversion section must live in a file bump-my-version actually reads.""" + found = [name for name in _DISCOVERABLE_CONFIGS if _has_bumpversion_section(root / name)] + hint = "" + if (root / ".rhiza" / ".cfg.toml").is_file(): + hint = ( + " A leftover .rhiza/.cfg.toml is present: that path is never auto-discovered " + "(it predates the fix for issue #1453) and can be deleted." + ) + assert found, ( + f"pyproject.toml declares version {declared_version!r} but no bumpversion config " + f"was found in any file bump-my-version searches ({', '.join(_DISCOVERABLE_CONFIGS)}). " + f"It will silently fall back to `git describe`, so a release can be cut at a version " + f"that already exists. Add a [tool.bumpversion] table to pyproject.toml.{hint}" + ) + + def test_pyproject_is_the_config_that_wins(self, root: Path, declared_version: str) -> None: + """No earlier-searched file may shadow pyproject.toml's table. + + Search order is significant: a ``.bumpversion.toml`` beats pyproject.toml and + takes ``[project].version`` out of the picture, so the two version numbers can + then drift apart unnoticed. A Python project keeps its version in one place. + """ + shadowing = [ + name for name in _DISCOVERABLE_CONFIGS if name != "pyproject.toml" and _has_bumpversion_section(root / name) + ] + assert not shadowing, ( + f"{shadowing} is searched before pyproject.toml and would shadow its " + f"[tool.bumpversion] table, detaching the bump from [project].version " + f"({declared_version!r})" + ) + + def test_config_does_not_duplicate_the_version(self, pyproject: dict, declared_version: str) -> None: + """``current_version`` is redundant in pyproject.toml, and drifts once stale.""" + section = pyproject.get("tool", {}).get("bumpversion") + if not isinstance(section, dict): + pytest.skip("no [tool.bumpversion] table — reported by test_a_discoverable_config_exists") + declared_in_config = section.get("current_version") + assert declared_in_config in (None, declared_version), ( + f"[tool.bumpversion].current_version is {declared_in_config!r} but " + f"[project].version is {declared_version!r}; bumping from the stale value cannot " + f"match the version in the file. Drop current_version — bump-my-version reads " + f"[project].version natively." + ) + + def test_the_release_flow_owns_the_commit_and_the_tag(self, pyproject: dict) -> None: + """``/rhiza:release`` folds the changelog into the bump commit and tags it itself.""" + section = pyproject.get("tool", {}).get("bumpversion") + if not isinstance(section, dict): + pytest.skip("no [tool.bumpversion] table — reported by test_a_discoverable_config_exists") + for key in ("commit", "tag"): + assert section.get(key, False) is False, ( + f"[tool.bumpversion].{key} must be false: the release flow commits and tags " + f"itself so the changelog lands in the bump commit, and a bare " + f"`bump-my-version bump` would otherwise add a second commit and a duplicate tag" + ) + + +class TestGitTagVersion: + """Tests for harmony between the latest git tag and pyproject.toml version. + + Reachability of that tag is asserted by ``test_release_tags.py``, which ``core`` + ships: the invariant is about git rather than about Python, and all three language + layers need it. + """ + + def test_latest_tag_matches_pyproject_version(self, latest_tag: str, project: dict) -> None: + """The latest git tag (vX.Y.Z) must match [project].version in pyproject.toml.""" + tag_version = str(Version(latest_tag.lstrip("v"))) + pyproject_version = str(Version(project.get("version", ""))) + assert tag_version == pyproject_version, ( + f"Latest git tag {latest_tag!r} (→ {tag_version!r}) does not match " + f"[project].version {pyproject_version!r} in pyproject.toml" + ) diff --git a/.rhiza/tests/test_readme.py b/.rhiza/tests/test_readme.py new file mode 100644 index 0000000..99c07ae --- /dev/null +++ b/.rhiza/tests/test_readme.py @@ -0,0 +1,140 @@ +"""Tests for the README that hold whatever the project is written in. + +This file and its associated tests flow down via a SYNC action from the +jebel-quant/rhiza repository (https://github.com/jebel-quant/rhiza). + +Owned by ``core`` because none of it is language-specific: every synced README documents +its gates in ``bash`` fences — ``make install``, ``make test``, ``make all`` — and a +fence with a syntax error is broken the same way in a Rust, Go or Python project. Before +this split (#1472) all of it lived in the ``tests`` bundle, which requires +``python-core``, so a Rust or Go repo had no README coverage at all. + +The Python-block half stays behind in ``tests`` as ``test_readme_validation.py``: it +executes ``python`` fences and diffs them against a ``result`` block, which only means +something where the project *is* Python. + +Note the split of labour with the fence flags: ``SKIP_FLAG`` and ``_should_skip`` are +duplicated across the two modules rather than shared. Bundles are copied +independently — a Rust project receives this file and not the other — so a shared helper +would need a third home that both bundles ship, which is a worse trade for four lines. +""" + +from __future__ import annotations + +import re +import subprocess # nosec B404 +from pathlib import Path + +import pytest + +# Bash code blocks — captures optional flags (e.g. "+RHIZA_SKIP") and the code body. +BASH_BLOCK = re.compile(r"```bash([^\n]*)\n(.*?)```", re.DOTALL) + +# Bash executable used for syntax checking; `bash -n` parses without executing. +BASH = "bash" + +# Flag marking a fence as intentionally excluded. Usage: add it after the language +# identifier on the opening fence line, e.g. ```bash +RHIZA_SKIP +SKIP_FLAG = "+RHIZA_SKIP" + +# Box-drawing characters mean the fence is a directory tree, not runnable shell. +_TREE_MARKERS = ("├──", "└──", "│") + + +def _should_skip(flags: str) -> bool: + """Return True if the fence flags string contains the +RHIZA_SKIP marker. + + Args: + flags: Text following the language identifier on the opening fence line. + + Returns: + True when the block is intentionally excluded. + """ + return SKIP_FLAG in flags + + +class TestReadmeExists: + """The README has to be there and be readable before anything else applies.""" + + def test_readme_file_exists_at_root(self, root: Path) -> None: + """README.md should exist at repository root.""" + readme = root / "README.md" + assert readme.exists(), "README.md not found at project root" + assert readme.is_file(), "README.md is not a regular file" + + def test_readme_is_readable(self, root: Path) -> None: + """README.md should be readable with UTF-8 encoding and non-empty.""" + content = (root / "README.md").read_text(encoding="utf-8") + assert content.strip(), "README.md is empty" + + +class TestReadmeBashFragments: + """Bash fences must parse, in any language's project. + + Only ``bash -n`` — the blocks are parsed, never executed. A README's shell examples + are usually destructive-adjacent (`make clean`, `git push`) and running them is not + what this is for; a fence that cannot even parse is a documentation bug regardless. + """ + + def test_bash_blocks_basic_syntax(self, root: Path, logger) -> None: + """Every non-skipped bash block should parse under `bash -n`.""" + content = (root / "README.md").read_text(encoding="utf-8") + bash_blocks = BASH_BLOCK.findall(content) + + logger.info("Found %d bash code block(s) in README", len(bash_blocks)) + + for i, (flags, code) in enumerate(bash_blocks): + if _should_skip(flags): + logger.info("Skipping bash block %d (%s flag)", i, SKIP_FLAG) + continue + + if any(marker in code for marker in _TREE_MARKERS): + logger.info("Skipping bash block %d (directory tree representation)", i) + continue + + # A block that is only comments has nothing to parse and no way to be wrong. + lines = [line.strip() for line in code.split("\n") if line.strip()] + if not [line for line in lines if not line.startswith("#")]: + logger.info("Skipping bash block %d (only comments)", i) + continue + + logger.debug("Checking bash block %d:\n%s", i, code) + + result = subprocess.run( # nosec B603 B607 - `bash -n` parses without executing + [BASH, "-n"], + input=code, + capture_output=True, + text=True, + ) + + if result.returncode != 0: + pytest.fail(f"Bash block {i} has syntax errors:\nCode:\n{code}\nError:\n{result.stderr}") + + +class TestSkipFlag: + """Tests for the +RHIZA_SKIP flag that excludes an individual fence.""" + + def test_should_skip_returns_true_for_skip_flag(self) -> None: + """+RHIZA_SKIP in flags string should cause _should_skip to return True.""" + assert _should_skip(" +RHIZA_SKIP") is True + assert _should_skip("+RHIZA_SKIP") is True + assert _should_skip(" +RHIZA_SKIP other-flag") is True + + def test_should_skip_returns_false_without_flag(self) -> None: + """Absence of +RHIZA_SKIP should cause _should_skip to return False.""" + assert _should_skip("") is False + assert _should_skip(" ") is False + assert _should_skip("other-flag") is False + + def test_bash_block_with_skip_flag_is_excluded(self, tmp_path: Path) -> None: + """A ```bash +RHIZA_SKIP block should not be syntax-checked.""" + readme = tmp_path / "README.md" + readme.write_text( + "```bash +RHIZA_SKIP\nnot-valid-bash @@@@\n```\n```bash\necho hello\n```\n", + encoding="utf-8", + ) + all_blocks = BASH_BLOCK.findall(readme.read_text(encoding="utf-8")) + assert len(all_blocks) == 2 + checked = [code for flags, code in all_blocks if not _should_skip(flags)] + assert len(checked) == 1 + assert "not-valid-bash" not in checked[0] diff --git a/.rhiza/tests/test_readme_validation.py b/.rhiza/tests/test_readme_validation.py new file mode 100644 index 0000000..3c935ee --- /dev/null +++ b/.rhiza/tests/test_readme_validation.py @@ -0,0 +1,130 @@ +"""Tests for executable Python examples in the README. + +This file and its associated tests flow down via a SYNC action from the jebel-quant/rhiza repository +(https://github.com/jebel-quant/rhiza). + +This module extracts Python code and expected result blocks from README.md, +executes the code, and verifies the output matches the documented result. + +The language-neutral half — that the README exists, and that its ``bash`` fences +parse — moved to ``core``'s ``test_readme.py`` in #1472, so a Rust or Go project gets it +too. What stays here only means something where the project itself is Python: running a +``python`` fence and diffing it against the following ``result`` block. + +``SKIP_FLAG`` and ``_should_skip`` are duplicated with that module rather than shared. +Bundles are copied independently, so a shared helper would need a third home both bundles +ship — a worse trade for four lines. +""" + +import re +import subprocess # nosec B404 +import sys + +import pytest + +# Regex for Python code blocks — captures optional flags (e.g. "+RHIZA_SKIP") and the code body. +CODE_BLOCK = re.compile(r"```python([^\n]*)\n(.*?)```", re.DOTALL) + +RESULT = re.compile(r"```result\n(.*?)```", re.DOTALL) + +# Flag that marks a code block as intentionally excluded from readme tests. +# Usage: add the flag after the language identifier on the opening fence line, +# e.g. ```python +RHIZA_SKIP or ```bash +RHIZA_SKIP +SKIP_FLAG = "+RHIZA_SKIP" + + +def _should_skip(flags: str) -> bool: + """Return True if the fence flags string contains the +RHIZA_SKIP marker.""" + return SKIP_FLAG in flags + + +def test_readme_runs(logger, root): + """Execute README code blocks and compare output to documented results.""" + readme = root / "README.md" + logger.info("Reading README from %s", readme) + readme_text = readme.read_text(encoding="utf-8") + all_code_blocks = CODE_BLOCK.findall(readme_text) + result_blocks = RESULT.findall(readme_text) + + code_blocks = [] + for i, (flags, code) in enumerate(all_code_blocks): + if _should_skip(flags): + logger.info("Skipping Python code block %d (%s flag)", i, SKIP_FLAG) + else: + code_blocks.append(code) + + logger.info( + "Found %d code block(s) (%d skipped) and %d result block(s) in README", + len(all_code_blocks), + len(all_code_blocks) - len(code_blocks), + len(result_blocks), + ) + + code = "".join(code_blocks) # merged code + expected = "".join(result_blocks) # merged results + + # Trust boundary: we execute Python snippets sourced from README.md in this repo. + # The README is part of the trusted repository content and reviewed in PRs. + logger.debug("Executing README code via %s -c ...", sys.executable) + result = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True, cwd=root) # nosec + + stdout = result.stdout + logger.debug("Execution finished with return code %d", result.returncode) + if result.stderr: + logger.debug("Stderr from README code:\n%s", result.stderr) + logger.debug("Stdout from README code:\n%s", stdout) + + assert result.returncode == 0, f"README code exited with {result.returncode}. Stderr:\n{result.stderr}" + logger.info("README code executed successfully; comparing output to expected result") + assert stdout.strip() == expected.strip() + logger.info("README code output matches expected result") + + +class TestReadmeTestEdgeCases: + """Edge cases for README code block testing.""" + + def test_readme_code_is_syntactically_valid(self, root): + """Python code blocks in README should be syntactically valid (skipped blocks are excluded).""" + readme = root / "README.md" + content = readme.read_text(encoding="utf-8") + all_code_blocks = CODE_BLOCK.findall(content) + + for i, (flags, code) in enumerate(all_code_blocks): + if _should_skip(flags): + continue + try: + compile(code, f"", "exec") + except SyntaxError as e: + pytest.fail(f"Code block {i} has syntax error: {e}") + + +class TestSkipFlag: + """Tests for the +RHIZA_SKIP flag as it applies to Python fences.""" + + def test_should_skip_returns_true_for_skip_flag(self): + """+RHIZA_SKIP in flags string should cause _should_skip to return True.""" + assert _should_skip(" +RHIZA_SKIP") is True + assert _should_skip("+RHIZA_SKIP") is True + assert _should_skip(" +RHIZA_SKIP other-flag") is True + + def test_should_skip_returns_false_without_flag(self): + """Absence of +RHIZA_SKIP should cause _should_skip to return False.""" + assert _should_skip("") is False + assert _should_skip(" ") is False + assert _should_skip("other-flag") is False + + def test_python_block_with_skip_flag_is_excluded(self, tmp_path): + """A ```python +RHIZA_SKIP block should not appear in the list of blocks to execute.""" + readme = tmp_path / "README.md" + readme.write_text( + '```python +RHIZA_SKIP\nraise RuntimeError("should not run")\n```\n' + "```python\nprint('hello')\n```\n" + "```result\nhello\n```\n", + encoding="utf-8", + ) + content = readme.read_text(encoding="utf-8") + all_blocks = CODE_BLOCK.findall(content) + assert len(all_blocks) == 2 + executed = [code for flags, code in all_blocks if not _should_skip(flags)] + assert len(executed) == 1 + assert "raise RuntimeError" not in executed[0] diff --git a/.rhiza/tests/test_release_tags.py b/.rhiza/tests/test_release_tags.py new file mode 100644 index 0000000..5fce672 --- /dev/null +++ b/.rhiza/tests/test_release_tags.py @@ -0,0 +1,63 @@ +"""Tests for the release tags every language layer's version config derives from. + +This file and its associated tests flow down via a SYNC action from the +jebel-quant/rhiza repository (https://github.com/jebel-quant/rhiza). + +Owned by ``core`` because the invariant is about git, not about a language. All three +layers depend on it for the same reason: ``python-core``'s ``[tool.bumpversion]`` table +and the root ``.bumpversion.toml`` that ``rust-core`` and ``go-core`` ship both fall back +to reading the newest tag when they cannot read a version from a file, and git-cliff +places changelog boundaries at tags. An unreachable tag breaks both. +""" + +from __future__ import annotations + +import shutil +import subprocess # nosec B404 +from pathlib import Path + +import pytest + +_GIT = shutil.which("git") or "/usr/bin/git" + + +def test_latest_tag_is_reachable_from_a_branch(latest_tag: str, root: Path) -> None: + """The newest tag must sit on a commit some branch contains (#1454). + + ``git tag --list`` happily reports an orphaned tag, which is how a repo can stay + green while ``git describe`` disagrees with it. A release cut on a branch that is + then squash-merged leaves its tag on the pre-squash commit while the content lands + on the default branch under a new SHA; no branch contains the tagged commit any + more. + + The consequence is not cosmetic. git-cliff cannot place a boundary at an + unreachable tag, so regenerating CHANGELOG.md deletes that version's section and + folds its commits into the next release. Bump tooling reading the version from + ``git describe`` skips the release for the same reason. + """ + if ( + subprocess.run( # nosec B603 + [_GIT, "rev-parse", "--is-shallow-repository"], capture_output=True, text=True, cwd=root + ).stdout.strip() + == "true" + ): + pytest.skip("shallow clone — the commit graph is incomplete") + + commit = subprocess.run( # nosec B603 + [_GIT, "rev-parse", f"{latest_tag}^{{commit}}"], capture_output=True, text=True, cwd=root + ) + if commit.returncode != 0: + pytest.skip(f"tagged commit for {latest_tag} is not present locally") + + contains = subprocess.run( # nosec B603 + [_GIT, "branch", "-a", "--contains", commit.stdout.strip(), "--format=%(refname:short)"], + capture_output=True, + text=True, + cwd=root, + ) + assert contains.stdout.strip(), ( + f"Tag {latest_tag} points at {commit.stdout.strip()[:12]}, which no branch contains. " + f"It is most likely the pre-squash commit of a squash-merged release branch: " + f"`git describe` skips this release and regenerating CHANGELOG.md will delete its " + f"section. Re-tag the merged commit and delete the orphaned tag." + ) diff --git a/Makefile b/Makefile index e0c6738..c328337 100644 --- a/Makefile +++ b/Makefile @@ -1,12 +1,10 @@ ## Makefile (repo-owned) # Keep this file small. It can be edited without breaking template sync. -DEFAULT_AI_MODEL=claude-sonnet-4.6 LOGO_FILE=.rhiza/assets/rhiza-logo.svg -GH_AW_ENGINE ?= copilot # Default AI engine for gh-aw workflows (copilot, claude, or codex) -# Override template default: fix quoting bug and typo (mkdocstring -> mkdocstrings) -MKDOCS_EXTRA_PACKAGES = --with-editable . --with 'mkdocstrings[python]' +# Override template default: include mkdocstrings plugin for API docs +MKDOCS_EXTRA_PACKAGES = --with 'mkdocstrings[python]' # Always include the Rhiza API (template-managed) include .rhiza/rhiza.mk diff --git a/cliff.toml b/cliff.toml new file mode 100644 index 0000000..6b1e880 --- /dev/null +++ b/cliff.toml @@ -0,0 +1,91 @@ +# cliff.toml — git-cliff configuration for CHANGELOG generation. +# +# Synced from the rhiza `core` bundle. Drives `make changelog`, which runs +# `uvx git-cliff --output CHANGELOG.md`. +# +# This template is intentionally forge-agnostic: it does not hard-code an +# owner/repo. Pull-request and issue references like `(#123)` are left intact +# in the generated entries, and both GitHub and GitLab auto-link bare `#123` +# references when rendering Markdown inside a repository. Downstream projects +# that want richer links (full URLs, contributor handles) can enable +# git-cliff's remote integration — see https://git-cliff.org/docs/integration. +# +# Reference: https://git-cliff.org/docs/configuration + +[changelog] +# A markdown header rendered once at the top of the changelog. +header = """ +# Changelog + +All notable changes to this project are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com), +and entries are generated from [Conventional Commits](https://www.conventionalcommits.org). + +""" +# The body is a Tera template rendered once per release. +# https://keats.github.io/tera/docs/#introduction +body = """ +{% if version %}\ + ## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }} +{% else %}\ + ## [Unreleased] +{% endif %}\ +{% for group, commits in commits | group_by(attribute="group") %} + ### {{ group | striptags | trim | upper_first }} + {% for commit in commits %}\ + - {% if commit.scope %}*({{ commit.scope }})* {% endif %}\ + {% if commit.breaking %}[**breaking**] {% endif %}\ + {{ commit.message | split(pat="\n") | first | trim | upper_first }} + {% endfor %}\ +{% endfor %}\n +""" +footer = """ + +""" +# Trim leading/trailing whitespace from the rendered body. +trim = true + +[git] +# Parse commits according to the Conventional Commits spec. +conventional_commits = true +# Keep non-conventional commits too, grouped under "Other Changes". +filter_unconventional = false +# Do not split a commit into multiple entries on newlines. +split_commits = false +# Do not drop commits that fail to match a parser below. +filter_commits = false +# Skip merge commits. +filter_merge_commits = true +# Match release tags (v1.2.3, v0.5.1, ...). +tag_pattern = "v[0-9].*" +# Order commits within a section oldest-first. +sort_commits = "oldest" +# Group commits into changelog sections. The leading HTML comment controls the +# section ordering and is stripped from the rendered heading via `striptags`. +commit_parsers = [ + # Drop automated noise commits that don't provide user-facing signal. + { message = ".*\\[skip ci\\].*", skip = true }, + # Only the release flow's own commits. A bare `bump` alternative here also ate every + # `chore(deps): bump ` — the rhiza-hooks v1.2.0 bump (#1487) vanished from + # v1.3.2's notes that way, and had been vanishing for a while unnoticed: a Dependabot + # subject escapes by the accident of its doubled `(deps)(deps)` scope, and #1482's + # identical bump survived only because it was typed `fix(deps):`. So this names + # `release` and the older `bump version` form explicitly rather than `bump` at large. + { message = "^chore(\\([^)]+\\))?:\\s*release\\b", skip = true }, + { message = "^chore(\\([^)]+\\))?:\\s*bump version\\b", skip = true }, + { message = "^chore:\\s*update changelog\\.md\\b", skip = true }, + { message = "^feat", group = "New Features" }, + { message = "^fix", group = "Bug Fixes" }, + { message = "^docs?", group = "Documentation" }, + { message = "^perf", group = "Performance" }, + { message = "^(build|chore)\\(deps[^)]*\\):", group = "Dependencies" }, + { message = "^refactor", group = "Maintenance" }, + { message = "^style", group = "Maintenance" }, + { message = "^chore", group = "Maintenance" }, + { message = "^build", group = "Maintenance" }, + { message = "^ci", group = "Maintenance" }, + { message = "^test", group = "Maintenance" }, + { message = "^revert", group = "Reverts" }, + { message = ".*", group = "Other Changes" }, +] diff --git a/docs/development/TESTS.md b/docs/development/TESTS.md index f77b134..bbc35fa 100644 --- a/docs/development/TESTS.md +++ b/docs/development/TESTS.md @@ -72,6 +72,15 @@ uv run pytest tests/property/ -v --hypothesis-max-examples=1000 uv run pytest tests/property/ -v --hypothesis-verbosity=verbose ``` +### Opting in to live DEBUG logs + +By default, the template disables live pytest CLI logging (`log_cli = false`) to keep normal test output concise. +When you need detailed live logs for debugging, enable them per-run: + +```bash +uv run pytest -o log_cli=true --log-cli-level=DEBUG +``` + ### Example Tests The following property-based tests are included as examples: @@ -256,7 +265,7 @@ pytest-benchmark>=5.2.3 pygal>=3.1.0 ``` -These are automatically installed when running `make install` or by installing from `.rhiza/requirements/tests.txt`. +These are provisioned on the fly by the relevant `make` targets via `uv run --with …` (e.g. `make test`, `make benchmark`), so no separate install step is required. ## Troubleshooting diff --git a/docs/index.md b/docs/index.md index 0e0b6b4..612c7a5 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,2 +1 @@ --8<-- "README.md" - diff --git a/pytest.ini b/pytest.ini index da5b41e..0dd0ef0 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,7 +1,11 @@ [pytest] testpaths = tests -# Enable live logs on console -log_cli = true +# Make the synced template test-suite importable (test_utils, api/, sync/, ...) +# without each conftest manipulating sys.path at import time. Resolved relative +# to rootdir; harmless when .rhiza/tests is absent. +pythonpath = .rhiza/tests +# Disable live logs on console by default (opt in with: pytest -o log_cli=true --log-cli-level=DEBUG) +log_cli = false # Show DEBUG+ messages log_cli_level = DEBUG log_cli_format = %(asctime)s %(levelname)s %(name)s: %(message)s @@ -9,6 +13,13 @@ log_cli_date_format = %H:%M:%S # Show extra summary info for skipped/failed tests addopts = -ra timeout = 60 +# Treat the class-scoped-instance-method fixture deprecation as an error so it +# cannot silently regress. Matched by message rather than by warning category: +# the category name is version-specific (PytestRemovedIn9Warning vs ...In10Warning) +# and referencing a missing class breaks `--resolution lowest-direct` runs at +# parse time. Third-party DeprecationWarnings stay non-fatal. +filterwarnings = + error:Class-scoped fixture defined as instance method is deprecated # Register custom markers markers = stress: marks tests as stress tests (deselect with '-m "not stress"') diff --git a/ruff.toml b/ruff.toml index 4563178..1678e75 100644 --- a/ruff.toml +++ b/ruff.toml @@ -3,8 +3,7 @@ # # Maximum line length for the entire project line-length = 120 -# Target Python version -target-version = "py311" +# Target Python version is inferred from project.requires-python. # Exclude directories with Jinja template variables in their names exclude = ["**/[{][{]*/", "**/*[}][}]*/"] @@ -67,22 +66,44 @@ select = [ extend-select = [ "D105", # pydocstyle - Require docstrings for magic methods "D107", # pydocstyle - Require docstrings for __init__ + "A", # flake8-builtins - Don't shadow Python builtins + "ANN001", # flake8-annotations - Require function argument annotations + "ANN2", # flake8-annotations - Require function return annotations + "ARG", # flake8-unused-arguments - Unused arguments (tests exempt below: pytest fixtures) "B", # flake8-bugbear - Find likely bugs and design problems + "BLE", # flake8-blind-except - No bare `except Exception` swallowing "C4", # flake8-comprehensions - Better list/set/dict comprehensions + "PIE", # flake8-pie - Miscellaneous lints (duplicate class fields, useless spread, ...) "SIM", # flake8-simplify - Simplify code "PT", # flake8-pytest-style - Check pytest best practices "RUF", # Ruff-specific rules "S", # flake8-bandit - Find security issues - #"ERA", # eradicate - Find commented out code - #"T10", # flake8-debugger - Check for debugger imports and calls "TRY", # flake8-try-except-raise - Try/except/raise checks "ICN", # flake8-import-conventions - Import convention enforcement - #"PIE", # flake8-pie - Miscellaneous rules - #"PL", # Pylint rules ] +# Deliberately NOT enabled — exclusions are decisions, not omissions. +# One-line rationale per family; revisit when the rationale stops holding: +# ERA: templates, notebooks, and shipped configs legitimately carry commented-out example code +# T10: stray breakpoints/pdb imports would fail local pre-commit mid-debugging; review catches leftovers +# PL: Pylint family is large and opinionated (magic values, arg counts) — high noise for a template repo +# FBT: boolean positional flags (e.g. dry_run=True) are idiomatic in our test/make drivers +# COM: trailing-comma layout is owned by `ruff format` +# ISC: implicit string concatenation is handled/conflicted by `ruff format` +# Q: quote style is owned by `ruff format` (double quotes, configured below) +# RET: return-statement micro-style; SIM already covers the valuable simplifications +# EM: literal exception messages are fine at our scale; TRY covers exception-flow issues +# DTZ: no runtime code handling datetimes ships from this repo +# SLF: private-member access is needed in tests; too coarse to enable repo-wide +# TCH/TID: no typing-only import cycles or import-tidiness issues at this size +# RSE: raise micro-style; TRY covers the error-prone patterns +# NPY/PD: no NumPy/pandas runtime code in this repository +# YTT: Python 2020 sys.version checks are irrelevant on py311+ +# PGH: its eval/blanket-ignore checks overlap with the S and RUF rules already enabled + # Resolve incompatible pydocstyle rules: prefer D211 and D212 over D203 and D213 ignore = [ + "ANN401", # dynamically typed *args/**kwargs in framework hooks are intentional "D203", # one-blank-line-before-class (conflicts with D211) "D213", # multi-line-summary-second-line (conflicts with D212) ] @@ -101,21 +122,24 @@ line-ending = "auto" # File-specific rule exceptions [lint.per-file-ignores] -# Test files - allow assert statements and subprocess calls for testing +# All test code, wherever it lives (project suite under tests/, smoke tests under +# .rhiza/tests/, and their bundle copies under bundles/). This glob subsumes the +# project suite; tests/**/*.py below adds project-suite-only allowances on top. "**/tests/**/*.py" = [ - "S101", # Allow assert statements in tests - "S603", # Allow subprocess calls without shell=False check - "S607", # Allow starting processes with partial paths in tests - "PLW1510", # Allow subprocess without explicit check parameter + "ANN", # tests prioritize readability and fixtures over strict annotation coverage + "S101", # assert is the test idiom + "S603", # tests drive git/make/uv via subprocess with fixed argument lists + "S607", # partial executable paths (git, make) are intentional in test drivers + "ARG", # pytest fixtures are requested by name for their side effects, not always read ] +# Project test suite only — allowances the synced .rhiza/tests should not inherit "tests/**/*.py" = [ - "ERA001", # Allow commented out code in project tests - "PLR2004", # Allow magic values in project tests - "RUF002", # Allow ambiguous unicode in project tests - "RUF012", # Allow mutable class attributes in project tests + "RUF002", # docstrings quote prose with typographic unicode (e.g. en dash) + "RUF012", # pytest class attributes are conventionally bare mutables, not ClassVar ] # Marimo notebooks - allow flexible coding patterns for interactive exploration "**/notebooks/*.py" = [ + "ANN", # notebooks prioritize interactive readability over strict annotation coverage "D100", # No module docstring - marimo requires `import marimo` as the first statement "N803", # Allow non-lowercase variable names in notebooks "S101", # Allow assert statements in notebooks @@ -124,8 +148,3 @@ line-ending = "auto" "RUF001", # Allow ambiguous unicode in notebooks "RUF002", # Allow ambiguous unicode in notebooks ] -# Internal utility scripts - specific exceptions for internal tooling -".rhiza/utils/*.py" = [ - "PLW2901", # Allow loop variable overwriting in utility scripts - "TRY003", # Allow long exception messages in utility scripts -] diff --git a/tests/test_rhiza_packaging.py b/tests/test_rhiza_packaging.py new file mode 100644 index 0000000..8c103c4 --- /dev/null +++ b/tests/test_rhiza_packaging.py @@ -0,0 +1,97 @@ +"""The first test a freshly synced Python project has. + +This file flows down via a SYNC action from the jebel-quant/rhiza repository +(https://github.com/jebel-quant/rhiza). + +**Why it exists.** Two reasons, and the second is the one that is easy to lose. + +It checks a real invariant: that the version the project *declares* in +``pyproject.toml`` is the version actually installed into the environment. Those drift +apart more often than anything else on a fresh checkout — an editable install left over +from a rename, a `uv sync` that never ran, a package directory the build backend is not +configured to pick up. Each shows up here as a mismatch rather than as a confusing +ImportError three files later. + +And it is the only test a freshly synced project has. ``make test`` searches +``TESTS_FOLDER`` for ``test_*.py``/``*_test.py``, and finding none it prints a warning +and **exits 0** — so a new repo passed ``make test``, and therefore ``make all``, while +measuring nothing (#1476). That was the third instance of one pattern: Go had it until +``go-core`` shipped ``internal/version/version_test.go`` (#1467), and ``rhiza-test`` had +it until the ``.rhiza/tests`` suite was actually delivered to every layer (#1469). Rust +never did, because ``cargo init --lib`` leaves an ``it_works`` test behind. + +Writing your own tests alongside this is the point. Deleting it and shipping nothing +else puts the vacuum back. + +**Why not simply fail when no tests exist?** Because that turns ``make all`` red on the +output of ``/rhiza:init``, before the author has written a line. The warning branch is +deliberate; what it needed was something to find. + +Deliberately self-contained: it uses no fixtures, because this lives in *your* ``tests/`` +directory and must not depend on the ``conftest.py`` that ships with ``.rhiza/tests``. +""" + +from __future__ import annotations + +import tomllib +from importlib.metadata import PackageNotFoundError +from importlib.metadata import version as installed_version +from pathlib import Path + +import pytest + +# tests/ sits at the project root, so the parent of this file's directory is the root. +# +# `.absolute()`, never `.resolve()`. In a synced project the difference is nil, but in +# rhiza's own repository this file is a *symlink* into `bundles/python-core/tests/`, and +# `.resolve()` follows it — making the "project root" come out as `bundles/python-core`, +# which has no pyproject.toml. The suite then skips for a plausible-looking wrong reason +# instead of running. `.rhiza/tests/conftest.py` avoids the same trap the same way. +_ROOT = Path(__file__).absolute().parent.parent + + +def _project_table() -> dict: + """Return the ``[project]`` table from pyproject.toml, skipping when unusable. + + Returns: + The parsed ``[project]`` table. + """ + pyproject = _ROOT / "pyproject.toml" + if not pyproject.is_file(): + pytest.skip("no pyproject.toml at the project root") + with pyproject.open("rb") as handle: + table = tomllib.load(handle).get("project") + if not isinstance(table, dict): + pytest.skip("pyproject.toml declares no [project] table") + return table + + +def test_the_installed_version_matches_pyproject() -> None: + """The distribution installed in the environment must match the declared version. + + Skips rather than fails when the project is not installed as a distribution at all — + a ``uv`` *virtual* project (no ``[build-system]``, ``source = {{ virtual = "." }}`` in + the lockfile) has no metadata to read, and rhiza's own repository is one. That is a + real configuration, not a broken one, so it is not this test's business. + """ + project = _project_table() + + name = project.get("name") + if not isinstance(name, str) or not name.strip(): + pytest.skip("[project].name is missing or not a plain string") + + declared = project.get("version") + if not isinstance(declared, str): + pytest.skip("[project].version is dynamic — there is no static value to compare") + + try: + found = installed_version(name) + except PackageNotFoundError: + pytest.skip(f"{name!r} is not installed as a distribution (a virtual project has no metadata)") + + assert found == declared, ( + f"pyproject.toml declares version {declared!r} but the installed {name!r} reports " + f"{found!r}. The environment is stale or the build backend is picking up a different " + f"tree — re-run `make install`, and check [build-system] and the packages it is told " + f"to include if that does not fix it." + )