From 8a717947c3ec62a5e1fd4d1275f46d5b2bb9282d Mon Sep 17 00:00:00 2001 From: Thomas Schmelzer Date: Thu, 9 Jul 2026 13:17:04 +0400 Subject: [PATCH 1/4] chore: bump rhiza to v1.1.1 --- .rhiza/template.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.rhiza/template.yml b/.rhiza/template.yml index 798cfc9..f4d341d 100644 --- a/.rhiza/template.yml +++ b/.rhiza/template.yml @@ -1,5 +1,5 @@ repository: "jebel-quant/rhiza" -ref: "v0.18.8" +ref: "v1.1.1" profiles: - github-project From 782024e4fa58a828db30465d95a119d9ed81c654 Mon Sep 17 00:00:00 2001 From: Thomas Schmelzer Date: Thu, 9 Jul 2026 13:23:55 +0400 Subject: [PATCH 2/4] chore: apply rhiza sync v1.1.1 --- .claude/commands/rhiza_book.md | 53 +++ .claude/commands/rhiza_quality.md | 98 +++++ .claude/commands/rhiza_release.md | 82 ++++ .claude/commands/rhiza_update.md | 152 ++++++++ .github/CONFIG.md | 66 ++++ .github/DISCUSSION_TEMPLATE/help-wanted.yml | 48 +++ .github/DISCUSSION_TEMPLATE/ideas.yml | 40 ++ .github/dependabot.yml | 2 +- .github/pull_request_template.md | 1 + .github/rulesets/main-branch-protection.json | 47 +++ .github/workflows/rhiza_benchmark.yml | 2 +- .github/workflows/rhiza_book.yml | 13 +- .github/workflows/rhiza_ci.yml | 7 +- .github/workflows/rhiza_codeql.yml | 10 +- .github/workflows/rhiza_fuzzing.yml | 41 ++ .github/workflows/rhiza_marimo.yml | 2 +- .github/workflows/rhiza_mutation.yml | 50 +++ .github/workflows/rhiza_release.yml | 133 +++++-- .github/workflows/rhiza_scorecard.yml | 45 +++ .github/workflows/rhiza_sync.yml | 8 +- .github/workflows/rhiza_weekly.yml | 2 +- .pre-commit-config.yaml | 21 +- .rhiza/.cfg.toml | 32 ++ .rhiza/.env | 20 + .rhiza/.rhiza-version | 2 +- .rhiza/completions/README.md | 16 +- .rhiza/completions/rhiza-completion.bash | 42 +- .rhiza/completions/rhiza-completion.zsh | 53 ++- .rhiza/make.d/book.mk | 2 +- .rhiza/make.d/bootstrap.mk | 39 +- .rhiza/make.d/github.mk | 70 ++++ .rhiza/make.d/marimo.mk | 8 + .rhiza/make.d/quality.mk | 34 +- .rhiza/make.d/releasing.mk | 27 +- .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 | 52 ++- .rhiza/template.lock | 47 +-- .rhiza/tests/README.md | 155 ++------ .rhiza/tests/api/conftest.py | 98 ----- .rhiza/tests/api/test_github_targets.py | 63 --- .../tests/api/test_make_variable_overrides.py | 161 -------- .rhiza/tests/api/test_makefile_api.py | 369 ------------------ .rhiza/tests/api/test_makefile_targets.py | 363 ----------------- .rhiza/tests/conftest.py | 8 +- .rhiza/tests/integration/test_book_targets.py | 163 -------- .rhiza/tests/integration/test_docs_targets.py | 48 --- .rhiza/tests/integration/test_test_mk.py | 53 --- .../integration/test_virtual_env_unexport.py | 37 -- .rhiza/tests/shell/test_scripts.sh | 245 ------------ .rhiza/tests/stress/README.md | 2 - .rhiza/tests/structure/test_project_layout.py | 57 --- .rhiza/tests/structure/test_requirements.py | 51 --- .rhiza/tests/sync/conftest.py | 100 ----- .rhiza/tests/{sync => }/test_docstrings.py | 0 .../{utils => }/test_git_repo_fixture.py | 0 .../tests/{structure => }/test_pyproject.py | 8 +- .../{sync => }/test_readme_validation.py | 0 .rhiza/utils/pip_audit_policy.py | 67 ---- .rhiza/utils/suppression_audit.py | 369 ------------------ Makefile | 6 +- cliff.toml | 84 ++++ demo.py | 4 +- docs/development/TESTS.md | 11 +- docs/index.md | 1 - pytest.ini | 15 +- ruff.toml | 56 ++- src/pycharting/api/interface.py | 4 +- src/pycharting/core/lifecycle.py | 12 +- src/pycharting/core/server.py | 2 +- src/pycharting/data/ingestion.py | 4 +- 75 files changed, 1522 insertions(+), 2691 deletions(-) create mode 100644 .claude/commands/rhiza_book.md create mode 100644 .claude/commands/rhiza_quality.md create mode 100644 .claude/commands/rhiza_release.md create mode 100644 .claude/commands/rhiza_update.md create mode 100644 .github/CONFIG.md create mode 100644 .github/DISCUSSION_TEMPLATE/help-wanted.yml create mode 100644 .github/DISCUSSION_TEMPLATE/ideas.yml create mode 100644 .github/rulesets/main-branch-protection.json create mode 100644 .github/workflows/rhiza_fuzzing.yml create mode 100644 .github/workflows/rhiza_mutation.yml create mode 100644 .github/workflows/rhiza_scorecard.yml create mode 100644 .rhiza/make.d/github.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/tests/api/conftest.py delete mode 100644 .rhiza/tests/api/test_github_targets.py delete mode 100644 .rhiza/tests/api/test_make_variable_overrides.py delete mode 100644 .rhiza/tests/api/test_makefile_api.py delete mode 100644 .rhiza/tests/api/test_makefile_targets.py delete mode 100644 .rhiza/tests/integration/test_book_targets.py delete mode 100644 .rhiza/tests/integration/test_docs_targets.py delete mode 100644 .rhiza/tests/integration/test_test_mk.py delete mode 100644 .rhiza/tests/integration/test_virtual_env_unexport.py delete mode 100755 .rhiza/tests/shell/test_scripts.sh delete mode 100644 .rhiza/tests/structure/test_project_layout.py delete mode 100644 .rhiza/tests/structure/test_requirements.py delete mode 100644 .rhiza/tests/sync/conftest.py rename .rhiza/tests/{sync => }/test_docstrings.py (100%) rename .rhiza/tests/{utils => }/test_git_repo_fixture.py (100%) rename .rhiza/tests/{structure => }/test_pyproject.py (98%) rename .rhiza/tests/{sync => }/test_readme_validation.py (100%) delete mode 100644 .rhiza/utils/pip_audit_policy.py delete mode 100644 .rhiza/utils/suppression_audit.py create mode 100644 cliff.toml diff --git a/.claude/commands/rhiza_book.md b/.claude/commands/rhiza_book.md new file mode 100644 index 0000000..af4fee6 --- /dev/null +++ b/.claude/commands/rhiza_book.md @@ -0,0 +1,53 @@ +--- +description: Build the documentation book into a local _book folder and open it in the default browser +allowed-tools: Bash, Read +--- + +Build the MkDocs/zensical documentation book locally and open it in the user's +standard web browser. Follow the command-execution policy: always prefer +`make `; never invoke `.venv/bin/...` directly. + +This command depends on the **book** bundle (it expects a root `mkdocs.yml` and a +`make book` target from `.rhiza/make.d/book.mk`). If `mkdocs.yml` is missing, +stop and tell the user the `book` bundle is not set up here rather than guessing. + +Do the following, in order: + +1. **Build the book.** Run `make book`. This regenerates the `_book/` output + folder via zensical (it also runs the `_book-reports` / `_book-notebooks` + prerequisites). Run it in the foreground so build errors are visible. If it + fails, show the relevant output, diagnose the root cause, and stop — do not + try to open a stale or partial book. + +2. **Confirm the output.** Verify `_book/index.html` exists after the build. If + it does not, report what `make book` produced instead and stop. + +3. **Serve it locally (background).** Static zensical sites need to be served + over HTTP — search and navigation break under `file://`. Start a local server + for the built folder **in the background** so it does not block the session: + + ``` + (cd _book && uv run python -m http.server 8000) + ``` + + Use port 8000 by default (this matches `make serve`). If 8000 is already in + use, pick the next free port (8001, 8002, …) and use that URL throughout. + Do **not** use `make serve` here — it rebuilds the book a second time and + blocks the terminal; the book is already built from step 1. + +4. **Open the default browser.** Open the served URL with the platform's + standard opener: + - macOS: `open http://localhost:8000` + - Linux: `xdg-open http://localhost:8000` + - Windows: `start http://localhost:8000` + + Detect the platform and use the right one. + +5. **Report.** Tell the user the book was built at `_book/`, the URL it is being + served at, and how to stop the background server (e.g. the background task / + PID, or "kill the `http.server` process on port 8000"). Note that `_book/` is + a generated, gitignored artifact. + +If `$ARGUMENTS` is non-empty, treat it as an override hint — e.g. a custom port +(`/rhiza_book 9000`) or a request to only build without serving/opening (`/rhiza_book +build-only`) — and adjust accordingly. diff --git a/.claude/commands/rhiza_quality.md b/.claude/commands/rhiza_quality.md new file mode 100644 index 0000000..292a782 --- /dev/null +++ b/.claude/commands/rhiza_quality.md @@ -0,0 +1,98 @@ +--- +description: Run the Rhiza code-quality gate and score the repo (lint, types, docs, deps, security, tests) +--- + +Assess the quality of this repo against Rhiza standards. Follow the +command-execution policy: always prefer `make `; never invoke +`.venv/bin/...` directly. Run the gates in order — cheapest checks first so fast +failures surface before the slow test suite — and collect results: + +1. `make fmt` — pre-commit hooks + linting (ruff format/check, markdownlint, bandit, actionlint, …) +2. `make typecheck` — static type checking (`ty`, and `mypy --strict` if configured) over `src/` +3. `make docs-coverage` — docstring coverage (interrogate) over `src/` +4. `make deptry` — unused/missing/misplaced dependency analysis +5. `make security` — pip-audit + bandit scans +6. `make validate` — validate project structure against the Rhiza template (`.rhiza/template.yml`) +7. `make test` — full test suite **with** its coverage gate (slowest, run last) + +Guidelines: + +- Run each gate as a single, bare `make ` command — one Bash call per + gate. Do **not** pipe (`| tee`, `| tail`), redirect (`2>&1 >`), chain + (`make fmt && make typecheck`), or prefix with `cd`. Bare `make ` + invocations match the allow-listed `Bash(make *)` rule and run without a + permission prompt; compound or piped commands do not and will prompt on every + gate. Read the full output directly from each call rather than capturing it to + a file. +- Run all gates even after an early failure, so the full picture is visible + rather than stopping at the first red. +- If something fails, show the relevant output, diagnose the root cause, and + propose (or apply, if clearly correct, low-risk, **and** the fix is in a + locally-owned file per the scoping rule below) a fix. +- If `$ARGUMENTS` is non-empty, scope the assessment to that path or topic + instead of the whole repo. +- End with a concise PASS/FAIL summary per gate. + +**Coverage expectation.** `make test` enforces a coverage gate +(`COVERAGE_FAIL_UNDER`, default 90%; many projects raise it to 100%). Treat +anything below the configured threshold on locally-owned `src/` as a gap to +flag, not an acceptable baseline. When scoring the test-coverage subcategory, +the configured threshold is the bar for a 10; report uncovered lines +(`file:line`) and the test that would close each. + +**`make validate`.** A failure means this repo has drifted from the Rhiza +template (a synced file edited locally, or a missing/extra file). That is +in-scope: fix it by re-syncing from Rhiza or by adjusting `.rhiza/template.yml`, +not by editing the synced artifact in place. + +Then report: + +- A pass/fail summary per step. +- Failures grouped by file, with the specific rule/error and line. +- A prioritized list of what to fix first (blocking errors before style nits). + +Then analyse the repo and give marks on a scale of 1 to 10 for all relevant +subcategories. Pick the subcategories that fit what you actually observe — e.g. +linting/style, type safety, test pass rate, test coverage & depth, code +structure & readability, documentation, dependency & security hygiene, CI/tooling +health. For each: the score, a one-line justification grounded in evidence from +the checks above (and a quick look at the code where needed), and what would +raise it. Close with an overall score and the single highest-leverage +improvement. + +**Scope the scorecard to locally-owned items — not what the mother repo (Rhiza) +owns.** This project syncs its dev infrastructure from `jebel-quant/rhiza`; see +`CLAUDE.md` for the authoritative split and the `files:` block of +`.rhiza/template.lock` for the machine-generated list of synced files. Score +only what this repo actually controls — `src/`, `tests/`, `pyproject.toml`, +`README.md`, project-specific docs, `.rhiza/template.yml`, and any +locally-hardened config. Do **not** let Rhiza-managed files (the +`.github/workflows/*`, `Makefile`, `.pre-commit-config.yaml`, `pytest.ini`, +`ruff.toml`, the typecheck/mutation/fuzzing targets, etc.) drive the marks — a +gap there is fixed upstream in Rhiza, not here. If a relevant signal is +Rhiza-owned, note it as "upstream/out-of-scope" rather than scoring it against +this repo. + +Then, from the scorecard above, identify **actionable issues to improve the +score** — one per subcategory scoring below 10 (skip any that are maxed). For +each, give: a concrete title, the subcategory and current→target score it moves, +the specific file(s)/lines or config to change, and a crisp acceptance criterion +("done when…"). Keep them in-scope (locally-owned, per the scoping rule above) — +flag anything Rhiza-owned as upstream rather than listing it as a local action. +Order them by leverage (biggest score gain for least effort first). This is a +list of recommendations only — do not change code unless I explicitly ask. + +Then offer to file the findings as issues — using a menu, not a free-text prompt. +Present the actionable findings as a multi-select menu (the AskUserQuestion tool +with `multiSelect: true`), one option per finding labelled by its title, so I can +pick exactly which ones to file — including none. Create nothing without an +explicit selection. For each finding I select, detect the hosting platform from +the git remote (`git remote get-url origin`) and create one issue with the +matching CLI — GitHub → `gh issue create`, GitLab → `glab issue create` (skip and +say so if the relevant CLI is unavailable or unauthenticated). Make each issue +self-contained: title from the finding, and a body carrying the subcategory, the +current→target score, the specific file(s)/lines or config to change, and the +"done when…" acceptance criterion. Report back the created issue URLs. + +If everything passes, say so plainly — but still produce the 1–10 subcategory +marks. Do not fix anything unless I ask — this command only assesses. diff --git a/.claude/commands/rhiza_release.md b/.claude/commands/rhiza_release.md new file mode 100644 index 0000000..8f36e2a --- /dev/null +++ b/.claude/commands/rhiza_release.md @@ -0,0 +1,82 @@ +--- +description: Cut a release — choose the version bump, push the tag, and watch the release workflow +allowed-tools: Bash, Read +--- + +Cut a release for this repository. Follow the command-execution policy: always +prefer `make `; never invoke `.venv/bin/...` directly. + +How releasing works here. `make release` delegates to `rhiza-tools release` +(`.rhiza/make.d/releasing.mk`). It **always bumps** the version, folds a freshly +generated `CHANGELOG.md` into the version-bump commit (git-cliff pre-commit +hook), creates a `v*` tag, and pushes it. The tag push triggers the +`rhiza_release.yml` GitHub Actions workflow (validate → build → SBOM → draft → +publish → finalize). There is no separate post-tag changelog commit — the tagged +commit already carries the changelog. + +**The bump type is a user decision.** Run interactively, `make release` prompts +the user to pick `MAJOR`, `MINOR`, or `PATCH`. Because this command runs in a +non-interactive shell (where the tool would silently default to `PATCH`), **you +must ask the user which bump to apply** and pass their choice through with +`BUMP=major|minor|patch` (supported by both `make release` and `make bump`). + +Do the following, in order: + +1. **Pre-flight checks.** Confirm the release is safe to cut and stop with a + clear explanation if any check fails (do not push a tag from a dirty or + behind branch): + - Working tree is clean (`git status --porcelain` is empty). + - On the default branch (`main`) — or confirm with the user if not. + - Local `main` is up to date with `origin/main` (`git fetch` then compare). + - `pyproject.toml` exists (the bump is skipped without it). + +2. **Ask the user for the bump type.** Just like `make release` does + interactively, ask the user to choose **MAJOR**, **MINOR**, or **PATCH** + (semver: breaking / feature / fix). Skip this question only when + `$ARGUMENTS` already names a bump type or authorises a default (see below). + Hold the chosen `` (lowercase: `major` / `minor` / `patch`) for the + next steps. + +3. **Preview the bump (dry run).** Run `make release DRY_RUN=1 BUMP=` to + show the user the planned new version and tag **before** anything is pushed. + The `DRY_RUN=1` flag previews the bump/tag/push without applying them. + Summarise what the real run will do (old → new version, tag name). + +4. **Confirm.** Unless `$ARGUMENTS` explicitly authorises proceeding (see + below), stop here and ask the user to confirm the previewed version before + cutting the real release. Pushing a tag triggers a public release workflow — + treat it as outward-facing and confirm first. + +5. **Cut the release.** Run `make release BUMP= PUSH=1`. This bumps, + commits (with changelog), tags, and pushes. The `PUSH=1` flag is **required + here**: without it the tool asks an interactive `Push tag to remote? [y/N]` + question that this non-interactive shell auto-declines, leaving the bump + commit and tag stranded locally (unpushed). Since you already confirmed with + the user in step 4, `PUSH=1` pushes straight through. Run it in the + foreground so any failure is visible. If it fails, show the relevant output, + diagnose the root cause, and stop — do not re-run blindly or hand-craft a tag. + +6. **Watch the workflow.** Run `make release-status` to show the release + workflow run and the latest release info. If the workflow is still in + progress, tell the user it is running and how to re-check + (`make release-status`). Report the final release URL once available. + +7. **Report.** Tell the user the version that was released, the tag, and the + release/workflow URL. If anything is still pending (workflow running, draft + not yet published), say so plainly. + +`$ARGUMENTS` handling: + +- Empty → run the full ask-bump → preview → confirm flow above. +- `major` / `minor` / `patch` → use that as the bump type and skip the question + in step 2 (still preview and confirm). +- `dry-run` (or `preview`) → do steps 1–3 only and stop; do **not** cut a real + release. If no bump type is also given, still ask for it in step 2. +- `yes` / `confirm` / `--force` → skip the interactive confirmation in step 4 + and proceed straight through (still run the dry-run preview in step 3 so the + user sees what shipped). If no bump type is also given, still ask for it in + step 2. +- `status` → skip to step 6 and just report current release/workflow status. + +Argument tokens combine, e.g. `minor yes` cuts a minor release without the +confirmation prompt; `minor dry-run` previews a minor bump only. diff --git a/.claude/commands/rhiza_update.md b/.claude/commands/rhiza_update.md new file mode 100644 index 0000000..a457a18 --- /dev/null +++ b/.claude/commands/rhiza_update.md @@ -0,0 +1,152 @@ +--- +description: Update the pinned Rhiza version in .rhiza/template.yml, sync, resolve conflicts, and verify +--- + +Update this repo's Rhiza template to a newer release: bump the pin in +`.rhiza/template.yml`, run the sync, resolve every conflict, verify the quality +gates, and open a PR. Follow the command-execution policy: always prefer +`make `; never invoke `.venv/bin/...` directly. + +`$ARGUMENTS` may name a target version (e.g. `v0.19.1`). If empty, target the +**latest** release of the upstream template repo. + +## 1. Determine current and target versions + +- Read `.rhiza/template.yml`: the `repository:` field is the upstream template + repo (usually `jebel-quant/rhiza`) and `ref:` is the currently pinned version. +- Resolve the target version: + - If `$ARGUMENTS` names a version, use it (verify the tag exists: + `gh api repos//git/ref/tags/`). + - Otherwise get the latest release: + `gh release view --repo --json tagName,publishedAt`. +- If `ref:` already equals the target, report "already up to date" and stop. +- Briefly summarize what's between the two versions when it's cheap to do so + (`gh release view`/release notes), so the reviewer knows what's landing. + +### The Rhiza CLI pin (`.rhiza/.rhiza-version`) — ask before changing it + +`.rhiza/.rhiza-version` separately pins the **Rhiza CLI** — the `rhiza` package on +PyPI that `make sync` runs as `uvx "rhiza=="`. It is independent of the +template `ref:` above, and there is no `$ARGUMENTS` override for it: always target +the **latest** published version. + +- Read the current pin from `.rhiza/.rhiza-version`. +- Resolve the latest published version on PyPI: + `curl -s https://pypi.org/pypi/rhiza/json` and read `.info.version`. +- If the pin already equals the latest, there is nothing to do — leave it. +- If a newer version is available, **ask the user whether to update the CLI** to + that latest version (state current → latest). Only bump `.rhiza/.rhiza-version` + if they agree; if they decline, leave the file untouched and proceed with the + template sync alone. + +## 2. Bump the pin(s) and commit (the tree must be clean to sync) + +- `make sync` refuses to run on a dirty tree, so the bump lands first. +- Branch off the default branch (don't work on `main`/`master` directly): + `git checkout -b sync/rhiza-`. +- Edit `ref:` in `.rhiza/template.yml` to the target version. If the user agreed + to a CLI bump above, also set `.rhiza/.rhiza-version` to the latest PyPI version + in the same step, so `make sync` runs with the chosen CLI. +- Commit the pin change(s) (e.g. `Chore: bump rhiza template ref `, + noting the CLI bump in the message when one was made). + +## 3. Sync + +- Run `make sync` (it invokes `rhiza sync`). Expect it to either complete + cleanly or report conflicts. It writes the refreshed `.rhiza/template.lock`. +- If it completes with no conflicts, skip to step 5. + +## 4. Resolve every conflict + +The sync is a 3-way merge. Two kinds of leftovers can appear — handle both, and +finish with **zero** `*.rej` files and **zero** conflict markers +(`<<<<<<<` / `=======` / `>>>>>>>`) anywhere tracked +(`git grep -lE '^(<<<<<<<|=======|>>>>>>>)'`). + +**`*.rej` files (rejected hunks).** The 3-way merge often *already applied* a +hunk and still drops a duplicate `.rej`. For each, verify whether the change is +already present in the file (the added `+` lines exist; no conflict markers +remain). If it is, the `.rej` is spurious — delete it. If a hunk genuinely did +not apply, apply it by hand, then delete the `.rej`. + +**Conflict-marked files.** Resolve by the ownership rule (see `CLAUDE.md` and the +`files:` block of `.rhiza/template.lock` for the authoritative managed-file list): + +- **Rhiza-managed files** (the `.github/workflows/*`, `Makefile`, + `.pre-commit-config.yaml`, `pytest.ini`, the `.rhiza/` engine, etc.): take the + **incoming/upstream** side — these are owned by the template and should match + it (`git checkout --theirs -- ` then `git add`). +- **Locally-owned or locally-hardened files** (notably `ruff.toml`, plus + `pyproject.toml`, `README.md`, `src/`, your `tests/`): **merge by hand** — + keep the local intent (e.g. stricter lint rules) while folding in genuine + upstream additions, and make the result internally coherent (dedupe, drop + comments that now contradict the config). + +Validate every touched workflow/YAML still parses before moving on. + +## 5. Verify the gates and fix fallout + +A version bump can tighten the gates (new lint rules, `mypy --strict`, expanded +docs-coverage scope, etc.) and surface pre-existing issues. Run them and get +them green: + +1. `make fmt` — pre-commit + lint +2. `make typecheck` +3. `make docs-coverage` +4. `make deptry` +5. `make security` +6. `make test` + +**Scope your fixes.** Fix issues only in **locally-owned** files (`src/`, +`tests/`, `pyproject.toml`, locally-hardened config). If a gate fails because of +a **Rhiza-managed** file, that is an upstream problem: fix it in +`jebel-quant/rhiza` and bump again — do **not** edit the synced artifact in +place. Call out any such upstream-owned failure explicitly rather than papering +over it locally. + +### Configure the CI variables/secrets the synced workflows need (fuzzing & mutation) + +If this sync added or updated the fuzzing/mutation workflows +(`.github/workflows/rhiza_fuzzing.yml`, `.github/workflows/rhiza_mutation.yml`), +make sure the configuration they read is present. These are **GitHub Actions +repository variables and secrets** — *not* local env vars or files — set under +**Settings → Secrets and variables → Actions** (or via `gh`), and documented in +`.github/CONFIG.md`. Skip this step entirely if neither workflow is present. + +Inspect what is already set (presence only — secret values are never readable): + +- `gh variable list` +- `gh secret list` + +**Mutation** (`rhiza_mutation.yml`) reads: + +- `MUTATION_ENABLED` (variable) — must be `true` for the mutation gate/badge to run. +- `GH_PAT` (secret) — git auth for installing private dependencies. +- `UV_EXTRA_INDEX_URL` (secret) — extra package index URL (with credentials) for private deps. + +**Fuzzing** (`rhiza_fuzzing.yml`) needs no user configuration — it uses the +automatic `GITHUB_TOKEN`, so do not prompt for any fuzzing secret. + +For each of the above that is **missing**, **ask the user** whether to set it and +for its value; set only the ones they provide: + +- variables: `gh variable set MUTATION_ENABLED --body true` +- secrets: `gh secret set GH_PAT` (let `gh` read the value from stdin — never + echo a secret value into the transcript or a commit). + +Leave anything the user declines unset, and note it in the PR body so the reviewer +knows the corresponding workflow step will be skipped or fail until it is configured. + +## 6. Commit, push, open a PR + +- Commit the resolution and any in-scope fixes with clear messages (one logical + change per commit: the conflict resolution, then each gate fix). +- Push the branch and open a PR (`gh pr create`) titled for the bump, e.g. + `Chore: sync Rhiza template `. In the body, summarize how each + conflict was resolved, list any gate fallout you fixed, and flag anything that + needs an **upstream** fix in Rhiza. +- Report a concise per-gate PASS/FAIL summary. If the workflow files changed, + note that pushing them needs a token with the `workflow` scope. + +Do not merge the PR. Stop after it is open and summarize what landed and what +(if anything) is blocked on an upstream Rhiza change. diff --git a/.github/CONFIG.md b/.github/CONFIG.md new file mode 100644 index 0000000..457eb25 --- /dev/null +++ b/.github/CONFIG.md @@ -0,0 +1,66 @@ +# GitHub Actions Configuration + +This document describes the secrets used by the Rhiza-provided GitHub Actions workflows +(`.github/workflows/rhiza_*.yml`) and how to configure them. + +## PAT_TOKEN (template sync) + +The sync workflow (`.github/workflows/rhiza_sync.yml`) keeps your repository up to date with the +upstream Rhiza template. It commits the synced files directly to a branch or opens a pull request. + +By default the workflow authenticates with the automatic `github.token`. That token **cannot push +changes to files under `.github/workflows/`** — GitHub rejects such pushes unless the token has the +`workflow` scope. Since template syncs regularly update workflow files, you should configure a +Personal Access Token (PAT) with that scope and store it as a repository secret named `PAT_TOKEN`. + +If `PAT_TOKEN` is not configured, the workflow falls back to `github.token` and prints a warning. +Syncs that touch only non-workflow files will still succeed. + +### Creating the token + +**Fine-grained PAT** (recommended): + +1. Go to **Settings → Developer settings → Fine-grained tokens → Generate new token** + (). +2. Restrict **Repository access** to the repository (or repositories) using Rhiza. +3. Under **Repository permissions**, grant: + - **Contents**: Read and write + - **Workflows**: Read and write + - **Pull requests**: Read and write (needed for the scheduled sync-PR mode) +4. Generate the token and copy it. + +**Classic PAT** (alternative): + +1. Go to **Settings → Developer settings → Tokens (classic) → Generate new token**. +2. Select the `repo` and `workflow` scopes. +3. Generate the token and copy it. + +### Storing the secret + +In the repository that consumes Rhiza: + +1. Go to **Settings → Secrets and variables → Actions → New repository secret**. +2. Name: `PAT_TOKEN` +3. Value: the token created above. + +Or with the GitHub CLI: + +```bash +gh secret set PAT_TOKEN +``` + +A PAT expires; when sync pushes start failing with a `refusing to allow ... workflow` error, +regenerate the token and update the secret. + +## Release workflow secrets (optional) + +The release workflow (`.github/workflows/rhiza_release.yml`) supports additional secrets, all +optional depending on which release features you use: + +| Secret | Purpose | +| --- | --- | +| `PYPI_TOKEN` | Publish the built package to PyPI. Not needed when using trusted publishing (OIDC). | +| `GH_PAT` | Git authentication for installing private dependencies during the release build. | +| `UV_EXTRA_INDEX_URL` | Extra package index URL (with credentials) for private dependencies. | + +`GITHUB_TOKEN` is provided automatically by GitHub Actions and needs no configuration. diff --git a/.github/DISCUSSION_TEMPLATE/help-wanted.yml b/.github/DISCUSSION_TEMPLATE/help-wanted.yml new file mode 100644 index 0000000..754369c --- /dev/null +++ b/.github/DISCUSSION_TEMPLATE/help-wanted.yml @@ -0,0 +1,48 @@ +title: "[Help Wanted] " +labels: ["help wanted"] +body: + - type: markdown + attributes: + value: | + Use this template to surface a contribution opportunity. It lets maintainers + describe work that is ready to be picked up — without opening a formal issue. + + - type: input + id: area + attributes: + label: Topic / Area + description: Which part of the project does this involve? + placeholder: e.g. CI workflows, documentation, a specific bundle + validations: + required: true + + - type: textarea + id: help-needed + attributes: + label: What Help Is Needed + description: Describe the task and what "done" looks like. + validations: + required: true + + - type: textarea + id: getting-started + attributes: + label: Getting Started + description: Pointers to relevant files, docs, or prior discussion that help a contributor begin. + placeholder: | + - Relevant files: ... + - Related docs: ... + validations: + required: false + + - type: dropdown + id: skill-level + attributes: + label: Skill Level + description: Roughly how much experience is needed to take this on? + options: + - Good first issue (beginner friendly) + - Intermediate + - Advanced + validations: + required: false diff --git a/.github/DISCUSSION_TEMPLATE/ideas.yml b/.github/DISCUSSION_TEMPLATE/ideas.yml new file mode 100644 index 0000000..3ed9990 --- /dev/null +++ b/.github/DISCUSSION_TEMPLATE/ideas.yml @@ -0,0 +1,40 @@ +title: "[Idea] " +labels: ["enhancement"] +body: + - type: markdown + attributes: + value: | + Propose an idea before implementing it. This lightweight RFC process lets the + community gauge interest and shape the design before any code is written. + + - type: textarea + id: idea + attributes: + label: The Idea + description: What are you proposing? Describe it clearly enough that others can react. + validations: + required: true + + - type: textarea + id: motivation + attributes: + label: Motivation + description: What problem does this solve, and who benefits? + validations: + required: true + + - type: textarea + id: alternatives + attributes: + label: Alternatives Considered + description: Other approaches you weighed, and why this one is preferable. + validations: + required: false + + - type: textarea + id: references + attributes: + label: References + description: Links to prior art, related discussions, issues, or external resources. + validations: + required: false diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 1da7ad1..9c4408e 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -58,7 +58,7 @@ updates: update-types: - "patch" - "minor" - + commit-message: prefix: "chore(deps)" include: "scope" diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 329b071..acdd49b 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -19,5 +19,6 @@ Closes # ## Checklist - [ ] Commit messages follow the [Conventional Commits](https://www.conventionalcommits.org/) format +- [ ] `CHANGELOG.md` entry added (or not needed for this change) - [ ] Documentation updated if behaviour changed - [ ] `make deptry` passes (no unused or missing dependencies) diff --git a/.github/rulesets/main-branch-protection.json b/.github/rulesets/main-branch-protection.json new file mode 100644 index 0000000..c1d80cb --- /dev/null +++ b/.github/rulesets/main-branch-protection.json @@ -0,0 +1,47 @@ +{ + "name": "main-branch-protection", + "target": "branch", + "enforcement": "active", + "conditions": { + "ref_name": { + "include": ["~DEFAULT_BRANCH"], + "exclude": [] + } + }, + "rules": [ + { "type": "deletion" }, + { "type": "non_fast_forward" }, + { + "type": "pull_request", + "parameters": { + "required_approving_review_count": 1, + "dismiss_stale_reviews_on_push": true, + "require_code_owner_review": true, + "require_last_push_approval": true, + "required_review_thread_resolution": false, + "allowed_merge_methods": ["squash", "merge", "rebase"] + } + }, + { + "type": "required_status_checks", + "parameters": { + "strict_required_status_checks_policy": false, + "required_status_checks": [ + { "context": "Pre-commit hooks" }, + { "context": "validation" }, + { "context": "Check dependencies with deptry" }, + { "context": "docs-coverage" }, + { "context": "Security scanning" }, + { "context": "License compliance scan" } + ] + } + } + ], + "bypass_actors": [ + { + "actor_type": "RepositoryRole", + "actor_id": 5, + "bypass_mode": "always" + } + ] +} diff --git a/.github/workflows/rhiza_benchmark.yml b/.github/workflows/rhiza_benchmark.yml index 66377c4..f6246d1 100644 --- a/.github/workflows/rhiza_benchmark.yml +++ b/.github/workflows/rhiza_benchmark.yml @@ -20,5 +20,5 @@ on: jobs: benchmark: - uses: jebel-quant/rhiza/.github/workflows/rhiza_benchmark.yml@v0.19.1 + uses: jebel-quant/rhiza/.github/workflows/rhiza_benchmark.yml@v1.1.1 secrets: inherit diff --git a/.github/workflows/rhiza_book.yml b/.github/workflows/rhiza_book.yml index a4d42aa..c15b190 100644 --- a/.github/workflows/rhiza_book.yml +++ b/.github/workflows/rhiza_book.yml @@ -6,7 +6,10 @@ # It combines API documentation, test coverage reports, test results, and # interactive notebooks into a single GitHub Pages site. # -# Trigger: This workflow runs on every push to the main or master branch +# Trigger: This workflow runs on every push (any branch), so every commit +# validates that the book still builds. The reusable workflow deploys +# to GitHub Pages only from the repository's default branch and never +# from a fork; other branches build and upload an artifact only. # # Components: # - 📓 Process Marimo notebooks @@ -19,12 +22,14 @@ name: "(RHIZA) BOOK" on: push: branches: - - main - - master + - '**' + +permissions: + contents: read jobs: book: - uses: jebel-quant/rhiza/.github/workflows/rhiza_book.yml@v0.19.1 + uses: jebel-quant/rhiza/.github/workflows/rhiza_book.yml@v1.1.1 secrets: inherit permissions: contents: read diff --git a/.github/workflows/rhiza_ci.yml b/.github/workflows/rhiza_ci.yml index 2e22316..16c9f4c 100644 --- a/.github/workflows/rhiza_ci.yml +++ b/.github/workflows/rhiza_ci.yml @@ -7,6 +7,11 @@ # pre-commit hooks, verify documentation coverage, validate the # project, run security scans, and check license compliance. # +# Python version matrix source of truth: +# - Implemented in the reusable workflow called below +# - Generated from `Programming Language :: Python :: 3.x` classifiers in pyproject.toml +# - Adding/removing classifiers updates CI Python coverage automatically +# # Trigger: On push and pull_request. name: "(RHIZA) CI" @@ -21,5 +26,5 @@ on: jobs: ci: - uses: jebel-quant/rhiza/.github/workflows/rhiza_ci.yml@v0.19.1 + uses: jebel-quant/rhiza/.github/workflows/rhiza_ci.yml@v1.1.1 secrets: inherit diff --git a/.github/workflows/rhiza_codeql.yml b/.github/workflows/rhiza_codeql.yml index ad83c34..4bcc821 100644 --- a/.github/workflows/rhiza_codeql.yml +++ b/.github/workflows/rhiza_codeql.yml @@ -14,9 +14,6 @@ name: "(RHIZA) CODEQL" permissions: - security-events: write - packages: read - actions: read contents: read on: @@ -29,5 +26,10 @@ on: jobs: codeql: - uses: jebel-quant/rhiza/.github/workflows/rhiza_codeql.yml@v0.19.1 + uses: jebel-quant/rhiza/.github/workflows/rhiza_codeql.yml@v1.1.1 secrets: inherit + permissions: + security-events: write # Upload CodeQL results to code scanning + packages: read + actions: read + contents: read diff --git a/.github/workflows/rhiza_fuzzing.yml b/.github/workflows/rhiza_fuzzing.yml new file mode 100644 index 0000000..a9a2009 --- /dev/null +++ b/.github/workflows/rhiza_fuzzing.yml @@ -0,0 +1,41 @@ +# This file is part of the jebel-quant/rhiza repository +# (https://github.com/jebel-quant/rhiza). +# +# Workflow: ClusterFuzzLite fuzzing +# +# Purpose: Run coverage-guided fuzzing for the repository's Python security +# parsing utilities. Pull requests run short code-change fuzzing, +# while main-branch pushes and the weekly schedule run batch fuzzing. +# +# Opt-in: fuzzing is OFF by default and very optional. Set the +# repository variable `FUZZING_ENABLED` to 'true' to run it (a +# .clusterfuzzlite/ config must also be present); otherwise the +# reusable workflow skips fuzzing (the run stays green). +# +# Thin stub: the fuzzing logic lives in the reusable workflow in +# jebel-quant/rhiza; this file only wires up the triggers. +# +# Trigger: Pull requests, pushes to main/master, weekly schedule, and manual +# dispatch. + +name: "(RHIZA) FUZZING" + +on: + pull_request: + branches: [ "main", "master" ] + push: + branches: [ "main", "master" ] + schedule: + - cron: '17 3 * * 6' + workflow_dispatch: + +permissions: + contents: read + +jobs: + fuzzing: + uses: jebel-quant/rhiza/.github/workflows/rhiza_fuzzing.yml@v1.1.1 + secrets: inherit + permissions: + contents: read + security-events: write # Upload fuzzing SARIF to code scanning diff --git a/.github/workflows/rhiza_marimo.yml b/.github/workflows/rhiza_marimo.yml index cd7f1a4..baaba33 100644 --- a/.github/workflows/rhiza_marimo.yml +++ b/.github/workflows/rhiza_marimo.yml @@ -28,5 +28,5 @@ on: jobs: marimo: - uses: jebel-quant/rhiza/.github/workflows/rhiza_marimo.yml@v0.19.1 + uses: jebel-quant/rhiza/.github/workflows/rhiza_marimo.yml@v1.1.1 secrets: inherit diff --git a/.github/workflows/rhiza_mutation.yml b/.github/workflows/rhiza_mutation.yml new file mode 100644 index 0000000..f487aba --- /dev/null +++ b/.github/workflows/rhiza_mutation.yml @@ -0,0 +1,50 @@ +# This file is part of the jebel-quant/rhiza repository +# (https://github.com/jebel-quant/rhiza). +# +# Workflow: Mutation Testing +# +# Purpose: Measure test *assertion strength* with mutmut. 100% line/branch +# coverage proves code is executed, not that a wrong result would be +# caught; surviving mutants reveal assertions that are too weak. +# +# Opt-in: mutation testing is OFF by default and very optional. Set the +# repository variable `MUTATION_ENABLED` to 'true' to run it; otherwise +# the reusable workflow skips the mutation job (the run stays green). +# +# Enforced gate (when enabled): mutation runs are required and fail when +# mutants survive (100% mutation score threshold in the reusable +# workflow). +# +# Thin stub: the mutation logic and the opt-in gate live in the +# reusable workflow in jebel-quant/rhiza; this file only wires up the +# triggers. +# +# Published mutation badge URL (when enabled): +# https://.github.io//mutation-badge.svg +# +# Trigger: Weekly schedule, manual dispatch, and pull_request so mutation +# testing is included in PR CI. + +name: "(RHIZA) MUTATION" + +permissions: + contents: read + +on: + pull_request: + schedule: + - cron: "0 9 * * 1" # Monday 09:00 UTC (after the rhiza weekly job at 08:00) + workflow_dispatch: + +jobs: + mutation: + # Opt-in gate: mutation testing is OFF by default. The job only runs when + # this repo sets the `MUTATION_ENABLED` variable to 'true'. Gating here in + # the caller keeps it optional regardless of the pinned reusable workflow. + if: ${{ vars.MUTATION_ENABLED == 'true' }} + uses: jebel-quant/rhiza/.github/workflows/rhiza_mutation.yml@v1.1.1 + secrets: inherit + permissions: + contents: read + pages: write # publish-mutation-badge deploys the badge to Pages + id-token: write # publish-mutation-badge needs OIDC for the Pages deploy diff --git a/.github/workflows/rhiza_release.yml b/.github/workflows/rhiza_release.yml index 26d8fb8..3cdeca1 100644 --- a/.github/workflows/rhiza_release.yml +++ b/.github/workflows/rhiza_release.yml @@ -21,11 +21,14 @@ # 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 rhiza-tools `bump` command folds a freshly +# generated CHANGELOG.md into the version-bump commit (git-cliff pre-commit hook), +# so the tagged commit already carries the changelog — no separate post-tag commit. # # 📦 SBOM Generation: # - Generated using CycloneDX format (industry standard for software supply chain security) @@ -102,16 +105,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: @@ -195,6 +205,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.0.3 @@ -207,7 +221,7 @@ jobs: version: "0.11.16" - name: Configure git auth for private packages - uses: jebel-quant/rhiza/.github/actions/configure-git-auth@v0.19.1 + uses: jebel-quant/rhiza/.github/actions/configure-git-auth@v1.1.1 with: token: ${{ secrets.GH_PAT }} @@ -270,12 +284,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.1.0 with: subject-path: sbom.cdx.json sbom-path: sbom.cdx.json + - name: Stage SBOM attestation for the GitHub release + # Attach the SBOM's Sigstore attestation bundle to the release as a + # recognised signature asset (*.sigstore.json). Non-buildable repos + # (no [build-system], so no dist/*.intoto.jsonl provenance) would + # otherwise ship releases without any signature asset, which fails + # OpenSSF Scorecard's Signed-Releases check. + if: hashFiles('pyproject.toml') != '' && github.event.repository.private == false + run: cp "${{ steps.attest-sbom.outputs.bundle-path }}" sbom.cdx.json.sigstore.json + - name: Upload SBOM artifacts if: hashFiles('pyproject.toml') != '' uses: actions/upload-artifact@v7.0.1 @@ -284,13 +308,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 +336,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 +359,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 +376,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.0.3 - 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 +384,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 +419,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 +442,8 @@ jobs: name: Generate Conda Recipe runs-on: ubuntu-latest needs: [tag, pypi] + permissions: + contents: read # Generates a recipe and uploads it as a workflow artifact only outputs: should_generate: ${{ steps.check_conda.outputs.should_generate }} @@ -455,7 +483,21 @@ jobs: mkdir -p /tmp/conda-recipe cd /tmp/conda-recipe - grayskull pypi "$PACKAGE_NAME" --strict-conda-forge + # PyPI metadata for a just-published release — especially the first + # release of a new package — can lag the upload by several minutes, + # so retry before giving up. + MAX_ATTEMPTS=5 + for attempt in $(seq 1 "$MAX_ATTEMPTS"); do + if grayskull pypi "$PACKAGE_NAME" --strict-conda-forge; then + break + fi + if [[ "$attempt" -eq "$MAX_ATTEMPTS" ]]; then + echo "::error::grayskull failed after $MAX_ATTEMPTS attempts — PyPI metadata for $PACKAGE_NAME may not be available yet" + exit 1 + fi + echo "grayskull attempt $attempt/$MAX_ATTEMPTS failed — waiting 60s for PyPI metadata to propagate" + sleep 60 + done RECIPE_PATH=$(find . -type f -path "*/meta.yaml" | head -n 1) if [[ -z "$RECIPE_PATH" ]]; then @@ -478,6 +520,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 +599,7 @@ jobs: - name: Build and Publish Devcontainer Image if: steps.check_publish.outputs.should_publish == 'true' - uses: devcontainers/ci@v0.3 + uses: devcontainers/ci@v0.3.1900000450 with: configFile: .devcontainer/devcontainer.json push: always @@ -566,6 +611,8 @@ jobs: runs-on: ubuntu-latest needs: [tag, pypi, conda, devcontainer] if: needs.pypi.result == 'success' || needs.conda.result == 'success' || needs.devcontainer.result == 'success' + permissions: + contents: write # Needed to undraft/publish the GitHub release steps: - name: Checkout Code uses: actions/checkout@v6.0.3 diff --git a/.github/workflows/rhiza_scorecard.yml b/.github/workflows/rhiza_scorecard.yml new file mode 100644 index 0000000..d5dcc81 --- /dev/null +++ b/.github/workflows/rhiza_scorecard.yml @@ -0,0 +1,45 @@ +# This file is part of the jebel-quant/rhiza repository +# (https://github.com/jebel-quant/rhiza). +# +# Workflow: OSSF Scorecard +# +# Purpose: Run the OpenSSF Scorecard supply-chain security analysis and upload +# the results to GitHub code scanning. On public repositories the +# results are also published to the OpenSSF REST API, which powers +# the README badge and lets adopters verify the score independently. +# Set the SCORECARD_ENABLED repository variable to 'true' to +# force-enable on private repos, 'false' to disable, or leave unset +# for auto-detect (public repositories only). +# +# Thin stub: the analysis logic and its enablement/visibility gate +# live in the reusable workflow in jebel-quant/rhiza; this file only +# wires up the triggers and grants the token scopes Scorecard needs. +# +# Trigger: Weekly schedule, pushes to main, branch-protection changes, and +# manual dispatch. + +name: "(RHIZA) SCORECARD" + +on: + # Re-evaluate when branch protection rules change (Scorecard's + # Branch-Protection check reads them). + branch_protection_rule: + schedule: + - cron: '34 2 * * 2' + push: + branches: [ "main", "master" ] + workflow_dispatch: + +# Least privilege by default; the called workflow grants its job only what +# Scorecard needs (see the job-level permissions below). +permissions: read-all + +jobs: + scorecard: + uses: jebel-quant/rhiza/.github/workflows/rhiza_scorecard.yml@v1.1.1 + secrets: inherit + permissions: + security-events: write # Upload the SARIF results to code scanning + id-token: write # Publish results to the OpenSSF REST API (badge) + contents: read + actions: read diff --git a/.github/workflows/rhiza_sync.yml b/.github/workflows/rhiza_sync.yml index 4065e7c..3477aa3 100644 --- a/.github/workflows/rhiza_sync.yml +++ b/.github/workflows/rhiza_sync.yml @@ -15,8 +15,7 @@ name: "(RHIZA) SYNC" permissions: - contents: write - pull-requests: write + contents: read on: push: @@ -36,8 +35,11 @@ on: jobs: sync: - uses: jebel-quant/rhiza/.github/workflows/rhiza_sync.yml@v0.19.1 + uses: jebel-quant/rhiza/.github/workflows/rhiza_sync.yml@v1.1.1 with: direct: ${{ github.event_name == 'push' }} create-pr: ${{ github.event_name != 'push' && (github.event_name == 'schedule' || inputs.create-pr == true) }} secrets: inherit + permissions: + contents: write # Commit/push synced files (direct mode) + pull-requests: write # Open the sync PR (scheduled/dispatch mode) diff --git a/.github/workflows/rhiza_weekly.yml b/.github/workflows/rhiza_weekly.yml index 0e05337..9055533 100644 --- a/.github/workflows/rhiza_weekly.yml +++ b/.github/workflows/rhiza_weekly.yml @@ -28,5 +28,5 @@ on: jobs: weekly: - uses: jebel-quant/rhiza/.github/workflows/rhiza_weekly.yml@v0.19.1 + uses: jebel-quant/rhiza/.github/workflows/rhiza_weekly.yml@v1.1.1 secrets: inherit diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 56689c7..ea145b3 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,12 @@ # This file is part of the jebel-quant/rhiza repository # (https://github.com/jebel-quant/rhiza). # +# Pin node so pre-commit provisions a compatible runtime instead of using the +# system node. Some npm-based hooks (markdownlint-cli) pull transitive deps that +# reject odd-numbered current node releases (e.g. v25), failing on EBADENGINE. +default_language_version: + node: "24.12.0" + repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v6.0.0 @@ -25,7 +31,7 @@ repos: pass_filenames: false - repo: https://github.com/astral-sh/ruff-pre-commit - rev: 'v0.15.14' + rev: 'v0.15.20' hooks: - id: ruff args: [ --fix, --exit-non-zero-on-fix, --unsafe-fixes ] @@ -34,13 +40,13 @@ repos: - id: ruff-format - repo: https://github.com/igorshubovych/markdownlint-cli - rev: v0.48.0 + rev: v0.49.0 hooks: - id: markdownlint args: ["--disable", "MD013"] - repo: https://github.com/python-jsonschema/check-jsonschema - rev: 0.37.2 + rev: 0.37.4 hooks: - id: check-renovate args: [ "--verbose" ] @@ -64,8 +70,13 @@ repos: - id: bandit args: ["--ini", ".bandit", "--exclude", ".venv,tests,.rhiza/tests,.git,.pytest_cache"] + - repo: https://github.com/betterleaks/betterleaks + rev: v1.6.1 + hooks: + - id: betterleaks + - repo: https://github.com/astral-sh/uv-pre-commit - rev: 0.11.16 + rev: 0.11.26 hooks: - id: uv-lock @@ -77,7 +88,7 @@ repos: files: ^src/ - repo: https://github.com/Jebel-Quant/rhiza-hooks - rev: v0.4.0 # Use the latest release + rev: v0.7.0 # Use the latest release hooks: # Migrated from rhiza - id: check-rhiza-workflow-names diff --git a/.rhiza/.cfg.toml b/.rhiza/.cfg.toml index c96b1dd..3c78bb5 100644 --- a/.rhiza/.cfg.toml +++ b/.rhiza/.cfg.toml @@ -32,3 +32,35 @@ values = [ filename = "pyproject.toml" search = 'version = "{current_version}"' replace = 'version = "{new_version}"' + +# Keep the reusable-workflow stubs in the bundles pinned to the current release. +# These stubs are synced into downstream repos and must reference an existing tag. +# The search is a regex anchored to the `jebel-quant/rhiza/...` path so it rewrites +# the pin from ANY prior version (not just {current_version}) — this prevents silent +# drift if a stub was left behind — while leaving third-party action pins +# (actions/checkout@vX, astral-sh/setup-uv@vX, ...) in the same files untouched. +# This block ships verbatim in the synced downstream .cfg.toml; there it is a +# harmless no-op because a downstream repo has no bundles/ tree (the glob matches +# nothing and bump-my-version skips it). +[[tool.bumpversion.files]] +glob = "bundles/**/.github/workflows/*.yml" +regex = true +search = "(jebel-quant/rhiza/[^@\\s]+)@v\\d+\\.\\d+\\.\\d+" +replace = "\\1@v{new_version}" +ignore_missing_version = true + +# Keep the reusable-workflow stubs in the bundles pinned to the current release. +# These stubs are synced into downstream repos and must reference an existing tag. +# The search is a regex anchored to the `jebel-quant/rhiza/...` path so it rewrites +# the pin from ANY prior version (not just {current_version}) — this prevents silent +# drift if a stub was left behind — while leaving third-party action pins +# (actions/checkout@vX, astral-sh/setup-uv@vX, ...) in the same files untouched. +# This block ships verbatim in the synced downstream .cfg.toml; there it is a +# harmless no-op because a downstream repo has no bundles/ tree (the glob matches +# nothing and bump-my-version skips it). +[[tool.bumpversion.files]] +glob = "bundles/**/.github/workflows/*.yml" +regex = true +search = "(jebel-quant/rhiza/[^@\\s]+)@v\\d+\\.\\d+\\.\\d+" +replace = "\\1@v{new_version}" +ignore_missing_version = true diff --git a/.rhiza/.env b/.rhiza/.env index 0fd6a6a..53239c0 100644 --- a/.rhiza/.env +++ b/.rhiza/.env @@ -1,3 +1,23 @@ +# .rhiza/.env — project-local environment overrides (optional) +# +# This file is optional. If absent, rhiza.mk falls back to built-in defaults: +# SOURCE_FOLDER = src +# MARIMO_FOLDER = docs/notebooks +# RHIZA_CI_OS_MATRIX = ["ubuntu-latest"] +# +# Add or uncomment only the variables you want to override. +# Variables defined here are exported to all Make recipes. + +# .rhiza/.env — project-local environment overrides (optional) +# +# This file is optional. If absent, rhiza.mk falls back to built-in defaults: +# SOURCE_FOLDER = src +# MARIMO_FOLDER = docs/notebooks +# RHIZA_CI_OS_MATRIX = ["ubuntu-latest"] +# +# Add or uncomment only the variables you want to override. +# Variables defined here are exported to all Make recipes. + MARIMO_FOLDER=docs/notebooks SOURCE_FOLDER=src RHIZA_CI_OS_MATRIX=["ubuntu-latest","macos-latest","windows-latest"] diff --git a/.rhiza/.rhiza-version b/.rhiza/.rhiza-version index 2a0970c..5543a76 100644 --- a/.rhiza/.rhiza-version +++ b/.rhiza/.rhiza-version @@ -1 +1 @@ -0.16.1 +0.17.6 diff --git a/.rhiza/completions/README.md b/.rhiza/completions/README.md index b37cb15..656be99 100644 --- a/.rhiza/completions/README.md +++ b/.rhiza/completions/README.md @@ -244,20 +244,20 @@ m te # Expands to: m test 1. **Target Discovery**: Parses `make -qp` output to find all targets 2. **Description Extraction**: Looks for `##` comments after target names 3. **Variable Detection**: Includes common Makefile variables -4. **Dynamic Completion**: Regenerates list each time you tab +4. **Cached Completion**: The target list is cached per directory and refreshed automatically ### Performance -- Completions are generated on-demand (when you press Tab) -- For large Makefiles (100+ targets), there may be a small delay -- Results are not cached to ensure targets are always current +- The target list is cached under `${XDG_CACHE_HOME:-~/.cache}/rhiza/`, keyed per directory +- The cache refreshes automatically whenever the `Makefile`, `local.mk`, + `.rhiza/rhiza.mk`, or any `.rhiza/make.d/*.mk` file changes +- Only the first Tab press after a makefile change pays the full `make -qp` parsing cost +- To force a refresh manually, delete the cache: `rm -rf "${XDG_CACHE_HOME:-$HOME/.cache}/rhiza"` +- If the cache directory cannot be created (e.g. read-only home), completion + falls back to direct parsing on every Tab press ## See Also - [Tools Reference](../../docs/reference/TOOLS_REFERENCE.md) - Complete command reference - [Quick Reference](../../docs/guides/QUICK_REFERENCE.md) - Quick command reference - [Extending Rhiza](../../docs/guides/EXTENDING_RHIZA.md) - How to add custom targets - ---- - -*Last updated: 2026-02-15* diff --git a/.rhiza/completions/rhiza-completion.bash b/.rhiza/completions/rhiza-completion.bash index 0f860da..09db85c 100644 --- a/.rhiza/completions/rhiza-completion.bash +++ b/.rhiza/completions/rhiza-completion.bash @@ -9,8 +9,19 @@ # sudo cp .rhiza/completions/rhiza-completion.bash /etc/bash_completion.d/rhiza # +# Return 0 (stale) when the cache file is missing or any makefile source +# changed since it was written. +_rhiza_make_cache_stale() { + local cache_file="$1" src + [[ -f "$cache_file" ]] || return 0 + for src in Makefile local.mk .rhiza/rhiza.mk .rhiza/make.d/*.mk; do + [[ -f "$src" && "$src" -nt "$cache_file" ]] && return 0 + done + return 1 +} + _rhiza_make_completion() { - local cur prev opts + local cur prev opts cache_dir cache_file COMPREPLY=() cur="${COMP_WORDS[COMP_CWORD]}" prev="${COMP_WORDS[COMP_CWORD-1]}" @@ -20,12 +31,29 @@ _rhiza_make_completion() { return 0 fi - # Extract make targets from Makefile and all included .mk files - # Looks for lines like: target: ## description - opts=$(make -qp 2>/dev/null | \ - awk -F':' '/^[a-zA-Z0-9][^$#\/\t=]*:([^=]|$)/ {split($1,A,/ /);for(i in A)print A[i]}' | \ - grep -v '^Makefile$' | \ - sort -u) + # Target extraction parses the full make database (make -qp), which is + # slow on large Makefiles - cache the result per directory and refresh + # only when a makefile source changes. + cache_dir="${XDG_CACHE_HOME:-$HOME/.cache}/rhiza" + cache_file="$cache_dir/targets-$(pwd | cksum | cut -d' ' -f1)" + + if _rhiza_make_cache_stale "$cache_file" && mkdir -p "$cache_dir" 2>/dev/null; then + # Extract make targets from Makefile and all included .mk files + make -qp 2>/dev/null | \ + awk -F':' '/^[a-zA-Z0-9][^$#\/\t=]*:([^=]|$)/ {split($1,A,/ /);for(i in A)print A[i]}' | \ + grep -v '^Makefile$' | \ + sort -u > "$cache_file" + fi + + if [[ -r "$cache_file" ]]; then + opts=$(cat "$cache_file") + else + # Cache unavailable (e.g. unwritable HOME): fall back to direct parsing + opts=$(make -qp 2>/dev/null | \ + awk -F':' '/^[a-zA-Z0-9][^$#\/\t=]*:([^=]|$)/ {split($1,A,/ /);for(i in A)print A[i]}' | \ + grep -v '^Makefile$' | \ + sort -u) + fi # Add common make variables that can be overridden local vars="DRY_RUN=1 BUMP=patch BUMP=minor BUMP=major ENV=dev ENV=staging ENV=prod" diff --git a/.rhiza/completions/rhiza-completion.zsh b/.rhiza/completions/rhiza-completion.zsh index 03931c9..0495103 100644 --- a/.rhiza/completions/rhiza-completion.zsh +++ b/.rhiza/completions/rhiza-completion.zsh @@ -19,17 +19,34 @@ # sudo cp .rhiza/completions/rhiza-completion.zsh /usr/local/share/zsh/site-functions/_make # +# Return 0 (stale) when the cache file is missing or any makefile source +# changed since it was written. +_rhiza_make_cache_stale() { + local cache_file="$1" src + [[ -f "$cache_file" ]] || return 0 + for src in Makefile local.mk .rhiza/rhiza.mk .rhiza/make.d/*.mk(N); do + [[ -f "$src" && "$src" -nt "$cache_file" ]] && return 0 + done + return 1 +} + _rhiza_make() { local -a targets variables - + local cache_dir cache_file + # Check if we're in a directory with a Makefile if [[ ! -f "Makefile" ]]; then return 0 fi - # Extract make targets with descriptions - # Format: target:description - targets=(${(f)"$( + # Target extraction parses the full make database (make -qp) twice, which + # is slow on large Makefiles - cache both lists per directory and refresh + # only when a makefile source changes. + cache_dir="${XDG_CACHE_HOME:-$HOME/.cache}/rhiza" + cache_file="$cache_dir/targets-$(pwd | cksum | cut -d' ' -f1)" + + if _rhiza_make_cache_stale "$cache_file.desc" && mkdir -p "$cache_dir" 2>/dev/null; then + # Extract make targets with descriptions (format: target:description) make -qp 2>/dev/null | \ awk -F':' ' /^# Files/,/^# Finished Make data base/ { @@ -43,20 +60,34 @@ _rhiza_make() { } ' | \ grep -v '^Makefile:' | \ - sort -u - )"}) + sort -u > "$cache_file.desc" - # Also get targets without descriptions - local -a plain_targets - plain_targets=(${(f)"$( + # Also get targets without descriptions make -qp 2>/dev/null | \ awk -F':' '/^[a-zA-Z0-9_-]+:([^=]|$)/ { split($1,A,/ /) for(i in A) print A[i] }' | \ grep -v '^Makefile$' | \ - sort -u - )"}) + sort -u > "$cache_file.plain" + fi + + local -a plain_targets + if [[ -r "$cache_file.desc" ]]; then + targets=(${(f)"$(cat "$cache_file.desc")"}) + plain_targets=(${(f)"$(cat "$cache_file.plain" 2>/dev/null)"}) + else + # Cache unavailable (e.g. unwritable HOME): fall back to direct parsing + plain_targets=(${(f)"$( + make -qp 2>/dev/null | \ + awk -F':' '/^[a-zA-Z0-9_-]+:([^=]|$)/ { + split($1,A,/ /) + for(i in A) print A[i] + }' | \ + grep -v '^Makefile$' | \ + sort -u + )"}) + fi # Common make variables variables=( diff --git a/.rhiza/make.d/book.mk b/.rhiza/make.d/book.mk index 6323b21..95791bf 100644 --- a/.rhiza/make.d/book.mk +++ b/.rhiza/make.d/book.mk @@ -34,7 +34,7 @@ _book-notebooks: name=$$(basename "$$nb" .py); \ printf "${BLUE}[INFO] Exporting $$nb -> ${ROOT}/docs/notebooks/$$name.html${RESET}\n"; \ abs_output="${ROOT}/docs/notebooks/$$name.html"; \ - (cd "$$(dirname "$$nb")" && ${UV_BIN} run marimo export html --sandbox "$$(basename "$$nb")" -o "$$abs_output"); \ + (cd "$$(dirname "$$nb")" && ${UV_BIN} run --with marimo marimo export html --sandbox "$$(basename "$$nb")" -o "$$abs_output"); \ done; \ else \ printf "${YELLOW}[WARN] MARIMO_FOLDER not set or missing, skipping notebook export${RESET}\n"; \ diff --git a/.rhiza/make.d/bootstrap.mk b/.rhiza/make.d/bootstrap.mk index 2852697..4780c02 100644 --- a/.rhiza/make.d/bootstrap.mk +++ b/.rhiza/make.d/bootstrap.mk @@ -37,7 +37,11 @@ install: pre-install install-uv ## install printf "${BLUE}[INFO] Using existing virtual environment at ${VENV}, skipping creation${RESET}\n"; \ fi - # Install the dependencies from pyproject.toml (if it exists) + # Install the dependencies from pyproject.toml (if it exists). + # --inexact leaves packages uv did not manage in place instead of pruning them each + # run, so repeated 'make' targets don't churn the environment. Per-target tooling + # (pytest, interrogate, mutmut, ...) is provisioned on the fly via `uv run --with` + # in the individual targets, so there is no separate dependency-install step here. @if [ -f "pyproject.toml" ]; then \ if [ -f "uv.lock" ]; then \ if ! ${UV_BIN} lock --check >/dev/null 2>&1; then \ @@ -47,35 +51,24 @@ install: pre-install install-uv ## install 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; }; \ + ${UV_BIN} sync $(UV_SYNC_ARGS) --inexact --frozen || { printf "${RED}[ERROR] Failed to install dependencies${RESET}\n"; exit 1; }; \ else \ printf "${YELLOW}[WARN] uv.lock not found. Generating lock file and installing dependencies...${RESET}\n"; \ - ${UV_BIN} sync $(UV_SYNC_ARGS) || { printf "${RED}[ERROR] Failed to install dependencies${RESET}\n"; exit 1; }; \ + ${UV_BIN} sync $(UV_SYNC_ARGS) --inexact || { printf "${RED}[ERROR] Failed to install dependencies${RESET}\n"; exit 1; }; \ fi; \ else \ printf "${YELLOW}[WARN] No pyproject.toml found, skipping install${RESET}\n"; \ fi - # Install 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 + # Install pre-commit hooks (skip when core.hooksPath is set, e.g. by an + # external hook manager — pre-commit refuses to install in that case) @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"; }; \ + if [ -n "$$(git config --get core.hooksPath 2>/dev/null)" ]; then \ + printf "${BLUE}[INFO] Skipping pre-commit hook install: core.hooksPath is set${RESET}\n"; \ + else \ + printf "${BLUE}[INFO] Installing pre-commit hooks...${RESET}\n"; \ + ${UVX_BIN} -p ${PYTHON_VERSION} pre-commit install || { printf "${YELLOW}[WARN] Failed to install pre-commit hooks${RESET}\n"; }; \ + fi; \ fi @$(MAKE) post-install @@ -106,4 +99,4 @@ clean: ## Clean project artifacts and stale local branches @git fetch --prune - @git branch -vv | awk '/: gone]/{print $$1}' | xargs -r git branch -D + @git branch -vv | awk '/: gone]/ && $$1 != "*" && $$1 != "+" {print $$1}' | xargs -r git branch -D diff --git a/.rhiza/make.d/github.mk b/.rhiza/make.d/github.mk new file mode 100644 index 0000000..346b617 --- /dev/null +++ b/.rhiza/make.d/github.mk @@ -0,0 +1,70 @@ +## github.mk - github repo maintenance and helpers +# This file is included by the main Makefile + +# ── Forge Detection ────────────────────────────────────────────────────────── +# FORGE_TYPE is set once and reused by any target that needs to know the forge. +# Priority: .github/workflows/ → .gitlab-ci.yml / .gitlab/ → unknown +FORGE_TYPE := $(if $(wildcard .github/workflows/),github,$(if $(or $(wildcard .gitlab-ci.yml),$(wildcard .gitlab/)),gitlab,unknown)) + +# Declare phony targets +.PHONY: gh-install require-gh view-prs view-issues failed-workflows workflow-status latest-release whoami print-logo + +# ── Internal guard ─────────────────────────────────────────────────────────── +# Require the gh CLI; hard-fail if missing so downstream targets can depend on it. +require-gh: + @if ! command -v gh >/dev/null 2>&1; then \ + printf "${RED}[ERROR] gh cli not found. Install from: https://github.com/cli/cli?tab=readme-ov-file#installation${RESET}\n"; \ + exit 1; \ + fi + +##@ GitHub Helpers +gh-install: ## check for gh cli existence and install extensions + @if ! command -v gh >/dev/null 2>&1; then \ + printf "${YELLOW}[WARN] gh cli not found.${RESET}\n"; \ + printf "${BLUE}[INFO] Please install it from: https://github.com/cli/cli?tab=readme-ov-file#installation${RESET}\n"; \ + else \ + printf "${GREEN}[INFO] gh cli is installed.${RESET}\n"; \ + fi + +view-prs: gh-install ## list open pull requests + @printf "${BLUE}[INFO] Open Pull Requests:${RESET}\n" + @gh pr list --json number,title,author,headRefName,updatedAt --template \ + '{{tablerow (printf "NUM" | color "bold") (printf "TITLE" | color "bold") (printf "AUTHOR" | color "bold") (printf "BRANCH" | color "bold") (printf "UPDATED" | color "bold")}}{{range .}}{{tablerow (printf "#%v" .number | color "green") .title (.author.login | color "cyan") (.headRefName | color "yellow") (timeago .updatedAt | color "white")}}{{end}}' + +view-issues: gh-install ## list open issues + @printf "${BLUE}[INFO] Open Issues:${RESET}\n" + @gh issue list --json number,title,author,labels,updatedAt --template \ + '{{tablerow (printf "NUM" | color "bold") (printf "TITLE" | color "bold") (printf "AUTHOR" | color "bold") (printf "LABELS" | color "bold") (printf "UPDATED" | color "bold")}}{{range .}}{{tablerow (printf "#%v" .number | color "green") .title (.author.login | color "cyan") (pluck "name" .labels | join ", " | color "yellow") (timeago .updatedAt | color "white")}}{{end}}' + +failed-workflows: gh-install ## list recent failing workflow runs + @printf "${BLUE}[INFO] Recent Failing Workflow Runs:${RESET}\n" + @gh run list --limit 10 --status failure --json conclusion,name,headBranch,event,createdAt --template \ + '{{tablerow (printf "STATUS" | color "bold") (printf "NAME" | color "bold") (printf "BRANCH" | color "bold") (printf "EVENT" | color "bold") (printf "TIME" | color "bold")}}{{range .}}{{tablerow (printf "%s" .conclusion | color "red") .name (.headBranch | color "cyan") (.event | color "yellow") (timeago .createdAt | color "white")}}{{end}}' + +whoami: gh-install ## check github auth status + @printf "${BLUE}[INFO] GitHub Authentication Status:${RESET}\n" + @gh auth status --hostname github.com --json hosts --template \ + '{{range $$host, $$accounts := .hosts}}{{range $$accounts}}{{if .active}} {{printf "✓" | color "green"}} Logged in to {{$$host}} account {{.login | color "bold"}} ({{.tokenSource}}){{"\n"}} Active account: {{printf "true" | color "green"}}{{"\n"}} Git operations protocol: {{.gitProtocol | color "yellow"}}{{"\n"}} Token scopes: {{.scopes | color "yellow"}}{{"\n"}}{{end}}{{end}}{{end}}' + +workflow-status: require-gh ## show recent runs for the release workflow + @printf "${BOLD}Release Workflow Status${RESET}\n" + @printf "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}\n" + @RELEASE_WF=$$(gh workflow list --json name,id --jq '.[] | select(.name | test("release";"i")) | .name' 2>/dev/null | head -1); \ + if [ -n "$$RELEASE_WF" ]; then \ + printf "${BLUE}[INFO] Workflow: ${GREEN}$$RELEASE_WF${RESET}\n\n"; \ + gh run list --workflow "$$RELEASE_WF" --limit 5 \ + --json status,conclusion,headBranch,event,createdAt,displayTitle,url \ + --template '{{tablerow (printf "STATUS" | color "bold") (printf "CONCLUSION" | color "bold") (printf "TITLE" | color "bold") (printf "EVENT" | color "bold") (printf "TIME" | color "bold")}}{{range .}}{{tablerow (printf "%s" .status | color "cyan") (printf "%s" (or .conclusion "—") | color (or (and (eq .conclusion "success") "green") (and (eq .conclusion "failure") "red") "yellow")) .displayTitle (.event | color "yellow") (timeago .createdAt | color "white")}}{{end}}'; \ + else \ + printf "${YELLOW}[WARN] No release workflow found in this repository${RESET}\n"; \ + fi + +latest-release: require-gh ## show information about the latest GitHub release + @printf "${BOLD}Latest Release${RESET}\n" + @printf "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}\n" + @if gh release view --json tagName --jq '.tagName' >/dev/null 2>&1; then \ + gh release view --json tagName,name,publishedAt,url,isDraft,isPrerelease,author \ + --template ' Tag: {{.tagName | color "green"}}{{"\n"}} Name: {{.name}}{{"\n"}} Author: {{.author.login}}{{"\n"}} Published: {{timeago .publishedAt}}{{"\n"}} Status: {{if .isDraft}}{{printf "Draft" | color "yellow"}}{{else if .isPrerelease}}{{printf "Pre-release" | color "yellow"}}{{else}}{{printf "Published" | color "green"}}{{end}}{{"\n"}} URL: {{.url}}{{"\n"}}'; \ + else \ + printf "${YELLOW}[WARN] No releases found in this repository${RESET}\n"; \ + fi diff --git a/.rhiza/make.d/marimo.mk b/.rhiza/make.d/marimo.mk index 36ae74c..9fdaace 100644 --- a/.rhiza/make.d/marimo.mk +++ b/.rhiza/make.d/marimo.mk @@ -1,6 +1,14 @@ ## Makefile.marimo - Marimo notebook targets # This file is included by the main Makefile +# Contribute the marimo notebook folder to the shared deptry scan defined in +# quality.mk. DEP004 (misplaced development dependency) is ignored because +# notebooks legitimately import packages declared as development dependencies. +ifneq ($(wildcard $(MARIMO_FOLDER)),) +DEPTRY_FOLDERS += $(MARIMO_FOLDER) +DEPTRY_IGNORE += --ignore DEP004 +endif + # Declare phony targets (they don't produce files) .PHONY: marimo-validate marimo diff --git a/.rhiza/make.d/quality.mk b/.rhiza/make.d/quality.mk index 7504828..467eddd 100644 --- a/.rhiza/make.d/quality.mk +++ b/.rhiza/make.d/quality.mk @@ -10,17 +10,27 @@ LICENSE_FAIL_ON ?= GPL;LGPL;AGPL ##@ 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 +# deptry scans one or more folders for dependency issues. Each feature bundle +# contributes the folders it owns to DEPTRY_FOLDERS (and any per-folder ignores +# to DEPTRY_IGNORE), so this core target never needs to know which bundles are +# present. Core itself contributes SOURCE_FOLDER when it exists; see e.g. +# marimo.mk for a bundle that appends its own folder. Rhiza's own test folder +# (.rhiza/tests) is deliberately excluded: its tooling is provisioned on the fly +# via `uv run --with` in the individual targets, not declared in the project's +# pyproject, so deptry (which validates against pyproject) would only emit noise +# for it. +DEPTRY_FOLDERS ?= +DEPTRY_IGNORE ?= +ifneq ($(wildcard $(SOURCE_FOLDER)),) +DEPTRY_FOLDERS += $(SOURCE_FOLDER) +endif - @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 \ +deptry: install-uv ## Run deptry over the folders contributed by each bundle + @if [ -n "$(strip $(DEPTRY_FOLDERS))" ]; then \ + printf "${BLUE}[INFO] Running deptry on:${RESET} $(strip $(DEPTRY_FOLDERS))\n"; \ + $(UVX_BIN) -p ${PYTHON_VERSION} deptry $(strip $(DEPTRY_FOLDERS) $(DEPTRY_IGNORE)); \ + else \ + printf "${YELLOW}[WARN] no deptry folders found, skipping.${RESET}\n"; \ fi fmt: install-uv ## check the pre-commit hooks and the linting @@ -42,9 +52,9 @@ todos: ## search and report all TODO/FIXME/HACK comments in the codebase printf "${GREEN}[SUCCESS] No TODO/FIXME/HACK comments found!${RESET}\n" @printf "\n${BLUE}[INFO] Search complete.${RESET}\n" -suppression-audit: ## scan codebase for inline suppressions and report (grade, detail, histogram) +suppression-audit: install-uv ## 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 + @${UVX_BIN} "rhiza-tools>=0.8.1" suppression-audit semgrep: install ## run Semgrep static analysis @printf "${BLUE}[INFO] Running Semgrep...${RESET}\n" diff --git a/.rhiza/make.d/releasing.mk b/.rhiza/make.d/releasing.mk index 5dee330..49fe386 100644 --- a/.rhiza/make.d/releasing.mk +++ b/.rhiza/make.d/releasing.mk @@ -2,7 +2,7 @@ # 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 +.PHONY: bump release release-status changelog pre-bump post-bump pre-release post-release # Hook targets (double-colon rules allow multiple definitions) pre-bump:: ; @: @@ -12,13 +12,19 @@ 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 +# BUMP support: pass BUMP=major|minor|patch to choose the bump type explicitly +# (omit BUMP to select the bump type interactively, the default behaviour) +_BUMP_FLAG := $(if $(BUMP),--bump $(BUMP),) +# PUSH support: pass PUSH=1 to push the tag without the interactive y/N prompt +# (omit PUSH to be prompted before the tag is pushed, the default behaviour) +_PUSH_FLAG := $(if $(PUSH),--push,) +_VERSION=0.7.1 ##@ Releasing and Versioning -bump: pre-bump ## bump version of the project (supports DRY_RUN=1) +bump: pre-bump ## bump version of the project (supports DRY_RUN=1, BUMP=major|minor|patch) @if [ -f "pyproject.toml" ]; then \ $(MAKE) install; \ - PATH="$(abspath ${VENV})/bin:$$PATH" ${UVX_BIN} "rhiza-tools>=$(_VERSION)" bump $(_DRY_RUN_FLAG); \ + PATH="$(abspath ${VENV})/bin:$$PATH" ${UVX_BIN} "rhiza-tools>=$(_VERSION)" bump $(_DRY_RUN_FLAG) $(_BUMP_FLAG); \ if [ -z "$(DRY_RUN)" ]; then \ printf "${BLUE}[INFO] Checking uv.lock file...${RESET}\n"; \ ${UV_BIN} lock; \ @@ -28,12 +34,8 @@ bump: pre-bump ## bump version of the project (supports DRY_RUN=1) 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); +release: pre-release install-uv ## bump version, create tag and push to trigger the release workflow (supports DRY_RUN=1, BUMP=major|minor|patch, PUSH=1) + ${UVX_BIN} "rhiza-tools>=$(_VERSION)" release $(_DRY_RUN_FLAG) $(_BUMP_FLAG) $(_PUSH_FLAG); @$(MAKE) post-release release-status: ## show release workflow status and latest release information @@ -46,5 +48,10 @@ else @printf "${RED}[ERROR] Could not detect forge type (.github/workflows/ or .gitlab-ci.yml not found)${RESET}\n" endif +changelog: install-uv ## generate/update CHANGELOG.md from git history using git-cliff (config: cliff.toml) + @printf "${BLUE}[INFO] Generating CHANGELOG.md with git-cliff...${RESET}\n" + @${UVX_BIN} git-cliff --output CHANGELOG.md + @printf "${GREEN}[OK] CHANGELOG.md updated.${RESET}\n" + diff --git a/.rhiza/make.d/test.mk b/.rhiza/make.d/test.mk index 3af2847..bd9a205 100644 --- a/.rhiza/make.d/test.mk +++ b/.rhiza/make.d/test.mk @@ -4,7 +4,7 @@ # 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 +.PHONY: test benchmark typecheck security docs-coverage hypothesis-test stress test-pyproject mutation # Default directory for tests TESTS_FOLDER := tests @@ -13,64 +13,123 @@ TESTS_FOLDER := tests # (Can be overridden in local.mk or via environment variable) COVERAGE_FAIL_UNDER ?= 90 +# Which static type checker(s) the 'typecheck' target runs: ty, mypy, or both. +# Running both is the default for backward compatibility, but ty and mypy +# occasionally disagree (e.g. one accepts a suppression the other still flags), +# forcing duplicate `# type: ignore` / `# ty: ignore` comments. Set this to +# 'ty' or 'mypy' in local.mk or .rhiza/.env to run a single checker instead. +TYPECHECKER ?= both + ##@ Development and Testing # The 'test' target runs the complete test suite. -# 1. Cleans up any previous test results in _tests/. +# 1. Cleans up any previous test results in _tests/ and stale coverage data. # 2. Creates directories for HTML coverage and test reports. # 3. Invokes pytest via the local virtual environment. # 4. Generates terminal output, HTML coverage, JSON coverage, and HTML test reports. +# +# Parallel (pytest-xdist) runs occasionally crash *during worker/session +# teardown* even though every test passed — e.g. the xdist +# `worker_workerfinished` KeyError or a pytest-html report-write race. pytest +# signals these runner-internal crashes with exit code 3 (INTERNALERROR), +# which is distinct from real test failures (1), interruptions (2) and usage +# errors (4). We therefore retry the suite once on exit code 3 only, so a +# teardown race no longer flips a green run red, while genuine failures still +# fail immediately. Stale `.coverage*` data is removed before each attempt so a +# previously crashed run cannot leave a corrupt data file that reports a false +# 0% coverage on the next run. test:: install ## run all tests - @rm -rf _tests; - - if [ -z "$$(find ${TESTS_FOLDER} -name 'test_*.py' -o -name '*_test.py' 2>/dev/null)" ]; then \ + @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; \ + set -- -n auto \ + --ignore=${TESTS_FOLDER}/benchmarks \ + --ignore=${TESTS_FOLDER}/stress \ + --cov=${SOURCE_FOLDER} \ + --cov-report=term \ + --cov-report=html:_tests/html-coverage \ + --cov-fail-under=$(COVERAGE_FAIL_UNDER) \ + --cov-report=json:_tests/coverage.json \ + --cov-report=xml:_tests/coverage.xml \ + --html=_tests/html-report/report.html; \ else \ printf "${YELLOW}[WARN] Source folder ${SOURCE_FOLDER} not found, running tests without coverage${RESET}\n"; \ - ${UV_BIN} run pytest \ - -n auto \ - --ignore=${TESTS_FOLDER}/benchmarks \ - --ignore=${TESTS_FOLDER}/stress \ - --html=_tests/html-report/report.html; \ - fi + set -- -n auto \ + --ignore=${TESTS_FOLDER}/benchmarks \ + --ignore=${TESTS_FOLDER}/stress \ + --html=_tests/html-report/report.html; \ + fi; \ + attempt=1; max_attempts=2; \ + while :; do \ + rm -f .coverage .coverage.* _tests/coverage.xml _tests/coverage.json 2>/dev/null || true; \ + mkdir -p _tests/html-coverage _tests/html-report; \ + ${UV_BIN} run --with pytest --with pytest-cov --with pytest-xdist --with pytest-html --with pytest-timeout --with pytest-mock pytest "$$@"; status=$$?; \ + if [ $$status -ne 3 ]; then exit $$status; fi; \ + if [ $$attempt -ge $$max_attempts ]; then \ + printf "${RED}[ERROR] pytest reported an internal (teardown) error after %s attempts; failing.${RESET}\n" "$$attempt"; \ + exit $$status; \ + fi; \ + printf "${YELLOW}[WARN] pytest exited 3 (xdist/teardown internal error, all tests may have passed); retrying suite (attempt %s/%s)...${RESET}\n" "$$((attempt + 1))" "$$max_attempts"; \ + attempt=$$((attempt + 1)); \ + done -# The 'typecheck' target runs static type analysis using ty. -# 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 +# The 'typecheck' target runs static type analysis using ty and/or mypy. +# 1. Builds a list of existing Python source folders to check. +# 2. Depending on TYPECHECKER (ty|mypy|both, default: both), runs ty, +# mypy in strict mode, or both in sequence as a cross-check. +typecheck: install ## run ty and/or mypy type checking (TYPECHECKER=ty|mypy|both, default: both) + @typecheck_paths=""; \ + if [ -d "${SOURCE_FOLDER}" ]; then \ + typecheck_paths="${SOURCE_FOLDER}"; \ + fi; \ + if [ -z "$${typecheck_paths}" ]; then \ + printf "${YELLOW}[WARN] No typecheck folders found (SOURCE_FOLDER='${SOURCE_FOLDER}'), skipping typecheck${RESET}\n"; \ + exit 0; \ + fi; \ + case "${TYPECHECKER}" in \ + ty) \ + printf "${BLUE}[INFO] Running ty type checking in:$${typecheck_paths}${RESET}\n"; \ + ${UV_BIN} run --with ty ty check $${typecheck_paths} \ + ;; \ + mypy) \ + printf "${BLUE}[INFO] Running mypy strict type checking in:$${typecheck_paths}${RESET}\n"; \ + ${UV_BIN} run --with mypy mypy --strict $${typecheck_paths} \ + ;; \ + both) \ + printf "${BLUE}[INFO] Running ty type checking in:$${typecheck_paths}${RESET}\n"; \ + ${UV_BIN} run --with ty ty check $${typecheck_paths} && \ + printf "${BLUE}[INFO] Running mypy strict type checking in:$${typecheck_paths}${RESET}\n"; \ + ${UV_BIN} run --with mypy mypy --strict $${typecheck_paths} \ + ;; \ + *) \ + printf "${RED}[ERROR] Invalid TYPECHECKER='${TYPECHECKER}' (expected: ty, mypy, or both)${RESET}\n"; \ + exit 1 \ + ;; \ + esac # 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. +# 1. Runs pip-audit via `rhiza-tools pip-audit` (tiered policy): fails on runtime +# dep CVEs, warns on tooling (pip/setuptools/wheel). +# 2. Runs bandit to find common security issues in Python source folders that exist. 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 + @${UVX_BIN} "rhiza-tools>=0.8.1" pip-audit ${PIP_AUDIT_ARGS} + @bandit_paths=""; \ + if [ -d "${SOURCE_FOLDER}" ]; then \ + bandit_paths="${SOURCE_FOLDER}"; \ + fi; \ + if [ -n "$${bandit_paths}" ]; then \ + printf "${BLUE}[INFO] Running bandit security scan in:$${bandit_paths}${RESET}\n"; \ + ${UVX_BIN} bandit -r $${bandit_paths} -ll -q --ini .bandit; \ + else \ + printf "${YELLOW}[WARN] No bandit scan folders found (SOURCE_FOLDER='${SOURCE_FOLDER}'), skipping bandit${RESET}\n"; \ + fi # The 'benchmark' target runs performance benchmarks using pytest-benchmark. # 1. Installs benchmarking dependencies (pytest-benchmark, pygal). @@ -80,9 +139,8 @@ security: install ## run security scans (pip-audit and bandit) benchmark:: install ## run performance benchmarks @if [ -d "${TESTS_FOLDER}/benchmarks" ]; then \ printf "${BLUE}[INFO] Running performance benchmarks...${RESET}\n"; \ - ${UV_BIN} pip install pytest-benchmark==5.2.3 pygal==3.1.0; \ mkdir -p _tests/benchmarks; \ - ${UV_BIN} run pytest "${TESTS_FOLDER}/benchmarks/" \ + ${UV_BIN} run --with pytest --with pytest-benchmark==5.2.3 --with pygal==3.1.0 pytest "${TESTS_FOLDER}/benchmarks/" \ --benchmark-only \ --benchmark-histogram=_tests/benchmarks/histogram \ --benchmark-json=_tests/benchmarks/results.json; \ @@ -92,14 +150,24 @@ benchmark:: install ## run performance benchmarks 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. +# 1. Builds a list of existing Python source folders to check. +# 2. Runs interrogate with verbose output against those folders. docs-coverage: install ## check documentation coverage with interrogate - @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}; \ + @docstring_paths=""; \ + if [ -d "${SOURCE_FOLDER}" ]; then \ + docstring_paths="${SOURCE_FOLDER}"; \ + fi; \ + if [ -d "tests" ]; then \ + docstring_paths="$${docstring_paths} tests"; \ + fi; \ + if [ -d ".rhiza/tests" ]; then \ + docstring_paths="$${docstring_paths} .rhiza/tests"; \ + fi; \ + if [ -n "$${docstring_paths}" ]; then \ + printf "${BLUE}[INFO] Checking documentation coverage in:$${docstring_paths}${RESET}\n"; \ + ${UV_BIN} run --with interrogate interrogate -vv --fail-under 100 --ignore-init-method --ignore-magic $${docstring_paths}; \ else \ - printf "${YELLOW}[WARN] Source folder ${SOURCE_FOLDER} not found, skipping docs-coverage${RESET}\n"; \ + printf "${YELLOW}[WARN] No docs-coverage folders found (SOURCE_FOLDER='${SOURCE_FOLDER}'), skipping docs-coverage${RESET}\n"; \ fi # The 'hypothesis-test' target runs property-based tests using Hypothesis. @@ -113,7 +181,7 @@ hypothesis-test:: install ## run property-based tests with Hypothesis fi; \ printf "${BLUE}[INFO] Running Hypothesis property-based tests...${RESET}\n"; \ mkdir -p _tests/hypothesis; \ - PYTEST_HTML_TITLE="Hypothesis tests" ${UV_BIN} run pytest \ + PYTEST_HTML_TITLE="Hypothesis tests" ${UV_BIN} run --with pytest --with hypothesis --with pytest-html pytest \ --ignore=${TESTS_FOLDER}/benchmarks \ -v \ --hypothesis-show-statistics \ @@ -139,7 +207,7 @@ stress:: install ## run stress/load tests fi; \ printf "${BLUE}[INFO] Running stress/load tests...${RESET}\n"; \ mkdir -p _tests/stress; \ - ${UV_BIN} run pytest \ + ${UV_BIN} run --with pytest --with pytest-html pytest \ -v \ -m stress \ --tb=short \ @@ -153,17 +221,17 @@ mutation: install ## run mutation tests with mutmut printf "${BLUE}[INFO] Running mutation tests on ${SOURCE_FOLDER}...${RESET}\n"; \ mkdir -p _tests/mutation; \ run_status=0; \ - ${UV_BIN} run mutmut run \ + ${UV_BIN} run --with mutmut mutmut run \ --paths-to-mutate="${SOURCE_FOLDER}" \ --tests-dir="${TESTS_FOLDER}" || run_status=$$?; \ - ${UV_BIN} run mutmut html || exit $$?; \ + ${UV_BIN} run --with mutmut mutmut html || exit $$?; \ rm -rf _tests/mutation/html; \ mv html _tests/mutation/html || exit $$?; \ - ${UV_BIN} run mutmut results || exit $$?; \ + ${UV_BIN} run --with mutmut mutmut results || exit $$?; \ exit $$run_status test-pyproject: install ## run pyproject.toml structure tests - @${UV_BIN} run pytest .rhiza/tests/structure/test_pyproject.py \ + @${UV_BIN} run --with pytest pytest .rhiza/tests/test_pyproject.py \ -v \ --tb=long \ --showlocals \ 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 index 19c57dd..d528c63 100644 --- a/.rhiza/rhiza.mk +++ b/.rhiza/rhiza.mk @@ -10,6 +10,37 @@ ifndef MAKE_VERSION $(error GNU Make is required. macOS ships BSD make — install GNU Make with: brew install make) endif +# Require a working POSIX shell. +# Recipes use POSIX shell syntax (mkdir -p, printf, [ ... ], curl | sh). GNU Make +# runs recipes with sh when it finds one on PATH -- even when make itself was +# launched from PowerShell or cmd -- so e.g. CI on windows-latest (which ships +# Git's sh.exe) works fine. Only when no POSIX shell is found does make fall back +# to cmd.exe, where the recipes fail on the first non-cmd line with errors like: +# process_begin: CreateProcess(NULL, # Ensure ... folder exists, ...) failed. +# So probe the shell make actually uses rather than guessing from the OS: run a +# POSIX command and require its output. Guarded by $(OS) so the probe only runs +# on Windows (no subprocess cost on Linux/macOS/WSL, where the shell is POSIX). +ifeq ($(OS),Windows_NT) +ifneq ($(shell printf POSIX),POSIX) +define RHIZA_WINDOWS_SHELL_ERROR + + This project's Makefile requires a POSIX shell, but make could not find one + and fell back to cmd.exe, which is not supported. + + Run 'make' from an environment that provides a POSIX shell, e.g.: + - WSL (recommended): https://learn.microsoft.com/windows/wsl/install + - Git Bash: bundled with Git for Windows (https://git-scm.com/download/win) + + Tip: if 'sh.exe' (from Git for Windows) is on your PATH, make will use it + automatically even from PowerShell or cmd. + + See README.md > Prerequisites for details. + +endef +$(error $(RHIZA_WINDOWS_SHELL_ERROR)) +endif +endif + # Colours for pretty output in help messages BLUE := \033[36m BOLD := \033[1m @@ -69,8 +100,25 @@ export UV_VENV_CLEAR := 1 unexport VIRTUAL_ENV # Load .rhiza/.env (if present) and export its variables so recipes see them. +# This file is optional — sensible defaults are defined below. -include .rhiza/.env +# --------------------------------------------------------------------------- +# Default values for variables that may be set in .rhiza/.env. +# These ?= assignments are skipped when the variable is already defined by +# the included file, by an environment variable, or by the root Makefile. +# --------------------------------------------------------------------------- + +# Directory that holds the project's Python source package(s). +SOURCE_FOLDER ?= src + +# Directory that holds Marimo notebooks (used by marimo.mk and book.mk). +MARIMO_FOLDER ?= docs/notebooks + +# JSON array of GitHub Actions runner OS labels used by the CI matrix. +# Override in .rhiza/.env or your root Makefile to add more platforms. +RHIZA_CI_OS_MATRIX ?= ["ubuntu-latest"] + # ============================================================================== # Rhiza Core # ============================================================================== @@ -134,7 +182,7 @@ summarise-sync: install-uv ## summarise differences created by sync with templat rhiza-test: install ## run rhiza's own tests (if any) @if [ -d ".rhiza/tests" ]; then \ - ${UV_BIN} run pytest .rhiza/tests; \ + ${UV_BIN} run --with pytest --with pytest-timeout --with python-dotenv --with packaging pytest .rhiza/tests; \ else \ printf "${YELLOW}[WARN] No .rhiza/tests directory found, skipping rhiza-tests${RESET}\n"; \ fi @@ -164,7 +212,7 @@ version-matrix: install-uv ## Emit the list of supported Python versions from py @${UVX_BIN} "rhiza-tools>=0.2.2" version-matrix ci-os-matrix: ## Emit GitHub CI OSes (RHIZA_CI_OS_MATRIX as JSON array, default ["ubuntu-latest"]) - @printf '%s\n' '$(or $(RHIZA_CI_OS_MATRIX),["ubuntu-latest"])' + @$(info $(or $(RHIZA_CI_OS_MATRIX),["ubuntu-latest"])) print-% : ## print the value of a variable (usage: make print-VARIABLE) @printf "${BLUE}[INFO] Printing value of variable '$*':${RESET}\n" diff --git a/.rhiza/template.lock b/.rhiza/template.lock index fb2a01f..5aaef0d 100644 --- a/.rhiza/template.lock +++ b/.rhiza/template.lock @@ -1,26 +1,37 @@ -sha: ae79c6f0729b21239655abe390269be9171f907d +sha: cc162bbe38291955e0aa5f3418c1bcfd10fbba24 repo: jebel-quant/rhiza host: github -ref: v0.18.8 +ref: v1.1.1 include: [] exclude: [] templates: [] files: - .bandit +- .claude/commands/rhiza_book.md +- .claude/commands/rhiza_quality.md +- .claude/commands/rhiza_release.md +- .claude/commands/rhiza_update.md - .editorconfig +- .github/CONFIG.md +- .github/DISCUSSION_TEMPLATE/help-wanted.yml +- .github/DISCUSSION_TEMPLATE/ideas.yml - .github/DISCUSSION_TEMPLATE/q-and-a.yml - .github/ISSUE_TEMPLATE/bug_report.yml - .github/ISSUE_TEMPLATE/feature_request.yml - .github/dependabot.yml - .github/pull_request_template.md - .github/release.yml +- .github/rulesets/main-branch-protection.json - .github/secret_scanning.yml - .github/workflows/rhiza_benchmark.yml - .github/workflows/rhiza_book.yml - .github/workflows/rhiza_ci.yml - .github/workflows/rhiza_codeql.yml +- .github/workflows/rhiza_fuzzing.yml - .github/workflows/rhiza_marimo.yml +- .github/workflows/rhiza_mutation.yml - .github/workflows/rhiza_release.yml +- .github/workflows/rhiza_scorecard.yml - .github/workflows/rhiza_sync.yml - .github/workflows/rhiza_weekly.yml - .gitignore @@ -39,43 +50,25 @@ files: - .rhiza/make.d/custom-env.mk - .rhiza/make.d/custom-task.mk - .rhiza/make.d/doctor.mk +- .rhiza/make.d/github.mk - .rhiza/make.d/marimo.mk - .rhiza/make.d/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_docstrings.py +- .rhiza/tests/test_git_repo_fixture.py +- .rhiza/tests/test_pyproject.py +- .rhiza/tests/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 +- cliff.toml - docs/assets/rhiza-logo.svg - docs/development/MARIMO.md - docs/development/TESTS.md @@ -85,5 +78,5 @@ files: - ruff.toml profiles: - github-project -synced_at: '2026-06-13T10:52:58Z' +synced_at: '2026-07-09T09:17:36Z' strategy: merge diff --git a/.rhiza/tests/README.md b/.rhiza/tests/README.md index 1cf5c26..e35b1ee 100644 --- a/.rhiza/tests/README.md +++ b/.rhiza/tests/README.md @@ -1,38 +1,28 @@ -# Rhiza Test Suite +# Rhiza Test Suite (`.rhiza/tests/`) -This directory contains the comprehensive test suite for the Rhiza project. +This directory is **synced from [jebel-quant/rhiza](https://github.com/jebel-quant/rhiza)** +via the `tests` bundle 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, docs, and +docstrings that vary per project — using the shared fixtures and helpers below. -## Test Organization +> Tests that only exercise Rhiza's *own* template files (Makefile targets, workflow stubs, +> the project skeleton) live in Rhiza's mother-repo `tests/` suite and are **not** synced +> here — they would be identical in every consumer and can't be changed downstream. Put +> your project's own tests under your `tests/` directory, not here. -Tests are organized into purpose-driven subdirectories: +## Layout -### `structure/` -Static assertions about file and directory presence. These tests verify that the repository contains the expected files, directories, and configuration structure without executing any subprocesses. +The suite is flat — one file per concern: -- `test_project_layout.py` — Validates root-level files and directories -- `test_requirements.py` — Validates `.rhiza/requirements/` structure +- `test_pyproject.py` — validates `pyproject.toml` structure and required fields +- `test_readme_validation.py` — executes/syntax-checks `README.md` code blocks (see below) +- `test_docstrings.py` — runs doctests across the modules in your source folder +- `test_git_repo_fixture.py` — self-test for the shared `git_repo` fixture +- `conftest.py` — shared fixtures (`root`, `logger`, `git_repo`) +- `test_utils.py` — shared helpers (`run_make`, `setup_rhiza_git_repo`, `strip_ansi`) +- `stress/` — scaffolding for optional load/concurrency tests (see [stress/README.md](stress/README.md)) -### `api/` -Makefile target validation via dry-runs. These tests verify that Makefile targets are properly defined and would execute the expected commands. - -- `test_makefile_targets.py` — Core Makefile targets (install, test, fmt, etc.) -- `test_makefile_api.py` — Makefile API (delegation, extension, hooks, overrides) -- `test_github_targets.py` — GitHub-specific Makefile targets - -### `integration/` -Tests requiring sandboxed git repositories or subprocess execution. These tests verify end-to-end workflows. - -- `test_release.py` — Release script functionality -- `test_book_targets.py` — Documentation book build targets - -### `sync/` -Template sync, workflows, versioning, and content validation tests. These tests ensure that template synchronization and content validation work correctly. - -- `test_rhiza_version.py` — Version reading and workflow validation -- `test_readme_validation.py` — README code block execution and validation -- `test_docstrings.py` — Doctest validation across source modules - -#### Skipping README code blocks with `+RHIZA_SKIP` +### Skipping README code blocks with `+RHIZA_SKIP` By default, every `python` and `bash` code block in `README.md` is executed or syntax-checked by `test_readme_validation.py`. To mark a block as intentionally @@ -56,114 +46,43 @@ Markdown renderers (including GitHub) ignore everything after the first word on a fence line, so the block still renders as a normal highlighted code block. Blocks without `+RHIZA_SKIP` continue to be validated as before. -### `utils/` -Tests for utility code and test infrastructure. These tests validate the testing framework itself and utility scripts. - -- `test_git_repo_fixture.py` — Validates the `git_repo` fixture - -### `deps/` -Dependency validation tests. These tests ensure that project dependencies are correctly specified and healthy. - -- `test_dependency_health.py` — Validates pyproject.toml and requirements files - -### `stress/` -Stress tests that verify Rhiza's stability under heavy load. These tests execute Rhiza-specific operations under concurrent load and repeated execution to detect race conditions, resource leaks, and performance degradation. - -- `test_makefile_stress.py` — Makefile operations under concurrent/repeated load -- `test_git_stress.py` — Git operations under concurrent load - -See [stress/README.md](stress/README.md) for detailed documentation. - ## Running Tests -### Run all tests ```bash -uv run pytest .rhiza/tests/ -# or -make test +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 +uv run pytest .rhiza/tests/ -m "not stress" # skip stress tests ``` -### Run tests from a specific category -```bash -uv run pytest .rhiza/tests/structure/ -uv run pytest .rhiza/tests/api/ -uv run pytest .rhiza/tests/integration/ -uv run pytest .rhiza/tests/sync/ -uv run pytest .rhiza/tests/utils/ -uv run pytest .rhiza/tests/deps/ -uv run pytest .rhiza/tests/stress/ -``` +Stress tests accept custom parameters (defaults: 100 iterations, 10 workers): -### Run stress tests with custom parameters ```bash -# Run all stress tests (default: 100 iterations, 10 workers) -uv run pytest .rhiza/tests/stress/ -v - -# Run with fewer iterations (faster) uv run pytest .rhiza/tests/stress/ -v --iterations=10 - -# Skip stress tests when running full test suite -uv run pytest .rhiza/tests/ -v -m "not stress" ``` -### Run a specific test file -```bash -uv run pytest .rhiza/tests/structure/test_project_layout.py -``` +## Fixtures -### Run with verbose output -```bash -uv run pytest .rhiza/tests/ -v -``` +Defined in `conftest.py` and available to every test without import: -### Run with coverage -```bash -uv run pytest .rhiza/tests/ --cov -``` +- `root` — repository root path (session-scoped) +- `logger` — configured logger instance (session-scoped) +- `git_repo` — sandboxed git repository (function-scoped) -## Fixtures +Shared helpers live in `test_utils.py` and are imported directly: -### Root-level fixtures (`conftest.py`) -- `root` — Repository root path (session-scoped) -- `logger` — Configured logger instance (session-scoped) -- `git_repo` — Sandboxed git repository (function-scoped) +```python +from test_utils import strip_ansi, run_make, setup_rhiza_git_repo +``` -### Category-specific fixtures -- `api/conftest.py` — `setup_tmp_makefile`, `run_make`, `setup_rhiza_git_repo` -- `sync/conftest.py` — `setup_sync_env` +`.rhiza/tests` is on `pythonpath` (see `pytest.ini`), so `test_utils` imports resolve +without any `sys.path` manipulation. ## Writing Tests -### Conventions - Use descriptive test names that explain what is being tested - Group related tests in classes when appropriate -- Use appropriate fixtures for setup/teardown - Add docstrings to test modules and complex test functions - Use `pytest.mark.skip` for tests that depend on optional features - -### Import Patterns -```python -# Import shared helpers from test_utils -from test_utils import strip_ansi, run_make, setup_rhiza_git_repo - -# Import from local category conftest (for fixtures and category-specific helpers) -from api.conftest import SPLIT_MAKEFILES, setup_tmp_makefile - -# Note: Fixtures defined in conftest.py are automatically available in tests -# and don't need to be explicitly imported -``` - -## Test Coverage - -The test suite aims for high coverage across: -- Configuration validation (structure, dependencies) -- Makefile target correctness (api) -- End-to-end workflows (integration) -- Template synchronization (sync) -- Utility code (utils) - -## Notes - -- Benchmarks are located in `tests/benchmarks/` and run via `make benchmark` -- Integration tests use sandboxed git repositories to avoid affecting the working tree -- All Makefile tests use dry-run mode (`make -n`) to avoid side effects +- Prefer the `git_repo` fixture over touching the working tree diff --git a/.rhiza/tests/api/conftest.py b/.rhiza/tests/api/conftest.py deleted file mode 100644 index 8733d6c..0000000 --- a/.rhiza/tests/api/conftest.py +++ /dev/null @@ -1,98 +0,0 @@ -"""Shared fixtures for Makefile API tests. - -This conftest provides: -- setup_tmp_makefile: Copies Makefile and split files to temp dir for isolated testing -- run_make: Helper to execute make commands with dry-run support (imported from test_utils) -- setup_rhiza_git_repo: Initialize a git repo configured as rhiza origin (imported from test_utils) -- SPLIT_MAKEFILES: List of split Makefile paths - -Security Notes: -- S101 (assert usage): Asserts are used in pytest tests to validate conditions -- S603/S607 (subprocess usage): Any subprocess calls (via run_make) are for testing - Makefile targets in isolated environments with controlled inputs -- Test code operates in a controlled environment with trusted inputs -""" - -from __future__ import annotations - -import os -import shutil -import sys -from pathlib import Path - -import pytest - -tests_root = Path(__file__).resolve().parents[1] -if str(tests_root) not in sys.path: - sys.path.insert(0, str(tests_root)) - -from test_utils import run_make, setup_rhiza_git_repo, strip_ansi # noqa: E402, F401 - -# Split Makefile paths that are included in the main Makefile -# These are now located in .rhiza/make.d/ directory -SPLIT_MAKEFILES = [ - ".rhiza/rhiza.mk", - ".rhiza/make.d/bootstrap.mk", - ".rhiza/make.d/quality.mk", - ".rhiza/make.d/releasing.mk", - ".rhiza/make.d/doctor.mk", - ".rhiza/make.d/test.mk", - ".rhiza/make.d/book.mk", - ".rhiza/make.d/marimo.mk", - ".rhiza/make.d/presentation.mk", - ".rhiza/make.d/github.mk", - ".rhiza/make.d/agentic.mk", - ".rhiza/make.d/gh-aw.mk", - ".rhiza/make.d/docker.mk", -] - - -@pytest.fixture(autouse=True) -def setup_tmp_makefile(logger, root, tmp_path: Path): - """Copy the Makefile and split Makefiles into a temp directory and chdir there. - - We rely on `make -n` so that no real commands are executed. - This fixture consolidates setup for both basic Makefile tests and GitHub targets. - """ - logger.debug("Setting up temporary Makefile test dir: %s", tmp_path) - - # Copy the main Makefile into the temporary working directory - shutil.copy(root / "Makefile", tmp_path / "Makefile") - - # Copy core Rhiza Makefiles - (tmp_path / ".rhiza").mkdir(exist_ok=True) - shutil.copy(root / ".rhiza" / "rhiza.mk", tmp_path / ".rhiza" / "rhiza.mk") - - # Copy .python-version file for PYTHON_VERSION variable - if (root / ".python-version").exists(): - shutil.copy(root / ".python-version", tmp_path / ".python-version") - - # Copy .rhiza/.env if it exists (needed for GitHub targets and other configuration) - if (root / ".rhiza" / ".env").exists(): - shutil.copy(root / ".rhiza" / ".env", tmp_path / ".rhiza" / ".env") - else: - # Create a minimal, deterministic .rhiza/.env for tests so they don't - # depend on the developer's local configuration which may vary. - env_content = "CUSTOM_SCRIPTS_FOLDER=.rhiza/customisations/scripts\n" - (tmp_path / ".rhiza" / ".env").write_text(env_content) - - logger.debug("Copied Makefile from %s to %s", root / "Makefile", tmp_path / "Makefile") - - # Copy split Makefiles if they exist (maintaining directory structure) - for split_file in SPLIT_MAKEFILES: - source_path = root / split_file - if source_path.exists(): - dest_path = tmp_path / split_file - dest_path.parent.mkdir(parents=True, exist_ok=True) - shutil.copy(source_path, dest_path) - logger.debug("Copied %s to %s", source_path, dest_path) - - # Move into tmp directory for isolation - old_cwd = Path.cwd() - os.chdir(tmp_path) - logger.debug("Changed working directory to %s", tmp_path) - try: - yield - finally: - os.chdir(old_cwd) - logger.debug("Restored working directory to %s", old_cwd) diff --git a/.rhiza/tests/api/test_github_targets.py b/.rhiza/tests/api/test_github_targets.py deleted file mode 100644 index 1008dee..0000000 --- a/.rhiza/tests/api/test_github_targets.py +++ /dev/null @@ -1,63 +0,0 @@ -"""Tests for the GitHub Makefile targets using safe dry-runs. - -These tests validate that the .github/github.mk targets are correctly exposed -and emit the expected commands without actually executing them. -""" - -from __future__ import annotations - -from pathlib import Path - -import pytest - -# Import run_make from local conftest (setup_tmp_makefile is autouse) -from api.conftest import run_make - -_GITHUB_MK = Path(__file__).resolve().parents[3] / ".rhiza" / "make.d" / "github.mk" -if not _GITHUB_MK.exists(): - pytest.skip("github.mk not found, skipping github targets tests", allow_module_level=True) - - -def test_gh_targets_exist(logger): - """Verify that GitHub targets are listed in help.""" - result = run_make(logger, ["help"], dry_run=False) - output = result.stdout - - expected_targets = ["gh-install", "view-prs", "view-issues", "failed-workflows", "whoami"] - - for target in expected_targets: - assert target in output, f"Target {target} not found in help output" - - -def test_gh_install_dry_run(logger): - """Verify gh-install target dry-run.""" - result = run_make(logger, ["gh-install"]) - # In dry-run, we expect to see the shell commands that would be executed. - # Since the recipe uses @if, make -n might verify the syntax or show the command if not silenced. - # However, with -s (silent), make -n might not show much for @ commands unless they are echoed. - # But we mainly want to ensure it runs without error. - assert result.returncode == 0 - - -def test_view_prs_dry_run(logger): - """Verify view-prs target dry-run.""" - result = run_make(logger, ["view-prs"]) - assert result.returncode == 0 - - -def test_view_issues_dry_run(logger): - """Verify view-issues target dry-run.""" - result = run_make(logger, ["view-issues"]) - assert result.returncode == 0 - - -def test_failed_workflows_dry_run(logger): - """Verify failed-workflows target dry-run.""" - result = run_make(logger, ["failed-workflows"]) - assert result.returncode == 0 - - -def test_whoami_dry_run(logger): - """Verify whoami target dry-run.""" - result = run_make(logger, ["whoami"]) - assert result.returncode == 0 diff --git a/.rhiza/tests/api/test_make_variable_overrides.py b/.rhiza/tests/api/test_make_variable_overrides.py deleted file mode 100644 index e5b5cfb..0000000 --- a/.rhiza/tests/api/test_make_variable_overrides.py +++ /dev/null @@ -1,161 +0,0 @@ -"""Tests for Makefile variable override behaviour. - -This file and its associated tests flow down via a SYNC action from the -jebel-quant/rhiza repository (https://github.com/jebel-quant/rhiza). - -Validates that key Makefile variables behave correctly when overridden on -the command line, ensuring downstream projects can customise coverage -thresholds, license checks, and Python tooling without modifying the -shared Makefile infrastructure. - -All tests use `make -n` (dry-run) to observe what commands *would* be -executed without running them — keeping the suite fast and side-effect-free. -""" - -from __future__ import annotations - -import os - -from api.conftest import run_make, strip_ansi - - -class TestCoverageFailUnder: - """COVERAGE_FAIL_UNDER controls the pytest --cov-fail-under threshold.""" - - def test_default_threshold_is_90(self, logger) -> None: - """Default COVERAGE_FAIL_UNDER value must be 90.""" - proc = run_make(logger, ["test"]) - assert "--cov-fail-under=90" in proc.stdout, ( - "Default coverage threshold should be 90; got:\n" + proc.stdout[:500] - ) - - def test_threshold_override_to_100(self, logger) -> None: - """COVERAGE_FAIL_UNDER=100 must propagate to pytest invocation.""" - proc = run_make(logger, ["test", "COVERAGE_FAIL_UNDER=100"]) - assert "--cov-fail-under=100" in proc.stdout - - def test_threshold_override_to_0(self, logger) -> None: - """COVERAGE_FAIL_UNDER=0 must propagate (useful for bootstrapping new projects).""" - proc = run_make(logger, ["test", "COVERAGE_FAIL_UNDER=0"]) - assert "--cov-fail-under=0" in proc.stdout - - def test_threshold_override_to_arbitrary_value(self, logger) -> None: - """Any integer override for COVERAGE_FAIL_UNDER must appear verbatim in the command.""" - proc = run_make(logger, ["test", "COVERAGE_FAIL_UNDER=73"]) - assert "--cov-fail-under=73" in proc.stdout - - -class TestLicenseFailOn: - """LICENSE_FAIL_ON controls which SPDX license identifiers cause a build failure.""" - - def test_default_fails_on_gpl(self, logger) -> None: - """Default LICENSE_FAIL_ON must include GPL to block copyleft licenses.""" - proc = run_make(logger, ["license"]) - assert "GPL" in proc.stdout, "Default license check should fail on GPL; got:\n" + proc.stdout[:500] - - def test_fail_on_override_single_license(self, logger) -> None: - """Custom single-license override must appear in the make license command.""" - proc = run_make(logger, ["license", "LICENSE_FAIL_ON=MIT"]) - assert "MIT" in proc.stdout - - def test_fail_on_override_multiple_licenses(self, logger) -> None: - """Semicolon-separated multi-license override must appear verbatim.""" - proc = run_make(logger, ["license", "LICENSE_FAIL_ON=AGPL-3.0;GPL-2.0;LGPL-2.1"]) - assert "AGPL-3.0" in proc.stdout - assert "GPL-2.0" in proc.stdout - - def test_fail_on_override_quoted_correctly(self, logger) -> None: - """LICENSE_FAIL_ON value must be quoted in the underlying pip-licenses call.""" - proc = run_make(logger, ["license", "LICENSE_FAIL_ON=MIT;Apache"]) - # The Makefile must quote the value to handle semicolons properly - assert '--fail-on="MIT;Apache"' in proc.stdout - - -class TestPythonVersionVariable: - """PYTHON_VERSION drives uvx -p ... in quality and formatting targets.""" - - def test_python_version_read_from_python_version_file(self, logger, tmp_path) -> None: - """When .python-version exists, PYTHON_VERSION should reflect its contents.""" - python_version_file = tmp_path / ".python-version" - if python_version_file.exists(): - version = python_version_file.read_text().strip() - proc = run_make(logger, ["print-PYTHON_VERSION"], dry_run=False) - out = strip_ansi(proc.stdout) - assert version in out, f"Expected {version} in PYTHON_VERSION output; got: {out}" - - def test_python_version_default_when_file_missing(self, logger, tmp_path) -> None: - """When .python-version is absent and PYTHON_VERSION env var is unset, default to 3.13.""" - pv_file = tmp_path / ".python-version" - if pv_file.exists(): - pv_file.unlink() - - env = os.environ.copy() - env.pop("PYTHON_VERSION", None) - - proc = run_make(logger, ["print-PYTHON_VERSION"], dry_run=False, env=env) - out = strip_ansi(proc.stdout) - assert "3.13" in out, f"Expected default 3.13; got: {out}" - - def test_python_version_used_in_fmt_target(self, logger, tmp_path) -> None: - """The fmt target must pass -p to uvx.""" - env = os.environ.copy() - env.pop("PYTHON_VERSION", None) - - proc = run_make(logger, ["fmt"], env=env) - assert "uvx -p" in proc.stdout, "fmt target should use uvx -p " - - -class TestSourceFolderVariable: - """SOURCE_FOLDER drives coverage collection and static analysis targets.""" - - def test_typecheck_uses_source_folder(self, logger, tmp_path) -> None: - """The typecheck target must check the directory set by SOURCE_FOLDER.""" - src_dir = tmp_path / "mypackage" - src_dir.mkdir(exist_ok=True) - - env_file = tmp_path / ".rhiza" / ".env" - if env_file.exists(): - env_file.write_text(env_file.read_text() + "\nSOURCE_FOLDER=mypackage\n") - - proc = run_make(logger, ["typecheck", "SOURCE_FOLDER=mypackage"]) - assert "mypackage" in proc.stdout, "typecheck should reference SOURCE_FOLDER; got:\n" + proc.stdout[:400] - - def test_deptry_uses_source_folder(self, logger, tmp_path) -> None: - """The deptry target must scan the directory set by SOURCE_FOLDER.""" - src_dir = tmp_path / "mypackage" - src_dir.mkdir(exist_ok=True) - - proc = run_make(logger, ["deptry", "SOURCE_FOLDER=mypackage"]) - assert "mypackage" in proc.stdout, "deptry should reference SOURCE_FOLDER; got:\n" + proc.stdout[:400] - - -class TestUvNoModifyPath: - """UV_NO_MODIFY_PATH must always be exported to 1 to avoid uv touching PATH.""" - - def test_uv_no_modify_path_is_1(self, logger) -> None: - """UV_NO_MODIFY_PATH must be exported as 1 in the Makefile.""" - proc = run_make(logger, ["print-UV_NO_MODIFY_PATH"], dry_run=False) - out = strip_ansi(proc.stdout) - assert "1" in out, f"UV_NO_MODIFY_PATH should be 1; got: {out}" - - def test_uv_no_modify_path_cannot_be_overridden_to_empty(self, logger) -> None: - """UV_NO_MODIFY_PATH must still appear in the printed value when queried.""" - proc = run_make(logger, ["print-UV_NO_MODIFY_PATH"], dry_run=False) - out = strip_ansi(proc.stdout) - assert "UV_NO_MODIFY_PATH" in out - - -class TestTestsFolder: - """TESTS_FOLDER defaults to 'tests' but can be overridden.""" - - def test_default_tests_folder_is_tests(self, logger) -> None: - """Default TESTS_FOLDER must be 'tests'.""" - proc = run_make(logger, ["print-TESTS_FOLDER"], dry_run=False) - out = strip_ansi(proc.stdout) - assert "tests" in out, f"Default TESTS_FOLDER should be 'tests'; got: {out}" - - def test_pytest_uses_tests_folder(self, logger) -> None: - """The test target must invoke pytest with the TESTS_FOLDER path.""" - proc = run_make(logger, ["test"]) - # The default tests folder must appear somewhere in the pytest invocation - assert "pytest" in proc.stdout diff --git a/.rhiza/tests/api/test_makefile_api.py b/.rhiza/tests/api/test_makefile_api.py deleted file mode 100644 index 8096006..0000000 --- a/.rhiza/tests/api/test_makefile_api.py +++ /dev/null @@ -1,369 +0,0 @@ -"""Tests for the new Makefile API structure (Wrapper + Makefile.rhiza).""" - -import os -import shutil -import subprocess # nosec -from pathlib import Path - -import pytest - -# Get absolute paths for executables to avoid S607 warnings from CodeFactor/Bandit -GIT = shutil.which("git") or "/usr/bin/git" - -# Files required for the API test environment -REQUIRED_FILES = [ - "Makefile", - "pyproject.toml", - "README.md", # is needed to do uv sync, etc. -] - -# Folders to copy recursively -REQUIRED_FOLDERS = [ - ".rhiza", -] - -OPTIONAL_FOLDERS = [ - "tests", # for tests/tests.mk - "docker", # for docker/docker.mk, if referenced - "book", - "presentation", -] - - -@pytest.fixture -def setup_api_env(logger, root, tmp_path: Path): - """Set up the Makefile API test environment in a temp folder.""" - logger.debug("Setting up Makefile API test env in: %s", tmp_path) - - # Copy files - for filename in REQUIRED_FILES: - src = root / filename - if src.exists(): - shutil.copy(src, tmp_path / filename) - else: - pytest.fail(f"Required file {filename} not found in root") - - # Copy required directories - for folder in REQUIRED_FOLDERS: - src = root / folder - if src.exists(): - dest = tmp_path / folder - if dest.exists(): - shutil.rmtree(dest) - shutil.copytree(src, dest) - else: - pytest.fail(f"Required folder {folder} not found in root") - - # Copy optional directories - for folder in OPTIONAL_FOLDERS: - src = root / folder - if src.exists(): - dest = tmp_path / folder - if dest.exists(): - shutil.rmtree(dest) - shutil.copytree(src, dest) - - # Create .rhiza/make.d and ensure no local.mk exists initially - (tmp_path / ".rhiza" / "make.d").mkdir(parents=True, exist_ok=True) - if (tmp_path / "local.mk").exists(): - (tmp_path / "local.mk").unlink() - - # Initialize git repo for rhiza tools (required for sync/validate) - subprocess.run([GIT, "init"], cwd=tmp_path, check=True, capture_output=True) # nosec - # Configure git user for commits if needed (some rhiza checks might need commits) - subprocess.run([GIT, "config", "user.email", "you@example.com"], cwd=tmp_path, check=True, capture_output=True) # nosec - subprocess.run([GIT, "config", "user.name", "Rhiza Test"], cwd=tmp_path, check=True, capture_output=True) # nosec - # Add origin remote to simulate being in the rhiza repo (triggers the skip logic in rhiza.mk) - subprocess.run( - [GIT, "remote", "add", "origin", "https://github.com/jebel-quant/rhiza.git"], - cwd=tmp_path, - check=True, - capture_output=True, - ) # nosec - - # Move to tmp dir - old_cwd = Path.cwd() - os.chdir(tmp_path) - try: - yield tmp_path - finally: - os.chdir(old_cwd) - - -# Import run_make from local conftest -from api.conftest import run_make # noqa: E402 - - -def test_api_delegation(logger, setup_api_env): - """Test that 'make help' works and delegates to .rhiza/rhiza.mk.""" - result = run_make(logger, ["help"], dry_run=False) - assert result.returncode == 0 - # "Rhiza Workflows" is a section in .rhiza/rhiza.mk - assert "Rhiza Workflows" in result.stdout - - # Core targets from .rhiza/make.d/ should be available - assert "test" in result.stdout or "install" in result.stdout - - -def test_minimal_setup_works(logger, setup_api_env): - """Test that make works even if optional folders (tests, docker, etc.) are missing.""" - # Remove optional folders - for folder in OPTIONAL_FOLDERS: - p = setup_api_env / folder - if p.exists(): - shutil.rmtree(p) - - # Also remove files that might be copied if they were in the root? - # Just mainly folders. - - # Run make help - result = run_make(logger, ["help"], dry_run=False) - assert result.returncode == 0 - - # Check that core rhiza targets exist - assert "Rhiza Workflows" in result.stdout - assert "sync" in result.stdout - - # Note: docker-build and other targets from .rhiza/make.d/ are always present - # but they gracefully skip if their respective folders/files don't exist. - # This is by design - targets are always available but handle missing resources. - - -def test_extension_mechanism(logger, setup_api_env): - """Test that custom targets can be added in the root Makefile.""" - # Add a custom target to the root Makefile (before include line) - makefile = setup_api_env / "Makefile" - original = makefile.read_text() - # Insert custom target before the include line - new_content = ( - """.PHONY: custom-target -custom-target: - @echo "Running custom target" - -""" - + original - ) - makefile.write_text(new_content) - - result = run_make(logger, ["custom-target"], dry_run=False) - assert result.returncode == 0 - assert "Running custom target" in result.stdout - - -def test_local_override(logger, setup_api_env): - """Test that local.mk is included and can match targets.""" - local_file = setup_api_env / "local.mk" - local_file.write_text(""" -.PHONY: local-target -local-target: - @echo "Running local target" -""") - - result = run_make(logger, ["local-target"], dry_run=False) - assert result.returncode == 0 - assert "Running local target" in result.stdout - - -def test_local_override_pre_hook(logger, setup_api_env): - """Test using local.mk to override a pre-hook.""" - local_file = setup_api_env / "local.mk" - # We override pre-sync to print a marker (using double-colon to match rhiza.mk) - local_file.write_text(""" -pre-sync:: - @echo "[[LOCAL_PRE_SYNC]]" -""") - - # Run sync in dry-run. - # Note: Makefile.rhiza defines pre-sync as empty rule (or with @:). - # Make warns if we redefine a target unless it's a double-colon rule or we are careful. - # But usually the last one loaded wins or they merge if double-colon. - # The current definition in Makefile.rhiza is `pre-sync: ; @echo ...` or similar. - # Wait, I defined it as `pre-sync: ; @:` (single colon). - # So redefining it in local.mk (which is included AFTER) might trigger a warning but should work. - - result = run_make(logger, ["sync"], dry_run=False) - # We might expect a warning about overriding commands for target `pre-sync` - # checking stdout/stderr for the marker - - assert "[[LOCAL_PRE_SYNC]]" in result.stdout - - -def test_hook_execution_order(logger, setup_api_env): - """Define hooks in root Makefile and verify execution order.""" - # Add hooks to root Makefile (before include line) - makefile = setup_api_env / "Makefile" - original = makefile.read_text() - new_content = ( - """pre-sync:: - @echo "STARTING_SYNC" - -post-sync:: - @echo "FINISHED_SYNC" - -""" - + original - ) - makefile.write_text(new_content) - - result = run_make(logger, ["sync"], dry_run=False) - assert result.returncode == 0 - output = result.stdout - - # Check that markers are present - assert "STARTING_SYNC" in output - assert "FINISHED_SYNC" in output - - # Check order: STARTING_SYNC comes before FINISHED_SYNC - start_index = output.find("STARTING_SYNC") - finish_index = output.find("FINISHED_SYNC") - assert start_index < finish_index - - -def test_override_core_target(logger, setup_api_env): - """Verify that the root Makefile can override a core target (with warning).""" - # Override 'fmt' which is defined in quality.mk - # Add override AFTER the include line so it takes precedence - makefile = setup_api_env / "Makefile" - original = makefile.read_text() - new_content = ( - original - + """ -fmt: - @echo "CUSTOM_FMT" -""" - ) - makefile.write_text(new_content) - - result = run_make(logger, ["fmt"], dry_run=False) - assert result.returncode == 0 - # It should run the custom one because it's defined after the include - assert "CUSTOM_FMT" in result.stdout - - # We expect a warning on stderr about overriding - assert "warning: overriding" in result.stderr.lower() - assert "fmt" in result.stderr.lower() - - -def test_global_variable_override(logger, setup_api_env): - """Test that global variables can be overridden in the root Makefile. - - This tests the pattern documented in CUSTOMIZATION.md: - Set variables before the include line to override defaults. - """ - # Add variable override to root Makefile (before include line) - makefile = setup_api_env / "Makefile" - original = makefile.read_text() - new_content = ( - """# Override default coverage threshold (defaults to 90) -COVERAGE_FAIL_UNDER := 42 -export COVERAGE_FAIL_UNDER - -""" - + original - ) - makefile.write_text(new_content) - - result = run_make(logger, ["print-COVERAGE_FAIL_UNDER"], dry_run=False) - assert result.returncode == 0 - assert "42" in result.stdout - - -def test_pre_install_hook(logger, setup_api_env): - """Test that pre-install hooks are executed before install. - - This tests the hook pattern documented in CUSTOMIZATION.md. - """ - makefile = setup_api_env / "Makefile" - original = makefile.read_text() - new_content = ( - """pre-install:: - @echo "[[PRE_INSTALL_HOOK]]" - -""" - + original - ) - makefile.write_text(new_content) - - # Run install in dry-run mode to avoid actual installation - result = run_make(logger, ["install"], dry_run=True) - assert result.returncode == 0 - # In dry-run mode, the echo command is printed (not executed) - assert "PRE_INSTALL_HOOK" in result.stdout - - -def test_post_install_hook(logger, setup_api_env): - """Test that post-install hooks are executed after install. - - This tests the hook pattern documented in CUSTOMIZATION.md. - """ - makefile = setup_api_env / "Makefile" - original = makefile.read_text() - new_content = ( - """post-install:: - @echo "[[POST_INSTALL_HOOK]]" - -""" - + original - ) - makefile.write_text(new_content) - - # Run install in dry-run mode - result = run_make(logger, ["install"], dry_run=True) - assert result.returncode == 0 - assert "POST_INSTALL_HOOK" in result.stdout - - -def test_multiple_hooks_accumulate(logger, setup_api_env): - """Test that multiple hook definitions accumulate rather than override. - - This is a key feature of double-colon rules: the root Makefile and - local.mk can both add to the same hook without conflicts. - """ - # Add hook in root Makefile - makefile = setup_api_env / "Makefile" - original = makefile.read_text() - new_content = ( - """pre-sync:: - @echo "[[HOOK_A]]" - -""" - + original - ) - makefile.write_text(new_content) - - # Add another hook in local.mk - (setup_api_env / "local.mk").write_text("""pre-sync:: - @echo "[[HOOK_B]]" -""") - - result = run_make(logger, ["sync"], dry_run=False) - assert result.returncode == 0 - # Both hooks should be present - assert "[[HOOK_A]]" in result.stdout - assert "[[HOOK_B]]" in result.stdout - - -def test_variable_override_before_include(logger, setup_api_env): - """Test that variables set before include take precedence. - - Variables defined in the root Makefile before the include line - should be available throughout the build. - """ - # Set a variable and use it in a target (before include) - makefile = setup_api_env / "Makefile" - original = makefile.read_text() - new_content = ( - """MY_CUSTOM_VAR := hello - -.PHONY: show-var -show-var: - @echo "MY_VAR=$(MY_CUSTOM_VAR)" - -""" - + original - ) - makefile.write_text(new_content) - - result = run_make(logger, ["show-var"], dry_run=False) - assert result.returncode == 0 - assert "MY_VAR=hello" in result.stdout diff --git a/.rhiza/tests/api/test_makefile_targets.py b/.rhiza/tests/api/test_makefile_targets.py deleted file mode 100644 index 0137c32..0000000 --- a/.rhiza/tests/api/test_makefile_targets.py +++ /dev/null @@ -1,363 +0,0 @@ -"""Tests for the Makefile targets and help output using safe dry-runs. - -This file and its associated tests flow down via a SYNC action from the jebel-quant/rhiza repository -(https://github.com/jebel-quant/rhiza). - -These tests validate that the Makefile exposes expected targets and emits -the correct commands without actually executing them, by invoking `make -n` -(dry-run). We also pass `-s` to reduce noise in CI logs. This approach keeps -tests fast, portable, and free of side effects like network or environment -changes. -""" - -from __future__ import annotations - -import os - -import pytest -from api.conftest import SPLIT_MAKEFILES, run_make, setup_rhiza_git_repo, strip_ansi - - -def assert_uvx_command_uses_version(output: str, tmp_path, command_fragment: str): - """Assert uvx command uses .python-version when present, else fallback checks.""" - python_version_file = tmp_path / ".python-version" - if python_version_file.exists(): - python_version = python_version_file.read_text().strip() - assert f"uvx -p {python_version} {command_fragment}" in output - else: - assert "uvx -p" in output - assert command_fragment in output - - -class TestMakefile: - """Smoke tests for Makefile help and common targets using make -n.""" - - def test_default_goal_is_help(self, logger): - """Default goal should render the help index with known targets.""" - proc = run_make(logger) - out = proc.stdout - assert "Usage:" in out - assert "Targets:" in out - # ensure a few known targets appear in the help index - for target in ["install", "fmt", "deptry", "test", "help"]: - assert target in out - - def test_help_target(self, logger): - """Explicit `make help` prints usage, targets, and section headers.""" - proc = run_make(logger, ["help"]) - out = proc.stdout - assert "Usage:" in out - assert "Targets:" in out - assert "Bootstrap" in out or "Meta" in out # section headers - - def test_doctor_target_appears_in_help(self, logger): - """Doctor target should appear in help under the Dev section.""" - proc = run_make(logger, ["help"]) - out = proc.stdout - assert "Dev" in out - assert "doctor" in out - - def test_doctor_fails_when_minimum_version_is_not_met(self, logger, tmp_path): - """Doctor should exit non-zero when a prerequisite version is below the minimum.""" - fake_bin = tmp_path / "fake-bin" - fake_bin.mkdir(exist_ok=True) - - for name, content in { - "uv": "#!/usr/bin/env sh\necho 'uv 0.3.0'\n", - "python": "#!/usr/bin/env sh\necho 'Python 3.12.2'\n", - "make": "#!/usr/bin/env sh\necho 'GNU Make 4.4.1'\n", - "git": "#!/usr/bin/env sh\necho 'git version 2.44.0'\n", - }.items(): - script = fake_bin / name - script.write_text(content) - script.chmod(0o755) - - env = os.environ.copy() - env["PATH"] = f"{fake_bin}:{env.get('PATH', '')}" - - proc = run_make(logger, ["doctor"], dry_run=False, check=False, env=env) - out = strip_ansi(proc.stdout) - assert proc.returncode != 0 - assert "[❌] uv" in out - assert "0.3.0" in out - assert "0.4.0" in out - - def test_fmt_target_dry_run(self, logger, tmp_path): - """Fmt target should invoke pre-commit via uvx with Python version in dry-run output.""" - # Create clean environment without PYTHON_VERSION so Makefile reads from .python-version - env = os.environ.copy() - env.pop("PYTHON_VERSION", None) - - proc = run_make(logger, ["fmt"], env=env) - out = proc.stdout - assert_uvx_command_uses_version(out, tmp_path, "pre-commit run --all-files") - - def test_deptry_target_dry_run(self, logger, tmp_path): - """Deptry target should invoke deptry via uvx with Python version in dry-run output.""" - # Create a mock SOURCE_FOLDER directory so the deptry command runs - source_folder = tmp_path / "src" - source_folder.mkdir(exist_ok=True) - - # Update .env to set SOURCE_FOLDER - env_file = tmp_path / ".rhiza" / ".env" - env_content = env_file.read_text() - env_content += "\nSOURCE_FOLDER=src\n" - env_file.write_text(env_content) - - # Create clean environment without PYTHON_VERSION so Makefile reads from .python-version - env = os.environ.copy() - env.pop("PYTHON_VERSION", None) - - proc = run_make(logger, ["deptry"], env=env) - - out = proc.stdout - assert_uvx_command_uses_version(out, tmp_path, "deptry src") - - def test_typecheck_target_dry_run(self, logger, tmp_path): - """Typecheck target should invoke ty via uv run in dry-run output.""" - # Create a mock SOURCE_FOLDER directory so the typecheck command runs - source_folder = tmp_path / "src" - source_folder.mkdir(exist_ok=True) - - # Update .env to set SOURCE_FOLDER - env_file = tmp_path / ".rhiza" / ".env" - env_content = env_file.read_text() - env_content += "\nSOURCE_FOLDER=src\n" - env_file.write_text(env_content) - - proc = run_make(logger, ["typecheck"]) - out = proc.stdout - # Check for uv run command - assert "uv run ty check src" in out - - def test_test_target_dry_run(self, logger): - """Test target should invoke pytest via uv with coverage and HTML outputs in dry-run output.""" - proc = run_make(logger, ["test"]) - out = proc.stdout - # Expect key steps - assert "mkdir -p _tests/html-coverage _tests/html-report" in out - # Check for uv command running pytest - assert "uv run pytest" in out - # Check for XML coverage report - assert "--cov-report=xml:_tests/coverage.xml" in out - - def test_test_target_without_source_folder(self, logger, tmp_path): - """Test target should run without coverage when SOURCE_FOLDER doesn't exist.""" - # Update .env to set SOURCE_FOLDER to a non-existent directory - env_file = tmp_path / ".rhiza" / ".env" - env_content = env_file.read_text() - env_content += "\nSOURCE_FOLDER=nonexistent_src\n" - env_file.write_text(env_content) - - # Create tests folder - tests_folder = tmp_path / "tests" - tests_folder.mkdir(exist_ok=True) - - proc = run_make(logger, ["test"]) - out = proc.stdout - # Should see warning about missing source folder - assert "if [ -d nonexistent_src ]" in out - # Should still run pytest but without coverage flags - assert "uv run pytest" in out - assert "--html=_tests/html-report/report.html" in out - - def test_python_version_defaults_to_3_13_if_missing(self, logger, tmp_path): - """`PYTHON_VERSION` should default to `3.13` if .python-version is missing.""" - # Ensure .python-version does not exist - python_version_file = tmp_path / ".python-version" - if python_version_file.exists(): - python_version_file.unlink() - - # Create clean environment without PYTHON_VERSION - env = os.environ.copy() - env.pop("PYTHON_VERSION", None) - - proc = run_make(logger, ["print-PYTHON_VERSION"], dry_run=False, env=env) - out = strip_ansi(proc.stdout) - assert "Value of PYTHON_VERSION:\n3.13" in out - - def test_uv_no_modify_path_is_exported(self, logger): - """`UV_NO_MODIFY_PATH` should be set to `1` in the Makefile.""" - proc = run_make(logger, ["print-UV_NO_MODIFY_PATH"], dry_run=False) - out = strip_ansi(proc.stdout) - assert "Value of UV_NO_MODIFY_PATH:\n1" in out - - def test_that_target_coverage_is_configurable(self, logger): - """Test target should respond to COVERAGE_FAIL_UNDER variable.""" - # Default case: ensure the flag is present - proc = run_make(logger, ["test"]) - assert "--cov-fail-under=" in proc.stdout - - # Override case: ensure the flag takes the specific value - proc_override = run_make(logger, ["test", "COVERAGE_FAIL_UNDER=42"]) - assert "--cov-fail-under=42" in proc_override.stdout - - def test_suppression_audit_target_dry_run(self, logger): - """Suppression-audit target should invoke the Python audit script via uv run in dry-run output.""" - proc = run_make(logger, ["suppression-audit"]) - out = proc.stdout - assert "uv run python" in out - assert "suppression_audit.py" in out - - def test_license_target_dry_run(self, logger): - """License target should invoke pip-licenses via uv run --with in dry-run output.""" - proc = run_make(logger, ["license"]) - out = proc.stdout - assert "uv run --with pip-licenses pip-licenses" in out - assert "--fail-on=" in out - assert "GPL" in out - - def test_license_fail_on_is_configurable(self, logger): - """License target should use the LICENSE_FAIL_ON variable for the fail-on list.""" - proc = run_make(logger, ["license", "LICENSE_FAIL_ON=MIT;Apache"]) - out = proc.stdout - assert '--fail-on="MIT;Apache"' in out - - def test_serve_target_uses_uv_run_python_http_server(self, logger): - """Serve target should use uv run instead of directly calling python3.""" - proc = run_make(logger, ["serve"]) - out = proc.stdout - assert "uv run python -m http.server 8000" in out - - -class TestMakefileRootFixture: - """Tests for root fixture usage in Makefile tests.""" - - def test_makefile_exists_at_root(self, root): - """Makefile should exist at repository root.""" - makefile = root / "Makefile" - assert makefile.exists() - assert makefile.is_file() - - def test_makefile_contains_targets(self, root): - """Makefile should contain expected targets (including split files).""" - makefile = root / "Makefile" - content = makefile.read_text() - - # Read split Makefiles as well - for split_file in SPLIT_MAKEFILES: - split_path = root / split_file - if split_path.exists(): - content += "\n" + split_path.read_text() - - expected_targets = ["install", "fmt", "test", "deptry", "help"] - for target in expected_targets: - assert f"{target}:" in content or f".PHONY: {target}" in content - - def test_validate_target_skips_in_rhiza_repo(self, logger): - """Validate target should skip execution in rhiza repository.""" - setup_rhiza_git_repo() - - proc = run_make(logger, ["validate"], dry_run=False) - # out = strip_ansi(proc.stdout) - # assert "[INFO] Skipping validate in rhiza repository" in out - assert proc.returncode == 0 - - def test_sync_target_skips_in_rhiza_repo(self, logger): - """Sync target should skip execution in rhiza repository.""" - setup_rhiza_git_repo() - - proc = run_make(logger, ["sync"], dry_run=False) - # out = strip_ansi(proc.stdout) - # assert "[INFO] Skipping sync in rhiza repository" in out - assert proc.returncode == 0 - - def test_sync_experimental_target_skips_in_rhiza_repo(self, logger): - """Sync-experimental target should skip execution in rhiza repository.""" - setup_rhiza_git_repo() - - proc = run_make(logger, ["sync-experimental"], dry_run=False) - assert proc.returncode == 0 - - def test_materialize_target_is_deprecated(self, logger): - """Materialize target should print a deprecation warning and delegate to sync.""" - setup_rhiza_git_repo() - - proc = run_make(logger, ["materialize"], dry_run=False) - out = strip_ansi(proc.stdout) - assert proc.returncode == 0 - assert "deprecated" in out.lower() - assert "sync" in out - - -class TestMakeBump: - """Tests for the 'make bump' target.""" - - @pytest.fixture - def mock_bin(self, tmp_path): - """Create mock uv and uvx scripts in ./bin.""" - bin_dir = tmp_path / "bin" - bin_dir.mkdir(exist_ok=True) - - uv = bin_dir / "uv" - uv.write_text('#!/bin/sh\necho "[MOCK] uv $@"\n') - uv.chmod(0o755) - - # Mock uvx to simulate version bump if arguments match - uvx = bin_dir / "uvx" - uvx_script = """#!/usr/bin/env python3 -import sys -import re -from pathlib import Path - -args = sys.argv[1:] -print(f"[MOCK] uvx {' '.join(args)}") - -# Check if this is the bump command: "rhiza-tools>=0.5.1" bump -if "bump" in args: - # Simulate bumping version in pyproject.toml - pyproject = Path("pyproject.toml") - if pyproject.exists(): - content = pyproject.read_text() - # Simple regex replacement for version - # Assuming version = "0.1.0" -> "0.1.1" - new_content = re.sub(r'version = "([0-9.]+)"', lambda m: f'version = "{m.group(1)[:-1]}{int(m.group(1)[-1]) + 1}"', content) - pyproject.write_text(new_content) - print(f"[MOCK] Bumped version in {pyproject}") -""" # noqa: E501 - uvx.write_text(uvx_script) - uvx.chmod(0o755) - - return bin_dir - - def test_bump_execution(self, logger, mock_bin, tmp_path): - """Test 'make bump' execution with mocked tools and verify version change.""" - # Create dummy pyproject.toml with initial version - pyproject = tmp_path / "pyproject.toml" - pyproject.write_text('version = "0.1.0"\n[project]\nname = "test"\n') - - uv_bin = mock_bin / "uv" - uvx_bin = mock_bin / "uvx" - - # Run make bump with dry_run=False to actually execute the shell commands - result = run_make(logger, ["bump", f"UV_BIN={uv_bin}", f"UVX_BIN={uvx_bin}"], dry_run=False) - - # Verify that the mock tools were called - assert "[MOCK] uvx rhiza-tools>=0.5.1 bump" in result.stdout - assert "[MOCK] uv lock" in result.stdout - - # Verify that 'make install' was called (which calls uv sync) - assert "[MOCK] uv sync" in result.stdout - - # Verify that the version was actually bumped by our mock - new_content = pyproject.read_text() - assert 'version = "0.1.1"' in new_content - - def test_bump_no_pyproject(self, logger, mock_bin, tmp_path): - """Test 'make bump' execution without pyproject.toml.""" - # Ensure pyproject.toml does not exist - pyproject = tmp_path / "pyproject.toml" - if pyproject.exists(): - pyproject.unlink() - - uv_bin = mock_bin / "uv" - uvx_bin = mock_bin / "uvx" - - result = run_make(logger, ["bump", f"UV_BIN={uv_bin}", f"UVX_BIN={uvx_bin}"], dry_run=False) - - # Check for warning message - assert "No pyproject.toml found, skipping bump" in result.stdout - - # Ensure bump commands are NOT executed - assert "[MOCK] uvx" not in result.stdout - assert "[MOCK] uv lock" not in result.stdout diff --git a/.rhiza/tests/conftest.py b/.rhiza/tests/conftest.py index 419f6d6..062103e 100644 --- a/.rhiza/tests/conftest.py +++ b/.rhiza/tests/conftest.py @@ -18,15 +18,9 @@ import pathlib import shutil import subprocess # nosec B404 - subprocess module needed for git operations in test fixtures -import sys import pytest - -tests_root = pathlib.Path(__file__).resolve().parent -if str(tests_root) not in sys.path: - sys.path.insert(0, str(tests_root)) - -from test_utils import GIT # noqa: E402 +from test_utils import GIT MOCK_MAKE_SCRIPT = """#!/usr/bin/env python3 import sys diff --git a/.rhiza/tests/integration/test_book_targets.py b/.rhiza/tests/integration/test_book_targets.py deleted file mode 100644 index 9c73666..0000000 --- a/.rhiza/tests/integration/test_book_targets.py +++ /dev/null @@ -1,163 +0,0 @@ -"""Tests for book-related Makefile targets and their resilience.""" - -import shutil -import subprocess # nosec - -import pytest - -MAKE = shutil.which("make") or "/usr/bin/make" - - -@pytest.fixture -def book_makefile(git_repo): - """Return the book.mk path or skip tests if missing.""" - makefile = git_repo / ".rhiza" / "make.d" / "book.mk" - if not makefile.exists(): - pytest.skip("book.mk not found, skipping test") - return makefile - - -def test_no_book_folder(git_repo, book_makefile): - """Test that make targets work gracefully when book folder is missing. - - Now that book-related targets are defined in .rhiza/make.d/, they are always - available but check internally for the existence of the book folder. - Using dry-run (-n) to test the target logic without actually executing. - """ - if (git_repo / "book").exists(): - shutil.rmtree(git_repo / "book") - assert not (git_repo / "book").exists() - - # Targets are now always defined via .rhiza/make.d/ - # Use dry-run to verify they exist and can be parsed - for target in ["book"]: - result = subprocess.run([MAKE, "-n", target], cwd=git_repo, capture_output=True, text=True) # nosec - # Target should exist (not "no rule to make target") - assert "no rule to make target" not in result.stderr.lower(), ( - f"Target {target} should be defined in .rhiza/make.d/" - ) - - -def test_book_folder_but_no_mk(git_repo, book_makefile): - """Test behavior when book folder exists but is empty. - - With the new architecture, targets are always defined in .rhiza/make.d/book.mk, - so they should exist regardless of the book folder contents. - """ - # ensure book folder exists but is empty - if (git_repo / "book").exists(): - shutil.rmtree(git_repo / "book") - # create an empty book folder - (git_repo / "book").mkdir() - - # assert the book folder exists - assert (git_repo / "book").exists() - # assert the git_repo / "book" folder is empty - assert not list((git_repo / "book").iterdir()) - - # Targets are now always defined via .rhiza/make.d/ - # Use dry-run to verify they exist and can be parsed - for target in ["book"]: - result = subprocess.run([MAKE, "-n", target], cwd=git_repo, capture_output=True, text=True) # nosec - # Target should exist (not "no rule to make target") - assert "no rule to make target" not in result.stderr.lower(), ( - f"Target {target} should be defined in .rhiza/make.d/" - ) - - -def test_book_folder(git_repo, book_makefile): - """Test that .rhiza/make.d/book.mk defines the expected phony targets.""" - content = book_makefile.read_text() - - # get the list of phony targets from the Makefile - phony_targets = [line.strip() for line in content.splitlines() if line.startswith(".PHONY:")] - if not phony_targets: - pytest.skip("No .PHONY targets found in book.mk") - - # Collect all targets from all .PHONY lines - all_targets = set() - for phony_line in phony_targets: - targets = phony_line.split(":")[1].strip().split() - all_targets.update(targets) - - expected_targets = {"book", "test", "benchmark", "stress", "hypothesis-test"} - assert expected_targets.issubset(all_targets), ( - f"Expected phony targets to include {expected_targets}, got {all_targets}" - ) - - -def test_book_noop_targets_defined(book_makefile): - """Test that book.mk defines no-op fallback targets for build resilience. - - These no-op double-colon rules ensure 'make book' succeeds even when - test.mk is not available or tests are not installed. - """ - content = book_makefile.read_text() - for target in ["test", "benchmark", "stress", "hypothesis-test"]: - assert f"{target}::" in content, ( - f"book.mk should define a no-op '::' fallback for '{target}' to ensure build resilience" - ) - - -def test_book_without_logo_file(git_repo, book_makefile): - """Test that book target works when LOGO_FILE is not set or empty. - - The build should succeed gracefully without a logo, and the generated - HTML template should hide the logo element via onerror handler. - """ - makefile = git_repo / "Makefile" - if not makefile.exists(): - pytest.skip("Makefile not found") - - # Read current Makefile content - content = makefile.read_text() - - # Remove or comment out LOGO_FILE if present - lines = content.splitlines() - new_lines = [] - for line in lines: - if line.strip().startswith("LOGO_FILE"): - # Comment out the line - new_lines.append(f"# {line}") - else: - new_lines.append(line) - makefile.write_text("\n".join(new_lines)) - - # Dry-run the book target - it should still be valid - result = subprocess.run([MAKE, "-n", "book"], cwd=git_repo, capture_output=True, text=True) # nosec - assert "no rule to make target" not in result.stderr.lower(), "book target should work without LOGO_FILE" - # Should not have errors about missing logo variable - assert result.returncode == 0, f"Dry-run failed: {result.stderr}" - - -def test_book_with_missing_logo_file(git_repo, book_makefile): - """Test that book target warns when LOGO_FILE points to non-existent file. - - The build should succeed but emit a warning about the missing logo. - """ - makefile = git_repo / "Makefile" - if not makefile.exists(): - pytest.skip("Makefile not found") - - # Read current Makefile content and set LOGO_FILE to non-existent path - content = makefile.read_text() - lines = content.splitlines() - new_lines = [] - logo_set = False - for line in lines: - if line.strip().startswith("LOGO_FILE"): - new_lines.append("LOGO_FILE=nonexistent/path/logo.svg") - logo_set = True - else: - new_lines.append(line) - if not logo_set: - # Insert LOGO_FILE before the include line - for i, line in enumerate(new_lines): - if line.strip().startswith("include"): - new_lines.insert(i, "LOGO_FILE=nonexistent/path/logo.svg") - break - makefile.write_text("\n".join(new_lines)) - - # Dry-run should still succeed - result = subprocess.run([MAKE, "-n", "book"], cwd=git_repo, capture_output=True, text=True) # nosec - assert result.returncode == 0, f"Dry-run failed with missing logo: {result.stderr}" diff --git a/.rhiza/tests/integration/test_docs_targets.py b/.rhiza/tests/integration/test_docs_targets.py deleted file mode 100644 index 44f3e21..0000000 --- a/.rhiza/tests/integration/test_docs_targets.py +++ /dev/null @@ -1,48 +0,0 @@ -"""Tests for book.mk Makefile targets and the MKDOCS_EXTRA_PACKAGES variable.""" - -import shutil -import subprocess # nosec - -import pytest - -MAKE = shutil.which("make") or "/usr/bin/make" - - -@pytest.fixture -def book_makefile(git_repo): - """Return the book.mk path or skip tests if missing.""" - makefile = git_repo / ".rhiza" / "make.d" / "book.mk" - if not makefile.exists(): - pytest.skip("book.mk not found, skipping test") - return makefile - - -def test_mkdocs_extra_packages_variable_defined(book_makefile): - """Test that MKDOCS_EXTRA_PACKAGES is declared with a default-empty value.""" - content = book_makefile.read_text() - assert "MKDOCS_EXTRA_PACKAGES ?=" in content, "book.mk should declare MKDOCS_EXTRA_PACKAGES with a ?= default" - - -def test_mkdocs_build_dry_run_with_extra_packages(git_repo, book_makefile): - """Test that passing MKDOCS_EXTRA_PACKAGES on the command line is accepted by make. - - Validates both a single package and multiple packages to confirm the variable - correctly extends the uvx invocation in all cases. - """ - for extra in [ - "--with mkdocs-graphviz", - "--with mkdocs-graphviz --with mkdocs-mermaid2", - ]: - result = subprocess.run( # nosec - [MAKE, "-n", "book", f"MKDOCS_EXTRA_PACKAGES={extra}"], - cwd=git_repo, - capture_output=True, - text=True, - ) - assert "no rule to make target" not in result.stderr.lower(), "book should be a defined target" - assert result.returncode == 0, f"Dry-run failed: {result.stderr}" - # Each extra package flag should appear in the dry-run output - for pkg in ["mkdocs-graphviz", "mkdocs-mermaid2"][: len(extra.split("--with")) - 1]: - assert pkg in result.stdout, ( - f"MKDOCS_EXTRA_PACKAGES package '{pkg}' should be visible in the dry-run command" - ) diff --git a/.rhiza/tests/integration/test_test_mk.py b/.rhiza/tests/integration/test_test_mk.py deleted file mode 100644 index 83102bd..0000000 --- a/.rhiza/tests/integration/test_test_mk.py +++ /dev/null @@ -1,53 +0,0 @@ -"""Integration test for .rhiza/make.d/test.mk to verify that it handles the case of missing test files correctly.""" - -from test_utils import run_make - - -def test_missing_tests_warning(git_repo, logger): - """Test that missing tests trigger a warning but do not fail (exit 0).""" - # 1. Setup a minimal Makefile in the test repo - # We include .rhiza/make.d/test.mk but mock the 'install' dependency - # and provide color variables used in the script. - makefile_content = r""" -YELLOW := \033[33m -RED := \033[31m -RESET := \033[0m - -# Define folders expected by test.mk -TESTS_FOLDER := tests -SOURCE_FOLDER := src -VENV := .venv - -# Mock install to avoid actual installation in test -install: - @echo "Mock install" - -# Include the target under test -include .rhiza/make.d/test.mk -""" - (git_repo / "Makefile").write_text(makefile_content, encoding="utf-8") - - # 2. Ensure 'tests' folder exists but is empty/has no python test files - tests_dir = git_repo / "tests" - if tests_dir.exists(): - import shutil - - shutil.rmtree(tests_dir) - tests_dir.mkdir() - - # 3. Run 'make test' - # We use dry_run=False so the shell commands in the recipe actually execute. - # The 'check=False' allows us to assert the return code ourselves, - # though we expect 0 now. - result = run_make(logger, ["test"], check=False, dry_run=False) - - # 4. output for debugging - logger.info("make stdout: %s", result.stdout) - logger.info("make stderr: %s", result.stderr) - - # 5. Verify results - assert result.returncode == 0, "make test should exit with 0 when no tests found" - - # The warning message matches what we put in test.mk - # "No test files found in {TESTS_FOLDER}, skipping tests" - assert "No test files found in tests, skipping tests" in result.stdout diff --git a/.rhiza/tests/integration/test_virtual_env_unexport.py b/.rhiza/tests/integration/test_virtual_env_unexport.py deleted file mode 100644 index fae30bb..0000000 --- a/.rhiza/tests/integration/test_virtual_env_unexport.py +++ /dev/null @@ -1,37 +0,0 @@ -"""Integration test to verify VIRTUAL_ENV is unset for uv commands.""" - -import os - -from test_utils import run_make - - -def test_virtual_env_not_exported(git_repo, logger): - """Test that VIRTUAL_ENV is not exported to child processes when set in the environment.""" - # 1. Setup a minimal Makefile that includes rhiza.mk - makefile_content = r""" -# Include rhiza.mk which has 'unexport VIRTUAL_ENV' -include .rhiza/rhiza.mk - -# Create a test target that checks if VIRTUAL_ENV is exported -.PHONY: test-env -test-env: - @echo "VIRTUAL_ENV in shell: '$$VIRTUAL_ENV'" -""" - (git_repo / "Makefile").write_text(makefile_content, encoding="utf-8") - - # 2. Set VIRTUAL_ENV in the environment (simulating an activated venv) - env = os.environ.copy() - env["VIRTUAL_ENV"] = "/some/absolute/path/.venv" - - # 3. Run 'make test-env' with VIRTUAL_ENV set - result = run_make(logger, ["test-env"], check=True, dry_run=False, env=env) - - # 4. Output for debugging - logger.info("make stdout: %s", result.stdout) - logger.info("make stderr: %s", result.stderr) - - # 5. Verify that VIRTUAL_ENV is empty in the shell (not exported) - # The output should contain "VIRTUAL_ENV in shell: ''" - assert "VIRTUAL_ENV in shell: ''" in result.stdout, ( - f"VIRTUAL_ENV should be empty in shell commands, but got: {result.stdout}" - ) diff --git a/.rhiza/tests/shell/test_scripts.sh b/.rhiza/tests/shell/test_scripts.sh deleted file mode 100755 index ee86799..0000000 --- a/.rhiza/tests/shell/test_scripts.sh +++ /dev/null @@ -1,245 +0,0 @@ -#!/bin/bash -# Shell script test suite -# Tests shell scripts in the repository for correctness and error handling -# -# Usage: ./test_scripts.sh [--verbose] -# --verbose: Show detailed output for each test - -set -euo pipefail - -# Test counters -TESTS_RUN=0 -TESTS_PASSED=0 -TESTS_FAILED=0 - -# Color output -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' # No Color - -VERBOSE=false -if [ "${1:-}" = "--verbose" ]; then - VERBOSE=true -fi - -# Test helper functions -assert_equal() { - local expected="$1" - local actual="$2" - local test_name="$3" - - TESTS_RUN=$((TESTS_RUN + 1)) - - if [ "$expected" = "$actual" ]; then - TESTS_PASSED=$((TESTS_PASSED + 1)) - if [ "$VERBOSE" = true ]; then - echo -e "${GREEN}✓${NC} PASS: $test_name" - fi - else - TESTS_FAILED=$((TESTS_FAILED + 1)) - echo -e "${RED}✗${NC} FAIL: $test_name" - echo " Expected: $expected" - echo " Got: $actual" - fi -} - -assert_contains() { - local haystack="$1" - local needle="$2" - local test_name="$3" - - TESTS_RUN=$((TESTS_RUN + 1)) - - if grep -q "$needle" <<< "$haystack"; then - TESTS_PASSED=$((TESTS_PASSED + 1)) - if [ "$VERBOSE" = true ]; then - echo -e "${GREEN}✓${NC} PASS: $test_name" - fi - else - TESTS_FAILED=$((TESTS_FAILED + 1)) - echo -e "${RED}✗${NC} FAIL: $test_name" - echo " Expected to find: $needle" - echo " In output: $haystack" - fi -} - -assert_exit_code() { - local expected_code="$1" - local actual_code="$2" - local test_name="$3" - - TESTS_RUN=$((TESTS_RUN + 1)) - - if [ "$expected_code" -eq "$actual_code" ]; then - TESTS_PASSED=$((TESTS_PASSED + 1)) - if [ "$VERBOSE" = true ]; then - echo -e "${GREEN}✓${NC} PASS: $test_name" - fi - else - TESTS_FAILED=$((TESTS_FAILED + 1)) - echo -e "${RED}✗${NC} FAIL: $test_name" - echo " Expected exit code: $expected_code" - echo " Got exit code: $actual_code" - fi -} - -# Find repository root (script is in .rhiza/tests/shell/) -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" - -echo -e "${BLUE}=== Shell Script Test Suite ===${NC}" -echo "Repository: $REPO_ROOT" -echo "" - -# ============================================================================ -# Test Suite: session-start.sh -# ============================================================================ -echo -e "${YELLOW}Testing: session-start.sh${NC}" - -# Test 1: Script has proper shebang -first_line=$(head -n 1 "$REPO_ROOT/.github/hooks/session-start.sh") -assert_equal "#!/bin/bash" "$first_line" "session-start.sh has bash shebang" - -# Test 2: Script uses strict mode -if grep -q "set -euo pipefail" "$REPO_ROOT/.github/hooks/session-start.sh"; then - TESTS_RUN=$((TESTS_RUN + 1)) - TESTS_PASSED=$((TESTS_PASSED + 1)) - if [ "$VERBOSE" = true ]; then - echo -e "${GREEN}✓${NC} PASS: session-start.sh uses strict error handling" - fi -else - TESTS_RUN=$((TESTS_RUN + 1)) - TESTS_FAILED=$((TESTS_FAILED + 1)) - echo -e "${RED}✗${NC} FAIL: session-start.sh missing 'set -euo pipefail'" -fi - -# Test 3: Normal mode with valid environment -if [ -d "$REPO_ROOT/.venv" ] && command -v uv >/dev/null 2>&1; then - output=$(bash "$REPO_ROOT/.github/hooks/session-start.sh" 2>&1) || true - assert_contains "$output" "Validating environment" "session-start.sh normal mode runs validation" -fi - -# ============================================================================ -# Test Suite: session-end.sh -# ============================================================================ -echo -e "${YELLOW}Testing: session-end.sh${NC}" - -# Test 4: Script has proper shebang -first_line=$(head -n 1 "$REPO_ROOT/.github/hooks/session-end.sh") -assert_equal "#!/bin/bash" "$first_line" "session-end.sh has bash shebang" - -# Test 5: Script uses strict mode -if grep -q "set -euo pipefail" "$REPO_ROOT/.github/hooks/session-end.sh"; then - TESTS_RUN=$((TESTS_RUN + 1)) - TESTS_PASSED=$((TESTS_PASSED + 1)) - if [ "$VERBOSE" = true ]; then - echo -e "${GREEN}✓${NC} PASS: session-end.sh uses strict error handling" - fi -else - TESTS_RUN=$((TESTS_RUN + 1)) - TESTS_FAILED=$((TESTS_FAILED + 1)) - echo -e "${RED}✗${NC} FAIL: session-end.sh missing 'set -euo pipefail'" -fi - -# ============================================================================ -# Test Suite: bootstrap.sh -# ============================================================================ -echo -e "${YELLOW}Testing: bootstrap.sh${NC}" - -# Test 6: Script has proper shebang -first_line=$(head -n 1 "$REPO_ROOT/.devcontainer/bootstrap.sh") -assert_equal "#!/bin/bash" "$first_line" "bootstrap.sh has bash shebang" - -# Test 7: Script uses strict mode -if grep -q "set -euo pipefail" "$REPO_ROOT/.devcontainer/bootstrap.sh"; then - TESTS_RUN=$((TESTS_RUN + 1)) - TESTS_PASSED=$((TESTS_PASSED + 1)) - if [ "$VERBOSE" = true ]; then - echo -e "${GREEN}✓${NC} PASS: bootstrap.sh uses strict error handling" - fi -else - TESTS_RUN=$((TESTS_RUN + 1)) - TESTS_FAILED=$((TESTS_FAILED + 1)) - echo -e "${RED}✗${NC} FAIL: bootstrap.sh missing 'set -euo pipefail'" -fi - -# Test 8: Script has error handler function -if grep -q "error_with_recovery" "$REPO_ROOT/.devcontainer/bootstrap.sh"; then - TESTS_RUN=$((TESTS_RUN + 1)) - TESTS_PASSED=$((TESTS_PASSED + 1)) - if [ "$VERBOSE" = true ]; then - echo -e "${GREEN}✓${NC} PASS: bootstrap.sh has error_with_recovery function" - fi -else - TESTS_RUN=$((TESTS_RUN + 1)) - TESTS_FAILED=$((TESTS_FAILED + 1)) - echo -e "${RED}✗${NC} FAIL: bootstrap.sh missing error_with_recovery function" -fi - -# Test 9: Script includes remediation messages -if grep -q "Remediation\|Suggested fix" "$REPO_ROOT/.devcontainer/bootstrap.sh"; then - TESTS_RUN=$((TESTS_RUN + 1)) - TESTS_PASSED=$((TESTS_PASSED + 1)) - if [ "$VERBOSE" = true ]; then - echo -e "${GREEN}✓${NC} PASS: bootstrap.sh includes remediation messages" - fi -else - TESTS_RUN=$((TESTS_RUN + 1)) - TESTS_FAILED=$((TESTS_FAILED + 1)) - echo -e "${RED}✗${NC} FAIL: bootstrap.sh missing remediation messages" -fi - -# Test 10: Script handles .python-version file -if grep -q ".python-version" "$REPO_ROOT/.devcontainer/bootstrap.sh"; then - TESTS_RUN=$((TESTS_RUN + 1)) - TESTS_PASSED=$((TESTS_PASSED + 1)) - if [ "$VERBOSE" = true ]; then - echo -e "${GREEN}✓${NC} PASS: bootstrap.sh checks for .python-version" - fi -else - TESTS_RUN=$((TESTS_RUN + 1)) - TESTS_FAILED=$((TESTS_FAILED + 1)) - echo -e "${RED}✗${NC} FAIL: bootstrap.sh doesn't check .python-version" -fi - -# ============================================================================ -# Test Suite: Shell script syntax validation -# ============================================================================ -echo -e "${YELLOW}Testing: Syntax validation${NC}" - -# Test 11-13: Validate syntax of all shell scripts -for script in \ - "$REPO_ROOT/.devcontainer/bootstrap.sh" \ - "$REPO_ROOT/.github/hooks/session-start.sh" \ - "$REPO_ROOT/.github/hooks/session-end.sh" -do - script_name=$(basename "$script") - if bash -n "$script" 2>/dev/null; then - TESTS_RUN=$((TESTS_RUN + 1)) - TESTS_PASSED=$((TESTS_PASSED + 1)) - if [ "$VERBOSE" = true ]; then - echo -e "${GREEN}✓${NC} PASS: $script_name has valid bash syntax" - fi - else - TESTS_RUN=$((TESTS_RUN + 1)) - TESTS_FAILED=$((TESTS_FAILED + 1)) - echo -e "${RED}✗${NC} FAIL: $script_name has syntax errors" - fi -done - -# ============================================================================ -# Test Summary -# ============================================================================ -echo "" -echo -e "${BLUE}=== Test Summary ===${NC}" -echo "Tests run: $TESTS_RUN" -echo -e "${GREEN}Tests passed: $TESTS_PASSED${NC}" -if [ $TESTS_FAILED -gt 0 ]; then - echo -e "${RED}Tests failed: $TESTS_FAILED${NC}" - exit 1 -else - echo -e "${GREEN}All tests passed!${NC}" - exit 0 -fi diff --git a/.rhiza/tests/stress/README.md b/.rhiza/tests/stress/README.md index 06e4919..8746548 100644 --- a/.rhiza/tests/stress/README.md +++ b/.rhiza/tests/stress/README.md @@ -138,6 +138,4 @@ def test_concurrent_operation(stress_iterations, concurrent_workers): ## See Also - [Main Test README](../README.md) - Overview of all test categories -- [Integration Tests](../integration/) - End-to-end workflow tests - [Benchmarks](../../../tests/benchmarks/) - Performance benchmarks -- [Property Tests](../../../tests/property/) - Property-based tests diff --git a/.rhiza/tests/structure/test_project_layout.py b/.rhiza/tests/structure/test_project_layout.py deleted file mode 100644 index 5580598..0000000 --- a/.rhiza/tests/structure/test_project_layout.py +++ /dev/null @@ -1,57 +0,0 @@ -"""Tests for the root pytest fixture that yields the repository root Path. - -This file and its associated tests flow down via a SYNC action from the jebel-quant/rhiza repository -(https://github.com/jebel-quant/rhiza). - -This module ensures the fixture resolves to the true project root and that -expected files/directories exist, enabling other tests to locate resources -reliably. -""" - -import pytest - - -class TestRootFixture: - """Tests for the root fixture that provides repository root path.""" - - def test_root_resolves_correctly_from_nested_location(self, root): - """Root should correctly resolve to repository root from .rhiza/tests/.""" - conftest_path = root / ".rhiza" / "tests" / "conftest.py" - assert conftest_path.exists() - - def test_root_contains_expected_directories(self, root): - """Root should contain all expected project directories.""" - required_dirs = [".rhiza"] - # optional_dirs = ["src", "tests", "book"] # src/ is optional (rhiza itself doesn't have one) - - for dirname in required_dirs: - assert (root / dirname).exists(), f"Required directory {dirname} not found" - - # Check that at least one CI directory exists (.github or .gitlab) - ci_dirs = [".github", ".gitlab"] - if not any((root / ci_dir).exists() for ci_dir in ci_dirs): - pytest.fail(f"At least one CI directory from {ci_dirs} must exist") - - # for dirname in optional_dirs: - # if not (root / dirname).exists(): - # pytest.skip(f"Optional directory {dirname} not present in this project") - - def test_root_contains_expected_files(self, root): - """Root should contain all expected configuration files.""" - required_files = [ - "pyproject.toml", - "README.md", - "Makefile", - ] - optional_files = [ - "ruff.toml", - ".gitignore", - ".editorconfig", - ] - - for filename in required_files: - assert (root / filename).exists(), f"Required file {filename} not found" - - for filename in optional_files: - if not (root / filename).exists(): - pytest.skip(f"Optional file {filename} not present in this project") diff --git a/.rhiza/tests/structure/test_requirements.py b/.rhiza/tests/structure/test_requirements.py deleted file mode 100644 index 1bf9d04..0000000 --- a/.rhiza/tests/structure/test_requirements.py +++ /dev/null @@ -1,51 +0,0 @@ -"""Tests for the .rhiza/requirements folder structure. - -This test ensures that the requirements folder exists and contains the expected -requirement files for development dependencies. -""" - -from typing import ClassVar - - -class TestRequirementsFolder: - """Tests for the .rhiza/requirements folder structure.""" - - # Expected requirements files - EXPECTED_REQUIREMENTS_FILES: ClassVar[list[str]] = [ - # "tests.txt", # may not be present in all repositories - # "marimo.txt", # may not be present in all repositories - "docs.txt", - "tools.txt", - ] - - def test_requirements_folder_exists(self, root): - """Requirements folder should exist in .rhiza directory.""" - requirements_dir = root / ".rhiza" / "requirements" - assert requirements_dir.exists(), ".rhiza/requirements directory should exist" - assert requirements_dir.is_dir(), ".rhiza/requirements should be a directory" - - def test_requirements_files_exist(self, root): - """All expected requirements files should exist.""" - requirements_dir = root / ".rhiza" / "requirements" - for filename in self.EXPECTED_REQUIREMENTS_FILES: - filepath = requirements_dir / filename - assert filepath.exists(), f"{filename} should exist in requirements folder" - assert filepath.is_file(), f"{filename} should be a file" - - def test_requirements_files_not_empty(self, root): - """Requirements files should not be empty.""" - requirements_dir = root / ".rhiza" / "requirements" - for filename in self.EXPECTED_REQUIREMENTS_FILES: - filepath = requirements_dir / filename - content = filepath.read_text() - # Filter out comments and empty lines - lines = [line.strip() for line in content.splitlines() if line.strip() and not line.strip().startswith("#")] - assert len(lines) > 0, f"{filename} should contain at least one dependency" - - def test_readme_exists_in_requirements_folder(self, root): - """README.md should exist in requirements folder.""" - readme_path = root / ".rhiza" / "requirements" / "README.md" - assert readme_path.exists(), "README.md should exist in requirements folder" - assert readme_path.is_file(), "README.md should be a file" - content = readme_path.read_text() - assert len(content) > 0, "README.md should not be empty" diff --git a/.rhiza/tests/sync/conftest.py b/.rhiza/tests/sync/conftest.py deleted file mode 100644 index fafa07a..0000000 --- a/.rhiza/tests/sync/conftest.py +++ /dev/null @@ -1,100 +0,0 @@ -"""Shared fixtures and helpers for sync tests. - -Provides environment setup for template sync, workflow versioning, -and content validation tests. - -Security Notes: -- S101 (assert usage): Asserts are used in pytest tests to validate conditions -- S603/S607 (subprocess usage): Any subprocess calls are for testing sync targets - in isolated environments with controlled inputs -- Test code operates in a controlled environment with trusted inputs -""" - -from __future__ import annotations - -import os -import shutil -import sys -from pathlib import Path - -import pytest - -tests_root = Path(__file__).resolve().parents[1] -if str(tests_root) not in sys.path: - sys.path.insert(0, str(tests_root)) - -from test_utils import run_make, setup_rhiza_git_repo, strip_ansi # noqa: E402, F401 - - -@pytest.fixture(autouse=True) -def setup_sync_env(logger, root, tmp_path: Path): - """Set up a temporary environment for sync tests with Makefile, templates, and git. - - This fixture creates a complete test environment with: - - Makefile and rhiza.mk configuration - - .rhiza-version file and .env configuration - - template.yml and pyproject.toml - - Initialized git repository (configured as rhiza origin) - - src/ and tests/ directories to satisfy validate target - """ - logger.debug("Setting up sync test environment: %s", tmp_path) - - # Copy the main Makefile into the temporary working directory - shutil.copy(root / "Makefile", tmp_path / "Makefile") - - # Copy core Rhiza Makefiles and version file - (tmp_path / ".rhiza").mkdir(exist_ok=True) - shutil.copy(root / ".rhiza" / "rhiza.mk", tmp_path / ".rhiza" / "rhiza.mk") - - # Copy split Makefiles from make.d directory - split_makefiles = [ - "bootstrap.mk", - "quality.mk", - "releasing.mk", - "test.mk", - "book.mk", - "marimo.mk", - "presentation.mk", - "github.mk", - "agentic.mk", - "docker.mk", - ] - (tmp_path / ".rhiza" / "make.d").mkdir(parents=True, exist_ok=True) - for mk_file in split_makefiles: - source_path = root / ".rhiza" / "make.d" / mk_file - if source_path.exists(): - shutil.copy(source_path, tmp_path / ".rhiza" / "make.d" / mk_file) - - # Copy .rhiza-version if it exists - if (root / ".rhiza" / ".rhiza-version").exists(): - shutil.copy(root / ".rhiza" / ".rhiza-version", tmp_path / ".rhiza" / ".rhiza-version") - - # Create a minimal, deterministic .rhiza/.env for tests - env_content = "CUSTOM_SCRIPTS_FOLDER=.rhiza/customisations/scripts\n" - (tmp_path / ".rhiza" / ".env").write_text(env_content) - - logger.debug("Copied Makefile from %s to %s", root / "Makefile", tmp_path / "Makefile") - - # Create a minimal .rhiza/template.yml - (tmp_path / ".rhiza" / "template.yml").write_text("repository: Jebel-Quant/rhiza\nref: v0.7.1\n") - - # Sort out pyproject.toml - (tmp_path / "pyproject.toml").write_text('[project]\nname = "test-project"\nversion = "0.1.0"\n') - - # Move into tmp directory for isolation - old_cwd = Path.cwd() - os.chdir(tmp_path) - logger.debug("Changed working directory to %s", tmp_path) - - # Initialize a git repo so that commands checking for it (like sync) don't fail validation - setup_rhiza_git_repo() - - # Create src and tests directories to satisfy validate - (tmp_path / "src").mkdir(exist_ok=True) - (tmp_path / "tests").mkdir(exist_ok=True) - - try: - yield - finally: - os.chdir(old_cwd) - logger.debug("Restored working directory to %s", old_cwd) diff --git a/.rhiza/tests/sync/test_docstrings.py b/.rhiza/tests/test_docstrings.py similarity index 100% rename from .rhiza/tests/sync/test_docstrings.py rename to .rhiza/tests/test_docstrings.py diff --git a/.rhiza/tests/utils/test_git_repo_fixture.py b/.rhiza/tests/test_git_repo_fixture.py similarity index 100% rename from .rhiza/tests/utils/test_git_repo_fixture.py rename to .rhiza/tests/test_git_repo_fixture.py diff --git a/.rhiza/tests/structure/test_pyproject.py b/.rhiza/tests/test_pyproject.py similarity index 98% rename from .rhiza/tests/structure/test_pyproject.py rename to .rhiza/tests/test_pyproject.py index 82f2b4d..3b6bbcc 100644 --- a/.rhiza/tests/structure/test_pyproject.py +++ b/.rhiza/tests/test_pyproject.py @@ -116,7 +116,7 @@ def test_description_is_non_empty_string(self, project: dict) -> None: class TestProjectUrls: """Tests for [project.urls] — Homepage and Repository links.""" - @pytest.fixture(scope="class") + @pytest.fixture def urls(self, project: dict) -> dict: """Return the [project.urls] table.""" table = project.get("urls") @@ -142,7 +142,7 @@ def test_repository_configured(self, urls: dict) -> None: class TestProjectClassifiers: """Tests for [project].classifiers — Python version and licence entries.""" - @pytest.fixture(scope="class") + @pytest.fixture def classifiers(self, project: dict) -> list[str]: """Return the classifiers list.""" cl = project.get("classifiers", []) @@ -166,7 +166,7 @@ def test_license_classifier_present(self, classifiers: list[str]) -> None: class TestDependencyGroups: """Tests for [dependency-groups] — ensures required groups are declared.""" - @pytest.fixture(scope="class") + @pytest.fixture def dependency_groups(self, pyproject: dict) -> dict: """Return the [dependency-groups] table.""" dg = pyproject.get("dependency-groups") @@ -193,7 +193,7 @@ def test_lint_group_present(self, dependency_groups: dict) -> None: class TestGitTagVersion: """Tests for harmony between the latest git tag and pyproject.toml version.""" - @pytest.fixture(scope="class") + @pytest.fixture def latest_tag(self, root: Path) -> str: """Return the latest semver git tag, or skip if none exist.""" result = subprocess.run( # nosec B603 diff --git a/.rhiza/tests/sync/test_readme_validation.py b/.rhiza/tests/test_readme_validation.py similarity index 100% rename from .rhiza/tests/sync/test_readme_validation.py rename to .rhiza/tests/test_readme_validation.py 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/Makefile b/Makefile index e0c6738..c328337 100644 --- a/Makefile +++ b/Makefile @@ -1,12 +1,10 @@ ## Makefile (repo-owned) # Keep this file small. It can be edited without breaking template sync. -DEFAULT_AI_MODEL=claude-sonnet-4.6 LOGO_FILE=.rhiza/assets/rhiza-logo.svg -GH_AW_ENGINE ?= copilot # Default AI engine for gh-aw workflows (copilot, claude, or codex) -# Override template default: fix quoting bug and typo (mkdocstring -> mkdocstrings) -MKDOCS_EXTRA_PACKAGES = --with-editable . --with 'mkdocstrings[python]' +# Override template default: include mkdocstrings plugin for API docs +MKDOCS_EXTRA_PACKAGES = --with 'mkdocstrings[python]' # Always include the Rhiza API (template-managed) include .rhiza/rhiza.mk diff --git a/cliff.toml b/cliff.toml new file mode 100644 index 0000000..2e0b04b --- /dev/null +++ b/cliff.toml @@ -0,0 +1,84 @@ +# cliff.toml — git-cliff configuration for CHANGELOG generation. +# +# Synced from the rhiza `core` bundle. Drives `make changelog`, which runs +# `uvx git-cliff --output CHANGELOG.md`. +# +# This template is intentionally forge-agnostic: it does not hard-code an +# owner/repo. Pull-request and issue references like `(#123)` are left intact +# in the generated entries, and both GitHub and GitLab auto-link bare `#123` +# references when rendering Markdown inside a repository. Downstream projects +# that want richer links (full URLs, contributor handles) can enable +# git-cliff's remote integration — see https://git-cliff.org/docs/integration. +# +# Reference: https://git-cliff.org/docs/configuration + +[changelog] +# A markdown header rendered once at the top of the changelog. +header = """ +# Changelog + +All notable changes to this project are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com), +and entries are generated from [Conventional Commits](https://www.conventionalcommits.org). + +""" +# The body is a Tera template rendered once per release. +# https://keats.github.io/tera/docs/#introduction +body = """ +{% if version %}\ + ## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }} +{% else %}\ + ## [Unreleased] +{% endif %}\ +{% for group, commits in commits | group_by(attribute="group") %} + ### {{ group | striptags | trim | upper_first }} + {% for commit in commits %}\ + - {% if commit.scope %}*({{ commit.scope }})* {% endif %}\ + {% if commit.breaking %}[**breaking**] {% endif %}\ + {{ commit.message | split(pat="\n") | first | trim | upper_first }} + {% endfor %}\ +{% endfor %}\n +""" +footer = """ + +""" +# Trim leading/trailing whitespace from the rendered body. +trim = true + +[git] +# Parse commits according to the Conventional Commits spec. +conventional_commits = true +# Keep non-conventional commits too, grouped under "Other Changes". +filter_unconventional = false +# Do not split a commit into multiple entries on newlines. +split_commits = false +# Do not drop commits that fail to match a parser below. +filter_commits = false +# Skip merge commits. +filter_merge_commits = true +# Match release tags (v1.2.3, v0.5.1, ...). +tag_pattern = "v[0-9].*" +# Order commits within a section oldest-first. +sort_commits = "oldest" +# Group commits into changelog sections. The leading HTML comment controls the +# section ordering and is stripped from the rendered heading via `striptags`. +commit_parsers = [ + # Drop automated noise commits that don't provide user-facing signal. + { message = ".*\\[skip ci\\].*", skip = true }, + { message = "^chore(\\([^)]+\\))?:\\s*(bump|release)( version)?\\b", skip = true }, + { message = "^chore:\\s*update changelog\\.md\\b", skip = true }, + { message = "^feat", group = "New Features" }, + { message = "^fix", group = "Bug Fixes" }, + { message = "^docs?", group = "Documentation" }, + { message = "^perf", group = "Performance" }, + { message = "^(build|chore)\\(deps[^)]*\\):", group = "Dependencies" }, + { message = "^refactor", group = "Maintenance" }, + { message = "^style", group = "Maintenance" }, + { message = "^chore", group = "Maintenance" }, + { message = "^build", group = "Maintenance" }, + { message = "^ci", group = "Maintenance" }, + { message = "^test", group = "Maintenance" }, + { message = "^revert", group = "Reverts" }, + { message = ".*", group = "Other Changes" }, +] diff --git a/demo.py b/demo.py index 16272f4..857b703 100644 --- a/demo.py +++ b/demo.py @@ -72,7 +72,7 @@ def generate_ohlc(n: int = 1000): return open_, high, low, close, overlays, subplots -def run_demo(choice: str): +def run_demo(choice: str) -> None: """Run the selected demo scenario identified by ``choice``.""" n = 5000 # Default size @@ -174,7 +174,7 @@ def run_demo(choice: str): print("Invalid choice.") -def main(): +def main() -> None: """Run the interactive demo menu loop.""" try: while True: diff --git a/docs/development/TESTS.md b/docs/development/TESTS.md index f77b134..bbc35fa 100644 --- a/docs/development/TESTS.md +++ b/docs/development/TESTS.md @@ -72,6 +72,15 @@ uv run pytest tests/property/ -v --hypothesis-max-examples=1000 uv run pytest tests/property/ -v --hypothesis-verbosity=verbose ``` +### Opting in to live DEBUG logs + +By default, the template disables live pytest CLI logging (`log_cli = false`) to keep normal test output concise. +When you need detailed live logs for debugging, enable them per-run: + +```bash +uv run pytest -o log_cli=true --log-cli-level=DEBUG +``` + ### Example Tests The following property-based tests are included as examples: @@ -256,7 +265,7 @@ pytest-benchmark>=5.2.3 pygal>=3.1.0 ``` -These are automatically installed when running `make install` or by installing from `.rhiza/requirements/tests.txt`. +These are provisioned on the fly by the relevant `make` targets via `uv run --with …` (e.g. `make test`, `make benchmark`), so no separate install step is required. ## Troubleshooting diff --git a/docs/index.md b/docs/index.md index 0e0b6b4..612c7a5 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,2 +1 @@ --8<-- "README.md" - diff --git a/pytest.ini b/pytest.ini index da5b41e..0dd0ef0 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,7 +1,11 @@ [pytest] testpaths = tests -# Enable live logs on console -log_cli = true +# Make the synced template test-suite importable (test_utils, api/, sync/, ...) +# without each conftest manipulating sys.path at import time. Resolved relative +# to rootdir; harmless when .rhiza/tests is absent. +pythonpath = .rhiza/tests +# Disable live logs on console by default (opt in with: pytest -o log_cli=true --log-cli-level=DEBUG) +log_cli = false # Show DEBUG+ messages log_cli_level = DEBUG log_cli_format = %(asctime)s %(levelname)s %(name)s: %(message)s @@ -9,6 +13,13 @@ log_cli_date_format = %H:%M:%S # Show extra summary info for skipped/failed tests addopts = -ra timeout = 60 +# Treat the class-scoped-instance-method fixture deprecation as an error so it +# cannot silently regress. Matched by message rather than by warning category: +# the category name is version-specific (PytestRemovedIn9Warning vs ...In10Warning) +# and referencing a missing class breaks `--resolution lowest-direct` runs at +# parse time. Third-party DeprecationWarnings stay non-fatal. +filterwarnings = + error:Class-scoped fixture defined as instance method is deprecated # Register custom markers markers = stress: marks tests as stress tests (deselect with '-m "not stress"') diff --git a/ruff.toml b/ruff.toml index 4563178..e1a5fc2 100644 --- a/ruff.toml +++ b/ruff.toml @@ -67,22 +67,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 +123,24 @@ line-ending = "auto" # File-specific rule exceptions [lint.per-file-ignores] -# Test files - allow assert statements and subprocess calls for testing +# All test code, wherever it lives (project suite under tests/, smoke tests under +# .rhiza/tests/, and their bundle copies under bundles/). This glob subsumes the +# project suite; tests/**/*.py below adds project-suite-only allowances on top. "**/tests/**/*.py" = [ - "S101", # Allow assert statements in tests - "S603", # Allow subprocess calls without shell=False check - "S607", # Allow starting processes with partial paths in tests - "PLW1510", # Allow subprocess without explicit check parameter + "ANN", # tests prioritize readability and fixtures over strict annotation coverage + "S101", # assert is the test idiom + "S603", # tests drive git/make/uv via subprocess with fixed argument lists + "S607", # partial executable paths (git, make) are intentional in test drivers + "ARG", # pytest fixtures are requested by name for their side effects, not always read ] +# Project test suite only — allowances the synced .rhiza/tests should not inherit "tests/**/*.py" = [ - "ERA001", # Allow commented out code in project tests - "PLR2004", # Allow magic values in project tests - "RUF002", # Allow ambiguous unicode in project tests - "RUF012", # Allow mutable class attributes in project tests + "RUF002", # docstrings quote prose with typographic unicode (e.g. en dash) + "RUF012", # pytest class attributes are conventionally bare mutables, not ClassVar ] # Marimo notebooks - allow flexible coding patterns for interactive exploration "**/notebooks/*.py" = [ + "ANN", # notebooks prioritize interactive readability over strict annotation coverage "D100", # No module docstring - marimo requires `import marimo` as the first statement "N803", # Allow non-lowercase variable names in notebooks "S101", # Allow assert statements in notebooks @@ -124,8 +149,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/src/pycharting/api/interface.py b/src/pycharting/api/interface.py index 35177a1..d9a2a10 100644 --- a/src/pycharting/api/interface.py +++ b/src/pycharting/api/interface.py @@ -258,7 +258,7 @@ def plot( return result -def stop_server(): +def stop_server() -> None: """Manually shut down the active chart server. While the server has an auto-shutdown feature (triggered after a timeout when no clients are connected), @@ -322,7 +322,7 @@ def get_server_status() -> dict[str, Any]: # Jupyter notebook support -def _repr_html_(): +def _repr_html_() -> str: """Jupyter notebook representation.""" status = get_server_status() if status["running"]: diff --git a/src/pycharting/core/lifecycle.py b/src/pycharting/core/lifecycle.py index de6dcd9..9406d72 100644 --- a/src/pycharting/core/lifecycle.py +++ b/src/pycharting/core/lifecycle.py @@ -45,7 +45,7 @@ def __init__( host: str = "127.0.0.1", port: int | None = None, auto_shutdown_timeout: float = 5.0, - ): + ) -> None: """Initialize the ChartServer controller. Args: @@ -70,11 +70,11 @@ def __init__( self.app = create_app() self._add_websocket_endpoint() - def _add_websocket_endpoint(self): + def _add_websocket_endpoint(self) -> None: """Add WebSocket heartbeat endpoint to the app.""" @self.app.websocket("/ws/heartbeat") - async def websocket_heartbeat(websocket: WebSocket): # pragma: no cover + async def websocket_heartbeat(websocket: WebSocket) -> None: # pragma: no cover """WebSocket endpoint for connection monitoring.""" await websocket.accept() self._websocket_connected = True @@ -96,7 +96,7 @@ async def websocket_heartbeat(websocket: WebSocket): # pragma: no cover logger.exception("WebSocket error") self._websocket_connected = False - def _monitor_connection(self): + def _monitor_connection(self) -> None: """Monitor WebSocket connection and trigger auto-shutdown if needed.""" while self._running and not self._shutdown_event.is_set(): time.sleep(1) @@ -119,7 +119,7 @@ def _monitor_connection(self): self.stop_server() break - def _run_server(self): + def _run_server(self) -> None: """Run the Uvicorn server (called in background thread).""" config = uvicorn.Config( self.app, @@ -187,7 +187,7 @@ def start_server(self) -> dict[str, Any]: "running": self._running, } - def stop_server(self): + def stop_server(self) -> None: """Gracefully stop the background server and monitor threads. This method signals the server to shut down, closes the Uvicorn loop, diff --git a/src/pycharting/core/server.py b/src/pycharting/core/server.py index 740cf1d..6aa507c 100644 --- a/src/pycharting/core/server.py +++ b/src/pycharting/core/server.py @@ -143,7 +143,7 @@ def create_app() -> FastAPI: # Root endpoint @app.get("/", response_class=HTMLResponse) - async def root(): + async def root() -> str: """Serve the main chart page.""" return """ diff --git a/src/pycharting/data/ingestion.py b/src/pycharting/data/ingestion.py index 448b28f..468c077 100644 --- a/src/pycharting/data/ingestion.py +++ b/src/pycharting/data/ingestion.py @@ -23,8 +23,6 @@ class DataValidationError(Exception): """Exception raised when input data fails validation checks.""" - pass - def validate_input( index: pd.Index | pd.Series | np.ndarray, @@ -246,7 +244,7 @@ def __init__( overlays: dict[str, pd.Series | np.ndarray | list] | None = None, subplots: dict[str, SubplotSpec] | None = None, trades: pd.Series | np.ndarray | list | None = None, - ): + ) -> None: """Validate the supplied series and store the normalized arrays for fast slicing.""" # Validate input and get normalized arrays validated = validate_input(index, open, high, low, close, overlays, subplots, trades) From d095553a7dd3a6500c65b8ab728cb9be5cbf4b33 Mon Sep 17 00:00:00 2001 From: Thomas Schmelzer Date: Sat, 11 Jul 2026 12:23:17 +0400 Subject: [PATCH 3/4] chore: bump rhiza to v1.1.3 --- .rhiza/template.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.rhiza/template.yml b/.rhiza/template.yml index f4d341d..435eb73 100644 --- a/.rhiza/template.yml +++ b/.rhiza/template.yml @@ -1,5 +1,5 @@ repository: "jebel-quant/rhiza" -ref: "v1.1.1" +ref: "v1.1.3" profiles: - github-project From f10bf535230cc0950b7765d9f4b3ccf7b667f511 Mon Sep 17 00:00:00 2001 From: Thomas Schmelzer Date: Sat, 11 Jul 2026 12:28:07 +0400 Subject: [PATCH 4/4] chore: apply rhiza sync v1.1.3 Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/commands/rhiza_book.md | 53 -------- .claude/commands/rhiza_quality.md | 98 -------------- .claude/commands/rhiza_release.md | 82 ------------ .claude/commands/rhiza_update.md | 152 --------------------- .github/workflows/rhiza_benchmark.yml | 2 +- .github/workflows/rhiza_book.yml | 2 +- .github/workflows/rhiza_ci.yml | 2 +- .github/workflows/rhiza_codeql.yml | 2 +- .github/workflows/rhiza_fuzzing.yml | 2 +- .github/workflows/rhiza_marimo.yml | 2 +- .github/workflows/rhiza_mutation.yml | 2 +- .github/workflows/rhiza_release.yml | 2 +- .github/workflows/rhiza_scorecard.yml | 2 +- .github/workflows/rhiza_sync.yml | 2 +- .github/workflows/rhiza_weekly.yml | 2 +- .rhiza/completions/README.md | 15 +++ .rhiza/make.d/completions.mk | 42 ++++++ .rhiza/rhiza.mk | 12 +- .rhiza/template.lock | 16 +-- .rhiza/tests/README.md | 22 +--- .rhiza/tests/conftest.py | 181 +------------------------- .rhiza/tests/stress/README.md | 141 -------------------- .rhiza/tests/stress/__init__.py | 5 - .rhiza/tests/stress/conftest.py | 50 ------- .rhiza/tests/test_git_repo_fixture.py | 132 ------------------- .rhiza/tests/test_utils.py | 70 ---------- 26 files changed, 85 insertions(+), 1008 deletions(-) delete mode 100644 .claude/commands/rhiza_book.md delete mode 100644 .claude/commands/rhiza_quality.md delete mode 100644 .claude/commands/rhiza_release.md delete mode 100644 .claude/commands/rhiza_update.md create mode 100644 .rhiza/make.d/completions.mk delete mode 100644 .rhiza/tests/stress/README.md delete mode 100644 .rhiza/tests/stress/__init__.py delete mode 100644 .rhiza/tests/stress/conftest.py delete mode 100644 .rhiza/tests/test_git_repo_fixture.py delete mode 100644 .rhiza/tests/test_utils.py diff --git a/.claude/commands/rhiza_book.md b/.claude/commands/rhiza_book.md deleted file mode 100644 index af4fee6..0000000 --- a/.claude/commands/rhiza_book.md +++ /dev/null @@ -1,53 +0,0 @@ ---- -description: Build the documentation book into a local _book folder and open it in the default browser -allowed-tools: Bash, Read ---- - -Build the MkDocs/zensical documentation book locally and open it in the user's -standard web browser. Follow the command-execution policy: always prefer -`make `; never invoke `.venv/bin/...` directly. - -This command depends on the **book** bundle (it expects a root `mkdocs.yml` and a -`make book` target from `.rhiza/make.d/book.mk`). If `mkdocs.yml` is missing, -stop and tell the user the `book` bundle is not set up here rather than guessing. - -Do the following, in order: - -1. **Build the book.** Run `make book`. This regenerates the `_book/` output - folder via zensical (it also runs the `_book-reports` / `_book-notebooks` - prerequisites). Run it in the foreground so build errors are visible. If it - fails, show the relevant output, diagnose the root cause, and stop — do not - try to open a stale or partial book. - -2. **Confirm the output.** Verify `_book/index.html` exists after the build. If - it does not, report what `make book` produced instead and stop. - -3. **Serve it locally (background).** Static zensical sites need to be served - over HTTP — search and navigation break under `file://`. Start a local server - for the built folder **in the background** so it does not block the session: - - ``` - (cd _book && uv run python -m http.server 8000) - ``` - - Use port 8000 by default (this matches `make serve`). If 8000 is already in - use, pick the next free port (8001, 8002, …) and use that URL throughout. - Do **not** use `make serve` here — it rebuilds the book a second time and - blocks the terminal; the book is already built from step 1. - -4. **Open the default browser.** Open the served URL with the platform's - standard opener: - - macOS: `open http://localhost:8000` - - Linux: `xdg-open http://localhost:8000` - - Windows: `start http://localhost:8000` - - Detect the platform and use the right one. - -5. **Report.** Tell the user the book was built at `_book/`, the URL it is being - served at, and how to stop the background server (e.g. the background task / - PID, or "kill the `http.server` process on port 8000"). Note that `_book/` is - a generated, gitignored artifact. - -If `$ARGUMENTS` is non-empty, treat it as an override hint — e.g. a custom port -(`/rhiza_book 9000`) or a request to only build without serving/opening (`/rhiza_book -build-only`) — and adjust accordingly. diff --git a/.claude/commands/rhiza_quality.md b/.claude/commands/rhiza_quality.md deleted file mode 100644 index 292a782..0000000 --- a/.claude/commands/rhiza_quality.md +++ /dev/null @@ -1,98 +0,0 @@ ---- -description: Run the Rhiza code-quality gate and score the repo (lint, types, docs, deps, security, tests) ---- - -Assess the quality of this repo against Rhiza standards. Follow the -command-execution policy: always prefer `make `; never invoke -`.venv/bin/...` directly. Run the gates in order — cheapest checks first so fast -failures surface before the slow test suite — and collect results: - -1. `make fmt` — pre-commit hooks + linting (ruff format/check, markdownlint, bandit, actionlint, …) -2. `make typecheck` — static type checking (`ty`, and `mypy --strict` if configured) over `src/` -3. `make docs-coverage` — docstring coverage (interrogate) over `src/` -4. `make deptry` — unused/missing/misplaced dependency analysis -5. `make security` — pip-audit + bandit scans -6. `make validate` — validate project structure against the Rhiza template (`.rhiza/template.yml`) -7. `make test` — full test suite **with** its coverage gate (slowest, run last) - -Guidelines: - -- Run each gate as a single, bare `make ` command — one Bash call per - gate. Do **not** pipe (`| tee`, `| tail`), redirect (`2>&1 >`), chain - (`make fmt && make typecheck`), or prefix with `cd`. Bare `make ` - invocations match the allow-listed `Bash(make *)` rule and run without a - permission prompt; compound or piped commands do not and will prompt on every - gate. Read the full output directly from each call rather than capturing it to - a file. -- Run all gates even after an early failure, so the full picture is visible - rather than stopping at the first red. -- If something fails, show the relevant output, diagnose the root cause, and - propose (or apply, if clearly correct, low-risk, **and** the fix is in a - locally-owned file per the scoping rule below) a fix. -- If `$ARGUMENTS` is non-empty, scope the assessment to that path or topic - instead of the whole repo. -- End with a concise PASS/FAIL summary per gate. - -**Coverage expectation.** `make test` enforces a coverage gate -(`COVERAGE_FAIL_UNDER`, default 90%; many projects raise it to 100%). Treat -anything below the configured threshold on locally-owned `src/` as a gap to -flag, not an acceptable baseline. When scoring the test-coverage subcategory, -the configured threshold is the bar for a 10; report uncovered lines -(`file:line`) and the test that would close each. - -**`make validate`.** A failure means this repo has drifted from the Rhiza -template (a synced file edited locally, or a missing/extra file). That is -in-scope: fix it by re-syncing from Rhiza or by adjusting `.rhiza/template.yml`, -not by editing the synced artifact in place. - -Then report: - -- A pass/fail summary per step. -- Failures grouped by file, with the specific rule/error and line. -- A prioritized list of what to fix first (blocking errors before style nits). - -Then analyse the repo and give marks on a scale of 1 to 10 for all relevant -subcategories. Pick the subcategories that fit what you actually observe — e.g. -linting/style, type safety, test pass rate, test coverage & depth, code -structure & readability, documentation, dependency & security hygiene, CI/tooling -health. For each: the score, a one-line justification grounded in evidence from -the checks above (and a quick look at the code where needed), and what would -raise it. Close with an overall score and the single highest-leverage -improvement. - -**Scope the scorecard to locally-owned items — not what the mother repo (Rhiza) -owns.** This project syncs its dev infrastructure from `jebel-quant/rhiza`; see -`CLAUDE.md` for the authoritative split and the `files:` block of -`.rhiza/template.lock` for the machine-generated list of synced files. Score -only what this repo actually controls — `src/`, `tests/`, `pyproject.toml`, -`README.md`, project-specific docs, `.rhiza/template.yml`, and any -locally-hardened config. Do **not** let Rhiza-managed files (the -`.github/workflows/*`, `Makefile`, `.pre-commit-config.yaml`, `pytest.ini`, -`ruff.toml`, the typecheck/mutation/fuzzing targets, etc.) drive the marks — a -gap there is fixed upstream in Rhiza, not here. If a relevant signal is -Rhiza-owned, note it as "upstream/out-of-scope" rather than scoring it against -this repo. - -Then, from the scorecard above, identify **actionable issues to improve the -score** — one per subcategory scoring below 10 (skip any that are maxed). For -each, give: a concrete title, the subcategory and current→target score it moves, -the specific file(s)/lines or config to change, and a crisp acceptance criterion -("done when…"). Keep them in-scope (locally-owned, per the scoping rule above) — -flag anything Rhiza-owned as upstream rather than listing it as a local action. -Order them by leverage (biggest score gain for least effort first). This is a -list of recommendations only — do not change code unless I explicitly ask. - -Then offer to file the findings as issues — using a menu, not a free-text prompt. -Present the actionable findings as a multi-select menu (the AskUserQuestion tool -with `multiSelect: true`), one option per finding labelled by its title, so I can -pick exactly which ones to file — including none. Create nothing without an -explicit selection. For each finding I select, detect the hosting platform from -the git remote (`git remote get-url origin`) and create one issue with the -matching CLI — GitHub → `gh issue create`, GitLab → `glab issue create` (skip and -say so if the relevant CLI is unavailable or unauthenticated). Make each issue -self-contained: title from the finding, and a body carrying the subcategory, the -current→target score, the specific file(s)/lines or config to change, and the -"done when…" acceptance criterion. Report back the created issue URLs. - -If everything passes, say so plainly — but still produce the 1–10 subcategory -marks. Do not fix anything unless I ask — this command only assesses. diff --git a/.claude/commands/rhiza_release.md b/.claude/commands/rhiza_release.md deleted file mode 100644 index 8f36e2a..0000000 --- a/.claude/commands/rhiza_release.md +++ /dev/null @@ -1,82 +0,0 @@ ---- -description: Cut a release — choose the version bump, push the tag, and watch the release workflow -allowed-tools: Bash, Read ---- - -Cut a release for this repository. Follow the command-execution policy: always -prefer `make `; never invoke `.venv/bin/...` directly. - -How releasing works here. `make release` delegates to `rhiza-tools release` -(`.rhiza/make.d/releasing.mk`). It **always bumps** the version, folds a freshly -generated `CHANGELOG.md` into the version-bump commit (git-cliff pre-commit -hook), creates a `v*` tag, and pushes it. The tag push triggers the -`rhiza_release.yml` GitHub Actions workflow (validate → build → SBOM → draft → -publish → finalize). There is no separate post-tag changelog commit — the tagged -commit already carries the changelog. - -**The bump type is a user decision.** Run interactively, `make release` prompts -the user to pick `MAJOR`, `MINOR`, or `PATCH`. Because this command runs in a -non-interactive shell (where the tool would silently default to `PATCH`), **you -must ask the user which bump to apply** and pass their choice through with -`BUMP=major|minor|patch` (supported by both `make release` and `make bump`). - -Do the following, in order: - -1. **Pre-flight checks.** Confirm the release is safe to cut and stop with a - clear explanation if any check fails (do not push a tag from a dirty or - behind branch): - - Working tree is clean (`git status --porcelain` is empty). - - On the default branch (`main`) — or confirm with the user if not. - - Local `main` is up to date with `origin/main` (`git fetch` then compare). - - `pyproject.toml` exists (the bump is skipped without it). - -2. **Ask the user for the bump type.** Just like `make release` does - interactively, ask the user to choose **MAJOR**, **MINOR**, or **PATCH** - (semver: breaking / feature / fix). Skip this question only when - `$ARGUMENTS` already names a bump type or authorises a default (see below). - Hold the chosen `` (lowercase: `major` / `minor` / `patch`) for the - next steps. - -3. **Preview the bump (dry run).** Run `make release DRY_RUN=1 BUMP=` to - show the user the planned new version and tag **before** anything is pushed. - The `DRY_RUN=1` flag previews the bump/tag/push without applying them. - Summarise what the real run will do (old → new version, tag name). - -4. **Confirm.** Unless `$ARGUMENTS` explicitly authorises proceeding (see - below), stop here and ask the user to confirm the previewed version before - cutting the real release. Pushing a tag triggers a public release workflow — - treat it as outward-facing and confirm first. - -5. **Cut the release.** Run `make release BUMP= PUSH=1`. This bumps, - commits (with changelog), tags, and pushes. The `PUSH=1` flag is **required - here**: without it the tool asks an interactive `Push tag to remote? [y/N]` - question that this non-interactive shell auto-declines, leaving the bump - commit and tag stranded locally (unpushed). Since you already confirmed with - the user in step 4, `PUSH=1` pushes straight through. Run it in the - foreground so any failure is visible. If it fails, show the relevant output, - diagnose the root cause, and stop — do not re-run blindly or hand-craft a tag. - -6. **Watch the workflow.** Run `make release-status` to show the release - workflow run and the latest release info. If the workflow is still in - progress, tell the user it is running and how to re-check - (`make release-status`). Report the final release URL once available. - -7. **Report.** Tell the user the version that was released, the tag, and the - release/workflow URL. If anything is still pending (workflow running, draft - not yet published), say so plainly. - -`$ARGUMENTS` handling: - -- Empty → run the full ask-bump → preview → confirm flow above. -- `major` / `minor` / `patch` → use that as the bump type and skip the question - in step 2 (still preview and confirm). -- `dry-run` (or `preview`) → do steps 1–3 only and stop; do **not** cut a real - release. If no bump type is also given, still ask for it in step 2. -- `yes` / `confirm` / `--force` → skip the interactive confirmation in step 4 - and proceed straight through (still run the dry-run preview in step 3 so the - user sees what shipped). If no bump type is also given, still ask for it in - step 2. -- `status` → skip to step 6 and just report current release/workflow status. - -Argument tokens combine, e.g. `minor yes` cuts a minor release without the -confirmation prompt; `minor dry-run` previews a minor bump only. diff --git a/.claude/commands/rhiza_update.md b/.claude/commands/rhiza_update.md deleted file mode 100644 index a457a18..0000000 --- a/.claude/commands/rhiza_update.md +++ /dev/null @@ -1,152 +0,0 @@ ---- -description: Update the pinned Rhiza version in .rhiza/template.yml, sync, resolve conflicts, and verify ---- - -Update this repo's Rhiza template to a newer release: bump the pin in -`.rhiza/template.yml`, run the sync, resolve every conflict, verify the quality -gates, and open a PR. Follow the command-execution policy: always prefer -`make `; never invoke `.venv/bin/...` directly. - -`$ARGUMENTS` may name a target version (e.g. `v0.19.1`). If empty, target the -**latest** release of the upstream template repo. - -## 1. Determine current and target versions - -- Read `.rhiza/template.yml`: the `repository:` field is the upstream template - repo (usually `jebel-quant/rhiza`) and `ref:` is the currently pinned version. -- Resolve the target version: - - If `$ARGUMENTS` names a version, use it (verify the tag exists: - `gh api repos//git/ref/tags/`). - - Otherwise get the latest release: - `gh release view --repo --json tagName,publishedAt`. -- If `ref:` already equals the target, report "already up to date" and stop. -- Briefly summarize what's between the two versions when it's cheap to do so - (`gh release view`/release notes), so the reviewer knows what's landing. - -### The Rhiza CLI pin (`.rhiza/.rhiza-version`) — ask before changing it - -`.rhiza/.rhiza-version` separately pins the **Rhiza CLI** — the `rhiza` package on -PyPI that `make sync` runs as `uvx "rhiza=="`. It is independent of the -template `ref:` above, and there is no `$ARGUMENTS` override for it: always target -the **latest** published version. - -- Read the current pin from `.rhiza/.rhiza-version`. -- Resolve the latest published version on PyPI: - `curl -s https://pypi.org/pypi/rhiza/json` and read `.info.version`. -- If the pin already equals the latest, there is nothing to do — leave it. -- If a newer version is available, **ask the user whether to update the CLI** to - that latest version (state current → latest). Only bump `.rhiza/.rhiza-version` - if they agree; if they decline, leave the file untouched and proceed with the - template sync alone. - -## 2. Bump the pin(s) and commit (the tree must be clean to sync) - -- `make sync` refuses to run on a dirty tree, so the bump lands first. -- Branch off the default branch (don't work on `main`/`master` directly): - `git checkout -b sync/rhiza-`. -- Edit `ref:` in `.rhiza/template.yml` to the target version. If the user agreed - to a CLI bump above, also set `.rhiza/.rhiza-version` to the latest PyPI version - in the same step, so `make sync` runs with the chosen CLI. -- Commit the pin change(s) (e.g. `Chore: bump rhiza template ref `, - noting the CLI bump in the message when one was made). - -## 3. Sync - -- Run `make sync` (it invokes `rhiza sync`). Expect it to either complete - cleanly or report conflicts. It writes the refreshed `.rhiza/template.lock`. -- If it completes with no conflicts, skip to step 5. - -## 4. Resolve every conflict - -The sync is a 3-way merge. Two kinds of leftovers can appear — handle both, and -finish with **zero** `*.rej` files and **zero** conflict markers -(`<<<<<<<` / `=======` / `>>>>>>>`) anywhere tracked -(`git grep -lE '^(<<<<<<<|=======|>>>>>>>)'`). - -**`*.rej` files (rejected hunks).** The 3-way merge often *already applied* a -hunk and still drops a duplicate `.rej`. For each, verify whether the change is -already present in the file (the added `+` lines exist; no conflict markers -remain). If it is, the `.rej` is spurious — delete it. If a hunk genuinely did -not apply, apply it by hand, then delete the `.rej`. - -**Conflict-marked files.** Resolve by the ownership rule (see `CLAUDE.md` and the -`files:` block of `.rhiza/template.lock` for the authoritative managed-file list): - -- **Rhiza-managed files** (the `.github/workflows/*`, `Makefile`, - `.pre-commit-config.yaml`, `pytest.ini`, the `.rhiza/` engine, etc.): take the - **incoming/upstream** side — these are owned by the template and should match - it (`git checkout --theirs -- ` then `git add`). -- **Locally-owned or locally-hardened files** (notably `ruff.toml`, plus - `pyproject.toml`, `README.md`, `src/`, your `tests/`): **merge by hand** — - keep the local intent (e.g. stricter lint rules) while folding in genuine - upstream additions, and make the result internally coherent (dedupe, drop - comments that now contradict the config). - -Validate every touched workflow/YAML still parses before moving on. - -## 5. Verify the gates and fix fallout - -A version bump can tighten the gates (new lint rules, `mypy --strict`, expanded -docs-coverage scope, etc.) and surface pre-existing issues. Run them and get -them green: - -1. `make fmt` — pre-commit + lint -2. `make typecheck` -3. `make docs-coverage` -4. `make deptry` -5. `make security` -6. `make test` - -**Scope your fixes.** Fix issues only in **locally-owned** files (`src/`, -`tests/`, `pyproject.toml`, locally-hardened config). If a gate fails because of -a **Rhiza-managed** file, that is an upstream problem: fix it in -`jebel-quant/rhiza` and bump again — do **not** edit the synced artifact in -place. Call out any such upstream-owned failure explicitly rather than papering -over it locally. - -### Configure the CI variables/secrets the synced workflows need (fuzzing & mutation) - -If this sync added or updated the fuzzing/mutation workflows -(`.github/workflows/rhiza_fuzzing.yml`, `.github/workflows/rhiza_mutation.yml`), -make sure the configuration they read is present. These are **GitHub Actions -repository variables and secrets** — *not* local env vars or files — set under -**Settings → Secrets and variables → Actions** (or via `gh`), and documented in -`.github/CONFIG.md`. Skip this step entirely if neither workflow is present. - -Inspect what is already set (presence only — secret values are never readable): - -- `gh variable list` -- `gh secret list` - -**Mutation** (`rhiza_mutation.yml`) reads: - -- `MUTATION_ENABLED` (variable) — must be `true` for the mutation gate/badge to run. -- `GH_PAT` (secret) — git auth for installing private dependencies. -- `UV_EXTRA_INDEX_URL` (secret) — extra package index URL (with credentials) for private deps. - -**Fuzzing** (`rhiza_fuzzing.yml`) needs no user configuration — it uses the -automatic `GITHUB_TOKEN`, so do not prompt for any fuzzing secret. - -For each of the above that is **missing**, **ask the user** whether to set it and -for its value; set only the ones they provide: - -- variables: `gh variable set MUTATION_ENABLED --body true` -- secrets: `gh secret set GH_PAT` (let `gh` read the value from stdin — never - echo a secret value into the transcript or a commit). - -Leave anything the user declines unset, and note it in the PR body so the reviewer -knows the corresponding workflow step will be skipped or fail until it is configured. - -## 6. Commit, push, open a PR - -- Commit the resolution and any in-scope fixes with clear messages (one logical - change per commit: the conflict resolution, then each gate fix). -- Push the branch and open a PR (`gh pr create`) titled for the bump, e.g. - `Chore: sync Rhiza template `. In the body, summarize how each - conflict was resolved, list any gate fallout you fixed, and flag anything that - needs an **upstream** fix in Rhiza. -- Report a concise per-gate PASS/FAIL summary. If the workflow files changed, - note that pushing them needs a token with the `workflow` scope. - -Do not merge the PR. Stop after it is open and summarize what landed and what -(if anything) is blocked on an upstream Rhiza change. diff --git a/.github/workflows/rhiza_benchmark.yml b/.github/workflows/rhiza_benchmark.yml index f6246d1..5eedff1 100644 --- a/.github/workflows/rhiza_benchmark.yml +++ b/.github/workflows/rhiza_benchmark.yml @@ -20,5 +20,5 @@ on: jobs: benchmark: - uses: jebel-quant/rhiza/.github/workflows/rhiza_benchmark.yml@v1.1.1 + uses: jebel-quant/rhiza/.github/workflows/rhiza_benchmark.yml@v1.1.3 secrets: inherit diff --git a/.github/workflows/rhiza_book.yml b/.github/workflows/rhiza_book.yml index c15b190..af8be5b 100644 --- a/.github/workflows/rhiza_book.yml +++ b/.github/workflows/rhiza_book.yml @@ -29,7 +29,7 @@ permissions: jobs: book: - uses: jebel-quant/rhiza/.github/workflows/rhiza_book.yml@v1.1.1 + uses: jebel-quant/rhiza/.github/workflows/rhiza_book.yml@v1.1.3 secrets: inherit permissions: contents: read diff --git a/.github/workflows/rhiza_ci.yml b/.github/workflows/rhiza_ci.yml index 16c9f4c..3c24501 100644 --- a/.github/workflows/rhiza_ci.yml +++ b/.github/workflows/rhiza_ci.yml @@ -26,5 +26,5 @@ on: jobs: ci: - uses: jebel-quant/rhiza/.github/workflows/rhiza_ci.yml@v1.1.1 + uses: jebel-quant/rhiza/.github/workflows/rhiza_ci.yml@v1.1.3 secrets: inherit diff --git a/.github/workflows/rhiza_codeql.yml b/.github/workflows/rhiza_codeql.yml index 4bcc821..975db9d 100644 --- a/.github/workflows/rhiza_codeql.yml +++ b/.github/workflows/rhiza_codeql.yml @@ -26,7 +26,7 @@ on: jobs: codeql: - uses: jebel-quant/rhiza/.github/workflows/rhiza_codeql.yml@v1.1.1 + uses: jebel-quant/rhiza/.github/workflows/rhiza_codeql.yml@v1.1.3 secrets: inherit permissions: security-events: write # Upload CodeQL results to code scanning diff --git a/.github/workflows/rhiza_fuzzing.yml b/.github/workflows/rhiza_fuzzing.yml index a9a2009..b396d4a 100644 --- a/.github/workflows/rhiza_fuzzing.yml +++ b/.github/workflows/rhiza_fuzzing.yml @@ -34,7 +34,7 @@ permissions: jobs: fuzzing: - uses: jebel-quant/rhiza/.github/workflows/rhiza_fuzzing.yml@v1.1.1 + uses: jebel-quant/rhiza/.github/workflows/rhiza_fuzzing.yml@v1.1.3 secrets: inherit permissions: contents: read diff --git a/.github/workflows/rhiza_marimo.yml b/.github/workflows/rhiza_marimo.yml index baaba33..98f46f2 100644 --- a/.github/workflows/rhiza_marimo.yml +++ b/.github/workflows/rhiza_marimo.yml @@ -28,5 +28,5 @@ on: jobs: marimo: - uses: jebel-quant/rhiza/.github/workflows/rhiza_marimo.yml@v1.1.1 + uses: jebel-quant/rhiza/.github/workflows/rhiza_marimo.yml@v1.1.3 secrets: inherit diff --git a/.github/workflows/rhiza_mutation.yml b/.github/workflows/rhiza_mutation.yml index f487aba..9c2b6cf 100644 --- a/.github/workflows/rhiza_mutation.yml +++ b/.github/workflows/rhiza_mutation.yml @@ -42,7 +42,7 @@ jobs: # this repo sets the `MUTATION_ENABLED` variable to 'true'. Gating here in # the caller keeps it optional regardless of the pinned reusable workflow. if: ${{ vars.MUTATION_ENABLED == 'true' }} - uses: jebel-quant/rhiza/.github/workflows/rhiza_mutation.yml@v1.1.1 + uses: jebel-quant/rhiza/.github/workflows/rhiza_mutation.yml@v1.1.3 secrets: inherit permissions: contents: read diff --git a/.github/workflows/rhiza_release.yml b/.github/workflows/rhiza_release.yml index 3cdeca1..f8dfb6f 100644 --- a/.github/workflows/rhiza_release.yml +++ b/.github/workflows/rhiza_release.yml @@ -221,7 +221,7 @@ jobs: version: "0.11.16" - name: Configure git auth for private packages - uses: jebel-quant/rhiza/.github/actions/configure-git-auth@v1.1.1 + uses: jebel-quant/rhiza/.github/actions/configure-git-auth@v1.1.3 with: token: ${{ secrets.GH_PAT }} diff --git a/.github/workflows/rhiza_scorecard.yml b/.github/workflows/rhiza_scorecard.yml index d5dcc81..91c3bf7 100644 --- a/.github/workflows/rhiza_scorecard.yml +++ b/.github/workflows/rhiza_scorecard.yml @@ -36,7 +36,7 @@ permissions: read-all jobs: scorecard: - uses: jebel-quant/rhiza/.github/workflows/rhiza_scorecard.yml@v1.1.1 + uses: jebel-quant/rhiza/.github/workflows/rhiza_scorecard.yml@v1.1.3 secrets: inherit permissions: security-events: write # Upload the SARIF results to code scanning diff --git a/.github/workflows/rhiza_sync.yml b/.github/workflows/rhiza_sync.yml index 3477aa3..a6ff63f 100644 --- a/.github/workflows/rhiza_sync.yml +++ b/.github/workflows/rhiza_sync.yml @@ -35,7 +35,7 @@ on: jobs: sync: - uses: jebel-quant/rhiza/.github/workflows/rhiza_sync.yml@v1.1.1 + uses: jebel-quant/rhiza/.github/workflows/rhiza_sync.yml@v1.1.3 with: direct: ${{ github.event_name == 'push' }} create-pr: ${{ github.event_name != 'push' && (github.event_name == 'schedule' || inputs.create-pr == true) }} diff --git a/.github/workflows/rhiza_weekly.yml b/.github/workflows/rhiza_weekly.yml index 9055533..1263b3c 100644 --- a/.github/workflows/rhiza_weekly.yml +++ b/.github/workflows/rhiza_weekly.yml @@ -28,5 +28,5 @@ on: jobs: weekly: - uses: jebel-quant/rhiza/.github/workflows/rhiza_weekly.yml@v1.1.1 + uses: jebel-quant/rhiza/.github/workflows/rhiza_weekly.yml@v1.1.3 secrets: inherit diff --git a/.rhiza/completions/README.md b/.rhiza/completions/README.md index 656be99..73c37c1 100644 --- a/.rhiza/completions/README.md +++ b/.rhiza/completions/README.md @@ -12,6 +12,21 @@ This directory contains shell completion scripts for Bash and Zsh that provide t ## 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 diff --git a/.rhiza/make.d/completions.mk b/.rhiza/make.d/completions.mk new file mode 100644 index 0000000..59c2239 --- /dev/null +++ b/.rhiza/make.d/completions.mk @@ -0,0 +1,42 @@ +## .rhiza/make.d/completions.mk - Install shell tab-completion for make targets +# Copies the bundled completion scripts (.rhiza/completions/) into the user's +# completion directory so `make ` completes Rhiza targets. See +# .rhiza/completions/README.md for manual setup, aliases, and troubleshooting. + +.PHONY: install-completions + +# Which shell(s) to install completion for: bash, zsh, or both. +# make cannot reliably detect the login shell (it forces $SHELL to /bin/sh for +# recipes), so this defaults to `both` — installing an unused script is harmless. +# Narrow it with e.g. `make install-completions SHELL_KIND=zsh`. +SHELL_KIND ?= both + +##@ Shell Completion +install-completions: ## install shell tab-completion for make targets (SHELL_KIND=bash|zsh|both) + @src=".rhiza/completions"; \ + if [ ! -d "$$src" ]; then \ + printf "${RED}[❌]${RESET} $$src not found — run from the project root.\n"; \ + exit 1; \ + fi; \ + install_bash() { \ + dest="$${XDG_DATA_HOME:-$$HOME/.local/share}/bash-completion/completions"; \ + mkdir -p "$$dest"; \ + cp "$$src/rhiza-completion.bash" "$$dest/make"; \ + printf "${GREEN}[✓]${RESET} bash: installed to %s\n" "$$dest/make"; \ + printf "${BLUE}[INFO]${RESET} start a new shell, or run: ${BOLD}source %s${RESET}\n" "$$dest/make"; \ + }; \ + install_zsh() { \ + dest="$${XDG_DATA_HOME:-$$HOME/.local/share}/zsh/site-functions"; \ + mkdir -p "$$dest"; \ + cp "$$src/rhiza-completion.zsh" "$$dest/_make"; \ + printf "${GREEN}[✓]${RESET} zsh: installed to %s\n" "$$dest/_make"; \ + printf "${BLUE}[INFO]${RESET} if completion does not activate, add to ~/.zshrc:\n"; \ + printf " ${BOLD}fpath=(%s \$$fpath); autoload -U compinit && compinit${RESET}\n" "$$dest"; \ + }; \ + case "$(SHELL_KIND)" in \ + bash) install_bash ;; \ + zsh) install_zsh ;; \ + both) install_bash; install_zsh ;; \ + *) printf "${YELLOW}[WARN]${RESET} unknown SHELL_KIND='%s'; installing both (use bash|zsh|both).\n" "$(SHELL_KIND)"; \ + install_bash; install_zsh ;; \ + esac diff --git a/.rhiza/rhiza.mk b/.rhiza/rhiza.mk index d528c63..92227ae 100644 --- a/.rhiza/rhiza.mk +++ b/.rhiza/rhiza.mk @@ -145,6 +145,12 @@ post-sync:: ; @: pre-validate:: ; @: post-validate:: ; @: +# Detected once at parse time: non-empty when origin is the jebel-quant/rhiza +# mother repository, which by design has no template.yml. The sync/summarise/ +# validate targets are no-ops there. Evaluated a single time and reused so the +# origin regex lives in exactly one place. +IS_MOTHER_REPO := $(shell git remote get-url origin 2>/dev/null | grep -iqE 'jebel-quant/rhiza(\.git)?$$' && echo 1) + ##@ Rhiza Workflows print-logo: @@ -152,7 +158,7 @@ print-logo: 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 \ + @if [ -n "$(IS_MOTHER_REPO)" ]; then \ printf "${BLUE}[INFO] Skipping sync in rhiza repository (no template.yml by design)${RESET}\n"; \ else \ $(MAKE) install-uv && \ @@ -173,7 +179,7 @@ materialize: ## [DEPRECATED] use 'make sync' instead — materialize --force is @$(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 \ + @if [ -n "$(IS_MOTHER_REPO)" ]; then \ printf "${BLUE}[INFO] Skipping summarise-sync in rhiza repository (no template.yml by design)${RESET}\n"; \ else \ $(MAKE) install-uv; \ @@ -188,7 +194,7 @@ rhiza-test: install ## run rhiza's own tests (if any) 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 \ + @if [ -n "$(IS_MOTHER_REPO)" ]; then \ printf "${BLUE}[INFO] Skipping validate in rhiza repository (no template.yml by design)${RESET}\n"; \ else \ $(MAKE) install-uv; \ diff --git a/.rhiza/template.lock b/.rhiza/template.lock index 5aaef0d..6a946eb 100644 --- a/.rhiza/template.lock +++ b/.rhiza/template.lock @@ -1,16 +1,12 @@ -sha: cc162bbe38291955e0aa5f3418c1bcfd10fbba24 +sha: 8e6a8d48b98db05a10b7da67d26fac7ed54508c8 repo: jebel-quant/rhiza host: github -ref: v1.1.1 +ref: v1.1.3 include: [] exclude: [] templates: [] files: - .bandit -- .claude/commands/rhiza_book.md -- .claude/commands/rhiza_quality.md -- .claude/commands/rhiza_release.md -- .claude/commands/rhiza_update.md - .editorconfig - .github/CONFIG.md - .github/DISCUSSION_TEMPLATE/help-wanted.yml @@ -47,6 +43,7 @@ files: - .rhiza/completions/rhiza-completion.zsh - .rhiza/make.d/book.mk - .rhiza/make.d/bootstrap.mk +- .rhiza/make.d/completions.mk - .rhiza/make.d/custom-env.mk - .rhiza/make.d/custom-task.mk - .rhiza/make.d/doctor.mk @@ -59,14 +56,9 @@ files: - .rhiza/semgrep.yml - .rhiza/tests/README.md - .rhiza/tests/conftest.py -- .rhiza/tests/stress/README.md -- .rhiza/tests/stress/__init__.py -- .rhiza/tests/stress/conftest.py - .rhiza/tests/test_docstrings.py -- .rhiza/tests/test_git_repo_fixture.py - .rhiza/tests/test_pyproject.py - .rhiza/tests/test_readme_validation.py -- .rhiza/tests/test_utils.py - Makefile - cliff.toml - docs/assets/rhiza-logo.svg @@ -78,5 +70,5 @@ files: - ruff.toml profiles: - github-project -synced_at: '2026-07-09T09:17:36Z' +synced_at: '2026-07-11T08:25:02Z' strategy: merge diff --git a/.rhiza/tests/README.md b/.rhiza/tests/README.md index e35b1ee..aa62c95 100644 --- a/.rhiza/tests/README.md +++ b/.rhiza/tests/README.md @@ -17,10 +17,7 @@ The suite is flat — one file per concern: - `test_pyproject.py` — validates `pyproject.toml` structure and required fields - `test_readme_validation.py` — executes/syntax-checks `README.md` code blocks (see below) - `test_docstrings.py` — runs doctests across the modules in your source folder -- `test_git_repo_fixture.py` — self-test for the shared `git_repo` fixture -- `conftest.py` — shared fixtures (`root`, `logger`, `git_repo`) -- `test_utils.py` — shared helpers (`run_make`, `setup_rhiza_git_repo`, `strip_ansi`) -- `stress/` — scaffolding for optional load/concurrency tests (see [stress/README.md](stress/README.md)) +- `conftest.py` — shared fixtures (`root`, `logger`) ### Skipping README code blocks with `+RHIZA_SKIP` @@ -53,13 +50,6 @@ make rhiza-test # run this suite (the usual ent 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 -uv run pytest .rhiza/tests/ -m "not stress" # skip stress tests -``` - -Stress tests accept custom parameters (defaults: 100 iterations, 10 workers): - -```bash -uv run pytest .rhiza/tests/stress/ -v --iterations=10 ``` ## Fixtures @@ -68,15 +58,8 @@ Defined in `conftest.py` and available to every test without import: - `root` — repository root path (session-scoped) - `logger` — configured logger instance (session-scoped) -- `git_repo` — sandboxed git repository (function-scoped) - -Shared helpers live in `test_utils.py` and are imported directly: - -```python -from test_utils import strip_ansi, run_make, setup_rhiza_git_repo -``` -`.rhiza/tests` is on `pythonpath` (see `pytest.ini`), so `test_utils` imports resolve +`.rhiza/tests` is on `pythonpath` (see `pytest.ini`), so intra-suite imports resolve without any `sys.path` manipulation. ## Writing Tests @@ -85,4 +68,3 @@ without any `sys.path` manipulation. - 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 -- Prefer the `git_repo` fixture over touching the working tree diff --git a/.rhiza/tests/conftest.py b/.rhiza/tests/conftest.py index 062103e..bee5d94 100644 --- a/.rhiza/tests/conftest.py +++ b/.rhiza/tests/conftest.py @@ -1,123 +1,18 @@ -"""Pytest configuration and fixtures for setting up a mock git repository with versioning. +"""Pytest configuration and fixtures for the rhiza test suite. This file and its associated tests flow down via a SYNC action from the jebel-quant/rhiza repository (https://github.com/jebel-quant/rhiza). -Provides test fixtures for testing git-based workflows and version management. +Provides shared session-scoped fixtures (``root`` and ``logger``) used across the test modules. Security Notes: - S101 (assert usage): Asserts are appropriate in test code for validating conditions -- S603 (subprocess without shell=True): All subprocess calls use lists of known commands (git), - not user input, making them safe from shell injection -- S607 (subprocess with partial path): Using 'git' from PATH is acceptable in test fixtures - as the test environment is controlled and git is a required development dependency """ import logging -import os import pathlib -import shutil -import subprocess # nosec B404 - subprocess module needed for git operations in test fixtures import pytest -from test_utils import GIT - -MOCK_MAKE_SCRIPT = """#!/usr/bin/env python3 -import sys - -if len(sys.argv) > 1 and sys.argv[1] == "help": - print("Mock Makefile Help") - print("target: ## Description") -""" - -MOCK_UV_SCRIPT = """#!/usr/bin/env python3 -import sys -import re - -try: - from packaging.version import parse, InvalidVersion - HAS_PACKAGING = True -except ImportError: - HAS_PACKAGING = False - -def get_version(): - with open("pyproject.toml", "r") as f: - content = f.read() - match = re.search(r'version = "(.*?)"', content) - return match.group(1) if match else "0.0.0" - -def set_version(new_version): - with open("pyproject.toml", "r") as f: - content = f.read() - new_content = re.sub(r'version = ".*?"', f'version = "{new_version}"', content) - with open("pyproject.toml", "w") as f: - f.write(new_content) - -def bump_version(current, bump_type): - major, minor, patch = map(int, current.split('.')) - if bump_type == "major": - return f"{major + 1}.0.0" - elif bump_type == "minor": - return f"{major}.{minor + 1}.0" - elif bump_type == "patch": - return f"{major}.{minor}.{patch + 1}" - return current - -def main(): - args = sys.argv[1:] - if not args: - sys.exit(1) - - if args[0] != "version": - # It might be a uvx call if we use the same script, but let's keep them separate or handle it here. - # For now, let's assume this is only for uv version commands as per original design. - sys.exit(1) - - # uv version --short - if "--short" in args and "--bump" not in args: - print(get_version()) - return - - # uv version --bump --dry-run --short - if "--bump" in args and "--dry-run" in args and "--short" in args: - bump_idx = args.index("--bump") + 1 - bump_type = args[bump_idx] - current = get_version() - print(bump_version(current, bump_type)) - return - - # uv version --bump (actual update) - if "--bump" in args and "--dry-run" not in args: - bump_idx = args.index("--bump") + 1 - bump_type = args[bump_idx] - current = get_version() - new_ver = bump_version(current, bump_type) - set_version(new_ver) - return - - # uv version --dry-run - if len(args) >= 2 and not args[1].startswith("-") and "--dry-run" in args: - version = args[1] - if HAS_PACKAGING: - try: - parse(version) - except InvalidVersion: - sys.exit(1) - else: - # Simple validation: must start with a digit - if not re.match(r"^\\d", version): - sys.exit(1) - # Just exit 0 if valid - return - - # uv version (actual update) - if len(args) == 2 and not args[1].startswith("-"): - set_version(args[1]) - return - -if __name__ == "__main__": - main() -""" @pytest.fixture(scope="session") @@ -137,75 +32,3 @@ def logger(): logging.Logger: Logger configured for the test session. """ return logging.getLogger(__name__) - - -@pytest.fixture -def git_repo(root, tmp_path, monkeypatch): - """Sets up a remote bare repo and a local clone with necessary files.""" - remote_dir = tmp_path / "remote.git" - local_dir = tmp_path / "local" - - # 1. Create bare remote - remote_dir.mkdir() - subprocess.run([GIT, "init", "--bare", str(remote_dir)], check=True) # nosec B603 - # Ensure the remote's default HEAD points to master for predictable behavior - subprocess.run([GIT, "symbolic-ref", "HEAD", "refs/heads/master"], cwd=remote_dir, check=True) # nosec B603 - - # 2. Clone to local - subprocess.run([GIT, "clone", str(remote_dir), str(local_dir)], check=True) # nosec B603 - - # Use monkeypatch to safely change cwd for the duration of the test - monkeypatch.chdir(local_dir) - - # Ensure local default branch is 'master' to match test expectations - subprocess.run([GIT, "checkout", "-b", "master"], check=True) # nosec B603 - - # Create pyproject.toml - with open("pyproject.toml", "w") as f: - f.write('[project]\nname = "test-project"\nversion = "0.1.0"\n') - - # Create dummy uv.lock - with open("uv.lock", "w") as f: - f.write("") - - # Create bin/uv mock - bin_dir = local_dir / "bin" - bin_dir.mkdir() - - uv_path = bin_dir / "uv" - with open(uv_path, "w") as f: - f.write(MOCK_UV_SCRIPT) - uv_path.chmod(0o755) - - make_path = bin_dir / "make" - with open(make_path, "w") as f: - f.write(MOCK_MAKE_SCRIPT) - make_path.chmod(0o755) - - # Ensure our bin comes first on PATH so 'uv' resolves to mock - monkeypatch.setenv("PATH", f"{bin_dir}:{os.environ.get('PATH', '')}") - - # Copy core Rhiza Makefiles - (local_dir / ".rhiza").mkdir(parents=True, exist_ok=True) - shutil.copy(root / ".rhiza" / "rhiza.mk", local_dir / ".rhiza" / "rhiza.mk") - shutil.copy(root / "Makefile", local_dir / "Makefile") - - # Copy .rhiza/make.d/ directory (contains split makefiles) - make_d_src = root / ".rhiza" / "make.d" - if make_d_src.is_dir(): - make_d_dst = local_dir / ".rhiza" / "make.d" - shutil.copytree(make_d_src, make_d_dst, dirs_exist_ok=True) - - book_src = root / "book" - book_dst = local_dir / "book" - if book_src.is_dir(): - shutil.copytree(book_src, book_dst, dirs_exist_ok=True) - - # Commit and push initial state - subprocess.run([GIT, "config", "user.email", "test@example.com"], check=True) # nosec B603 - subprocess.run([GIT, "config", "user.name", "Test User"], check=True) # nosec B603 - subprocess.run([GIT, "add", "."], check=True) # nosec B603 - subprocess.run([GIT, "commit", "-m", "Initial commit"], check=True) # nosec B603 - subprocess.run([GIT, "push", "origin", "master"], check=True) # nosec B603 - - return local_dir diff --git a/.rhiza/tests/stress/README.md b/.rhiza/tests/stress/README.md deleted file mode 100644 index 8746548..0000000 --- a/.rhiza/tests/stress/README.md +++ /dev/null @@ -1,141 +0,0 @@ -# Stress Tests for Rhiza Framework - -This directory contains stress tests that verify the stability and performance of the Rhiza framework under heavy load conditions. - -## Overview - -Stress tests differ from regular integration tests and benchmarks: -- **Integration tests** verify that workflows execute correctly -- **Benchmarks** measure performance of individual operations -- **Stress tests** verify system stability under concurrent load and repeated operations - -These tests focus specifically on Rhiza's core operations: Makefile execution and Git operations used by release and sync workflows. - -## Test Categories - -### 1. Makefile Stress Tests (`test_makefile_stress.py`) - -Tests Rhiza's Makefile operations under stress: -- Concurrent invocations of targets (help, dry-run) -- Repeated executions to detect resource leaks -- Parallel variable printing and Makefile parsing - -### 2. Git Operations Stress Tests (`test_git_stress.py`) - -Tests Git operations used by Rhiza (release scripts, sync) under concurrent load: -- Concurrent git status/log/diff/show commands -- Repeated git operations (status, log, branch, rev-parse) -- Rapid git rev-parse (used in release script) - -## Running Stress Tests - -### Run all stress tests -```bash -uv run pytest .rhiza/tests/stress/ -v -``` - -### Run specific stress test category -```bash -uv run pytest .rhiza/tests/stress/test_makefile_stress.py -v -uv run pytest .rhiza/tests/stress/test_git_stress.py -v -``` - -### Run with custom iteration count -```bash -# Reduce iterations for faster testing -uv run pytest .rhiza/tests/stress/ -v --iterations=10 - -# Increase iterations for more thorough testing -uv run pytest .rhiza/tests/stress/ -v --iterations=500 -``` - -### Run with custom worker count -```bash -# Test with more concurrent workers -uv run pytest .rhiza/tests/stress/ -v --workers=20 -``` - -### Skip stress tests (when running full test suite) -```bash -uv run pytest .rhiza/tests/ -v -m "not stress" -``` - -## Test Markers - -All tests in this directory are marked with `@pytest.mark.stress` to allow selective execution: - -```python -@pytest.mark.stress -def test_concurrent_operations(): - # Test concurrent operations - pass -``` - -## Expected Behavior - -Stress tests should: -1. **Pass consistently** - No flakiness or race conditions -2. **Complete in reasonable time** - Generally < 60 seconds per test -3. **Clean up resources** - No leaked file handles, processes, or temporary files -4. **Report clear failures** - When failures occur, provide actionable error messages - -## Acceptance Criteria - -For Rhiza framework stress tests, we aim for: -- **100% success rate** - All operations should complete successfully -- **No resource leaks** - Memory and file handles should be cleaned up -- **Deterministic behavior** - Tests should produce consistent results -- **Reasonable performance** - Operations should complete within expected time bounds - -## Troubleshooting - -### Tests timeout -- Reduce iteration count: `pytest --iterations=10` -- Reduce worker count: `pytest --workers=5` -- Check system resources (CPU, memory, disk) - -### Intermittent failures -- Run with verbose output: `pytest -vv` -- Check for resource contention with other processes -- Verify git configuration (may affect git operations) - -### Out of memory errors -- Reduce concurrent workers -- Check for memory leaks in test code -- Ensure proper cleanup in fixtures - -## Contributing - -When adding new stress tests: -1. Use the `@pytest.mark.stress` decorator -2. Use provided fixtures (`stress_iterations`, `concurrent_workers`) -3. Ensure proper cleanup (use context managers or fixtures) -4. Document expected behavior and acceptance criteria -5. Keep tests focused on one stress scenario -6. Provide clear assertion messages - -Example: -```python -import pytest -import concurrent.futures - -@pytest.mark.stress -def test_concurrent_operation(stress_iterations, concurrent_workers): - """Test concurrent execution of operation X.""" - - def perform_operation(): - # Operation to stress test - return True - - with concurrent.futures.ThreadPoolExecutor(max_workers=concurrent_workers) as executor: - futures = [executor.submit(perform_operation) for _ in range(stress_iterations)] - results = [f.result() for f in concurrent.futures.as_completed(futures)] - - success_rate = sum(results) / len(results) - assert success_rate == 1.0, f"Expected 100% success rate, got {success_rate * 100:.1f}%" -``` - -## See Also - -- [Main Test README](../README.md) - Overview of all test categories -- [Benchmarks](../../../tests/benchmarks/) - Performance benchmarks diff --git a/.rhiza/tests/stress/__init__.py b/.rhiza/tests/stress/__init__.py deleted file mode 100644 index 8ce3020..0000000 --- a/.rhiza/tests/stress/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Stress tests for Rhiza framework. - -This module contains stress tests that verify system stability and performance -under heavy load conditions. -""" diff --git a/.rhiza/tests/stress/conftest.py b/.rhiza/tests/stress/conftest.py deleted file mode 100644 index 80bdc04..0000000 --- a/.rhiza/tests/stress/conftest.py +++ /dev/null @@ -1,50 +0,0 @@ -"""Pytest configuration for stress tests. - -Provides fixtures and utilities specific to stress testing scenarios. - -Security Note: -- S101 (assert usage): Safe in test code - asserts are expected in pytest tests -- S603/S607 (subprocess usage): Not used in this file, but documented for completeness - Any subprocess calls in stress tests are for testing make/git commands in isolated - temporary environments with controlled inputs -""" - -from __future__ import annotations - -import pytest - - -def pytest_addoption(parser): - """Add custom command-line options for stress tests.""" - parser.addoption( - "--iterations", - action="store", - default=100, - type=int, - help="Number of iterations for stress tests (default: 100)", - ) - parser.addoption( - "--workers", - action="store", - default=10, - type=int, - help="Number of concurrent workers for stress tests (default: 10)", - ) - - -@pytest.fixture -def stress_iterations(request): - """Return the number of iterations for stress tests. - - Default is 100 iterations. Can be overridden via --iterations command line option. - """ - return request.config.getoption("--iterations") - - -@pytest.fixture -def concurrent_workers(request): - """Return the number of concurrent workers for stress tests. - - Default is 10 workers. Can be overridden via --workers command line option. - """ - return request.config.getoption("--workers") diff --git a/.rhiza/tests/test_git_repo_fixture.py b/.rhiza/tests/test_git_repo_fixture.py deleted file mode 100644 index 223cfee..0000000 --- a/.rhiza/tests/test_git_repo_fixture.py +++ /dev/null @@ -1,132 +0,0 @@ -"""Tests for the git_repo pytest fixture that creates a mock Git repository. - -This file and its associated tests flow down via a SYNC action from the jebel-quant/rhiza repository -(https://github.com/jebel-quant/rhiza). - -This module validates the temporary repository structure, git initialization, -mocked tool executables, environment variables, and basic git configuration the -fixture is expected to provide for integration-style tests. -""" - -import os -import shutil -import subprocess # nosec B404 -from pathlib import Path - -# Get absolute path for git to avoid S607 warnings -GIT = shutil.which("git") or "/usr/bin/git" - - -class TestGitRepoFixture: - """Tests for the git_repo fixture that sets up a mock git repository.""" - - def test_git_repo_creates_temporary_directory(self, git_repo): - """Git repo fixture should create a temporary directory.""" - assert git_repo.exists() - assert git_repo.is_dir() - - def test_git_repo_contains_pyproject_toml(self, git_repo): - """Git repo should contain a pyproject.toml file.""" - pyproject = git_repo / "pyproject.toml" - assert pyproject.exists() - content = pyproject.read_text() - assert 'name = "test-project"' in content - assert 'version = "0.1.0"' in content - - def test_git_repo_contains_uv_lock(self, git_repo): - """Git repo should contain a uv.lock file.""" - assert (git_repo / "uv.lock").exists() - - def test_git_repo_has_bin_directory_with_mocks(self, git_repo): - """Git repo should have bin directory with mock tools.""" - bin_dir = git_repo / "bin" - assert bin_dir.exists() - assert (bin_dir / "uv").exists() - - def test_git_repo_mock_tools_are_executable(self, git_repo): - """Mock tools should be executable.""" - for tool in ["uv"]: - tool_path = git_repo / "bin" / tool - assert os.access(tool_path, os.X_OK), f"{tool} is not executable" - - def test_git_repo_is_initialized(self, git_repo): - """Git repo should be properly initialized.""" - result = subprocess.run( # nosec B603 - [GIT, "rev-parse", "--git-dir"], - cwd=git_repo, - capture_output=True, - text=True, - ) - assert result.returncode == 0 - assert ".git" in result.stdout - - def test_git_repo_has_master_branch(self, git_repo): - """Git repo should be on master branch.""" - result = subprocess.run( # nosec B603 - [GIT, "branch", "--show-current"], - cwd=git_repo, - capture_output=True, - text=True, - ) - assert result.returncode == 0 - assert result.stdout.strip() == "master" - - def test_git_repo_has_initial_commit(self, git_repo): - """Git repo should have an initial commit.""" - result = subprocess.run( # nosec B603 - [GIT, "log", "--oneline"], - cwd=git_repo, - capture_output=True, - text=True, - ) - assert result.returncode == 0 - assert "Initial commit" in result.stdout - - def test_git_repo_has_remote_configured(self, git_repo): - """Git repo should have origin remote configured.""" - result = subprocess.run( # nosec B603 - [GIT, "remote", "-v"], - cwd=git_repo, - capture_output=True, - text=True, - ) - assert result.returncode == 0 - assert "origin" in result.stdout - - def test_git_repo_user_config_is_set(self, git_repo): - """Git repo should have user.email and user.name configured.""" - email = subprocess.check_output( # nosec B603 - [GIT, "config", "user.email"], - cwd=git_repo, - text=True, - ).strip() - name = subprocess.check_output( # nosec B603 - [GIT, "config", "user.name"], - cwd=git_repo, - text=True, - ).strip() - assert email == "test@example.com" - assert name == "Test User" - - def test_git_repo_working_tree_is_clean(self, git_repo): - """Git repo should start with a clean working tree.""" - result = subprocess.run( # nosec B603 - [GIT, "status", "--porcelain"], - cwd=git_repo, - capture_output=True, - text=True, - ) - assert result.returncode == 0 - assert result.stdout.strip() == "" - - def test_git_repo_changes_current_directory(self, git_repo): - """Git repo fixture should change to the temporary directory.""" - current_dir = Path.cwd() - assert current_dir == git_repo - - def test_git_repo_modifies_path_environment(self, git_repo): - """Git repo fixture should prepend bin directory to PATH.""" - path_env = os.environ.get("PATH", "") - bin_dir = str(git_repo / "bin") - assert bin_dir in path_env - assert path_env.startswith(bin_dir) diff --git a/.rhiza/tests/test_utils.py b/.rhiza/tests/test_utils.py deleted file mode 100644 index a7c1da4..0000000 --- a/.rhiza/tests/test_utils.py +++ /dev/null @@ -1,70 +0,0 @@ -"""Shared test utilities. - -Helper functions used across the test suite. Extracted from conftest.py to avoid -relative imports and __init__.py requirements in test directories. - -This file and its associated utilities flow down via a SYNC action from the -jebel-quant/rhiza repository (https://github.com/jebel-quant/rhiza). - -Security Notes: -- S101 (assert usage): Asserts are used in test utilities to validate test setup conditions -- S603 (subprocess without shell=True): All subprocess calls use command lists with known - executables (git, make), not user input, preventing shell injection -- S607 (subprocess with partial path): Git and make are resolved from PATH via shutil.which() - with fallbacks, which is safe in controlled test environments -""" - -import re -import shutil -import subprocess # nosec B404 - subprocess module needed for git/make operations in test utilities - -# Get absolute paths for executables to avoid S607 warnings -GIT = shutil.which("git") or "/usr/bin/git" -MAKE = shutil.which("make") or "/usr/bin/make" - - -def strip_ansi(text: str) -> str: - """Strip ANSI escape sequences from text.""" - ansi_escape = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])") - return ansi_escape.sub("", text) - - -def run_make( - logger, args: list[str] | None = None, check: bool = True, dry_run: bool = True, env: dict[str, str] | None = None -) -> subprocess.CompletedProcess: - """Run `make` with optional arguments and return the completed process. - - Args: - logger: Logger used to emit diagnostic messages during the run - args: Additional arguments for make - check: If True, raise on non-zero return code - dry_run: If True, use -n to avoid executing commands - env: Optional environment variables to pass to the subprocess - """ - cmd = [MAKE] - if args: - cmd.extend(args) - # Use -s to reduce noise, -n to avoid executing commands - flags = "-sn" if dry_run else "-s" - cmd.insert(1, flags) - logger.info("Running command: %s", " ".join(cmd)) - result = subprocess.run(cmd, capture_output=True, text=True, env=env) # nosec B603 - logger.debug("make exited with code %d", result.returncode) - if result.stdout: - logger.debug("make stdout (truncated to 500 chars):\n%s", result.stdout[:500]) - if result.stderr: - logger.debug("make stderr (truncated to 500 chars):\n%s", result.stderr[:500]) - if check and result.returncode != 0: - msg = f"make failed with code {result.returncode}:\nSTDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}" - raise AssertionError(msg) - return result - - -def setup_rhiza_git_repo(): - """Initialize a git repository and set remote to rhiza.""" - subprocess.run([GIT, "init"], check=True, capture_output=True) # nosec B603 - subprocess.run( # nosec B603 - [GIT, "remote", "add", "origin", "https://github.com/jebel-quant/rhiza"], - check=True, - capture_output=True, - )