From e015c7f61eb224faf58e52af610736652a8a9445 Mon Sep 17 00:00:00 2001 From: Thomas Schmelzer Date: Tue, 29 Sep 2026 09:18:33 +0400 Subject: [PATCH 1/5] chore: bump rhiza to v1.9.0 Co-Authored-By: Claude Opus 5.5 --- .rhiza/template.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.rhiza/template.yml b/.rhiza/template.yml index 798cfc9..52f86dc 100644 --- a/.rhiza/template.yml +++ b/.rhiza/template.yml @@ -1,5 +1,5 @@ repository: "jebel-quant/rhiza" -ref: "v0.18.8" +ref: "v1.9.0" profiles: - github-project From a6247c46e7f5b9b4e9d3b257fed51eb38993fd78 Mon Sep 17 00:00:00 2001 From: Thomas Schmelzer Date: Tue, 29 Sep 2026 09:19:26 +0400 Subject: [PATCH 2/5] chore: apply rhiza sync v1.9.0 Co-Authored-By: Claude Opus 5.5 --- .bandit | 17 ++ .github/CONFIG.md | 17 ++ .github/secret_scanning.yml | 1 - .github/workflows/rhiza_benchmark.yml | 9 +- .github/workflows/rhiza_book.yml | 27 ++- .github/workflows/rhiza_ci.yml | 14 +- .github/workflows/rhiza_codeql.yml | 16 +- .github/workflows/rhiza_marimo.yml | 9 +- .github/workflows/rhiza_release.yml | 314 +++++++++++++++++++++----- .github/workflows/rhiza_scorecard.yml | 44 ++++ .github/workflows/rhiza_weekly.yml | 9 +- .gitignore | 29 +-- .pre-commit-config.yaml | 64 +++++- .rhiza/template.lock | 73 +----- Makefile | 76 ++++++- cliff.toml | 15 +- docs/development/rhiza.md | 23 ++ docs/mkdocs-base.yml | 43 ++-- pytest.ini | 8 +- ruff.toml | 62 +++-- tests/test_rhiza_packaging.py | 8 +- 21 files changed, 665 insertions(+), 213 deletions(-) create mode 100644 .github/workflows/rhiza_scorecard.yml create mode 100644 docs/development/rhiza.md diff --git a/.bandit b/.bandit index a1be520..3f48085 100644 --- a/.bandit +++ b/.bandit @@ -1,2 +1,19 @@ +# Bandit configuration. This file — not the pre-commit hook's args — is the +# single source of truth for bandit's scope, because it is the only part any +# other runner can see. CodeFactor, IDE plugins and a contributor typing +# `bandit -r .` all read `.bandit` and none of them read our hook args, so +# scope kept in the args made every external analyser disagree with CI (#1493). +# +# Both spellings of each path are listed deliberately. Bandit matches an +# exclude entry against the path string it is handed, and that string depends on +# how it was invoked: a recursive `bandit -r .` discovers `./tests/foo.py`, +# whereas pre-commit passes `tests/foo.py`. So `./tests` alone silently covers +# only the recursive case and `tests` alone only the pre-commit case — a +# one-spelling list looks correct and half-works. Verified in +# tests/security/test_security_patterns.py, which runs bandit both ways. +# +# Note these are *added* to bandit's own defaults (.git, __pycache__, .tox, +# .eggs, …), so those need no repeating here. [bandit] +exclude = tests,./tests,.venv,./.venv skips = B101 diff --git a/.github/CONFIG.md b/.github/CONFIG.md index b7db081..176a233 100644 --- a/.github/CONFIG.md +++ b/.github/CONFIG.md @@ -61,3 +61,20 @@ optional depending on which release features you use: | `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. + +`GH_PAT` and `UV_EXTRA_INDEX_URL` are read by the CI, benchmark, CodeQL, marimo, book, +weekly and docker workflows too. The stubs that call those reusable workflows forward each secret by +name rather than with `secrets: inherit`, because GitHub only honours `inherit` when the +caller sits in the same organisation or enterprise as `jebel-quant/rhiza` — from any other +organisation the secrets simply never arrived, and private dependencies failed to install +with `could not read Password for 'https://***@github.com'` (#1689). A secret that is not +defined is forwarded empty and the workflow falls back to `github.token`, so nothing is +required for a project with no private dependencies. Pull requests from forks never receive +secrets at all, so a fork PR that needs a private dependency fails at install; that is +GitHub's rule rather than a rhiza setting. + +The docker workflow is the one place the runner's git configuration cannot reach, because +`uv sync` runs inside the image build. It passes both secrets to `docker buildx build` as +BuildKit secrets instead, which exist only for that one instruction and are written into no +layer; a build argument would be readable with `docker history` (#1691). See +`docs/development/DOCKER.md` for building such an image locally. diff --git a/.github/secret_scanning.yml b/.github/secret_scanning.yml index fdfd809..7fb3042 100644 --- a/.github/secret_scanning.yml +++ b/.github/secret_scanning.yml @@ -13,7 +13,6 @@ paths-ignore: # Ignore test fixtures that may contain example/fake secrets - - ".rhiza/tests/**" - "tests/**" # Ignore documentation that references example tokens/keys - "docs/**/*.md" diff --git a/.github/workflows/rhiza_benchmark.yml b/.github/workflows/rhiza_benchmark.yml index 40ef5fb..f67c63d 100644 --- a/.github/workflows/rhiza_benchmark.yml +++ b/.github/workflows/rhiza_benchmark.yml @@ -20,5 +20,10 @@ on: jobs: benchmark: - uses: jebel-quant/rhiza/.github/workflows/rhiza_benchmark.yml@v0.19.9 - secrets: inherit + uses: jebel-quant/rhiza/.github/workflows/rhiza_benchmark.yml@v1.9.0 + # Forwarded explicitly: `secrets: inherit` only reaches a reusable workflow in the + # caller's own organisation or enterprise (#1689). A secret this repository has not + # defined arrives empty and the workflow falls back to `github.token`. + secrets: + GH_PAT: ${{ secrets.GH_PAT }} + UV_EXTRA_INDEX_URL: ${{ secrets.UV_EXTRA_INDEX_URL }} diff --git a/.github/workflows/rhiza_book.yml b/.github/workflows/rhiza_book.yml index e31dac8..afb31e4 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,14 +22,28 @@ 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 - secrets: inherit + uses: jebel-quant/rhiza/.github/workflows/rhiza_book.yml@v1.9.0 + # Forwarded explicitly: `secrets: inherit` only reaches a reusable workflow in the + # caller's own organisation or enterprise (#1689). A secret this repository has not + # defined arrives empty and the workflow falls back to `github.token`. + secrets: + GH_PAT: ${{ secrets.GH_PAT }} + UV_EXTRA_INDEX_URL: ${{ secrets.UV_EXTRA_INDEX_URL }} permissions: contents: read pages: write id-token: write + # Set `deploy-pages: false` for artifact-only mode -- the reusable workflow + # still uploads the generic `book` artifact, and a consumer-owned job below + # can download it and deploy to Cloudflare Pages, Azure Static Web Apps, + # S3/CloudFront, an internal web server, etc. See docs/guides/BOOK.md for a + # full Cloudflare Pages example. + # with: + # deploy-pages: false diff --git a/.github/workflows/rhiza_ci.yml b/.github/workflows/rhiza_ci.yml index a54003d..4633344 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,10 @@ on: jobs: ci: - uses: jebel-quant/rhiza/.github/workflows/rhiza_ci.yml@v0.19.9 - secrets: inherit + uses: jebel-quant/rhiza/.github/workflows/rhiza_ci.yml@v1.9.0 + # Forwarded explicitly: `secrets: inherit` only reaches a reusable workflow in the + # caller's own organisation or enterprise (#1689). A secret this repository has not + # defined arrives empty and the workflow falls back to `github.token`. + secrets: + GH_PAT: ${{ secrets.GH_PAT }} + UV_EXTRA_INDEX_URL: ${{ secrets.UV_EXTRA_INDEX_URL }} diff --git a/.github/workflows/rhiza_codeql.yml b/.github/workflows/rhiza_codeql.yml index 56b5bdd..11c7ce7 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,14 @@ on: jobs: codeql: - uses: jebel-quant/rhiza/.github/workflows/rhiza_codeql.yml@v0.19.9 - secrets: inherit + uses: jebel-quant/rhiza/.github/workflows/rhiza_codeql.yml@v1.9.0 + # Forwarded explicitly: `secrets: inherit` only reaches a reusable workflow in the + # caller's own organisation or enterprise (#1689). A secret this repository has not + # defined arrives empty and the workflow falls back to `github.token`. + secrets: + GH_PAT: ${{ secrets.GH_PAT }} + permissions: + security-events: write # Upload CodeQL results to code scanning + packages: read + actions: read + contents: read diff --git a/.github/workflows/rhiza_marimo.yml b/.github/workflows/rhiza_marimo.yml index 4fb4866..29513aa 100644 --- a/.github/workflows/rhiza_marimo.yml +++ b/.github/workflows/rhiza_marimo.yml @@ -28,5 +28,10 @@ on: jobs: marimo: - uses: jebel-quant/rhiza/.github/workflows/rhiza_marimo.yml@v0.19.9 - secrets: inherit + uses: jebel-quant/rhiza/.github/workflows/rhiza_marimo.yml@v1.9.0 + # Forwarded explicitly: `secrets: inherit` only reaches a reusable workflow in the + # caller's own organisation or enterprise (#1689). A secret this repository has not + # defined arrives empty and the workflow falls back to `github.token`. + secrets: + GH_PAT: ${{ secrets.GH_PAT }} + UV_EXTRA_INDEX_URL: ${{ secrets.UV_EXTRA_INDEX_URL }} diff --git a/.github/workflows/rhiza_release.yml b/.github/workflows/rhiza_release.yml index 0af5877..6614faa 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) @@ -63,7 +67,10 @@ # - SBOM attestations generated for supply chain transparency (public repos only) # # 📄 Requirements: -# - pyproject.toml with top-level version field (for Python packages) +# - pyproject.toml declaring a version (for Python packages): either written as +# `[project].version`, or listed in `[project].dynamic` for the build backend to +# derive from the VCS (hatch-vcs, setuptools-scm). A derived version is checked +# against the tag on the built distribution rather than in the file. # - Package registered on PyPI as Trusted Publisher (for PyPI publishing) # - Conda recipe generation is optional and only runs when PyPI publishing is active # - PUBLISH_DEVCONTAINER variable set to "true" (for devcontainer publishing) @@ -102,16 +109,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 +159,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 +251,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,22 +267,53 @@ 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 }} - name: Verify version matches tag if: hashFiles('pyproject.toml') != '' + env: + # Read from the environment, not interpolated into the script: that is what + # lets tests/api/test_release_version_verification.py run this exact shell + # against purpose-built projects instead of asserting on the step's name. + TAG: ${{ needs.tag.outputs.tag }} run: | - TAG_VERSION="${{ needs.tag.outputs.tag }}" - TAG_VERSION=${TAG_VERSION#v} - PROJECT_VERSION=$(uv version --short) + TAG_VERSION="${TAG#v}" # Normalize tag version to PEP 440 format for comparison. # Tags use semver format (e.g., 0.11.1-beta.1) while uv version --short # returns PEP 440 normalized format (e.g., 0.11.1b1). NORMALIZED_TAG=$(uv run --with packaging --no-project python3 -c "from packaging.version import Version; print(Version('$TAG_VERSION'))") + # A project may derive its version from the VCS rather than write one: PEP 621's + # `dynamic = ["version"]` with a backend plugin such as hatch-vcs. There is then + # no number in the file to compare, and `uv version --short` does not return a + # differing one -- it exits 2 ("We cannot get or set dynamic project versions"), + # which would fail every release of such a project. The tag-versus-version check + # moves to "Verify built distribution matches the tag" below, which is where a + # derived version can actually be wrong. + DYNAMIC=$(uv run --with tomli --no-project python3 -c " + import pathlib, tomli + project = tomli.loads(pathlib.Path('pyproject.toml').read_text()).get('project') or {} + print('dynamic' if 'version' in (project.get('dynamic') or []) else 'written')") + + case "$DYNAMIC" in + dynamic) + echo "[project].version is dynamic: derived from the VCS, so there is no written number to compare against $NORMALIZED_TAG. The distribution built below is checked against the tag instead." + exit 0 + ;; + written) + ;; + *) + # Never assume "written": that would call `uv version --short` on a dynamic + # project and fail with an error about the probe, not about the release. + echo "::error::Could not tell whether [project].version is written or dynamic (probe returned '$DYNAMIC')" + exit 1 + ;; + esac + + PROJECT_VERSION=$(uv version --short) if [[ "$PROJECT_VERSION" != "$NORMALIZED_TAG" ]]; then echo "::error::Version mismatch: pyproject.toml has '$PROJECT_VERSION' but tag is '$NORMALIZED_TAG' (from tag '$TAG_VERSION')" exit 1 @@ -244,6 +335,52 @@ jobs: printf "[INFO] Building package...\n" uv build + - name: Verify built distribution matches the tag + if: steps.buildable.outputs.buildable == 'true' + env: + TAG: ${{ needs.tag.outputs.tag }} + run: | + # The only place a version *derived* from the VCS can be caught being wrong. + # hatch-vcs and setuptools-scm fall back rather than fail, so a checkout that + # cannot see the tag builds `0.1.dev1+g` and publishes it under a green + # tick with no error anywhere -- which is why the checkout above uses + # fetch-depth: 0. Runs for a written version too: it costs one filename parse + # and closes the gap between what the step above read and what `uv build` + # actually produced. + TAG_VERSION="${TAG#v}" + uv run --with packaging --no-project python3 -c ' + import pathlib + import sys + + from packaging.utils import parse_sdist_filename, parse_wheel_filename + from packaging.version import Version + + expected = Version(sys.argv[1]) + dist = pathlib.Path("dist") + built = sorted(dist.glob("*.whl")) + sorted(dist.glob("*.tar.gz")) + if not built: + print("::error::uv build produced no distribution in dist/ -- there is nothing to publish or verify") + sys.exit(1) + + mismatched = [] + for artifact in built: + parse = parse_wheel_filename if artifact.name.endswith(".whl") else parse_sdist_filename + found = parse(artifact.name)[1] + print(f"{artifact.name}: {found}") + if found != expected: + mismatched.append(f"{artifact.name} carries {found}") + + if mismatched: + print( + f"::error::Built distribution does not carry the release version {expected}: " + + ", ".join(mismatched) + + ". A version derived from the VCS falls back to a dev version when the tag " + "is not visible to the build -- check the checkout depth and that tags were fetched." + ) + sys.exit(1) + print(f"Built distribution verified at {expected}") + ' "$TAG_VERSION" + - name: Install Python for SBOM generation if: hashFiles('pyproject.toml') != '' uses: actions/setup-python@v6.3.0 @@ -270,12 +407,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.2 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 +431,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 +459,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 +482,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 +499,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 +507,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 +542,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 +565,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 }} @@ -446,7 +597,11 @@ jobs: - name: Install grayskull if: steps.check_conda.outputs.should_generate == 'true' - run: python -m pip install --upgrade grayskull + # Pinned: grayskull 3.2 changed its default output from meta.yaml to a V1 + # recipe.yaml that needs conda-recipe-manager, so an unpinned install + # silently broke this job (#1701). Bump deliberately, together with the + # flags in the next step. + run: python -m pip install "grayskull==3.2.0" - name: Generate conda recipe with grayskull if: steps.check_conda.outputs.should_generate == 'true' @@ -455,13 +610,40 @@ jobs: mkdir -p /tmp/conda-recipe cd /tmp/conda-recipe - grayskull pypi "$PACKAGE_NAME" --strict-conda-forge - - RECIPE_PATH=$(find . -type f -path "*/meta.yaml" | head -n 1) - if [[ -z "$RECIPE_PATH" ]]; then - echo "::error::grayskull did not produce a meta.yaml file" - exit 1 - fi + # 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. + # + # Success is judged by the recipe on disk, not by grayskull's exit + # status: `grayskull pypi` prints "Package seems to be missing" on a + # PyPI 404 and still exits 0, so an exit-code loop breaks on the very + # first attempt and never retries (#1701). + # + # A non-zero exit is the opposite case: a real failure (a crash, a + # missing dependency, a bad flag) that no amount of waiting fixes, so + # fail at once instead of spending the retries on it. + # + # --no-use-v1-format keeps the meta.yaml this job ships. grayskull >= 3.2 + # otherwise writes a V1 recipe.yaml and crashes without conda-recipe-manager. + MAX_ATTEMPTS=5 + for attempt in $(seq 1 "$MAX_ATTEMPTS"); do + status=0 + grayskull pypi "$PACKAGE_NAME" --strict-conda-forge --no-use-v1-format || status=$? + if [[ "$status" -ne 0 ]]; then + echo "::error::grayskull exited $status on attempt $attempt — a real failure, not a PyPI propagation delay, so not retrying" + exit 1 + fi + RECIPE_PATH=$(find . -type f -path "*/meta.yaml" | head -n 1) + if [[ -n "$RECIPE_PATH" ]]; then + break + fi + if [[ "$attempt" -eq "$MAX_ATTEMPTS" ]]; then + echo "::error::grayskull produced no meta.yaml after $MAX_ATTEMPTS attempts — PyPI metadata for $PACKAGE_NAME may not be available yet" + exit 1 + fi + echo "grayskull attempt $attempt/$MAX_ATTEMPTS produced no recipe — waiting 60s for PyPI metadata to propagate" + sleep 60 + done mkdir -p "$GITHUB_WORKSPACE/conda-recipe" cp "$RECIPE_PATH" "$GITHUB_WORKSPACE/conda-recipe/meta.yaml" @@ -478,6 +660,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 +739,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 @@ -565,7 +750,26 @@ jobs: name: Finalise Release runs-on: ubuntu-latest needs: [tag, pypi, conda, devcontainer] - if: needs.pypi.result == 'success' || needs.conda.result == 'success' || needs.devcontainer.result == 'success' + # `!cancelled() &&` is what makes the rest of this condition mean anything (#1537). + # GitHub combines a job's own `if` with an implicit `success()` over every entry in + # `needs` *unless* the expression names a status function -- so the OR below was + # evaluated only in runs where conda had already succeeded, and a conda failure skipped + # this job however true the OR was. That is not theoretical: releasing jointview v0.2.0, + # PyPI published successfully, grayskull 404'd on metadata that had not propagated, and + # the GitHub release was stranded as the draft `untagged-547fe31ec1ed6de3ef9b` holding + # SBOM and provenance assets that never became visible. + # + # `!cancelled()` rather than `always()`, deliberately: `always()` would finalise a + # release during a run somebody had cancelled, which is the one case where publishing is + # certainly wrong. A cancelled run still skips this job. + # + # The OR is unchanged, so the failure modes it encodes are too: if every publishing job + # fails or is skipped there is nothing to finalise, and this job skips. Recipe generation + # is the only one of the three that is a downstream convenience -- the workflow header + # has always called it optional, and now the failure path agrees. + if: ${{ !cancelled() && (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..2e85a56 --- /dev/null +++ b/.github/workflows/rhiza_scorecard.yml @@ -0,0 +1,44 @@ +# 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.9.0 + 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..8398434 100644 --- a/.github/workflows/rhiza_weekly.yml +++ b/.github/workflows/rhiza_weekly.yml @@ -28,5 +28,10 @@ on: jobs: weekly: - uses: jebel-quant/rhiza/.github/workflows/rhiza_weekly.yml@v0.19.9 - secrets: inherit + uses: jebel-quant/rhiza/.github/workflows/rhiza_weekly.yml@v1.9.0 + # Forwarded explicitly: `secrets: inherit` only reaches a reusable workflow in the + # caller's own organisation or enterprise (#1689). A secret this repository has not + # defined arrives empty and the workflow falls back to `github.token`. + secrets: + GH_PAT: ${{ secrets.GH_PAT }} + UV_EXTRA_INDEX_URL: ${{ secrets.UV_EXTRA_INDEX_URL }} diff --git a/.gitignore b/.gitignore index 91262f7..d1a4c4e 100644 --- a/.gitignore +++ b/.gitignore @@ -28,7 +28,6 @@ _pdoc docs/notebooks/*.html docs/reports docs/reports.md -_marimushka _mkdocs _benchmarks _jupyter @@ -41,6 +40,9 @@ docs/paper/*.fls docs/paper/*.log docs/paper/*.out docs/paper/*.toc +docs/paper/*.pdf +docs/paper/*.bbl +docs/paper/*.blg # temp file used by Junie .output.txt @@ -115,30 +117,23 @@ cover/ *.mo *.pot -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - # Cython debug symbols cython_debug/ -# Makefile -local.mk +# Makefile -- `local.mk` is deliberately not ignored. The Makefile is template-owned and +# overwritten by every sync, so `local.mk` is the only place a repo's own make targets can +# live, and anything CI invokes has to be committed. rhiza's own `make e2e`, which +# rhiza_e2e.yml runs in all three language jobs, is exactly that case. +# +# `local-setup.sh` is not ignored either, and for the same reason one step further out: it +# is the hook every layer's `install` runs to provision a native binary the project needs +# (graphviz, libpq, pandoc), so CI invokes it on every job. A gitignored provisioning script +# is one that works on its author's machine and nowhere else. .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 - -# graft's local graph cache — regenerable, not committed (run `graft build`). -graft/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 56689c7..349ba41 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,14 +1,22 @@ # 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 hooks: - id: check-toml + groups: ["fast"] - id: check-yaml args: ['--unsafe'] exclude: ^recipe/meta\.yaml$ + groups: ["fast"] - repo: local hooks: @@ -17,57 +25,75 @@ repos: language: fail entry: Python cache files (__pycache__, .pyc, .pyo, .pyd) must not be committed. Run 'make clean' to remove them. files: '(/__pycache__/|\.py[cod]$)' + groups: ["python", "fast"] - id: no-rej-files name: Reject .rej files entry: "bash -c 'find . -type f -name \"*.rej\" | grep -q . && { echo \"ERROR: .rej files detected\"; find . -type f -name \"*.rej\"; exit 1; } || exit 0'" language: system pass_filenames: false + groups: ["fast"] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: 'v0.15.14' + rev: 'v0.16.6' hooks: - id: ruff args: [ --fix, --exit-non-zero-on-fix, --unsafe-fixes ] + groups: ["python", "lint", "fast"] # Run the formatter - id: ruff-format + groups: ["python", "format", "fast"] - repo: https://github.com/igorshubovych/markdownlint-cli - rev: v0.48.0 + rev: v0.49.1 hooks: - id: markdownlint args: ["--disable", "MD013"] + groups: ["lint", "fast"] - repo: https://github.com/python-jsonschema/check-jsonschema - rev: 0.37.2 + rev: 0.38.0 hooks: - id: check-renovate args: [ "--verbose" ] + groups: ["fast"] - id: check-github-workflows args: ["--verbose"] + groups: ["fast"] - repo: https://github.com/rhysd/actionlint rev: v1.7.12 hooks: - id: actionlint + groups: ["lint", "fast"] - repo: https://github.com/abravalheri/validate-pyproject - rev: v0.25 + rev: "0.26" hooks: - id: validate-pyproject + groups: ["python", "fast"] - repo: https://github.com/PyCQA/bandit rev: 1.9.4 hooks: - id: bandit - args: ["--ini", ".bandit", "--exclude", ".venv,tests,.rhiza/tests,.git,.pytest_cache"] + # Scope deliberately lives in .bandit, not here — see that file (#1493). + args: ["--ini", ".bandit"] + groups: ["python", "security", "slow"] + + - repo: https://github.com/betterleaks/betterleaks + rev: v1.8.1 + hooks: + - id: betterleaks + groups: ["security", "slow"] - repo: https://github.com/astral-sh/uv-pre-commit - rev: 0.11.16 + rev: 0.12.10 hooks: - id: uv-lock + groups: ["python", "fast"] - repo: https://github.com/econchick/interrogate rev: 1.7.0 @@ -75,15 +101,39 @@ repos: - id: interrogate args: [--config=pyproject.toml] files: ^src/ + groups: ["python", "lint", "slow"] - repo: https://github.com/Jebel-Quant/rhiza-hooks - rev: v0.4.0 # Use the latest release + rev: v1.3.0 # Use the latest release hooks: # Migrated from rhiza - id: check-rhiza-workflow-names + groups: ["fast"] - id: update-readme-help + groups: ["fast"] # Additional utility hooks - id: check-rhiza-config + groups: ["fast"] + # check-managed-files refuses a commit touching any path in .rhiza/template.lock's + # `files:` list (minus `exclude:` in template.yml), enforcing the rule every managed + # repo's CLAUDE.md opens with. Only paths differing from HEAD are reported, so + # `make fmt` and CI stay green on a clean tree under --all-files. Its adoption was + # gated on the sync commit gaining `SKIP=check-managed-files` -- see CLAUDE.md's + # "Enforcing template ownership" for why that order is not optional. + - id: check-managed-files + groups: ["fast"] - id: check-makefile-targets + groups: ["fast"] - id: check-python-version-consistency + groups: ["python", "fast"] + # 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 pytest-rhiza's test_pyproject check — + # which this layer names in RHIZA_CHECKS — 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/template.lock b/.rhiza/template.lock index fb2a01f..3215f27 100644 --- a/.rhiza/template.lock +++ b/.rhiza/template.lock @@ -1,19 +1,20 @@ -sha: ae79c6f0729b21239655abe390269be9171f907d +sha: 77a450079014daba000d42908b6e8419d1e97135 repo: jebel-quant/rhiza host: github -ref: v0.18.8 +ref: v1.9.0 include: [] exclude: [] templates: [] +profiles: +- github-project files: - .bandit - .editorconfig -- .github/DISCUSSION_TEMPLATE/q-and-a.yml -- .github/ISSUE_TEMPLATE/bug_report.yml -- .github/ISSUE_TEMPLATE/feature_request.yml +- .github/CONFIG.md - .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 @@ -21,69 +22,19 @@ files: - .github/workflows/rhiza_codeql.yml - .github/workflows/rhiza_marimo.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/custom-env.mk -- .rhiza/make.d/custom-task.mk -- .rhiza/make.d/doctor.mk -- .rhiza/make.d/marimo.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 - Makefile -- docs/assets/rhiza-logo.svg -- docs/development/MARIMO.md -- docs/development/TESTS.md +- cliff.toml +- docs/development/rhiza.md - docs/index.md - 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-09-29T05:18:38Z' strategy: merge diff --git a/Makefile b/Makefile index e0c6738..052baca 100644 --- a/Makefile +++ b/Makefile @@ -1,15 +1,71 @@ -## Makefile (repo-owned) -# Keep this file small. It can be edited without breaking template sync. +## Makefile (template-owned) -- synced from rhiza's `core` bundle. Edit it there. +# +# A compatibility shim, not the documented interface -- that is `uv run rhiza-task `. +# It exists for workflows pinned at @v1.3.3 and earlier that still call `make test`, and for +# repos with no Python project to hold the pin. +# +# `uvx rhiza-task shim` used to print this file and each repo owned the copy it printed. +# That put a *template* inside the task runner: the CLI had to know about `local.mk`, the +# `##` help convention and the ./bin/uvx bootstrap, and rhiza then hand-maintained a variant +# of the generator's output anyway. The worse half was the pin below -- the shim wrote the +# version of whichever CLI happened to print it, so moving a repo's gates forward was a +# per-repo hand edit `/rhiza:update` could not make, and every consumer silently lagged. +# +# The template owns the front door instead, the way it owns every other config file, and +# `RHIZA_TASK` travels with the sync -- the property `RHIZA_CHECKS_VERSION` already had: a +# repo synced at a tag runs that tag's gates. +# +# Repo-specific *tasks* go in a `rhiza_task.tasks` entry point, repo-specific *targets* in +# `local.mk`, which core deliberately does not ignore. Nothing goes below the shim: this +# file is synced, so the next `/rhiza:update` overwrites whatever was appended to it. +RHIZA_TASK ?= rhiza-task@1.7.0 -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) +# uv cannot be delegated, because uv is what runs the CLI. Prepended so a machine carrying +# an older uv still resolves the pin, exported because task bodies shell out to bare `uv`. +INSTALL_DIR ?= $(abspath ./bin) +UVX ?= $(shell command -v uvx 2>/dev/null || echo $(INSTALL_DIR)/uvx) +export PATH := $(INSTALL_DIR):$(PATH) -# Override template default: fix quoting bug and typo (mkdocstring -> mkdocstrings) -MKDOCS_EXTRA_PACKAGES = --with-editable . --with 'mkdocstrings[python]' +# `UV` too, for `local.mk` to reach: the astral installer writes both binaries into the +# same directory, so once $(UVX) exists this does, and the empty recipe both satisfies +# make's remake attempt and keeps the catch-all from forwarding the path as a task name. +UV ?= $(shell command -v uv 2>/dev/null || echo $(INSTALL_DIR)/uv) +$(UV): $(UVX) ; -# Always include the Rhiza API (template-managed) -include .rhiza/rhiza.mk +.DEFAULT_GOAL := help -# Optional: developer-local extensions (not committed) +.PHONY: help + +# `rhiza-task list` cannot know about the targets `local.mk` adds, so anything there with +# a `##` comment is listed under them. This is what lets a repo move its own targets out +# of this file without losing them from `make help`. +help: $(UVX) + @$(UVX) $(RHIZA_TASK) list + @own=$$(grep -hE '^[a-zA-Z0-9_-]+:.*##' $(MAKEFILE_LIST) | sed -e 's/:.*##/ -- /' -e 's/^/ /'); \ + [ -z "$$own" ] || printf '\nRepo-owned targets:\n%s\n' "$$own" + +# Every task, and every typo -- the CLI's "unknown task" error is the backstop. `FORCE` is +# what keeps them phony: .PHONY takes no patterns, but a phony prerequisite is never up to +# date, so `make book` next to a `book/` directory still runs. Recursive `=` because `$@` +# only has a value while make is running the rule. +RHIZA_TASK_GOAL = $@ + +%: $(UVX) FORCE + @$(UVX) $(RHIZA_TASK) $(RHIZA_TASK_GOAL) + +# A file target, so make's up-to-date check is the idempotence. `$(UVX)` and not +# `$(INSTALL_DIR)/uvx`, because an on-PATH uvx would otherwise be matched by the catch-all +# whose prerequisite is that same file: `make: Circular ... dependency dropped`. +$(UVX): + @echo "[INFO] uv not found; installing into $(@D)" + @curl -LsSf https://astral.sh/uv/install.sh | UV_INSTALL_DIR="$(@D)" sh >/dev/null + +.PHONY: FORCE +FORCE: + +# Repo-specific one-offs. An explicit rule beats a pattern rule, so these win. -include local.mk + +# Both are targets make tries to remake, and the catch-all would route that to the CLI. +local.mk: ; +Makefile: ; diff --git a/cliff.toml b/cliff.toml index 6b1e880..86fbf84 100644 --- a/cliff.toml +++ b/cliff.toml @@ -64,8 +64,19 @@ 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 }, + # Drop automated noise commits that don't provide user-facing signal -- the machine-written + # `Update the compiled paper [skip ci]` kind, which puts the marker in its *subject*. + # + # Anchored to the subject line, and that is load-bearing rather than tidy. git-cliff matches + # this against the whole message, so the unanchored `.*\[skip ci\].*` also dropped any commit + # whose *body* merely mentioned the marker -- a commit message quoting the format of another + # commit message is enough. That silently ate `feat: give the paper branch a README` (#1626) + # out of v1.6.0's notes, for one backticked mention twenty lines down. Same failure as the + # `bump` alternative below: a substring search treating a mention as the thing itself. + # + # `^` with no `(?m)` is start-of-message, and `[^\n]*` cannot cross a newline, so only the + # subject can match. + { message = "^[^\\n]*\\[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 diff --git a/docs/development/rhiza.md b/docs/development/rhiza.md new file mode 100644 index 0000000..795346f --- /dev/null +++ b/docs/development/rhiza.md @@ -0,0 +1,23 @@ +# Rhiza documentation + +Your development tooling — the `Makefile` front door, the gates behind `make all`, the CI +workflows, the pre-commit hooks — comes from [Rhiza](https://github.com/Jebel-Quant/rhiza) +and is documented there rather than here. This page is the way back to it. + +| Topic | Page | +|---|---| +| The test suite, coverage and the testing extras | [TESTS](https://jebel-quant.github.io/rhiza/development/TESTS/) | +| Dev Container setup and usage | [DEVCONTAINER](https://jebel-quant.github.io/rhiza/development/DEVCONTAINER/) | +| Recommended VS Code extensions | [VSCODE_EXTENSIONS](https://jebel-quant.github.io/rhiza/development/VSCODE_EXTENSIONS/) | +| Marimo notebooks | [MARIMO](https://jebel-quant.github.io/rhiza/development/MARIMO/) | +| Compiling a LaTeX paper | [PAPER](https://jebel-quant.github.io/rhiza/development/PAPER/) | + +Everything else — bundles, profiles, the sync, the settings in `[tool.rhiza-task]` — starts +at the [documentation site](https://jebel-quant.github.io/rhiza/). + +These pages used to be copied into this repository. They said nothing about *this* project, +nothing here linked to them, and a copy stops being updated the moment it is made — so they +are published once, upstream, and this page points at them. + + diff --git a/docs/mkdocs-base.yml b/docs/mkdocs-base.yml index 095ba11..47e2041 100644 --- a/docs/mkdocs-base.yml +++ b/docs/mkdocs-base.yml @@ -1,14 +1,8 @@ # docs/mkdocs-base.yml — Base MkDocs configuration for rhiza-based projects. # -# USAGE (standalone) -# Build or serve this file directly if you have no root-level mkdocs.yml: -# uvx --with mkdocs-material mkdocs serve -f docs/mkdocs-base.yml -# The rhiza build system (make book) will pick this file up automatically -# as a fallback when no root-level mkdocs.yml is found. -# -# USAGE (with INHERIT) -# To extend this config from a root-level mkdocs.yml, add the following -# at the top of your mkdocs.yml and override only what you need: +# HOW IT IS READ +# Only through `INHERIT:` from a root-level `mkdocs.yml`. Add this at the top of +# that file and override only what you need: # # INHERIT: docs/mkdocs-base.yml # @@ -17,15 +11,31 @@ # repo_url: https://github.com/example/my-project # repo_name: example/my-project # -# docs_dir: docs # always set this explicitly — base uses docs_dir: . which -# # resolves relative to docs/mkdocs-base.yml, not mkdocs.yml -# -# nav: # nav is fully replaced — not merged — by the child config +# nav: # nav is fully replaced -- not merged -- by the child config # - Home: index.md # - Guide: guide.md # -# Any key you omit in mkdocs.yml is inherited from this file. -# The 'nav' key is always fully replaced when defined in the child. +# Every other key you omit is inherited, `docs_dir` included: relative paths in +# this file resolve against the *primary* config, so `docs_dir: docs` below means +# `/docs` and a child need not restate it. +# +# HOW IT IS NOT READ -- three claims this header used to make, all measured false (#1633) +# * There is no standalone build. Point mkdocs at this file directly and it aborts: +# `Config value 'site_name': Required configuration not provided.` -- this file +# declares no site, deliberately, because the site is the consumer's. +# * `book` does not fall back to it. rhiza-task's task requires a root `mkdocs.yml` +# and prints `skipped book no mkdocs.yml` without one. The fallback was real +# under `book.mk` and retired with the make layer. +# * A child that omits `INHERIT:` gets none of this, and nothing says so. That is +# the whole of #1633: 120 synced lines a consumer can silently not read. +# +# WHAT IT ACTUALLY BUYS +# The book is built by zensical, not mkdocs, and zensical already defaults to the +# Material theme and to most of the markdown extensions below. Measured against a +# bare config, inheriting this one adds the light/dark palette toggle, the +# `theme.features` list, the back-to-top button and `pymdownx.snippets` +# resolution -- and `mkdocstrings`, which is why `mkdocs-extra-packages` is +# non-empty by default. The rest is there for a consumer who builds with mkdocs. docs_dir: docs site_dir: _book @@ -62,6 +72,9 @@ theme: icon: material/weather-night name: Switch to light mode + logo: https://jebel-quant.github.io/rhiza/assets/rhiza-logo.svg + favicon: https://jebel-quant.github.io/rhiza/assets/rhiza-logo.svg + # ---------------------------------------------------------------------------- # Markdown extensions # ---------------------------------------------------------------------------- diff --git a/pytest.ini b/pytest.ini index 0dd0ef0..3bdff29 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,9 +1,9 @@ [pytest] testpaths = tests -# 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 +# No `pythonpath` here any more. It existed for exactly one reason: the rhiza checks were +# synced into `.rhiza/tests/` and had to be importable so the suite's modules could import +# each other. Since #1540 they arrive installed, as pytest-rhiza, so import resolution is +# pip's job and the project's own suite is reached through `testpaths` above. # 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 diff --git a/ruff.toml b/ruff.toml index 4563178..1439352 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,27 @@ 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 (the project suite under tests/, and the bundle +# copies under bundles/). This glob subsumes the project suite; tests/**/*.py below adds +# project-suite-only allowances on top. It used to cover a third tree, `.rhiza/tests/`, +# which the template synced; since #1540 the rhiza checks are an installed dependency and +# there is no template-owned Python in a consumer's tree to exempt. "**/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 broader glob above should not hand to the +# bundle copies under bundles/ "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 +151,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 index 8c103c4..3c51a81 100644 --- a/tests/test_rhiza_packaging.py +++ b/tests/test_rhiza_packaging.py @@ -17,7 +17,7 @@ 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 +it until the rhiza checks were 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 @@ -28,7 +28,8 @@ 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``. +directory, and must not depend on the fixtures pytest-rhiza contributes to ``make +rhiza-test``. """ from __future__ import annotations @@ -46,7 +47,8 @@ # 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. +# instead of running. pytest-rhiza's own ``latest_tag`` fixture avoids the same trap the +# same way. _ROOT = Path(__file__).absolute().parent.parent From 97d7d1b6d0e93e4b577d669edd94fb9d4ea51e63 Mon Sep 17 00:00:00 2001 From: Thomas Schmelzer Date: Tue, 29 Sep 2026 09:23:55 +0400 Subject: [PATCH 3/5] chore: remove files retired by rhiza v1.9.0 Co-Authored-By: Claude Opus 5.5 --- .github/DISCUSSION_TEMPLATE/q-and-a.yml | 25 -- .github/ISSUE_TEMPLATE/bug_report.yml | 57 ---- .github/ISSUE_TEMPLATE/feature_request.yml | 41 --- .github/pull_request_template.md | 24 -- .github/workflows/rhiza_sync.yml | 43 --- .rhiza/.cfg.toml | 34 -- .rhiza/.env | 3 - .rhiza/.gitignore | 2 - .rhiza/.rhiza-version | 1 - .rhiza/assets/rhiza-logo.svg | 81 ----- .rhiza/completions/README.md | 277 ---------------- .rhiza/completions/rhiza-completion.bash | 75 ----- .rhiza/completions/rhiza-completion.zsh | 116 ------- .rhiza/make.d/book.mk | 58 ---- .rhiza/make.d/bootstrap.mk | 109 ------ .rhiza/make.d/custom-env.mk | 9 - .rhiza/make.d/custom-task.mk | 12 - .rhiza/make.d/doctor.mk | 60 ---- .rhiza/make.d/marimo.mk | 42 --- .rhiza/make.d/quality.mk | 59 ---- .rhiza/make.d/releasing.mk | 50 --- .rhiza/make.d/test.mk | 172 ---------- .rhiza/requirements/README.md | 27 -- .rhiza/requirements/docs.txt | 4 - .rhiza/requirements/marimo.txt | 2 - .rhiza/requirements/tests.txt | 18 - .rhiza/requirements/tools.txt | 7 - .rhiza/rhiza.mk | 178 ---------- .rhiza/tests/README.md | 81 ----- .rhiza/tests/conftest.py | 72 ---- .rhiza/utils/pip_audit_policy.py | 67 ---- .rhiza/utils/suppression_audit.py | 369 --------------------- docs/assets/rhiza-logo.svg | 81 ----- docs/development/MARIMO.md | 134 -------- docs/development/TESTS.md | 288 ---------------- 35 files changed, 2678 deletions(-) delete mode 100644 .github/DISCUSSION_TEMPLATE/q-and-a.yml delete mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml delete mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml delete mode 100644 .github/pull_request_template.md delete mode 100644 .github/workflows/rhiza_sync.yml delete mode 100644 .rhiza/.cfg.toml delete mode 100644 .rhiza/.env delete mode 100644 .rhiza/.gitignore delete mode 100644 .rhiza/.rhiza-version delete mode 100644 .rhiza/assets/rhiza-logo.svg delete mode 100644 .rhiza/completions/README.md delete mode 100644 .rhiza/completions/rhiza-completion.bash delete mode 100644 .rhiza/completions/rhiza-completion.zsh delete mode 100644 .rhiza/make.d/book.mk delete mode 100644 .rhiza/make.d/bootstrap.mk delete mode 100644 .rhiza/make.d/custom-env.mk delete mode 100644 .rhiza/make.d/custom-task.mk delete mode 100644 .rhiza/make.d/doctor.mk delete mode 100644 .rhiza/make.d/marimo.mk delete mode 100644 .rhiza/make.d/quality.mk delete mode 100644 .rhiza/make.d/releasing.mk delete mode 100644 .rhiza/make.d/test.mk delete mode 100644 .rhiza/requirements/README.md delete mode 100644 .rhiza/requirements/docs.txt delete mode 100644 .rhiza/requirements/marimo.txt delete mode 100644 .rhiza/requirements/tests.txt delete mode 100644 .rhiza/requirements/tools.txt delete mode 100644 .rhiza/rhiza.mk delete mode 100644 .rhiza/tests/README.md delete mode 100644 .rhiza/tests/conftest.py delete mode 100644 .rhiza/utils/pip_audit_policy.py delete mode 100644 .rhiza/utils/suppression_audit.py delete mode 100644 docs/assets/rhiza-logo.svg delete mode 100644 docs/development/MARIMO.md delete mode 100644 docs/development/TESTS.md diff --git a/.github/DISCUSSION_TEMPLATE/q-and-a.yml b/.github/DISCUSSION_TEMPLATE/q-and-a.yml deleted file mode 100644 index c83b0a7..0000000 --- a/.github/DISCUSSION_TEMPLATE/q-and-a.yml +++ /dev/null @@ -1,25 +0,0 @@ -title: "[Question] " -labels: ["question"] -body: - - type: markdown - attributes: - value: | - Welcome! Use this space to ask questions, share how your use-case, or explore ideas with the community. - - - type: textarea - id: question - attributes: - label: Your Question or Topic - description: What would you like to discuss? - validations: - required: true - - - type: textarea - id: context - attributes: - label: Context - description: Any relevant code, configuration, or background that helps frame your question. - placeholder: | - ```python - # your code snippet here - ``` diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml deleted file mode 100644 index 060ab29..0000000 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ /dev/null @@ -1,57 +0,0 @@ -name: Bug Report -description: Report a bug or unexpected behaviour -labels: ["bug"] -body: - - type: markdown - attributes: - value: | - Thanks for taking the time to report a bug. Please fill out the sections below. - - - type: textarea - id: description - attributes: - label: Description - description: A clear and concise description of what the bug is. - placeholder: What happened? - validations: - required: true - - - type: textarea - id: steps - attributes: - label: Steps to reproduce - description: Minimal steps to reproduce the behaviour. - placeholder: | - 1. Run `make ...` - 2. See error - validations: - required: true - - - type: textarea - id: expected - attributes: - label: Expected behaviour - description: What did you expect to happen? - validations: - required: true - - - type: textarea - id: environment - attributes: - label: Environment - description: | - Relevant versions and system info. Run `make info` if available. - placeholder: | - - OS: macOS 14 / Ubuntu 24.04 / Windows 11 - - Python: 3.13.x - - rhiza version: - validations: - required: false - - - type: textarea - id: context - attributes: - label: Additional context - description: Logs, screenshots, or anything else that may be helpful. - validations: - required: false diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml deleted file mode 100644 index 1b0de2d..0000000 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ /dev/null @@ -1,41 +0,0 @@ -name: Feature Request -description: Suggest a new feature or enhancement -labels: ["enhancement"] -body: - - type: markdown - attributes: - value: | - Thanks for proposing a feature. Please align with the team before investing significant effort. - - - type: textarea - id: problem - attributes: - label: Problem / motivation - description: What problem does this solve? Why is it valuable? - placeholder: As a contributor I find it hard to ... because ... - validations: - required: true - - - type: textarea - id: solution - attributes: - label: Proposed solution - description: Describe the solution you have in mind. - validations: - required: true - - - type: textarea - id: alternatives - attributes: - label: Alternatives considered - description: Other approaches you have considered and why you ruled them out. - validations: - required: false - - - type: textarea - id: context - attributes: - label: Additional context - description: Links, mockups, prior art, or anything else that may be helpful. - validations: - required: false diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md deleted file mode 100644 index d9e50ec..0000000 --- a/.github/pull_request_template.md +++ /dev/null @@ -1,24 +0,0 @@ -## Summary - - - -Closes # - -## Changes - - - -- - -## Testing - -- [ ] `make test` passes locally -- [ ] `make fmt` has been run -- [ ] New tests added (or explain why not needed) - -## 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 deps` passes (no unused or missing dependencies) diff --git a/.github/workflows/rhiza_sync.yml b/.github/workflows/rhiza_sync.yml deleted file mode 100644 index 45d0bae..0000000 --- a/.github/workflows/rhiza_sync.yml +++ /dev/null @@ -1,43 +0,0 @@ -# This file is part of the jebel-quant/rhiza repository -# (https://github.com/jebel-quant/rhiza). -# -# Workflow: Sync -# -# Purpose: Synchronizes the repository with its upstream rhiza template. -# On Renovate/rhiza branch push: auto-commits synced files directly -# to the branch. On schedule/dispatch: opens a pull request. -# -# IMPORTANT: A PAT with 'workflow' scope (PAT_TOKEN) is required when workflow -# files are modified. See .github/CONFIG.md for setup instructions. -# -# Trigger: On Renovate/rhiza branch push, weekly schedule, and manual dispatch. - -name: "(RHIZA) SYNC" - -permissions: - contents: write - pull-requests: write - -on: - push: - branches: - - 'renovate/jebel-quant-rhiza-**' - - 'rhiza/**' - paths: - - '.rhiza/template.yml' - schedule: - - cron: '0 0 * * 1' # Weekly on Monday - workflow_dispatch: - inputs: - create-pr: - description: "Create a pull request" - type: boolean - default: true - -jobs: - sync: - uses: jebel-quant/rhiza/.github/workflows/rhiza_sync.yml@v0.19.9 - with: - direct: ${{ github.event_name == 'push' }} - create-pr: ${{ github.event_name != 'push' && (github.event_name == 'schedule' || inputs.create-pr == true) }} - secrets: inherit diff --git a/.rhiza/.cfg.toml b/.rhiza/.cfg.toml deleted file mode 100644 index c96b1dd..0000000 --- a/.rhiza/.cfg.toml +++ /dev/null @@ -1,34 +0,0 @@ -[tool.bumpversion] -parse = "(?P\\d+)\\.(?P\\d+)\\.(?P\\d+)(?:[-]?(?P[a-z]+)[\\.]?(?P\\d+))?(?:\\+build\\.(?P\\d+))?" -serialize = ["{major}.{minor}.{patch}-{release}.{pre_n}+build.{build_n}", "{major}.{minor}.{patch}+build.{build_n}", "{major}.{minor}.{patch}-{release}.{pre_n}", "{major}.{minor}.{patch}"] -search = "{current_version}" -replace = "{new_version}" -regex = false -ignore_missing_version = false -ignore_missing_files = false -tag = true -sign_tags = false -tag_name = "v{new_version}" -tag_message = "Bump version: {current_version} → {new_version}" -allow_dirty = false -commit = true -message = "Chore: bump version {current_version} → {new_version}" -commit_args = "" -pre_commit_hooks = ["uv sync", "git add uv.lock"] # Ensure uv.lock is updated - -[tool.bumpversion.parts.release] -optional_value = "prod" -values = [ - "dev", - "alpha", - "a", # PEP 440 short form for alpha - "beta", - "b", # PEP 440 short form for beta - "rc", - "prod" -] - -[[tool.bumpversion.files]] -filename = "pyproject.toml" -search = 'version = "{current_version}"' -replace = 'version = "{new_version}"' diff --git a/.rhiza/.env b/.rhiza/.env deleted file mode 100644 index 0fd6a6a..0000000 --- a/.rhiza/.env +++ /dev/null @@ -1,3 +0,0 @@ -MARIMO_FOLDER=docs/notebooks -SOURCE_FOLDER=src -RHIZA_CI_OS_MATRIX=["ubuntu-latest","macos-latest","windows-latest"] diff --git a/.rhiza/.gitignore b/.rhiza/.gitignore deleted file mode 100644 index f0ea93e..0000000 --- a/.rhiza/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -# Dedicated rhiza ignore rules -!.env \ No newline at end of file diff --git a/.rhiza/.rhiza-version b/.rhiza/.rhiza-version deleted file mode 100644 index 2a0970c..0000000 --- a/.rhiza/.rhiza-version +++ /dev/null @@ -1 +0,0 @@ -0.16.1 diff --git a/.rhiza/assets/rhiza-logo.svg b/.rhiza/assets/rhiza-logo.svg deleted file mode 100644 index ff1c9f5..0000000 --- a/.rhiza/assets/rhiza-logo.svg +++ /dev/null @@ -1,81 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/.rhiza/completions/README.md b/.rhiza/completions/README.md deleted file mode 100644 index ff62b4d..0000000 --- a/.rhiza/completions/README.md +++ /dev/null @@ -1,277 +0,0 @@ -# Shell Completion for Rhiza Make Targets - -This directory contains shell completion scripts for Bash and Zsh that provide tab-completion for make targets in Rhiza-based projects. - -## Features - -- ✅ Tab-complete all available make targets -- ✅ Show target descriptions in Zsh -- ✅ 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 - -Add to your `~/.bashrc` or `~/.bash_profile`: - -```bash -# Rhiza make completion -if [ -f /path/to/project/.rhiza/completions/rhiza-completion.bash ]; then - source /path/to/project/.rhiza/completions/rhiza-completion.bash -fi -``` - -Replace `/path/to/project` with the actual path to your Rhiza project. - -#### Method 2: System-wide installation - -```bash -# Copy to bash completion directory -sudo cp .rhiza/completions/rhiza-completion.bash /etc/bash_completion.d/rhiza - -# Reload completions -source /etc/bash_completion.d/rhiza -``` - -#### Method 3: User-local installation - -```bash -# Create local completion directory -mkdir -p ~/.local/share/bash-completion/completions - -# Copy completion script -cp .rhiza/completions/rhiza-completion.bash ~/.local/share/bash-completion/completions/make - -# Reload bash -source ~/.bashrc -``` - -### Zsh - -#### Method 1: User-local installation (Recommended) - -```bash -# Create completion directory -mkdir -p ~/.zsh/completion - -# Copy completion script -cp .rhiza/completions/rhiza-completion.zsh ~/.zsh/completion/_make - -# Add to ~/.zshrc (if not already present) -echo 'fpath=(~/.zsh/completion $fpath)' >> ~/.zshrc -echo 'autoload -U compinit && compinit' >> ~/.zshrc - -# Reload zsh -source ~/.zshrc -``` - -#### Method 2: Source directly - -Add to your `~/.zshrc`: - -```zsh -# Rhiza make completion -if [ -f /path/to/project/.rhiza/completions/rhiza-completion.zsh ]; then - source /path/to/project/.rhiza/completions/rhiza-completion.zsh -fi -``` - -#### Method 3: System-wide installation - -```bash -# Copy to system completion directory -sudo cp .rhiza/completions/rhiza-completion.zsh /usr/local/share/zsh/site-functions/_make - -# Reload zsh -exec zsh -``` - -## Usage - -Once installed, you can tab-complete make targets: - -```bash -# Tab-complete targets -make - -# Complete with prefix -make te # Expands to: make test - -# Complete variables -make ENV= # Shows: dev, staging, prod - -# Works with any target -make doc # Shows: docs, docker-build, docker-run, etc. -``` - -### Zsh Benefits - -In Zsh, you'll also see descriptions for targets: - -```bash -make -# Shows: -# test -- run all tests -# fmt -- check the pre-commit hooks and the linting -# install -- install -# book -- build documentation site via zensical -# ... -``` - -## Common Variables - -The completion scripts understand these common variables: - -| Variable | Values | Description | -|----------|--------|-------------| -| `DRY_RUN` | `1` | Preview mode without making changes | -| `ENV` | `dev`, `staging`, `prod` | Target environment | -| `COVERAGE_FAIL_UNDER` | (number) | Minimum coverage threshold | -| `PYTHON_VERSION` | (version) | Override Python version | - -Example usage: - -```bash -# Tab-complete after typing DRY_ -make DRY_ # Expands to: make DRY_RUN=1 - -# Tab-complete variable values -make ENV= # Shows: dev staging prod - -# Combine with targets -make deploy ENV= -``` - -## Troubleshooting - -### Bash: Completions not working - -1. Check if bash-completion is installed: - ```bash - # Debian/Ubuntu - sudo apt-get install bash-completion - - # macOS - brew install bash-completion@2 - ``` - -2. Ensure completion is enabled in your shell: - ```bash - # Add to ~/.bashrc if not present - if [ -f /etc/bash_completion ]; then - . /etc/bash_completion - fi - ``` - -3. Reload your shell configuration: - ```bash - source ~/.bashrc - ``` - -### Zsh: Completions not working - -1. Check if compinit is called in your `~/.zshrc`: - ```zsh - autoload -U compinit && compinit - ``` - -2. Clear the completion cache: - ```bash - rm -f ~/.zcompdump - compinit - ``` - -3. Ensure the script is in your fpath: - ```zsh - echo $fpath - ``` - -4. Reload your shell configuration: - ```zsh - source ~/.zshrc - ``` - -### No targets appearing - -1. Ensure you're in a directory with a Makefile: - ```bash - ls -la Makefile - ``` - -2. Test that make can parse the Makefile: - ```bash - make -qp 2>/dev/null | head - ``` - -3. Manually source the completion script to test: - ```bash - # Bash - source .rhiza/completions/rhiza-completion.bash - - # Zsh - source .rhiza/completions/rhiza-completion.zsh - ``` - -## Optional Aliases - -You can add shortcuts in your shell config: - -```bash -# Add to ~/.bashrc or ~/.zshrc -alias m='make' - -# For bash: -complete -F _rhiza_make_completion m - -# For zsh: -compdef _rhiza_make m -``` - -Then use: -```bash -m te # Expands to: m test -``` - -## Technical Details - -### How it works - -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. **Cached Completion**: The target list is cached per directory and refreshed automatically - -### Performance - -- 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 diff --git a/.rhiza/completions/rhiza-completion.bash b/.rhiza/completions/rhiza-completion.bash deleted file mode 100644 index eca9e8a..0000000 --- a/.rhiza/completions/rhiza-completion.bash +++ /dev/null @@ -1,75 +0,0 @@ -#!/usr/bin/env bash -# Bash completion for Rhiza make targets -# -# Installation: -# Source this file in your ~/.bashrc or ~/.bash_profile: -# source /path/to/.rhiza/completions/rhiza-completion.bash -# -# Or copy to bash completion directory: -# 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 cache_dir cache_file - COMPREPLY=() - cur="${COMP_WORDS[COMP_CWORD]}" - prev="${COMP_WORDS[COMP_CWORD-1]}" - - # Check if we're in a directory with a Makefile - if [[ ! -f "Makefile" ]]; then - return 0 - fi - - # 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 ENV=dev ENV=staging ENV=prod" - opts="$opts $vars" - - # Generate completions - COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) ) - return 0 -} - -# Register the completion function for make command -complete -F _rhiza_make_completion make - -# Also complete for direct make invocation with path -complete -F _rhiza_make_completion ./Makefile - -# Helpful aliases (optional - uncomment if desired) -# alias m='make' -# complete -F _rhiza_make_completion m diff --git a/.rhiza/completions/rhiza-completion.zsh b/.rhiza/completions/rhiza-completion.zsh deleted file mode 100644 index f1c3891..0000000 --- a/.rhiza/completions/rhiza-completion.zsh +++ /dev/null @@ -1,116 +0,0 @@ -#compdef make -# Zsh completion for Rhiza make targets -# -# Installation: -# Add this file to your fpath and ensure compinit is called: -# -# Method 1 (User-local): -# mkdir -p ~/.zsh/completion -# cp .rhiza/completions/rhiza-completion.zsh ~/.zsh/completion/_make -# Add to ~/.zshrc: -# fpath=(~/.zsh/completion $fpath) -# autoload -U compinit && compinit -# -# Method 2 (Source directly): -# Add to ~/.zshrc: -# source /path/to/.rhiza/completions/rhiza-completion.zsh -# -# Method 3 (System-wide): -# 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 - - # 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/ { - if (/^[a-zA-Z0-9_-]+:.*##/) { - target=$1 - desc=$0 - sub(/^[^#]*## */, "", desc) - gsub(/^[ \t]+/, "", target) - print target ":" desc - } - } - ' | \ - grep -v '^Makefile:' | \ - sort -u > "$cache_file.desc" - - # 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 > "$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' - 'ENV=dev:development environment' - 'ENV=staging:staging environment' - 'ENV=prod:production environment' - 'COVERAGE_FAIL_UNDER=:minimum coverage threshold' - 'PYTHON_VERSION=:override Python version' - ) - - # Combine all completions - local -a all_completions - all_completions=($targets $plain_targets $variables) - - # Show completions with descriptions - _describe 'make targets' all_completions -} - -# Register the completion function -compdef _rhiza_make make - -# Optional: Add completion for common aliases -# Uncomment these if you use these aliases -# alias m='make' -# compdef _rhiza_make m diff --git a/.rhiza/make.d/book.mk b/.rhiza/make.d/book.mk deleted file mode 100644 index 6323b21..0000000 --- a/.rhiza/make.d/book.mk +++ /dev/null @@ -1,58 +0,0 @@ -## book.mk - Book-building targets (MkDocs-based) - -ROOT := $(shell git rev-parse --show-toplevel) - -.PHONY: book serve test benchmark stress hypothesis-test _book-reports _book-notebooks - -# No-op stubs — overridden by test.mk / bench.mk when present -test:: ; @: -benchmark:: ; @: -stress:: ; @: -hypothesis-test:: ; @: - -BOOK_OUTPUT ?= _book - -MKDOCS_EXTRA_PACKAGES ?= -ZENSICAL_VERSION ?= >=0.0.36 - -##@ Book - -_book-reports: test benchmark stress hypothesis-test - @if [ -d "${ROOT}/_tests" ] && [ -n "$$(ls -A "${ROOT}/_tests" 2>/dev/null)" ]; then \ - printf "${BLUE}[INFO] Copying ${ROOT}/_tests -> docs/reports${RESET}\n"; \ - mkdir -p ${ROOT}/docs/reports; cp -r "${ROOT}/_tests/." "${ROOT}/docs/reports/"; \ - else \ - printf "${YELLOW}[WARN] ${ROOT}/_tests not found or empty, skipping${RESET}\n"; \ - fi - -# Export each Marimo notebook to a self-contained HTML file under docs/notebooks/. -# Skipped silently when MARIMO_FOLDER is not set or does not exist. -_book-notebooks: - @if [ -d "$(MARIMO_FOLDER)" ]; then \ - printf "${BLUE}[INFO] Exporting Marimo notebooks from $(MARIMO_FOLDER)${RESET}\n"; \ - for nb in $(MARIMO_FOLDER)/*.py; do \ - 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"); \ - done; \ - else \ - printf "${YELLOW}[WARN] MARIMO_FOLDER not set or missing, skipping notebook export${RESET}\n"; \ - fi - -# Serve the built book locally on port 8000. -# Uses Python's built-in HTTP server so the JetBrains built-in server (which -# refuses to serve gitignored directories like _book) is not needed. -serve: book ## build and serve the book at http://localhost:8000 - @printf "${BLUE}[INFO] Serving book at http://localhost:8000 (Ctrl-C to stop)${RESET}\n" - @cd $(BOOK_OUTPUT) && ${UV_BIN} run python -m http.server 8000 - -book:: _book-reports _book-notebooks ## compile the companion book via MkDocs - @rm -rf "$(BOOK_OUTPUT)" - @${UVX_BIN} $(MKDOCS_EXTRA_PACKAGES) 'zensical$(ZENSICAL_VERSION)' build -f "$(ROOT)/mkdocs.yml" - @mkdir -p "$(BOOK_OUTPUT)" && touch "$(BOOK_OUTPUT)/.nojekyll" - @if [ -f "${ROOT}/_tests/coverage.xml" ]; then \ - printf "${BLUE}[INFO] Generating coverage badge${RESET}\n"; \ - ${UVX_BIN} "genbadge[coverage]" coverage -i "${ROOT}/_tests/coverage.xml" -o "$(BOOK_OUTPUT)/coverage-badge.svg"; \ - fi - @printf "${GREEN}[SUCCESS] Book built at $(BOOK_OUTPUT)/${RESET}\n" diff --git a/.rhiza/make.d/bootstrap.mk b/.rhiza/make.d/bootstrap.mk deleted file mode 100644 index 2852697..0000000 --- a/.rhiza/make.d/bootstrap.mk +++ /dev/null @@ -1,109 +0,0 @@ -## .rhiza/make.d/bootstrap.mk - Bootstrap and Installation -# This file provides targets for setting up the development environment, -# installing dependencies, and cleaning project artifacts. - -# Declare phony targets (they don't produce files) -.PHONY: install-uv install clean pre-install post-install - -UV_SYNC_ARGS ?= --all-extras --all-groups - -# Hook targets (double-colon rules allow multiple definitions) -pre-install:: ; @: -post-install:: ; @: - -##@ Bootstrap -install-uv: ## ensure uv/uvx is installed - # Ensure the ${INSTALL_DIR} folder exists - @mkdir -p ${INSTALL_DIR} - - # Install uv/uvx only if they are not already present in PATH or in the install dir - @if command -v uv >/dev/null 2>&1 && command -v uvx >/dev/null 2>&1; then \ - :; \ - elif [ -x "${INSTALL_DIR}/uv" ] && [ -x "${INSTALL_DIR}/uvx" ]; then \ - printf "${BLUE}[INFO] uv and uvx already installed in ${INSTALL_DIR}, skipping.${RESET}\n"; \ - else \ - printf "${BLUE}[INFO] Installing uv and uvx into ${INSTALL_DIR}...${RESET}\n"; \ - if ! curl -LsSf https://astral.sh/uv/install.sh | UV_INSTALL_DIR="${INSTALL_DIR}" sh >/dev/null 2>&1; then \ - printf "${RED}[ERROR] Failed to install uv${RESET}\n"; \ - exit 1; \ - 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)" - - # Remove ignored files/directories, but keep .env files, tested with futures project - @git clean -d -X -f \ - -e '!.env' \ - -e '!.env.*' - - # Remove build & test artifacts - @rm -rf \ - dist \ - build \ - *.egg-info \ - .coverage \ - .pytest_cache \ - .benchmarks - - @printf "%bRemoving local branches with no remote counterpart...%b\n" "$(BLUE)" "$(RESET)" - - @git fetch --prune - - @git branch -vv | awk '/: gone]/{print $$1}' | xargs -r git branch -D diff --git a/.rhiza/make.d/custom-env.mk b/.rhiza/make.d/custom-env.mk deleted file mode 100644 index f5ec964..0000000 --- a/.rhiza/make.d/custom-env.mk +++ /dev/null @@ -1,9 +0,0 @@ -## .rhiza/make.d/custom-env.mk - Custom Environment Configuration -# This file example shows how to set variables for the project. - -# Custom variables for this repository -PROJECT_NAME_EXTRA := Rhiza Platform -LOG_LEVEL ?= INFO - -# Overriding core variables (be careful) -# VENV := .venv_custom diff --git a/.rhiza/make.d/custom-task.mk b/.rhiza/make.d/custom-task.mk deleted file mode 100644 index 086a8e7..0000000 --- a/.rhiza/make.d/custom-task.mk +++ /dev/null @@ -1,12 +0,0 @@ -## .rhiza/make.d/custom-task.mk - Custom Repository Tasks -# This file example shows how to add new targets. - -.PHONY: hello-rhiza - -##@ Custom Tasks -hello-rhiza: ## a custom greeting task - @printf "${GREEN}[INFO] Hello from the customised Rhiza project!${RESET}\n" - -# Adding logic to existing hooks -post-install:: ## run custom logic after core install - @printf "${BLUE}[INFO] Running custom post-install steps...${RESET}\n" diff --git a/.rhiza/make.d/doctor.mk b/.rhiza/make.d/doctor.mk deleted file mode 100644 index 398dca4..0000000 --- a/.rhiza/make.d/doctor.mk +++ /dev/null @@ -1,60 +0,0 @@ -## .rhiza/make.d/doctor.mk - Developer prerequisite diagnostics - -.PHONY: doctor - -##@ Dev -doctor: ## verify local prerequisites and print actionable guidance - @failed=0; \ - version_ge() { \ - awk -v a="$$1" -v b="$$2" 'BEGIN { \ - split(a, A, /[^0-9]+/); \ - split(b, B, /[^0-9]+/); \ - for (i = 1; i <= 3; i++) { \ - ai = A[i] + 0; \ - bi = B[i] + 0; \ - if (ai > bi) exit 0; \ - if (ai < bi) exit 1; \ - } \ - exit 0; \ - }'; \ - }; \ - 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"; \ - 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"; \ - 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"; \ - failed=1; \ - return; \ - fi; \ - if version_ge "$$version" "$$min"; then \ - if [ "$$gnu_required" = "gnu" ]; then \ - extra=" (GNU required)"; \ - fi; \ - printf "${GREEN}[✅]${RESET} %-9s %-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"; \ - failed=1; \ - fi; \ - }; \ - check_tool "uv" "0.4.0" "https://docs.astral.sh/uv/getting-started/installation/" "uv --version | awk 'NR==1 {print \$$2}'" ""; \ - check_tool "make" "3.8.0" "https://www.gnu.org/software/make/" "make --version | awk 'NR==1 {for (i=1; i<=NF; i++) if (\$$i ~ /^[0-9]+(\\.[0-9]+)+$$/) {print \$$i; exit}}'" "gnu"; \ - check_tool "git" "2.0.0" "https://git-scm.com" "git --version | awk 'NR==1 {print \$$3}'" ""; \ - if [ "$$failed" -ne 0 ]; then \ - printf "\n${YELLOW}[WARN] One or more prerequisites are missing or below minimum version.${RESET}\n"; \ - exit 1; \ - fi diff --git a/.rhiza/make.d/marimo.mk b/.rhiza/make.d/marimo.mk deleted file mode 100644 index 36ae74c..0000000 --- a/.rhiza/make.d/marimo.mk +++ /dev/null @@ -1,42 +0,0 @@ -## Makefile.marimo - Marimo notebook targets -# This file is included by the main Makefile - -# Declare phony targets (they don't produce files) -.PHONY: marimo-validate marimo - -##@ Marimo Notebooks -marimo-validate: install ## validate all Marimo notebooks can run - @printf "${BLUE}[INFO] Validating all notebooks in ${MARIMO_FOLDER}...${RESET}\n" - @if [ ! -d "${MARIMO_FOLDER}" ]; then \ - printf "${YELLOW}[WARN] Directory '${MARIMO_FOLDER}' does not exist. Skipping validation.${RESET}\n"; \ - else \ - failed=0; \ - for notebook in ${MARIMO_FOLDER}/*.py; do \ - if [ -f "$$notebook" ]; then \ - notebook_name=$$(basename "$$notebook"); \ - notebook_stem=$$(basename "$$notebook" .py); \ - artefact_folder="results/$$notebook_stem"; \ - mkdir -p "$$artefact_folder"; \ - printf "${BLUE}[INFO] Validating $$notebook_name (artefacts → $$artefact_folder)...${RESET}\n"; \ - if NOTEBOOK_OUTPUT_FOLDER="$$artefact_folder" ${UV_BIN} run "$$notebook" > /dev/null 2>&1; then \ - printf "${GREEN}[SUCCESS] $$notebook_name is valid${RESET}\n"; \ - else \ - printf "${RED}[ERROR] $$notebook_name failed validation${RESET}\n"; \ - failed=$$((failed + 1)); \ - fi; \ - fi; \ - done; \ - if [ $$failed -eq 0 ]; then \ - printf "${GREEN}[SUCCESS] All notebooks validated successfully${RESET}\n"; \ - else \ - printf "${RED}[ERROR] $$failed notebook(s) failed validation${RESET}\n"; \ - exit 1; \ - fi; \ - fi - -marimo: install ## fire up Marimo server - @if [ ! -d "${MARIMO_FOLDER}" ]; then \ - printf " ${YELLOW}[WARN] Marimo folder '${MARIMO_FOLDER}' not found, skipping start${RESET}\n"; \ - else \ - ${UV_BIN} run --no-project --with marimo --directory "${MARIMO_FOLDER}" marimo edit --no-token --headless; \ - fi diff --git a/.rhiza/make.d/quality.mk b/.rhiza/make.d/quality.mk deleted file mode 100644 index 7504828..0000000 --- a/.rhiza/make.d/quality.mk +++ /dev/null @@ -1,59 +0,0 @@ -## .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 - -# Declare phony targets (they don't produce files) -.PHONY: all deptry fmt license todos suppression-audit semgrep - -##@ 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 - -fmt: install-uv ## check the pre-commit hooks and the linting - @${UVX_BIN} -p ${PYTHON_VERSION} pre-commit run --all-files - -todos: ## search and report all TODO/FIXME/HACK comments in the codebase - @printf "${BLUE}[INFO] Searching for TODO, FIXME, and HACK comments...${RESET}\n" - @printf "${BOLD}Found the following items:${RESET}\n\n" - @find . -type f \( -name "*.py" -o -name "*.mk" -o -name "*.sh" -o -name "*.md" -o -name "*.yml" -o -name "*.yaml" \) \ - -not -path "./.venv/*" \ - -not -path "./.git/*" \ - -not -path "./node_modules/*" \ - -not -path "./.tox/*" \ - -not -path "./build/*" \ - -not -path "./dist/*" \ - -print0 | xargs -0 grep -nHE "(TODO|FIXME|HACK):" 2>/dev/null | \ - grep -v "make todos" | \ - awk -F: '{ printf "${YELLOW}%s${RESET}:${GREEN}%s${RESET}: %s\n", $$1, $$2, substr($$0, index($$0,$$3)) }' || \ - 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 \ - ${UVX_BIN} semgrep --config .rhiza/semgrep.yml ${SOURCE_FOLDER}; \ - else \ - 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}" diff --git a/.rhiza/make.d/releasing.mk b/.rhiza/make.d/releasing.mk deleted file mode 100644 index 5dee330..0000000 --- a/.rhiza/make.d/releasing.mk +++ /dev/null @@ -1,50 +0,0 @@ -## .rhiza/make.d/releasing.mk - Releasing and Versioning -# This file provides targets for version bumping and release management. - -# Declare phony targets (they don't produce files) -.PHONY: bump release publish release-status pre-bump post-bump pre-release post-release - -# Hook targets (double-colon rules allow multiple definitions) -pre-bump:: ; @: -post-bump:: ; @: -pre-release:: ; @: -post-release:: ; @: - -# DRY_RUN support: pass DRY_RUN=1 to preview changes without applying them -_DRY_RUN_FLAG := $(if $(DRY_RUN),--dry-run,) -_VERSION=0.5.1 - -##@ Releasing and Versioning -bump: pre-bump ## bump version of the project (supports DRY_RUN=1) - @if [ -f "pyproject.toml" ]; then \ - $(MAKE) install; \ - PATH="$(abspath ${VENV})/bin:$$PATH" ${UVX_BIN} "rhiza-tools>=$(_VERSION)" bump $(_DRY_RUN_FLAG); \ - if [ -z "$(DRY_RUN)" ]; then \ - printf "${BLUE}[INFO] Checking uv.lock file...${RESET}\n"; \ - ${UV_BIN} lock; \ - fi; \ - else \ - printf "${YELLOW}[WARN] No pyproject.toml found, skipping bump${RESET}\n"; \ - fi - @$(MAKE) post-bump - -release: pre-release install-uv ## create tag and push to remote repository triggering release workflow (supports DRY_RUN=1) - ${UVX_BIN} "rhiza-tools>=$(_VERSION)" release $(_DRY_RUN_FLAG); - @$(MAKE) post-release - -publish: pre-release install-uv ## bump version, create tag and push in one step (supports DRY_RUN=1) - ${UVX_BIN} "rhiza-tools>=$(_VERSION)" release --with-bump $(_DRY_RUN_FLAG); - @$(MAKE) post-release - -release-status: ## show release workflow status and latest release information -ifeq ($(FORGE_TYPE),github) - @{ $(MAKE) --no-print-directory workflow-status; printf "\n"; $(MAKE) --no-print-directory latest-release; } 2>&1 | $${PAGER:-less -R} -else ifeq ($(FORGE_TYPE),gitlab) - @printf "${YELLOW}[WARN] GitLab detected — release-status is not yet supported for GitLab repositories.${RESET}\n" - @printf "${BLUE}[INFO] Please check your pipeline status in the GitLab UI.${RESET}\n" -else - @printf "${RED}[ERROR] Could not detect forge type (.github/workflows/ or .gitlab-ci.yml not found)${RESET}\n" -endif - - - diff --git a/.rhiza/make.d/test.mk b/.rhiza/make.d/test.mk deleted file mode 100644 index 3af2847..0000000 --- a/.rhiza/make.d/test.mk +++ /dev/null @@ -1,172 +0,0 @@ -## Makefile.tests - Testing and benchmarking targets -# This file is included by the main Makefile. -# It provides targets for running the test suite with coverage and -# executing performance benchmarks. - -# Declare phony targets (they don't produce files) -.PHONY: test benchmark typecheck security docs-coverage hypothesis-test coverage-badge stress test-pyproject 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 - -# 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/" \ - --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. -# 3. Generates detailed hypothesis examples and statistics. -hypothesis-test:: install ## run property-based tests with Hypothesis - @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 hypothesis tests.${RESET}\n"; \ - exit 0; \ - fi; \ - printf "${BLUE}[INFO] Running Hypothesis property-based tests...${RESET}\n"; \ - mkdir -p _tests/hypothesis; \ - PYTEST_HTML_TITLE="Hypothesis tests" ${UV_BIN} run pytest \ - --ignore=${TESTS_FOLDER}/benchmarks \ - -v \ - --hypothesis-show-statistics \ - --hypothesis-seed=0 \ - -m "hypothesis or property" \ - --tb=short \ - --html=_tests/hypothesis/report.html; \ - exit_code=$$?; \ - if [ $$exit_code -eq 5 ]; then \ - printf "${YELLOW}[WARN] No hypothesis/property tests collected, skipping.${RESET}\n"; \ - exit 0; \ - fi; \ - exit $$exit_code - -# The 'stress' target runs stress/load tests. -# 1. Checks if stress tests exist in the tests/stress directory. -# 2. Runs pytest with the stress marker to execute only stress tests. -# 3. Generates an HTML report of stress test results. -stress:: install ## run stress/load tests - @if [ ! -d "${TESTS_FOLDER}/stress" ]; then \ - printf "${YELLOW}[WARN] Stress tests folder not found, skipping stress tests.${RESET}\n"; \ - exit 0; \ - fi; \ - printf "${BLUE}[INFO] Running stress/load tests...${RESET}\n"; \ - mkdir -p _tests/stress; \ - ${UV_BIN} run pytest \ - -v \ - -m stress \ - --tb=short \ - --html=_tests/stress/report.html - -mutation: install ## run mutation tests with mutmut - @if [ ! -d ${SOURCE_FOLDER} ]; then \ - printf "${YELLOW}[WARN] Source folder ${SOURCE_FOLDER} not found, skipping mutation tests.${RESET}\n"; \ - exit 0; \ - fi; \ - printf "${BLUE}[INFO] Running mutation tests on ${SOURCE_FOLDER}...${RESET}\n"; \ - mkdir -p _tests/mutation; \ - run_status=0; \ - ${UV_BIN} run mutmut run \ - --paths-to-mutate="${SOURCE_FOLDER}" \ - --tests-dir="${TESTS_FOLDER}" || run_status=$$?; \ - ${UV_BIN} run mutmut html || exit $$?; \ - rm -rf _tests/mutation/html; \ - mv html _tests/mutation/html || exit $$?; \ - ${UV_BIN} run 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/requirements/README.md b/.rhiza/requirements/README.md deleted file mode 100644 index b98278a..0000000 --- a/.rhiza/requirements/README.md +++ /dev/null @@ -1,27 +0,0 @@ -# Requirements Folder - -This folder contains the development dependencies for the Rhiza project, organized by purpose. - -## Files - -- **tests.txt** - Testing dependencies (pytest, pytest-cov, pytest-html, pytest-mock, PyYAML, defusedxml, hypothesis, pytest-benchmark, pygal) -- **marimo.txt** - Marimo notebook dependencies -- **docs.txt** - Documentation generation dependencies (interrogate, mkdocs, mkdocs-material, mkdocstrings) -- **tools.txt** - Development tools (pre-commit, python-dotenv, typer, ty) - -## Usage - -These requirements files are automatically installed by the `make install` command. - -To install specific requirement files manually: - -```bash -uv pip install -r .rhiza/requirements/tests.txt -uv pip install -r .rhiza/requirements/marimo.txt -uv pip install -r .rhiza/requirements/docs.txt -uv pip install -r .rhiza/requirements/tools.txt -``` - -## CI/CD - -GitHub Actions workflows automatically install these requirements as needed. diff --git a/.rhiza/requirements/docs.txt b/.rhiza/requirements/docs.txt deleted file mode 100644 index bbded50..0000000 --- a/.rhiza/requirements/docs.txt +++ /dev/null @@ -1,4 +0,0 @@ -# Documentation dependencies for rhiza -interrogate>=1.7.0 -mike>=2.2.0 -zensical>=0.0.33 diff --git a/.rhiza/requirements/marimo.txt b/.rhiza/requirements/marimo.txt deleted file mode 100644 index 032c872..0000000 --- a/.rhiza/requirements/marimo.txt +++ /dev/null @@ -1,2 +0,0 @@ -# Marimo dependencies for rhiza -marimo>=0.18.0 diff --git a/.rhiza/requirements/tests.txt b/.rhiza/requirements/tests.txt deleted file mode 100644 index 96cc631..0000000 --- a/.rhiza/requirements/tests.txt +++ /dev/null @@ -1,18 +0,0 @@ -# Test dependencies for rhiza -pytest>=8.0 -python-dotenv>=1.0 -pytest-cov>=6.0 -pytest-html>=4.0 -pytest-mock>=3.0 -pytest-xdist>=3.0 -pytest-timeout>=2.0 -PyYAML>=6.0 -defusedxml>=0.7.0 -mutmut>=2.0,<3.0 - -# For property-based testing -hypothesis>=6.150.0 - -# For benchmarks -pytest-benchmark>=5.2.3 -pygal>=3.1.0 diff --git a/.rhiza/requirements/tools.txt b/.rhiza/requirements/tools.txt deleted file mode 100644 index 92bbda1..0000000 --- a/.rhiza/requirements/tools.txt +++ /dev/null @@ -1,7 +0,0 @@ -# Development tool dependencies for rhiza -pre-commit==4.5.1 -python-dotenv==1.2.2 - -# for now needed until rhiza-tools is finished -typer==0.21.1 -ty>=0.0.30 diff --git a/.rhiza/rhiza.mk b/.rhiza/rhiza.mk deleted file mode 100644 index 19c57dd..0000000 --- a/.rhiza/rhiza.mk +++ /dev/null @@ -1,178 +0,0 @@ -## Makefile for jebel-quant/rhiza -# (https://github.com/jebel-quant/rhiza) -# -# Purpose: Developer tasks using uv/uvx (install, test, book). -# Lines with `##` after a target are parsed into help text, -# and lines starting with `##@` create section headers in the help output. -# -# Require GNU Make (MAKE_VERSION is unset in BSD make) -ifndef MAKE_VERSION -$(error GNU Make is required. macOS ships BSD make — install GNU Make with: brew install make) -endif - -# Colours for pretty output in help messages -BLUE := \033[36m -BOLD := \033[1m -GREEN := \033[32m -RED := \033[31m -YELLOW := \033[33m -RESET := \033[0m - -# Default goal when running `make` with no target -.DEFAULT_GOAL := help - -# 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 - -# 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) -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. --include .rhiza/.env - -# ============================================================================== -# Rhiza Core -# ============================================================================== - -# RHIZA_LOGO definition -define RHIZA_LOGO - ____ _ _ - | _ \| |__ (_)______ _ - | |_) | '_ \| |_ / _\`| - | _ <| | | | |/ / (_| | - |_| \_\_| |_|_/___\__,_| - -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:: ; @: - -##@ Rhiza Workflows - -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) - +@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"])' - -print-% : ## print the value of a variable (usage: make print-VARIABLE) - @printf "${BLUE}[INFO] Printing value of variable '$*':${RESET}\n" - @printf "${BOLD}Value of $*:${RESET}\n" - @printf "${GREEN}" - @printf "%s\n" "$($*)" - @printf "${RESET}" - @printf "${BLUE}[INFO] End of value for '$*'${RESET}\n" - -# Optional: repo extensions (committed) --include .rhiza/make.d/*.mk diff --git a/.rhiza/tests/README.md b/.rhiza/tests/README.md deleted file mode 100644 index b79c24b..0000000 --- a/.rhiza/tests/README.md +++ /dev/null @@ -1,81 +0,0 @@ -# Rhiza Test Suite (`.rhiza/tests/`) - -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. - -> 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. - -## Layout - -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: - -| 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 | - -Every profile pairs `core` with exactly one language layer, so `conftest.py` is always -present alongside whichever layer's modules arrived. - -### Skipping README code blocks with `+RHIZA_SKIP` - -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 -# This block will NOT be executed or syntax-checked -from my_env import some_function -some_function() -``` - -```bash +RHIZA_SKIP -# This bash block will NOT be syntax-checked -run-something --only-on-ci -``` -~~~ - -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. - -## Running Tests - -```bash -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 - -Defined in `conftest.py` and available to every test without import: - -- `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 - -- Use descriptive test names that explain what is being tested -- Group related tests in classes when appropriate -- Add docstrings to test modules and complex test functions -- Use `pytest.mark.skip` for tests that depend on optional features diff --git a/.rhiza/tests/conftest.py b/.rhiza/tests/conftest.py deleted file mode 100644 index 7005ded..0000000 --- a/.rhiza/tests/conftest.py +++ /dev/null @@ -1,72 +0,0 @@ -"""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 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 -""" - -import logging -import pathlib -import shutil -import subprocess # nosec B404 - -import pytest - -_GIT = shutil.which("git") or "/usr/bin/git" - - -@pytest.fixture(scope="session") -def root(): - """Return the repository root directory as a pathlib.Path. - - Used by tests to locate files and scripts relative to the project root. - """ - return pathlib.Path(__file__).parent.parent.parent - - -@pytest.fixture(scope="session") -def logger(): - """Provide a session-scoped logger for tests. - - Returns: - logging.Logger: Logger configured for the test session. - """ - return logging.getLogger(__name__) - - -@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/utils/pip_audit_policy.py b/.rhiza/utils/pip_audit_policy.py deleted file mode 100644 index 4a5db0c..0000000 --- a/.rhiza/utils/pip_audit_policy.py +++ /dev/null @@ -1,67 +0,0 @@ -"""Run pip-audit with a tiered vulnerability policy. - -Fails the build for vulnerabilities in runtime dependencies. -Warns (without failing) for tooling packages: pip, setuptools, wheel, distribute. -Any extra arguments are forwarded to pip-audit (e.g. ``--ignore-vuln CVE-XXXX-YYYY``). -""" - -from __future__ import annotations - -import json -import shutil -import subprocess # nosec B404 -import sys - -_RESET = "\033[0m" -_RED = "\033[31m" -_YELLOW = "\033[33m" -_GREEN = "\033[32m" - -# Packages treated as build tooling — CVEs warn but do not fail CI. -_TOOLING: frozenset[str] = frozenset({"pip", "setuptools", "wheel", "distribute"}) - - -def _vuln_ids(vuln: dict) -> str: # type: ignore[type-arg] - """Return a human-readable string of all IDs for a vulnerability entry.""" - ids = [vuln["id"]] + [a for a in vuln.get("aliases", []) if a != vuln["id"]] - return ", ".join(ids) - - -def main() -> int: - """Run pip-audit and apply tiered vulnerability policy.""" - uvx = shutil.which("uvx") or "uvx" - cmd = [uvx, "pip-audit", "--format", "json", *sys.argv[1:]] - proc = subprocess.run(cmd, capture_output=True, text=True) # noqa: S603 # nosec B603 - - if proc.returncode == 0: - print(f"{_GREEN}[OK] pip-audit: no vulnerabilities found{_RESET}") - return 0 - - try: - data = json.loads(proc.stdout) - except json.JSONDecodeError: - sys.stdout.write(proc.stdout) - sys.stderr.write(proc.stderr) - return proc.returncode - - deps = data.get("dependencies", []) - tooling_vulns = [d for d in deps if d.get("vulns") and d["name"].lower() in _TOOLING] - runtime_vulns = [d for d in deps if d.get("vulns") and d["name"].lower() not in _TOOLING] - - for dep in tooling_vulns: - for v in dep["vulns"]: - print( - f"{_YELLOW}[WARN] {dep['name']}=={dep['version']}: {_vuln_ids(v)} (tooling — not failing build){_RESET}" - ) - - if not runtime_vulns: - return 0 - - for dep in runtime_vulns: - for v in dep["vulns"]: - print(f"{_RED}[FAIL] {dep['name']}=={dep['version']}: {_vuln_ids(v)}{_RESET}") - return 1 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/.rhiza/utils/suppression_audit.py b/.rhiza/utils/suppression_audit.py deleted file mode 100644 index 069d2e7..0000000 --- a/.rhiza/utils/suppression_audit.py +++ /dev/null @@ -1,369 +0,0 @@ -"""Suppression audit: scan codebase for inline suppressions of security, coverage, docs, and linting. - -Detects and reports on inline suppression comments such as: -- ``# noqa`` / ``# noqa: CODE`` (ruff/flake8 linting suppressions) -- ``# nosec`` / ``# nosec: CODE`` (bandit security suppressions) -- ``# type: ignore`` / ``# type: ignore[CODE]`` (mypy/pyright type-checking suppressions) -- ``# pragma: no cover`` (coverage suppressions) -- ``# noinspection CODE`` (PyCharm/IDE suppressions) - -Outputs a detailed per-file report, an ASCII histogram, and a letter grade. -""" - -from __future__ import annotations - -import argparse -import io -import json -import re -import shutil -import subprocess # nosec B404 -import sys -import tokenize -from collections import Counter -from dataclasses import dataclass, field -from pathlib import Path - -# --------------------------------------------------------------------------- -# Suppression patterns -# --------------------------------------------------------------------------- - -# Each entry: (kind_label, compiled_regex). -# The first capture group (if any) captures the comma-separated rule codes. -SUPPRESSION_PATTERNS: list[tuple[str, re.Pattern[str]]] = [ - ( - "noqa", - re.compile(r"#\s*noqa(?:\s*:\s*([A-Z0-9]+(?:\s*,\s*[A-Z0-9]+)*))?", re.IGNORECASE), - ), - ( - "nosec", - re.compile(r"#\s*nosec(?:\s*:?\s*([A-Z0-9]+(?:\s*,\s*[A-Z0-9]+)*))?", re.IGNORECASE), - ), - ( - "type:ignore", - re.compile(r"#\s*type\s*:\s*ignore(?:\[([^\]]+)\])?", re.IGNORECASE), - ), - ( - "no cover", - re.compile(r"#\s*pragma\s*:\s*no\s+cover", re.IGNORECASE), - ), - ( - "noinspection", - re.compile(r"#\s*noinspection\s+(\w+)", re.IGNORECASE), - ), -] - -# Directories to skip during the scan -_SKIP_DIRS = {".venv", ".git", "node_modules", ".tox", "build", "dist", "__pycache__", "tests"} - - -# --------------------------------------------------------------------------- -# Data model -# --------------------------------------------------------------------------- - - -@dataclass -class Suppression: - """Represents a single suppression comment found in the codebase.""" - - file: str - line_no: int - kind: str - codes: list[str] = field(default_factory=list) - raw: str = "" - - -# --------------------------------------------------------------------------- -# Scanning helpers -# --------------------------------------------------------------------------- - - -def _should_skip(path: Path) -> bool: - """Return True if any path component is in the skip-list.""" - return bool(_SKIP_DIRS.intersection(path.parts)) - - -def _is_rhiza_repo(root: Path) -> bool: - """Return True if *root* is the rhiza framework repo itself. - - Consumer repos have a ``.rhiza/template.yml`` file that records the upstream - rhiza repository reference. The rhiza repo itself never has this file — its - absence is the reliable signal that we are running inside the framework repo. - """ - return not (root / ".rhiza" / "template.yml").exists() - - -def scan_file(path: Path) -> list[Suppression]: - """Scan a single Python file and return all suppressions found. - - Uses Python's ``tokenize`` module so that only actual comment tokens are - inspected — patterns that appear inside string literals or docstrings are - correctly ignored. - """ - suppressions: list[Suppression] = [] - try: - source = path.read_text(encoding="utf-8", errors="replace") - except OSError: - return suppressions - - try: - tokens = tokenize.generate_tokens(io.StringIO(source).readline) - for tok_type, tok_string, tok_start, _tok_end, _line in tokens: - if tok_type != tokenize.COMMENT: - continue - line_no = tok_start[0] - for kind, pattern in SUPPRESSION_PATTERNS: - match = pattern.search(tok_string) - if match: - codes_raw = match.group(1) if match.lastindex and match.group(1) else "" - codes = [c.strip() for c in codes_raw.split(",") if c.strip()] if codes_raw else [] - suppressions.append( - Suppression( - file=str(path), - line_no=line_no, - kind=kind, - codes=codes, - raw=tok_string.strip(), - ) - ) - break # count each comment line once - except tokenize.TokenError: - pass # skip files with tokenization errors (e.g. incomplete source) - - return suppressions - - -def count_non_empty_lines(path: Path) -> int: - """Count non-empty lines in a file.""" - try: - return sum(1 for line in path.read_text(encoding="utf-8", errors="replace").splitlines() if line.strip()) - except OSError: - return 0 - - -# --------------------------------------------------------------------------- -# Grading -# --------------------------------------------------------------------------- - -# Grade thresholds: suppressions per 100 lines of code -_GRADE_THRESHOLDS: list[tuple[float, str]] = [ - (0.0, "A+"), - (0.5, "A"), - (1.0, "B"), - (2.0, "C"), - (3.0, "D"), -] - - -def compute_grade(density: float) -> str: - """Return a letter grade based on suppression density (count per 100 lines).""" - grade = "F" - for threshold, letter in _GRADE_THRESHOLDS: - if density <= threshold: - grade = letter - break - return grade - - -# --------------------------------------------------------------------------- -# Rendering helpers -# --------------------------------------------------------------------------- - -_BAR_WIDTH = 24 - - -def _bar(count: int, max_count: int) -> str: - """Render a fixed-width ASCII progress bar.""" - if max_count == 0: - return "░" * _BAR_WIDTH - filled = round(count / max_count * _BAR_WIDTH) - return "█" * filled + "░" * (_BAR_WIDTH - filled) - - -_GRADE_COLOURS = { - "A+": "\033[92m", # bright green - "A": "\033[32m", # green - "B": "\033[32m", # green - "C": "\033[33m", # yellow - "D": "\033[33m", # yellow - "F": "\033[31m", # red -} -_RESET = "\033[0m" -_BOLD = "\033[1m" -_BLUE = "\033[36m" -_YELLOW = "\033[33m" -_GREEN = "\033[32m" -_RED = "\033[31m" -_CVE_RE = re.compile(r"\bCVE-\d{4}-\d+\b", re.IGNORECASE) - - -# --------------------------------------------------------------------------- -# Main -# --------------------------------------------------------------------------- - - -def _active_pip_audit_ids(extra_args: list[str]) -> set[str]: - """Return vulnerability IDs currently reported by pip-audit.""" - uvx = shutil.which("uvx") or "uvx" - cmd = [uvx, "pip-audit", "--format", "json", *extra_args] - proc = subprocess.run(cmd, capture_output=True, text=True) # noqa: S603 # nosec B603 - - if proc.returncode not in {0, 1}: - sys.stdout.write(proc.stdout) - sys.stderr.write(proc.stderr) - raise RuntimeError("pip-audit execution failed") - - try: - data = json.loads(proc.stdout or "{}") - except json.JSONDecodeError as exc: - raise RuntimeError("pip-audit did not return valid JSON") from exc - - ids: set[str] = set() - for dep in data.get("dependencies", []): - for vuln in dep.get("vulns", []): - vuln_id = vuln.get("id") - if vuln_id: - ids.add(str(vuln_id).upper()) - for alias in vuln.get("aliases", []): - ids.add(str(alias).upper()) - return ids - - -def _nosec_cves(suppressions: list[Suppression]) -> set[str]: - """Extract CVE identifiers referenced by # nosec suppressions.""" - cves: set[str] = set() - for sup in suppressions: - if sup.kind != "nosec": - continue - cves.update(match.upper() for match in _CVE_RE.findall(sup.raw)) - return cves - - -def _collect_suppressions(root: Path) -> tuple[list[Path], list[Suppression], int]: - """Collect Python files, suppressions, and non-empty line counts.""" - in_rhiza_repo = _is_rhiza_repo(root) - - def _include(p: Path) -> bool: - if _should_skip(p): - return False - # In consumer repos, skip the .rhiza/ framework directory entirely - return not (not in_rhiza_repo and ".rhiza" in p.parts) - - py_files = sorted(p for p in root.rglob("*.py") if _include(p)) - - all_suppressions: list[Suppression] = [] - total_lines = 0 - for py_file in py_files: - all_suppressions.extend(scan_file(py_file)) - total_lines += count_non_empty_lines(py_file) - - return py_files, all_suppressions, total_lines - - -def _print_report(py_files: list[Path], all_suppressions: list[Suppression], total_lines: int) -> None: - """Print the suppression audit report.""" - # ----------------------------------------------------------------------- - # Header - # ----------------------------------------------------------------------- - print() - print(f"{_BOLD}{'=' * 62}{_RESET}") - print(f"{_BOLD} Suppression Audit Report{_RESET}") - print(f"{_BOLD}{'=' * 62}{_RESET}") - print() - - # ----------------------------------------------------------------------- - # Detailed per-file report - # ----------------------------------------------------------------------- - print(f"{_BOLD}Detailed Report:{_RESET}") - if all_suppressions: - for sup in all_suppressions: - codes_str = f"[{', '.join(sup.codes)}]" if sup.codes else "" - print(f" {_YELLOW}{sup.file}{_RESET}:{_GREEN}{sup.line_no}{_RESET}: # {sup.kind}{codes_str}") - else: - print(f" {_GREEN}No inline suppressions found.{_RESET}") - print() - - # ----------------------------------------------------------------------- - # Histogram by code - # ----------------------------------------------------------------------- - print(f"{_BOLD}Histogram (by suppression code):{_RESET}") - code_counter: Counter[str] = Counter() - for sup in all_suppressions: - if sup.codes: - for code in sup.codes: - code_counter[f"{sup.kind}[{code}]"] += 1 - else: - code_counter[f"{sup.kind}"] += 1 - if code_counter: - max_code_count = max(code_counter.values()) - total_code_count = sum(code_counter.values()) - for label, count in code_counter.most_common(): - pct = count / total_code_count * 100 - print(f" {label:<20} {_BLUE}{_bar(count, max_code_count)}{_RESET} {count:>3} ({pct:.0f}%)") - else: - print(" (none)") - print() - - # ----------------------------------------------------------------------- - # Summary + Grade - # ----------------------------------------------------------------------- - density = (len(all_suppressions) / total_lines * 100) if total_lines > 0 else 0.0 - grade = compute_grade(density) - grade_colour = _GRADE_COLOURS.get(grade, _RESET) - - print(f"{_BOLD}Summary:{_RESET}") - print(f" Files scanned : {len(py_files)}") - print(f" Lines scanned : {total_lines:,}") - print(f" Suppressions : {len(all_suppressions)}") - print(f" Density : {density:.2f} per 100 lines") - print() - print(f" Grade : {grade_colour}{_BOLD}{grade}{_RESET}") - print() - - -def _check_stale_nosec_cves(suppressions: list[Suppression], pip_audit_args: list[str]) -> int: - """Validate CVE-tagged # nosec suppressions against active pip-audit findings.""" - suppressed_cves = _nosec_cves(suppressions) - if not suppressed_cves: - print(f"{_GREEN}[OK]{_RESET} No CVE-tagged # nosec suppressions found.") - return 0 - - try: - active_cves = _active_pip_audit_ids(pip_audit_args) - except RuntimeError as exc: - print(f"{_RED}[FAIL]{_RESET} {exc}") - return 2 - - stale = sorted(cve for cve in suppressed_cves if cve not in active_cves) - if stale: - print(f"{_RED}[FAIL]{_RESET} Stale # nosec CVE suppressions detected:") - for cve in stale: - print(f" - {cve}") - return 1 - - print(f"{_GREEN}[OK]{_RESET} All CVE-tagged # nosec suppressions match active pip-audit findings.") - return 0 - - -def main(argv: list[str] | None = None) -> int: - """Run the suppression audit and print a structured report.""" - parser = argparse.ArgumentParser(add_help=True) - parser.add_argument( - "--fail-stale-nosec-cve", - action="store_true", - help="Fail when # nosec comments reference CVEs that pip-audit no longer reports.", - ) - args, pip_audit_args = parser.parse_known_args(argv) - - root = Path(".") - py_files, all_suppressions, total_lines = _collect_suppressions(root) - _print_report(py_files, all_suppressions, total_lines) - - if args.fail_stale_nosec_cve: - return _check_stale_nosec_cves(all_suppressions, pip_audit_args) - - return 0 - - -if __name__ == "__main__": - sys.exit(main(sys.argv[1:])) diff --git a/docs/assets/rhiza-logo.svg b/docs/assets/rhiza-logo.svg deleted file mode 100644 index ff1c9f5..0000000 --- a/docs/assets/rhiza-logo.svg +++ /dev/null @@ -1,81 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/docs/development/MARIMO.md b/docs/development/MARIMO.md deleted file mode 100644 index 1aa87af..0000000 --- a/docs/development/MARIMO.md +++ /dev/null @@ -1,134 +0,0 @@ -# Marimo Notebooks - -This directory contains interactive [Marimo](https://marimo.io/) notebooks for the Rhiza project. - -## Features - -Marimo notebooks support a wide range of features, including: - -- **Interactive UI Elements**: Sliders, dropdowns, text inputs, checkboxes, and multiselect -- **Reactive Programming**: Automatic cell updates when dependencies change -- **Data Visualisation**: Interactive plots using Plotly -- **DataFrames**: Working with Pandas data -- **Layout Components**: Columns, tabs, and accordions for organised content -- **Forms**: Dictionary-based forms for collecting user input -- **Rich Text**: Markdown and LaTeX support for documentation -- **Advanced Features**: Callouts, collapsible accordions, and more - -## Running the Notebooks - -### Using the Makefile - -From the repository root: - -```bash -make marimo -``` - -This will start the Marimo server and open all notebooks in the `docs/notebooks` directory. - -### Running a Specific Notebook - -To run a single notebook: - -```bash -marimo edit docs/notebooks/my_notebook.py -``` - -### Using uv (Recommended) - -The notebooks include inline dependency metadata, making them self-contained: - -```bash -uv run docs/notebooks/my_notebook.py -``` - -This will automatically install the required dependencies and run the notebook. - -## Notebook Structure - -Marimo notebooks are **pure Python files** (`.py`), not JSON. This means: - -- ✅ Easy version control with Git -- ✅ Standard code review workflows -- ✅ No hidden metadata -- ✅ Compatible with all Python tools - -Each notebook includes inline metadata that specifies its dependencies: - -```python -# /// script -# requires-python = ">=3.11" -# dependencies = [ -# "marimo==0.18.4", -# "numpy>=1.24.0", -# ] -# /// -``` - -## Configuration - -Marimo is configured in `pyproject.toml` to properly import the local package: - -```toml -[tool.marimo.runtime] -pythonpath = ["src"] -``` - -## CI/CD Integration - -The `.github/workflows/rhiza_marimo.yml` workflow automatically: - -1. Discovers all `.py` files in this directory -2. Runs each notebook in a fresh environment -3. Verifies that notebooks can bootstrap themselves -4. Ensures reproducibility - -This guarantees that all notebooks remain functional and up-to-date. - -## Creating New Notebooks - -To create a new Marimo notebook: - -1. Create a new `.py` file in this directory: - ```bash - marimo edit docs/notebooks/my_notebook.py - ``` - -2. Add inline metadata at the top: - ```python - # /// script - # requires-python = ">=3.11" - # dependencies = [ - # "marimo==0.18.4", - # # ... other dependencies - # ] - # /// - ``` - -3. Start building your notebook with cells - -4. Test it runs in a clean environment: - ```bash - uv run docs/notebooks/my_notebook.py - ``` - -5. Commit and push - the CI will validate it automatically - -## Learn More - -- **Marimo Documentation**: [https://docs.marimo.io/](https://docs.marimo.io/) -- **Example Gallery**: [https://marimo.io/examples](https://marimo.io/examples) -- **Community Discord**: [https://discord.gg/JE7nhX6mD8](https://discord.gg/JE7nhX6mD8) - -## Tips - -- **Reactivity**: Remember that cells automatically re-run when their dependencies change -- **Pure Python**: Edit notebooks in any text editor, not just Marimo's UI -- **Git-Friendly**: Notebooks diff and merge like regular Python files -- **Self-Contained**: Use inline metadata to make notebooks reproducible -- **Interactive**: Take advantage of Marimo's rich UI components for better user experience - ---- - -*Happy exploring with Marimo! 🚀* diff --git a/docs/development/TESTS.md b/docs/development/TESTS.md deleted file mode 100644 index f77b134..0000000 --- a/docs/development/TESTS.md +++ /dev/null @@ -1,288 +0,0 @@ -# Property-Based and Load/Stress Testing - -This document describes the property-based testing and load/stress testing infrastructure added to the Rhiza project. - -## Overview - -Rhiza now includes two additional types of testing: - -1. **Property-Based Testing** (using Hypothesis) - Tests that verify properties hold across a wide range of generated inputs -2. **Load/Stress Testing** (using pytest-benchmark) - Tests that measure performance and verify stability under load - -## README Code Block Testing - -The test file `.rhiza/tests/sync/test_readme_validation.py` automatically executes every `python` -code block in `README.md` and checks that its output matches the adjacent ` ```result ` block. It -also syntax-checks every `bash` block using `bash -n`. - -### Skipping individual blocks with `+RHIZA_SKIP` - -To exclude a specific code block from being executed or syntax-checked, append `+RHIZA_SKIP` to -the opening fence line: - -~~~markdown -```python +RHIZA_SKIP -# This block will NOT be executed or syntax-checked by the readme tests. -# Use it for illustrative examples, environment-specific code, or incomplete snippets. -from my_env import some_function -some_function() -``` - -```bash +RHIZA_SKIP -# This bash block will NOT be syntax-checked. -run-something --only-on-ci -``` -~~~ - -Markdown renderers (including GitHub) ignore everything after the first word on a fence line, so -the block still renders as a normal syntax-highlighted code block. All blocks that do **not** carry -`+RHIZA_SKIP` continue to be validated as before. - -## Property-Based Testing - -Property-based tests use the [Hypothesis](https://hypothesis.readthedocs.io/) library to automatically generate test cases that verify certain properties always hold true. - -### Locations - -In a standard Rhiza project, there are two relevant locations for property-based tests: - -- **Project property-based tests** live in `tests/property/`. These are part of your normal test suite and are discovered by `pytest` via `pytest.ini:testpaths = tests`. -- **Rhiza's own template/internal property-based tests** (if present) live in `.rhiza/tests/property/`. These are not part of your project's main test suite by default. - -### Running Property-Based Tests - -In a typical setup, the Make targets map to these suites as follows: - -- `make test`: runs `pytest` over everything under `tests/` (including `tests/property/`). -- `make rhiza-test`: runs Rhiza's internal tests under `.rhiza/tests/` (including `.rhiza/tests/property/` if any exist). - -You can also invoke the corresponding `pytest` commands directly: - -```bash -# Run all project property-based tests (what make test covers) -uv run pytest tests/property/ -v - -# Run Rhiza's internal/template property-based tests (if you have any in .rhiza) -uv run pytest .rhiza/tests/property/ -v - -# Run project property-based tests with more examples (increase coverage) -uv run pytest tests/property/ -v --hypothesis-max-examples=1000 - -# Run project property-based tests with verbose Hypothesis output -uv run pytest tests/property/ -v --hypothesis-verbosity=verbose -``` - -### Example Tests - -The following property-based tests are included as examples: - -#### Generic Property Tests -- **test_sort_correctness_using_properties**: Verifies that sorted() correctly orders lists and preserves all elements including duplicates - -## Load/Stress Testing - -Load and stress tests use [pytest-benchmark](https://pytest-benchmark.readthedocs.io/) to measure performance and verify system stability under load. - -### Location - -Benchmark and stress tests are located in `tests/benchmarks/` (if present) - -### Running Benchmark Tests - -```bash -# Run all benchmarks -make benchmark - -# Or with pytest directly -uv run pytest tests/benchmarks/ -v - -# Run benchmarks and generate histogram -uv run pytest tests/benchmarks/ --benchmark-histogram=_tests/benchmarks/histogram - -# Run benchmarks and save results -uv run pytest tests/benchmarks/ --benchmark-json=_tests/benchmarks/results.json - -# Skip benchmarks (for CI) -uv run pytest tests/benchmarks/ --benchmark-skip - -# Run only stress tests (note: these don't run with make benchmark by default) -uv run pytest tests/benchmarks/ -m stress -v - -# Skip stress tests (run only performance benchmarks) -uv run pytest tests/benchmarks/ -m "not stress" -v -``` - -**Note**: The `make benchmark` target runs with `--benchmark-only`, which means stress tests (that don't use the `benchmark` fixture) will be skipped. To run stress tests explicitly, use `uv run pytest tests/benchmarks/ -m stress -v`. - -### Benchmark Test Categories - -#### 1. Makefile Performance -Tests that measure the performance of common Makefile operations: -- `test_help_target_performance` - Measures help target execution time -- `test_print_variable_performance` - Measures variable printing performance -- `test_dry_run_install_performance` - Measures dry-run install performance -- `test_makefile_parsing_overhead` - Measures Makefile parsing overhead - -#### 2. File System Operations -Tests that benchmark file system operations: -- `test_directory_traversal_performance` - Measures directory traversal speed -- `test_file_reading_performance` - Measures file reading performance -- `test_multiple_file_checks_performance` - Measures file existence checking - -#### 3. Subprocess Overhead -Tests that measure subprocess creation overhead: -- `test_subprocess_creation_overhead` - Measures subprocess creation time -- `test_git_command_performance` - Measures git command execution time - -#### 4. Stress Scenarios -Tests that verify stability under load (marked with `@pytest.mark.stress`): -- `test_repeated_help_invocations` - Stress tests repeated help invocations (100 iterations) -- `test_concurrent_print_variable_stress` - Tests concurrent Makefile invocations (deterministic) -- `test_file_system_stress` - Tests rapid file creation/deletion (100 iterations) - -**Note**: Stress tests can be slow and are marked with the `stress` marker. They don't use the `benchmark` fixture, so they won't run with `make benchmark` (which uses `--benchmark-only`). Use `uv run pytest tests/benchmarks/ -m stress -v` to run them explicitly. - -### Understanding Benchmark Results - -Benchmark output includes: -- **Min/Max**: Minimum and maximum execution times -- **Mean**: Average execution time -- **StdDev**: Standard deviation (consistency) -- **Median**: Median execution time -- **IQR**: Interquartile range -- **Outliers**: Number of outlier measurements -- **OPS**: Operations per second (1/Mean) - -Example output: -``` ---------------------------------------------------- benchmark: 1 tests --------------------------------------------------- -Name (time in ms) Min Max Mean StdDev Median IQR Outliers OPS Rounds Iterations --------------------------------------------------------------------------------------------------------------------------- -test_help_target_performance 16.5255 18.0592 16.9294 0.3194 16.8354 0.4791 15;1 59.0689 55 1 --------------------------------------------------------------------------------------------------------------------------- -``` - -## Integration with CI/CD - -### Running in CI - -The property-based tests run as part of the regular test suite: - -```bash -# Run all tests including property-based tests -make test -``` - -Benchmarks can be run separately or as part of validation: - -```bash -# Run benchmarks -make benchmark -``` - -## Best Practices - -### Writing Property-Based Tests - -1. **Focus on invariants**: Test properties that should always hold true -2. **Use appropriate strategies**: Choose Hypothesis strategies that generate realistic inputs -3. **Keep tests fast**: Property tests run multiple times, so keep them quick -4. **Test edge cases**: Use `@example` decorator to test specific known edge cases - -Example: -```python -from hypothesis import given, strategies as st, example - -@given(version=st.from_regex(r"^\d+\.\d+\.\d+$", fullmatch=True)) -@example(version="0.0.0") # Test specific edge case -def test_version_parsing(version): - parts = version.split(".") - assert len(parts) == 3 - assert all(p.isdigit() for p in parts) -``` - -### Writing Benchmark Tests - -1. **Benchmark real operations**: Test actual operations users will perform -2. **Use fixtures wisely**: Use module or session-scoped fixtures for expensive setup -3. **Test multiple scenarios**: Benchmark best case, average case, and worst case -4. **Monitor trends**: Track benchmark results over time to detect regressions - -Example: -```python -def test_operation_performance(benchmark): - def run_operation(): - # Operation to benchmark - return perform_operation() - - result = benchmark(run_operation) - assert result is not None -``` - -### Writing Stress Tests - -1. **Test realistic scenarios**: Simulate real-world usage patterns -2. **Set reasonable thresholds**: Allow small failure rates for resource contention -3. **Test concurrency**: Use ThreadPoolExecutor or ProcessPoolExecutor for concurrent tests -4. **Monitor resource usage**: Consider memory, CPU, and I/O in addition to time - -Example: -```python -def test_concurrent_operations(root): - import concurrent.futures - - def operation(): - # Operation to stress test - return perform_operation() - - with concurrent.futures.ThreadPoolExecutor(max_workers=10) as executor: - futures = [executor.submit(operation) for _ in range(100)] - results = [f.result() for f in concurrent.futures.as_completed(futures)] - - success_rate = sum(results) / len(results) - assert success_rate == 1.0 # Rhiza template stress tests require 100% success -``` - -## Dependencies - -The following dependencies are required for property-based and load/stress testing: - -``` -# Property-based testing -hypothesis>=6.150.0 - -# Benchmarking and performance testing -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`. - -## Troubleshooting - -### Hypothesis Hangs or Times Out - -If Hypothesis tests hang, you can: -1. Reduce the number of examples: `pytest --hypothesis-max-examples=10` -2. Set a deadline: `pytest --hypothesis-deadline=1000` -3. Use the CI profile: `pytest --hypothesis-profile=ci` - -### Benchmarks Vary Too Much - -If benchmark results have high variance: -1. Close other applications to reduce system load -2. Increase the number of rounds: `pytest --benchmark-min-rounds=10` -3. Run on a consistent environment (CI is preferred for accurate benchmarks) - -### Stress Tests Fail - -If stress tests fail occasionally: -1. Check system resources (memory, CPU) -2. Increase acceptable failure rate if resource contention is expected -3. Reduce iteration count for local development - -## References - -- [Hypothesis Documentation](https://hypothesis.readthedocs.io/) -- [pytest-benchmark Documentation](https://pytest-benchmark.readthedocs.io/) -- [GitHub Actions Benchmark Action](https://github.com/benchmark-action/github-action-benchmark) From f92f031ac8606fa85b0ac3f16268b0fe9960abb0 Mon Sep 17 00:00:00 2001 From: Thomas Schmelzer Date: Tue, 29 Sep 2026 09:43:06 +0400 Subject: [PATCH 4/5] fix(ci): satisfy rhiza v1.9.0 lint and docs-coverage gates - ruff A/ARG/BLE/PIE: drop redundant pass, prefix unused handler args, narrow the static-mount except to RuntimeError; keep 'open' as the public OHLC keyword and the best-effort browser catch, with noqa reasons - interrogate now covers tests/: document two nested stress helpers Co-Authored-By: Claude Opus 5.5 --- src/pycharting/api/interface.py | 6 +++--- src/pycharting/core/server.py | 6 +++--- src/pycharting/data/ingestion.py | 6 ++---- tests/stress/test_session_registry_stress.py | 2 ++ 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/pycharting/api/interface.py b/src/pycharting/api/interface.py index 4b14922..a156f3f 100644 --- a/src/pycharting/api/interface.py +++ b/src/pycharting/api/interface.py @@ -74,7 +74,7 @@ def _notify(message: str) -> None: def plot( index: np.ndarray | pd.Series | list, - open: np.ndarray | pd.Series | list | None = None, + open: np.ndarray | pd.Series | list | None = None, # noqa: A002 # public OHLC keyword; renaming would break the API high: np.ndarray | pd.Series | list | None = None, low: np.ndarray | pd.Series | list | None = None, close: np.ndarray | pd.Series | list | None = None, @@ -172,7 +172,7 @@ def plot( if isinstance(index, list): index = np.array(index) if isinstance(open, list): - open = np.array(open) + open = np.array(open) # noqa: A001 # public OHLC keyword; renaming would break the API if isinstance(high, list): high = np.array(high) if isinstance(low, list): @@ -253,7 +253,7 @@ def plot( logger.info(f"Opening browser: {chart_url}") try: webbrowser.open(chart_url) - except Exception as e: + except Exception as e: # noqa: BLE001 # opening a browser is best-effort; the server is already up logger.warning(f"Could not open browser: {e}") _notify(f"Please open this URL manually: {chart_url}") diff --git a/src/pycharting/core/server.py b/src/pycharting/core/server.py index f4a5ae5..c3d7808 100644 --- a/src/pycharting/core/server.py +++ b/src/pycharting/core/server.py @@ -145,7 +145,7 @@ def create_app() -> FastAPI: try: app.mount("/static", NoCacheStaticFiles(directory=str(static_dir)), name="static") logger.info(f"Static files mounted from: {static_dir}") - except Exception as e: # pragma: no cover + except RuntimeError as e: # pragma: no cover logger.warning(f"Could not mount static files: {e}") # Root endpoint @@ -217,12 +217,12 @@ async def health_check() -> dict[str, str]: # Error handlers @app.exception_handler(404) - async def not_found_handler(request: Request, exc: Exception) -> JSONResponse: + async def not_found_handler(request: Request, _exc: Exception) -> JSONResponse: """Handle 404 errors.""" return JSONResponse(status_code=404, content={"error": "Not found", "path": str(request.url.path)}) @app.exception_handler(500) - async def server_error_handler(request: Request, exc: Exception) -> JSONResponse: # pragma: no cover + async def server_error_handler(_request: Request, exc: Exception) -> JSONResponse: # pragma: no cover """Handle 500 errors.""" logger.error(f"Server error: {exc}") return JSONResponse(status_code=500, content={"error": "Internal server error"}) diff --git a/src/pycharting/data/ingestion.py b/src/pycharting/data/ingestion.py index 6ab08d0..e895acc 100644 --- a/src/pycharting/data/ingestion.py +++ b/src/pycharting/data/ingestion.py @@ -23,12 +23,10 @@ class DataValidationError(Exception): """Exception raised when input data fails validation checks.""" - pass - def validate_input( index: pd.Index | pd.Series | np.ndarray, - open: pd.Series | np.ndarray | None = None, + open: pd.Series | np.ndarray | None = None, # noqa: A002 # public OHLC keyword; renaming would break the API high: pd.Series | np.ndarray | None = None, low: pd.Series | np.ndarray | None = None, close: pd.Series | np.ndarray | None = None, @@ -281,7 +279,7 @@ class DataManager: def __init__( self, index: pd.Index | pd.Series | np.ndarray, - open: pd.Series | np.ndarray | None = None, + open: pd.Series | np.ndarray | None = None, # noqa: A002 # public OHLC keyword; renaming would break the API high: pd.Series | np.ndarray | None = None, low: pd.Series | np.ndarray | None = None, close: pd.Series | np.ndarray | None = None, diff --git a/tests/stress/test_session_registry_stress.py b/tests/stress/test_session_registry_stress.py index c5fa916..9c3018c 100644 --- a/tests/stress/test_session_registry_stress.py +++ b/tests/stress/test_session_registry_stress.py @@ -55,6 +55,7 @@ def test_many_concurrent_sessions_stay_isolated(client: TestClient) -> None: payload = _series(1_000) def register(i: int) -> str: + """Register session ``i`` with every bar offset by ``i``.""" # Shift the whole bar, not just close — validate_input enforces # high >= max(open, close), so offsetting one series in isolation # is rejected. @@ -79,6 +80,7 @@ def test_concurrent_chunk_reads_are_consistent(client: TestClient) -> None: expected = manager.get_chunk(0, 500)["close"] def read(_: int) -> list[float]: + """Read the first 500 closes from the shared session.""" return manager.get_chunk(0, 500)["close"] with ThreadPoolExecutor(max_workers=WORKERS) as pool: From 57992f261ae42ea9d8caf0d1bfde6c30eab8dcfd Mon Sep 17 00:00:00 2001 From: Thomas Schmelzer Date: Tue, 29 Sep 2026 09:45:53 +0400 Subject: [PATCH 5/5] style: apply ruff format to README code blocks ruff 0.16 formats fenced Python in Markdown; the pre-commit gate now checks README.md. Co-Authored-By: Claude Opus 5.5 --- README.md | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 425dcf4..e1ce1da 100644 --- a/README.md +++ b/README.md @@ -78,12 +78,12 @@ Once you have your OHLC series, you pass additional series to `plot` in two diff ```python +RHIZA_SKIP overlays = { - "SMA_50": sma(close, 50), # rendered on top of price + "SMA_50": sma(close, 50), # rendered on top of price "EMA_200": ema(close, 200), } subplots = { - "RSI_like": rsi_like_series, # rendered in its own panel below price + "RSI_like": rsi_like_series, # rendered in its own panel below price "Stoch_like": stoch_series, } @@ -110,23 +110,19 @@ Each subplot value can be a plain array (line), a dict with options, or a list o subplots = { # Simple line (default) "RSI": rsi_array, - # Bar chart — green if value ≥ 0, red if < 0, centered at y=0 "Volume": {"data": volume_array, "type": "bar"}, - # Scatter plot "Events": {"data": events_array, "type": "scatter", "color": "#9C27B0"}, - # Multi-series panel: two lines + histogram bars in one subplot "MACD": [ - {"data": macd_line, "type": "line", "color": "#2196F3", "label": "MACD"}, + {"data": macd_line, "type": "line", "color": "#2196F3", "label": "MACD"}, {"data": signal_line, "type": "line", "color": "#FF9800", "label": "Signal"}, - {"data": histogram, "type": "bar", "label": "Histogram"}, + {"data": histogram, "type": "bar", "label": "Histogram"}, ], - # RSI with its own moving average overlay "RSI+SMA": [ - {"data": rsi, "type": "line", "color": "#FF9800", "label": "RSI"}, + {"data": rsi, "type": "line", "color": "#FF9800", "label": "RSI"}, {"data": rsi_sma, "type": "line", "color": "#2196F3", "label": "RSI SMA(20)"}, ], } @@ -143,8 +139,8 @@ You can overlay buy/sell arrows on the price chart by passing a `trades` array a import numpy as np trades = np.zeros(len(index), dtype=int) -trades[42] = 1 # buy at bar 42 -trades[100] = -1 # sell at bar 100 +trades[42] = 1 # buy at bar 42 +trades[100] = -1 # sell at bar 100 plot( index,