From 3ca5f774d1a7cee1a1a11d2767259f7d1a025620 Mon Sep 17 00:00:00 2001 From: Thomas Schmelzer Date: Mon, 8 Jun 2026 12:50:19 +0400 Subject: [PATCH 1/2] bump rhiza ref to v0.18.8 Co-Authored-By: Claude Sonnet 4.6 --- .rhiza/template.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.rhiza/template.yml b/.rhiza/template.yml index b735a1d..798cfc9 100644 --- a/.rhiza/template.yml +++ b/.rhiza/template.yml @@ -1,5 +1,5 @@ repository: "jebel-quant/rhiza" -ref: "v0.18.6" +ref: "v0.18.8" profiles: - github-project From 1c8d9fc5e485705bce04ccff2e869fde5fe1f1ea Mon Sep 17 00:00:00 2001 From: Thomas Schmelzer Date: Mon, 8 Jun 2026 12:52:04 +0400 Subject: [PATCH 2/2] sync rhiza template v0.18.8 Co-Authored-By: Claude Sonnet 4.6 --- .bandit | 2 + .editorconfig | 41 ++ .github/DISCUSSION_TEMPLATE/q-and-a.yml | 25 + .github/ISSUE_TEMPLATE/bug_report.yml | 57 ++ .github/ISSUE_TEMPLATE/feature_request.yml | 41 ++ .github/dependabot.yml | 80 +++ .github/pull_request_template.md | 23 + .github/release.yml | 67 ++ .github/secret_scanning.yml | 20 + .github/workflows/rhiza_benchmark.yml | 24 + .github/workflows/rhiza_book.yml | 32 + .github/workflows/rhiza_ci.yml | 25 + .github/workflows/rhiza_codeql.yml | 33 + .github/workflows/rhiza_marimo.yml | 32 + .github/workflows/rhiza_release.yml | 659 ++++++++++++++++++ .github/workflows/rhiza_sync.yml | 43 ++ .github/workflows/rhiza_weekly.yml | 32 + .gitignore | 143 ++-- .pre-commit-config.yaml | 89 +++ .python-version | 2 + .rhiza/.cfg.toml | 34 + .rhiza/.env | 3 + .rhiza/.gitignore | 2 + .rhiza/.rhiza-version | 1 + .rhiza/assets/rhiza-logo.svg | 81 +++ .rhiza/completions/README.md | 263 +++++++ .rhiza/completions/rhiza-completion.bash | 47 ++ .rhiza/completions/rhiza-completion.zsh | 88 +++ .rhiza/make.d/book.mk | 58 ++ .rhiza/make.d/bootstrap.mk | 109 +++ .rhiza/make.d/custom-env.mk | 9 + .rhiza/make.d/custom-task.mk | 12 + .rhiza/make.d/doctor.mk | 60 ++ .rhiza/make.d/marimo.mk | 42 ++ .rhiza/make.d/quality.mk | 59 ++ .rhiza/make.d/releasing.mk | 50 ++ .rhiza/make.d/test.mk | 172 +++++ .rhiza/requirements/README.md | 27 + .rhiza/requirements/docs.txt | 4 + .rhiza/requirements/marimo.txt | 2 + .rhiza/requirements/tests.txt | 18 + .rhiza/requirements/tools.txt | 7 + .rhiza/rhiza.mk | 178 +++++ .rhiza/semgrep.yml | 84 +++ .rhiza/template.lock | 89 +++ .rhiza/tests/README.md | 169 +++++ .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 | 217 ++++++ .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 | 143 ++++ .rhiza/tests/stress/__init__.py | 5 + .rhiza/tests/stress/conftest.py | 50 ++ .rhiza/tests/structure/test_project_layout.py | 57 ++ .rhiza/tests/structure/test_pyproject.py | 217 ++++++ .rhiza/tests/structure/test_requirements.py | 51 ++ .rhiza/tests/sync/conftest.py | 100 +++ .rhiza/tests/sync/test_docstrings.py | 123 ++++ .rhiza/tests/sync/test_readme_validation.py | 197 ++++++ .rhiza/tests/test_utils.py | 70 ++ .rhiza/tests/utils/test_git_repo_fixture.py | 132 ++++ .rhiza/utils/pip_audit_policy.py | 67 ++ .rhiza/utils/suppression_audit.py | 369 ++++++++++ Makefile | 18 +- docs/assets/rhiza-logo.svg | 81 +++ docs/development/MARIMO.md | 134 ++++ docs/development/TESTS.md | 288 ++++++++ docs/index.md | 2 + docs/mkdocs-base.yml | 120 ++++ pytest.ini | 16 + ruff.toml | 131 ++++ 78 files changed, 7252 insertions(+), 74 deletions(-) create mode 100644 .bandit create mode 100644 .editorconfig create mode 100644 .github/DISCUSSION_TEMPLATE/q-and-a.yml create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml create mode 100644 .github/dependabot.yml create mode 100644 .github/pull_request_template.md create mode 100644 .github/release.yml create mode 100644 .github/secret_scanning.yml create mode 100644 .github/workflows/rhiza_benchmark.yml create mode 100644 .github/workflows/rhiza_book.yml create mode 100644 .github/workflows/rhiza_ci.yml create mode 100644 .github/workflows/rhiza_codeql.yml create mode 100644 .github/workflows/rhiza_marimo.yml create mode 100644 .github/workflows/rhiza_release.yml create mode 100644 .github/workflows/rhiza_sync.yml create mode 100644 .github/workflows/rhiza_weekly.yml create mode 100644 .pre-commit-config.yaml create mode 100644 .python-version create mode 100644 .rhiza/.cfg.toml create mode 100644 .rhiza/.env create mode 100644 .rhiza/.gitignore create mode 100644 .rhiza/.rhiza-version create mode 100644 .rhiza/assets/rhiza-logo.svg create mode 100644 .rhiza/completions/README.md create mode 100644 .rhiza/completions/rhiza-completion.bash create mode 100644 .rhiza/completions/rhiza-completion.zsh create mode 100644 .rhiza/make.d/book.mk create mode 100644 .rhiza/make.d/bootstrap.mk create mode 100644 .rhiza/make.d/custom-env.mk create mode 100644 .rhiza/make.d/custom-task.mk create mode 100644 .rhiza/make.d/doctor.mk create mode 100644 .rhiza/make.d/marimo.mk create mode 100644 .rhiza/make.d/quality.mk create mode 100644 .rhiza/make.d/releasing.mk create mode 100644 .rhiza/make.d/test.mk create mode 100644 .rhiza/requirements/README.md create mode 100644 .rhiza/requirements/docs.txt create mode 100644 .rhiza/requirements/marimo.txt create mode 100644 .rhiza/requirements/tests.txt create mode 100644 .rhiza/requirements/tools.txt create mode 100644 .rhiza/rhiza.mk create mode 100644 .rhiza/semgrep.yml create mode 100644 .rhiza/template.lock create mode 100644 .rhiza/tests/README.md create mode 100644 .rhiza/tests/api/conftest.py create mode 100644 .rhiza/tests/api/test_github_targets.py create mode 100644 .rhiza/tests/api/test_make_variable_overrides.py create mode 100644 .rhiza/tests/api/test_makefile_api.py create mode 100644 .rhiza/tests/api/test_makefile_targets.py create mode 100644 .rhiza/tests/conftest.py create mode 100644 .rhiza/tests/integration/test_book_targets.py create mode 100644 .rhiza/tests/integration/test_docs_targets.py create mode 100644 .rhiza/tests/integration/test_test_mk.py create mode 100644 .rhiza/tests/integration/test_virtual_env_unexport.py create mode 100755 .rhiza/tests/shell/test_scripts.sh create mode 100644 .rhiza/tests/stress/README.md create mode 100644 .rhiza/tests/stress/__init__.py create mode 100644 .rhiza/tests/stress/conftest.py create mode 100644 .rhiza/tests/structure/test_project_layout.py create mode 100644 .rhiza/tests/structure/test_pyproject.py create mode 100644 .rhiza/tests/structure/test_requirements.py create mode 100644 .rhiza/tests/sync/conftest.py create mode 100644 .rhiza/tests/sync/test_docstrings.py create mode 100644 .rhiza/tests/sync/test_readme_validation.py create mode 100644 .rhiza/tests/test_utils.py create mode 100644 .rhiza/tests/utils/test_git_repo_fixture.py create mode 100644 .rhiza/utils/pip_audit_policy.py create mode 100644 .rhiza/utils/suppression_audit.py create mode 100644 docs/assets/rhiza-logo.svg create mode 100644 docs/development/MARIMO.md create mode 100644 docs/development/TESTS.md create mode 100644 docs/index.md create mode 100644 docs/mkdocs-base.yml create mode 100644 pytest.ini create mode 100644 ruff.toml diff --git a/.bandit b/.bandit new file mode 100644 index 0000000..a1be520 --- /dev/null +++ b/.bandit @@ -0,0 +1,2 @@ +[bandit] +skips = B101 diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..caad48d --- /dev/null +++ b/.editorconfig @@ -0,0 +1,41 @@ +# This file is part of the jebel-quant/rhiza repository +# (https://github.com/jebel-quant/rhiza). +# +root = true + +# Default settings for all files +[*] +end_of_line = lf +trim_trailing_whitespace = true +insert_final_newline = true +charset = utf-8 + +# Python, reStructuredText, and text files +[*.{py,rst,txt}] +indent_style = space +indent_size = 4 + +# YAML, JSON, and other config files +[*.{yml,yaml,json}] +indent_style = space +indent_size = 2 + +# Markdown files +# [*.{md,markdown}] +# trim_trailing_whitespace = false + +# Makefiles require tabs +[{Makefile,*.mk,*.make}] +indent_style = tab +indent_size = 4 + +# Don't apply editorconfig rules to vendor/ resources +# This is a "defensive" rule for the day we may have +# the vendor folder +[vendor/**] +charset = unset +end_of_line = unset +indent_size = unset +indent_style = unset +insert_final_newline = unset +trim_trailing_whitespace = unset diff --git a/.github/DISCUSSION_TEMPLATE/q-and-a.yml b/.github/DISCUSSION_TEMPLATE/q-and-a.yml new file mode 100644 index 0000000..c83b0a7 --- /dev/null +++ b/.github/DISCUSSION_TEMPLATE/q-and-a.yml @@ -0,0 +1,25 @@ +title: "[Question] " +labels: ["question"] +body: + - type: markdown + attributes: + value: | + Welcome! Use this space to ask questions, share how your use-case, or explore ideas with the community. + + - type: textarea + id: question + attributes: + label: Your Question or Topic + description: What would you like to discuss? + validations: + required: true + + - type: textarea + id: context + attributes: + label: Context + description: Any relevant code, configuration, or background that helps frame your question. + placeholder: | + ```python + # your code snippet here + ``` diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..060ab29 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,57 @@ +name: Bug Report +description: Report a bug or unexpected behaviour +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to report a bug. Please fill out the sections below. + + - type: textarea + id: description + attributes: + label: Description + description: A clear and concise description of what the bug is. + placeholder: What happened? + validations: + required: true + + - type: textarea + id: steps + attributes: + label: Steps to reproduce + description: Minimal steps to reproduce the behaviour. + placeholder: | + 1. Run `make ...` + 2. See error + validations: + required: true + + - type: textarea + id: expected + attributes: + label: Expected behaviour + description: What did you expect to happen? + validations: + required: true + + - type: textarea + id: environment + attributes: + label: Environment + description: | + Relevant versions and system info. Run `make info` if available. + placeholder: | + - OS: macOS 14 / Ubuntu 24.04 / Windows 11 + - Python: 3.13.x + - rhiza version: + validations: + required: false + + - type: textarea + id: context + attributes: + label: Additional context + description: Logs, screenshots, or anything else that may be helpful. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..1b0de2d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,41 @@ +name: Feature Request +description: Suggest a new feature or enhancement +labels: ["enhancement"] +body: + - type: markdown + attributes: + value: | + Thanks for proposing a feature. Please align with the team before investing significant effort. + + - type: textarea + id: problem + attributes: + label: Problem / motivation + description: What problem does this solve? Why is it valuable? + placeholder: As a contributor I find it hard to ... because ... + validations: + required: true + + - type: textarea + id: solution + attributes: + label: Proposed solution + description: Describe the solution you have in mind. + validations: + required: true + + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + description: Other approaches you have considered and why you ruled them out. + validations: + required: false + + - type: textarea + id: context + attributes: + label: Additional context + description: Links, mockups, prior art, or anything else that may be helpful. + validations: + required: false diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..1da7ad1 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,80 @@ +# This file is part of the jebel-quant/rhiza repository +# (https://github.com/jebel-quant/rhiza). +# +# Configuration: Dependabot +# +# Purpose: Automate dependency updates for Python packages, GitHub Actions, and Docker images. +# +# Documentation: https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file + +version: 2 +updates: + # Python dependencies using uv + - package-ecosystem: "uv" + directory: "/" + schedule: + interval: "weekly" + day: "tuesday" + time: "09:00" + timezone: "Asia/Dubai" + ignore: + - dependency-name: "*" + update-types: ["version-update:semver-major"] + open-pull-requests-limit: 10 + labels: + - "dependencies" + - "python" + groups: + python-dependencies: + patterns: + - "*" + update-types: + - "patch" + - "minor" + commit-message: + prefix: "chore(deps)" + prefix-development: "chore(deps-dev)" + include: "scope" + + # GitHub Actions + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + day: "tuesday" + time: "09:00" + timezone: "Asia/Dubai" + ignore: + - dependency-name: "*" + update-types: ["version-update:semver-major"] + open-pull-requests-limit: 10 + labels: + - "dependencies" + - "github-actions" + groups: + github-actions: + patterns: + - "*" + update-types: + - "patch" + - "minor" + + commit-message: + prefix: "chore(deps)" + include: "scope" + + # Docker + #- package-ecosystem: "docker" + # directory: "/docker" + # schedule: + # interval: "weekly" + # day: "tuesday" + # time: "09:00" + # timezone: "Asia/Dubai" + # open-pull-requests-limit: 10 + # labels: + # - "dependencies" + # - "docker" + # commit-message: + # prefix: "chore(deps)" + # include: "scope" diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..329b071 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,23 @@ +## Summary + + + +Closes # + +## Changes + + + +- + +## Testing + +- [ ] `make test` passes locally +- [ ] `make fmt` has been run +- [ ] New tests added (or explain why not needed) + +## Checklist + +- [ ] Commit messages follow the [Conventional Commits](https://www.conventionalcommits.org/) format +- [ ] Documentation updated if behaviour changed +- [ ] `make deptry` passes (no unused or missing dependencies) diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 0000000..63cb6ab --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,67 @@ +# GitHub Release Notes Configuration +# This file configures automatic categorization of pull requests in release notes +# See: https://docs.github.com/en/repositories/releasing-projects-on-github/automatically-generated-release-notes + +changelog: + exclude: + labels: + - ignore-for-release + - duplicate + - question + - invalid + - wontfix + authors: + - dependabot + - renovate + + categories: + - title: ๐Ÿš€ Features + labels: + - feature + - enhancement + + - title: ๐Ÿ› Bug Fixes + labels: + - bug + - fix + + - title: ๐Ÿ“š Documentation + labels: + - documentation + - docs + + - title: ๐Ÿ”ง Technical Debt + labels: + - technical-debt + - refactor + - cleanup + + - title: ๐Ÿ—๏ธ Infrastructure + labels: + - ci + - build + - infrastructure + + - title: โšก Performance + labels: + - performance + - optimization + + - title: ๐Ÿงช Testing + labels: + - test + - testing + + - title: ๐Ÿ”’ Security + labels: + - security + - vulnerability + + - title: ๐Ÿ“ฆ Dependencies + labels: + - dependencies + - deps + + - title: ๐Ÿ”„ Other Changes + labels: + - "*" diff --git a/.github/secret_scanning.yml b/.github/secret_scanning.yml new file mode 100644 index 0000000..fdfd809 --- /dev/null +++ b/.github/secret_scanning.yml @@ -0,0 +1,20 @@ +# This file is part of the jebel-quant/rhiza repository +# (https://github.com/jebel-quant/rhiza). +# +# Configuration: GitHub Secret Scanning +# +# Purpose: Configure GitHub secret scanning to identify and prevent accidental +# exposure of secrets, credentials, and tokens in the repository. +# +# Note: Secret scanning must be enabled in repository Settings > +# Security > Code security and analysis > Secret scanning. +# +# Documentation: https://docs.github.com/en/code-security/secret-scanning/configuring-secret-scanning-for-your-repository + +paths-ignore: + # Ignore test fixtures that may contain example/fake secrets + - ".rhiza/tests/**" + - "tests/**" + # Ignore documentation that references example tokens/keys + - "docs/**/*.md" + - "book/**" diff --git a/.github/workflows/rhiza_benchmark.yml b/.github/workflows/rhiza_benchmark.yml new file mode 100644 index 0000000..48b1c30 --- /dev/null +++ b/.github/workflows/rhiza_benchmark.yml @@ -0,0 +1,24 @@ +# This file is part of the jebel-quant/rhiza repository +# (https://github.com/jebel-quant/rhiza). +# +# Workflow: Benchmarks +# +# Purpose: Run the benchmark suite on pushes to main to create a measurable +# performance baseline over time. +# +# Trigger: This workflow runs on pushes to the main branch. + +name: "(RHIZA) BENCHMARK" + +permissions: + contents: read + +on: + push: + branches: + - main + +jobs: + benchmark: + uses: jebel-quant/rhiza/.github/workflows/rhiza_benchmark.yml@v0.18.7 + secrets: inherit diff --git a/.github/workflows/rhiza_book.yml b/.github/workflows/rhiza_book.yml new file mode 100644 index 0000000..368ec16 --- /dev/null +++ b/.github/workflows/rhiza_book.yml @@ -0,0 +1,32 @@ +# This file is part of the jebel-quant/rhiza repository +# (https://github.com/jebel-quant/rhiza). +# +# Workflow: Book +# Purpose: This workflow builds and deploys comprehensive documentation for the project. +# 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 +# +# Components: +# - ๐Ÿ““ Process Marimo notebooks +# - ๐Ÿ“– Build documentation site via zensical +# - ๐Ÿงช Run tests and generate coverage reports +# - ๐Ÿš€ Deploy combined documentation to GitHub Pages + +name: "(RHIZA) BOOK" + +on: + push: + branches: + - main + - master + +jobs: + book: + uses: jebel-quant/rhiza/.github/workflows/rhiza_book.yml@v0.18.7 + secrets: inherit + permissions: + contents: read + pages: write + id-token: write diff --git a/.github/workflows/rhiza_ci.yml b/.github/workflows/rhiza_ci.yml new file mode 100644 index 0000000..cfa4846 --- /dev/null +++ b/.github/workflows/rhiza_ci.yml @@ -0,0 +1,25 @@ +# This file is part of the jebel-quant/rhiza repository +# (https://github.com/jebel-quant/rhiza). +# +# Workflow: Continuous Integration +# +# Purpose: Run tests on multiple Python versions, check dependencies, run +# pre-commit hooks, verify documentation coverage, validate the +# project, run security scans, and check license compliance. +# +# Trigger: On push and pull_request. + +name: "(RHIZA) CI" + +permissions: + contents: read + actions: read + +on: + push: + pull_request: + +jobs: + ci: + uses: jebel-quant/rhiza/.github/workflows/rhiza_ci.yml@v0.18.7 + secrets: inherit diff --git a/.github/workflows/rhiza_codeql.yml b/.github/workflows/rhiza_codeql.yml new file mode 100644 index 0000000..8d2ef04 --- /dev/null +++ b/.github/workflows/rhiza_codeql.yml @@ -0,0 +1,33 @@ +# This file is part of the jebel-quant/rhiza repository +# (https://github.com/jebel-quant/rhiza). +# +# Workflow: CodeQL +# +# Purpose: Automated code scanning for Python and GitHub Actions using +# GitHub's CodeQL engine. Free for public repositories; requires +# GitHub Advanced Security for private repositories. +# Set the CODEQL_ENABLED repository variable to 'true' to force-enable +# on private repos, 'false' to disable, or leave unset for auto-detect. +# +# Trigger: On push to main/master, pull requests, and weekly schedule. + +name: "(RHIZA) CODEQL" + +permissions: + security-events: write + packages: read + actions: read + contents: read + +on: + push: + branches: [ "main", "master" ] + pull_request: + branches: [ "main", "master" ] + schedule: + - cron: '27 1 * * 1' + +jobs: + codeql: + uses: jebel-quant/rhiza/.github/workflows/rhiza_codeql.yml@v0.18.7 + secrets: inherit diff --git a/.github/workflows/rhiza_marimo.yml b/.github/workflows/rhiza_marimo.yml new file mode 100644 index 0000000..0083ab6 --- /dev/null +++ b/.github/workflows/rhiza_marimo.yml @@ -0,0 +1,32 @@ +# This file is part of the jebel-quant/rhiza repository +# (https://github.com/jebel-quant/rhiza). +# +# Workflow: Marimo Notebooks +# +# Purpose: This workflow discovers and executes all Marimo notebooks in the +# repository. It builds a dynamic matrix to run each notebook in +# parallel to surface errors early and keep notebooks reproducible. +# +# Trigger: This workflow runs on every push and on pull requests to main/master +# branches (including from forks) +# +# Components: +# - ๐Ÿ”Ž Discover notebooks in book/marimo +# - ๐Ÿงช Run each notebook in parallel using a matrix strategy +# - โœ… Fail-fast disabled to report all failing notebooks + +name: "(RHIZA) MARIMO" + +permissions: + contents: read + +on: + push: + branches: [ main, master ] + pull_request: + branches: [ main, master ] + +jobs: + marimo: + uses: jebel-quant/rhiza/.github/workflows/rhiza_marimo.yml@v0.18.7 + secrets: inherit diff --git a/.github/workflows/rhiza_release.yml b/.github/workflows/rhiza_release.yml new file mode 100644 index 0000000..91963cb --- /dev/null +++ b/.github/workflows/rhiza_release.yml @@ -0,0 +1,659 @@ +# This file is part of the jebel-quant/rhiza repository +# (https://github.com/jebel-quant/rhiza). +# +# โš ๏ธ WHY THIS WORKFLOW CANNOT USE A STUB +# +# Rhiza normally distributes workflows as thin stubs that delegate to a reusable +# workflow in this repository via `workflow_call`. That pattern cannot be used here +# because PyPI Trusted Publishing (OIDC) validates the *exact* workflow file path +# and repository that invokes `pypa/gh-action-pypi-publish`. When a stub delegates +# to a reusable workflow in jebel-quant/rhiza, PyPI sees rhiza's repository as the +# publisher โ€” not the downstream project's โ€” and rejects the OIDC token. This file +# must therefore be synced directly into each downstream project so that the publish +# step runs under the correct repository identity. +# +# Release Workflow for Python Packages with Optional Devcontainer Publishing +# +# This workflow implements a secure, maintainable release pipeline with distinct phases: +# +# ๐Ÿ“‹ Pipeline Phases: +# 1. ๐Ÿ” Validate Tag - Check tag format and ensure release doesn't already exist +# 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 +# +# ๐Ÿ“ฆ SBOM Generation: +# - Generated using CycloneDX format (industry standard for software supply chain security) +# - Creates both JSON and XML formats for maximum compatibility +# - SBOM attestations are created and stored (public repos only) +# - Attached to GitHub releases for transparency and compliance +# - Skipped if pyproject.toml doesn't exist +# +# ๐Ÿณ Devcontainer Publishing: +# - Only occurs when PUBLISH_DEVCONTAINER repository variable is set to "true" +# - Requires .devcontainer directory to exist +# - Uses the release tag (e.g., v1.2.3) as the image tag +# - Image name: {registry}/{owner}/{repository}/devcontainer +# - Registry defaults to ghcr.io (override with DEVCONTAINER_REGISTRY variable) +# - Config file: Always .devcontainer/devcontainer.json +# - Adds devcontainer image link to GitHub release notes +# +# ๐Ÿš€ PyPI Publishing: +# - Skipped if no dist/ artifacts exist +# - Skipped if pyproject.toml contains "Private :: Do Not Upload" +# - Uses Trusted Publishing (OIDC) for PyPI (no stored credentials) +# - For custom feeds, use PYPI_REPOSITORY_URL and PYPI_TOKEN secrets +# - Adds PyPI/custom feed link to GitHub release notes +# +# ๐Ÿ“ฆ Conda Recipe Generation: +# - Runs only when PyPI publishing is enabled and succeeds +# - Uses grayskull to generate a conda-forge compatible recipe from PyPI metadata +# - Uploads conda-recipe/meta.yaml as a workflow artifact for downstream feedstock updates +# +# ๐Ÿ” Security: +# - No PyPI credentials stored; relies on Trusted Publishing via GitHub OIDC +# - For custom feeds, PYPI_TOKEN secret is used with default username __token__ +# - Container registry uses GITHUB_TOKEN for authentication +# - SLSA provenance attestations generated for build artifacts (public repos only) +# - SBOM attestations generated for supply chain transparency (public repos only) +# +# ๐Ÿ“„ Requirements: +# - pyproject.toml with top-level version field (for Python packages) +# - Package registered on PyPI as Trusted Publisher (for PyPI publishing) +# - Conda recipe generation is optional and only runs when PyPI publishing is active +# - PUBLISH_DEVCONTAINER variable set to "true" (for devcontainer publishing) +# - .devcontainer/devcontainer.json file (for devcontainer publishing) +# +# โœ… To Trigger: +# Create and push a version tag: +# git tag v1.2.3 +# git push origin v1.2.3 +# +# ๐ŸŽฏ For repos without Python packages: +# The workflow gracefully skips Python-related steps if pyproject.toml doesn't exist. +# +# ๐ŸŽฏ For repos without devcontainers: +# The workflow gracefully skips devcontainer steps if PUBLISH_DEVCONTAINER is not +# set to "true" or .devcontainer directory doesn't exist. + + +name: (RHIZA) RELEASE + +on: + push: + tags: + - 'v*' + workflow_call: + inputs: + tag: + description: 'Version tag to release (e.g. v1.2.3). Required when called via workflow_call.' + required: true + type: string + secrets: + GH_PAT: + required: false + UV_EXTRA_INDEX_URL: + required: false + PYPI_TOKEN: + required: false + +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) + +jobs: + tag: + name: Validate Tag + runs-on: ubuntu-latest + outputs: + tag: ${{ steps.set_tag.outputs.tag }} + steps: + - name: Checkout Code + uses: actions/checkout@v6.0.2 + with: + fetch-depth: 0 + + - name: Set Tag Variable + id: set_tag + run: | + if [[ -n "${{ inputs.tag }}" ]]; then + TAG="${{ inputs.tag }}" + else + TAG="${GITHUB_REF#refs/tags/}" + fi + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + + - name: Validate Release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ steps.set_tag.outputs.tag }} + run: | + if gh release view "$TAG" >/dev/null 2>&1; then + DRAFT_STATUS=$(gh release view "$TAG" --json isDraft --jq '.isDraft') + if [ "$DRAFT_STATUS" != "true" ]; then + echo "::error::Release '$TAG' already exists and is not a draft. Please use a new tag." + exit 1 + else + echo "::warning::Release '$TAG' exists as a draft. Will proceed to update it." + fi + fi + + - name: Install uv + uses: astral-sh/setup-uv@v7.6.0 + + - name: Ensure release version is newer than latest published + env: + TAG: ${{ steps.set_tag.outputs.tag }} + run: | + # Backstop for issue #1126: refuse a tag that is not strictly newer than the + # highest version already released. A diverged branch with a stale pyproject.toml + # can otherwise publish an older version (e.g. v0.3.3 after v0.4.0). The bump + # tooling enforces the same rule; this guards manually-pushed tags too. + git tag -l 'v*' | uv run --with packaging --no-project python3 -c ' + import sys + from packaging.version import Version, InvalidVersion + + new = sys.argv[1].lstrip("v") + try: + new_v = Version(new) + except InvalidVersion: + print(f"::error::Tag {sys.argv[1]} is not a valid version") + sys.exit(1) + + latest = None + for line in sys.stdin: + tag = line.strip().lstrip("v") + if not tag: + continue + try: + candidate = Version(tag) + except InvalidVersion: + continue + if candidate == new_v: + continue + if latest is None or candidate > latest: + latest = candidate + + if latest is not None and new_v <= latest: + print( + f"::error::Refusing to release v{new_v}: it is not newer than the latest " + f"released version v{latest} (issue #1126). Sync with the default branch and bump again." + ) + sys.exit(1) + print(f"v{new_v} is newer than the latest released version v{latest}" if latest + else f"v{new_v} is the first release") + ' "$TAG" + + build: + name: Build + runs-on: ubuntu-latest + needs: tag + steps: + - name: Checkout Code + uses: actions/checkout@v6.0.2 + with: + fetch-depth: 0 + + - name: Install uv + uses: astral-sh/setup-uv@v7.6.0 + with: + version: "0.11.16" + + - name: Configure git auth for private packages + uses: jebel-quant/rhiza/.github/actions/configure-git-auth@v0.15.2 + with: + token: ${{ secrets.GH_PAT }} + + - name: Verify version matches tag + if: hashFiles('pyproject.toml') != '' + run: | + TAG_VERSION="${{ needs.tag.outputs.tag }}" + TAG_VERSION=${TAG_VERSION#v} + PROJECT_VERSION=$(uv version --short) + + # Normalize tag version to PEP 440 format for comparison. + # Tags use semver format (e.g., 0.11.1-beta.1) while uv version --short + # returns PEP 440 normalized format (e.g., 0.11.1b1). + NORMALIZED_TAG=$(uv run --with packaging --no-project python3 -c "from packaging.version import Version; print(Version('$TAG_VERSION'))") + + if [[ "$PROJECT_VERSION" != "$NORMALIZED_TAG" ]]; then + echo "::error::Version mismatch: pyproject.toml has '$PROJECT_VERSION' but tag is '$NORMALIZED_TAG' (from tag '$TAG_VERSION')" + exit 1 + fi + echo "Version verified: $PROJECT_VERSION matches tag (normalized: $NORMALIZED_TAG)" + + - name: Detect buildable Python package + id: buildable + run: | + if [[ -f pyproject.toml ]] && grep -q '^\[build-system\]' pyproject.toml; then + echo "buildable=true" >> "$GITHUB_OUTPUT" + else + echo "buildable=false" >> "$GITHUB_OUTPUT" + fi + + - name: Build + if: steps.buildable.outputs.buildable == 'true' + run: | + printf "[INFO] Building package...\n" + uv build + + - name: Install Python for SBOM generation + if: hashFiles('pyproject.toml') != '' + uses: actions/setup-python@v6.2.0 + with: + python-version-file: .python-version + + - name: Sync environment for SBOM generation + if: hashFiles('pyproject.toml') != '' + run: | + export UV_EXTRA_INDEX_URL="${{ secrets.UV_EXTRA_INDEX_URL }}" + uv sync --all-extras --all-groups --frozen + + - name: Generate SBOM (CycloneDX) + if: hashFiles('pyproject.toml') != '' + run: | + printf "[INFO] Generating SBOM in CycloneDX format...\n" + # Note: uvx caches the tool environment, so the second call is fast + uvx --from 'cyclonedx-bom>=7.0.0' cyclonedx-py environment --pyproject pyproject.toml --of JSON -o sbom.cdx.json + uvx --from 'cyclonedx-bom>=7.0.0' cyclonedx-py environment --pyproject pyproject.toml --of XML -o sbom.cdx.xml + printf "[INFO] SBOM generation complete\n" + printf "Generated files:\n" + ls -lh sbom.cdx.* + + - 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. + 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: Upload SBOM artifacts + if: hashFiles('pyproject.toml') != '' + uses: actions/upload-artifact@v7.0.1 + with: + name: sbom + path: | + sbom.cdx.json + sbom.cdx.xml + + - name: Generate SLSA provenance attestations + if: steps.buildable.outputs.buildable == 'true' && github.event.repository.private == false + uses: actions/attest-build-provenance@v4 + with: + subject-path: dist/* + + - name: Upload dist artifact + if: steps.buildable.outputs.buildable == 'true' + uses: actions/upload-artifact@v7.0.1 + with: + name: dist + path: dist + + # Don't try to upload artifacts to GitHub Release at all + draft-release: + name: Draft GitHub Release + runs-on: ubuntu-latest + needs: [tag, build] + + steps: + - name: Checkout Code + uses: actions/checkout@v6.0.2 + with: + fetch-depth: 0 + + - name: Install uv + uses: astral-sh/setup-uv@v7.6.0 + + - name: Generate release notes with git-cliff + run: uvx git-cliff --latest --output RELEASE_NOTES.md + + - name: Download SBOM artifact + # Downloads sbom.cdx.json and sbom.cdx.xml into sbom/ directory + uses: actions/download-artifact@v8.0.1 + with: + name: sbom + path: sbom + continue-on-error: true + + - name: Create GitHub Release with artifacts + uses: ncipollo/release-action@v1.21.0 + with: + tag: ${{ needs.tag.outputs.tag }} + name: ${{ needs.tag.outputs.tag }} + 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.2 + 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 }} + + # Decide at step-level whether to publish + pypi: + name: Publish to PyPI + runs-on: ubuntu-latest + environment: release + needs: [tag, build, draft-release] + outputs: + should_publish: ${{ steps.check_dist.outputs.should_publish }} + + steps: + - name: Checkout Code + uses: actions/checkout@v6.0.2 + with: + fetch-depth: 0 + + - name: Download dist artifact + uses: actions/download-artifact@v8.0.1 + with: + name: dist + path: dist + # Continue if dist folder is not found. Don't fail the job. + continue-on-error: true + + - name: Check if dist contains artifacts and not marked as private + id: check_dist + run: | + if [[ ! -d dist ]]; then + echo "::warning::No folder dist/. Skipping PyPI publish." + echo "should_publish=false" >> "$GITHUB_OUTPUT" + else + if grep -R "Private :: Do Not Upload" pyproject.toml; then + echo "should_publish=false" >> "$GITHUB_OUTPUT" + else + echo "should_publish=true" >> "$GITHUB_OUTPUT" + fi + fi + cat "$GITHUB_OUTPUT" + + # 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 + with: + packages-dir: dist/ + skip-existing: true + repository-url: ${{ vars.PYPI_REPOSITORY_URL }} + password: ${{ secrets.PYPI_TOKEN }} + + + conda: + name: Generate Conda Recipe + runs-on: ubuntu-latest + needs: [tag, pypi] + outputs: + should_generate: ${{ steps.check_conda.outputs.should_generate }} + + steps: + - name: Checkout Code + uses: actions/checkout@v6.0.2 + with: + fetch-depth: 0 + + - name: Check if conda recipe should be generated + id: check_conda + env: + PUBLISH_CONDA: ${{ vars.PUBLISH_CONDA }} + run: | + PUBLISH_CONDA="${PUBLISH_CONDA:-true}" + if [[ "$PUBLISH_CONDA" == "true" ]] && [[ "${{ needs.pypi.outputs.should_publish }}" == "true" ]] && [[ "${{ needs.pypi.result }}" == "success" ]]; then + echo "should_generate=true" >> "$GITHUB_OUTPUT" + else + echo "should_generate=false" >> "$GITHUB_OUTPUT" + echo "โญ๏ธ Skipping conda recipe generation (PUBLISH_CONDA not true, or PyPI publish disabled or failed)" + fi + + - name: Set up Python + if: steps.check_conda.outputs.should_generate == 'true' + uses: actions/setup-python@v6.2.0 + with: + python-version-file: .python-version + + - name: Install grayskull + if: steps.check_conda.outputs.should_generate == 'true' + run: python -m pip install --upgrade grayskull + + - name: Generate conda recipe with grayskull + if: steps.check_conda.outputs.should_generate == 'true' + run: | + PACKAGE_NAME=$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['name'])") + mkdir -p /tmp/conda-recipe + cd /tmp/conda-recipe + + grayskull pypi "$PACKAGE_NAME" --strict-conda-forge + + RECIPE_PATH=$(find . -type f -path "*/meta.yaml" | head -n 1) + if [[ -z "$RECIPE_PATH" ]]; then + echo "::error::grayskull did not produce a meta.yaml file" + exit 1 + fi + + mkdir -p "$GITHUB_WORKSPACE/conda-recipe" + cp "$RECIPE_PATH" "$GITHUB_WORKSPACE/conda-recipe/meta.yaml" + + - name: Upload conda recipe artifact + if: steps.check_conda.outputs.should_generate == 'true' + uses: actions/upload-artifact@v7.0.1 + with: + name: conda-recipe + path: conda-recipe/meta.yaml + + devcontainer: + name: Publish Devcontainer Image + runs-on: ubuntu-latest + environment: release + needs: [tag, build, draft-release] + outputs: + should_publish: ${{ steps.check_publish.outputs.should_publish }} + image_name: ${{ steps.image_name.outputs.image_name }} + steps: + - name: Checkout Code + uses: actions/checkout@v6.0.2 + with: + fetch-depth: 0 + + - name: Check if devcontainer should be published + id: check_publish + env: + PUBLISH_DEVCONTAINER: ${{ vars.PUBLISH_DEVCONTAINER }} + run: | + if [[ "$PUBLISH_DEVCONTAINER" == "true" ]] && [[ -d ".devcontainer" ]]; then + echo "should_publish=true" >> "$GITHUB_OUTPUT" + echo "๐Ÿš€ Will build and publish devcontainer image" + else + echo "should_publish=false" >> "$GITHUB_OUTPUT" + echo "โญ๏ธ Skipping devcontainer publish (PUBLISH_DEVCONTAINER not true or .devcontainer missing)" + fi + + - name: Set registry + if: steps.check_publish.outputs.should_publish == 'true' + id: registry + run: | + REGISTRY="${{ vars.DEVCONTAINER_REGISTRY }}" + if [ -z "$REGISTRY" ]; then + REGISTRY="ghcr.io" + fi + echo "registry=$REGISTRY" >> "$GITHUB_OUTPUT" + + - name: Login to Container Registry + if: steps.check_publish.outputs.should_publish == 'true' + uses: docker/login-action@v4.2.0 + with: + registry: ${{ steps.registry.outputs.registry }} + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Get lowercase repository owner + if: steps.check_publish.outputs.should_publish == 'true' + id: repo_owner + run: echo "owner_lc=$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT" + + - name: Get Image Name + if: steps.check_publish.outputs.should_publish == 'true' + id: image_name + run: | + # Check if custom name is provided, otherwise use default + if [ -z "${{ vars.DEVCONTAINER_IMAGE_NAME }}" ]; then + REPO_NAME_LC=$(echo "${{ github.event.repository.name }}" | tr '[:upper:]' '[:lower:]') + # Sanitize repo name: replace invalid characters with hyphens + # Docker image names must match [a-z0-9]+([._-][a-z0-9]+)* + # Replace leading dots and multiple consecutive separators + REPO_NAME_SANITIZED=$(echo "$REPO_NAME_LC" | sed 's/^[._-]*//; s/[._-][._-]*/-/g') + IMAGE_NAME="$REPO_NAME_SANITIZED/devcontainer" + echo "Using default image name component: $IMAGE_NAME" + else + IMAGE_NAME="${{ vars.DEVCONTAINER_IMAGE_NAME }}" + echo "Using custom image name component: $IMAGE_NAME" + fi + + # Validate the image component matches [a-z0-9]+([._-][a-z0-9]+)* with optional / separators + if ! echo "$IMAGE_NAME" | grep -qE '^[a-z0-9]+([._-][a-z0-9]+)*(/[a-z0-9]+([._-][a-z0-9]+)*)*$'; then + echo "::error::Invalid image name component: $IMAGE_NAME" + echo "::error::Each component must match [a-z0-9]+([._-][a-z0-9]+)* separated by /" + exit 1 + fi + + IMAGE_NAME="${{ steps.registry.outputs.registry }}/${{ steps.repo_owner.outputs.owner_lc }}/$IMAGE_NAME" + echo "โœ… Final image name: $IMAGE_NAME" + echo "image_name=$IMAGE_NAME" >> "$GITHUB_OUTPUT" + + - name: Build and Publish Devcontainer Image + if: steps.check_publish.outputs.should_publish == 'true' + uses: devcontainers/ci@v0.3 + with: + configFile: .devcontainer/devcontainer.json + push: always + imageName: ${{ steps.image_name.outputs.image_name }} + imageTag: ${{ needs.tag.outputs.tag }} + + finalise-release: + name: Finalise Release + runs-on: ubuntu-latest + needs: [tag, pypi, conda, devcontainer] + if: needs.pypi.result == 'success' || needs.conda.result == 'success' || needs.devcontainer.result == 'success' + steps: + - name: Checkout Code + uses: actions/checkout@v6.0.2 + with: + fetch-depth: 0 + + - name: Install uv + uses: astral-sh/setup-uv@v7.6.0 + with: + version: "0.11.16" + + - name: "Sync the virtual environment for ${{ github.repository }}" + shell: bash + run: | + export UV_EXTRA_INDEX_URL="${{ secrets.uv_extra_index_url }}" + # will just use .python-version? + uv sync --all-extras --all-groups --frozen + + - name: Set up Python + uses: actions/setup-python@v6.2.0 + + - name: Generate Devcontainer Link + id: devcontainer_link + if: needs.devcontainer.outputs.should_publish == 'true' && needs.devcontainer.result == 'success' + run: | + FULL_IMAGE="${{ needs.devcontainer.outputs.image_name }}:${{ needs.tag.outputs.tag }}" + { + echo "message<> "$GITHUB_OUTPUT" + + - name: Generate PyPI Link + id: pypi_link + if: needs.pypi.outputs.should_publish == 'true' && needs.pypi.result == 'success' + run: | + PACKAGE_NAME=$(uv run python -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['name'])") + VERSION="${{ needs.tag.outputs.tag }}" + VERSION=${VERSION#v} + + REPO_URL="${{ vars.PYPI_REPOSITORY_URL || '' }}" + if [ -z "$REPO_URL" ]; then + LINK="https://pypi.org/project/$PACKAGE_NAME/$VERSION/" + NAME="PyPI Package" + else + LINK="$REPO_URL" + NAME="Custom Feed Package" + fi + + { + echo "message<> "$GITHUB_OUTPUT" + + - name: Generate Conda Recipe Note + id: conda_link + if: needs.conda.outputs.should_generate == 'true' && needs.conda.result == 'success' + run: | + { + echo "message<> "$GITHUB_OUTPUT" + + - name: Publish Release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ needs.tag.outputs.tag }} + DEVCONTAINER_MSG: ${{ steps.devcontainer_link.outputs.message }} + PYPI_MSG: ${{ steps.pypi_link.outputs.message }} + CONDA_MSG: ${{ steps.conda_link.outputs.message }} + run: | + # Get existing auto-generated release notes (gracefully handle missing release) + EXISTING=$(gh release view "$TAG" --json body --jq '.body // ""' 2>/dev/null || echo "") + + # Append links to release body + { + printf '%s' "$EXISTING" + [ -n "$DEVCONTAINER_MSG" ] && printf '\n\n%s' "$DEVCONTAINER_MSG" + [ -n "$PYPI_MSG" ] && printf '\n\n%s' "$PYPI_MSG" + [ -n "$CONDA_MSG" ] && printf '\n\n%s' "$CONDA_MSG" + } > /tmp/notes.md + + gh release edit "$TAG" --draft=false --notes-file /tmp/notes.md diff --git a/.github/workflows/rhiza_sync.yml b/.github/workflows/rhiza_sync.yml new file mode 100644 index 0000000..5002724 --- /dev/null +++ b/.github/workflows/rhiza_sync.yml @@ -0,0 +1,43 @@ +# This file is part of the jebel-quant/rhiza repository +# (https://github.com/jebel-quant/rhiza). +# +# Workflow: Sync +# +# Purpose: Synchronizes the repository with its upstream rhiza template. +# On Renovate/rhiza branch push: auto-commits synced files directly +# to the branch. On schedule/dispatch: opens a pull request. +# +# IMPORTANT: A PAT with 'workflow' scope (PAT_TOKEN) is required when workflow +# files are modified. See .github/CONFIG.md for setup instructions. +# +# Trigger: On Renovate/rhiza branch push, weekly schedule, and manual dispatch. + +name: "(RHIZA) SYNC" + +permissions: + contents: write + pull-requests: write + +on: + push: + branches: + - 'renovate/jebel-quant-rhiza-**' + - 'rhiza/**' + paths: + - '.rhiza/template.yml' + schedule: + - cron: '0 0 * * 1' # Weekly on Monday + workflow_dispatch: + inputs: + create-pr: + description: "Create a pull request" + type: boolean + default: true + +jobs: + sync: + uses: jebel-quant/rhiza/.github/workflows/rhiza_sync.yml@v0.18.4 + with: + direct: ${{ github.event_name == 'push' }} + create-pr: ${{ github.event_name != 'push' && (github.event_name == 'schedule' || inputs.create-pr == true) }} + secrets: inherit diff --git a/.github/workflows/rhiza_weekly.yml b/.github/workflows/rhiza_weekly.yml new file mode 100644 index 0000000..0d2f65f --- /dev/null +++ b/.github/workflows/rhiza_weekly.yml @@ -0,0 +1,32 @@ +# This file is part of the jebel-quant/rhiza repository +# (https://github.com/jebel-quant/rhiza). +# +# Workflow: Weekly +# +# Purpose: Runs checks that are too slow or noisy for every push: +# dep-compat-test โ€” resolves dependencies fresh (ignoring the lockfile) and +# runs the full test suite to catch newly-released packages +# that break compatibility before Renovate picks them up. +# link-check โ€” verifies all hyperlinks in README.md are reachable. +# +# Trigger: Weekly schedule (Monday 08:00 UTC) and manual dispatch. + +name: "(RHIZA) WEEKLY" + +permissions: + contents: read + +on: + #push: + # branches: [main] + # paths: [README.md] + #pull_request: + # paths: [README.md] + schedule: + - cron: "0 8 * * 1" # Every Monday at 08:00 UTC + workflow_dispatch: + +jobs: + weekly: + uses: jebel-quant/rhiza/.github/workflows/rhiza_weekly.yml@v0.18.4 + secrets: inherit diff --git a/.gitignore b/.gitignore index 2c77f55..4cbaa80 100644 --- a/.gitignore +++ b/.gitignore @@ -1,21 +1,61 @@ -# Python +### Python template +.idea +.venv +.ruff_cache +.ty_cache + +# HTML outputs from docstring examples (e.g. report.save("output/...")) +output/ + +### Don't expose API keys, etc. +.env + +__marimo__ + +_tests +_book +_pdoc +docs/notebooks/*.html +docs/reports +docs/reports.md +_marimushka +_mkdocs +_benchmarks +_jupyter +_site + +# LaTeX build artifacts +docs/paper/*.aux +docs/paper/*.fdb_latexmk +docs/paper/*.fls +docs/paper/*.log +docs/paper/*.out +docs/paper/*.toc + +# temp file used by Junie +.output.txt + +# folder used for programs, e.g. uv, uvx, task, etc. +bin + +# Byte-compiled / optimized / DLL files __pycache__/ -**/__pycache__/ *.py[cod] *$py.class + +# Generated presentation files +presentation.html +presentation.pdf +*.pptx + +# C extensions *.so -.Python -# Virtual environments -venv/ -.venv/ -env/ -.env/ -ENV/ -env.bak/ -venv.bak/ +# .DS_Store +.DS_Store # Distribution / packaging +.Python build/ develop-eggs/ dist/ @@ -28,16 +68,17 @@ parts/ sdist/ var/ wheels/ +share/python-wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST -# PyInstaller -*.manifest -*.spec +# Installer logs +pip-log.txt +pip-delete-this-directory.txt -# Unit test / coverage +# Unit test / coverage reports htmlcov/ .tox/ .nox/ @@ -46,64 +87,36 @@ htmlcov/ .cache nosetests.xml coverage.xml +coverage.json *.cover +*.py,cover .hypothesis/ +.benchmarks/ .pytest_cache/ +cover/ -# Logs -logs/ -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -dev-debug.log - -# Environment variables -.env -.env.local -.env.*.local - -# Editor directories and files -.idea/ -.vscode/ -*.suo -*.ntvs* -*.njsproj -*.sln -*.sw? -*.swp -*~ - -# OS specific -.DS_Store -Thumbs.db - -# Project specific -data/ -!src/pycharting/data/ -*.csv -*.parquet +# Security scanning baselines (regenerate as needed) +.bandit-baseline.json -# Ignore Taskmaster and cursor folders, and their contents, anywhere in the project -.taskmaster/ -taskmaster/ -cursor/ -.cursor/ - -# Do NOT ignore .cursor and .taskmaster if they are inside node_modules -!node_modules/.cursor/ -!node_modules/.taskmaster/ +# Translations +*.mo +*.pot +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal -# Dependency directories -node_modules/ +# Flask stuff: +instance/ +.webassets-cache -# Jupyter Notebook -.ipynb_checkpoints +# Cython debug symbols +cython_debug/ -# Poetry -poetry.lock +# Makefile +local.mk +.bandit-baseline.json -# Exclude everything in folder demo and its content -/demos/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..56689c7 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,89 @@ +# This file is part of the jebel-quant/rhiza repository +# (https://github.com/jebel-quant/rhiza). +# +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 + hooks: + - id: check-toml + - id: check-yaml + args: ['--unsafe'] + exclude: ^recipe/meta\.yaml$ + + - repo: local + hooks: + - id: no-python-cache-files + name: Prevent committing Python cache files + language: fail + entry: Python cache files (__pycache__, .pyc, .pyo, .pyd) must not be committed. Run 'make clean' to remove them. + files: '(/__pycache__/|\.py[cod]$)' + + - id: no-rej-files + name: Reject .rej files + entry: "bash -c 'find . -type f -name \"*.rej\" | grep -q . && { echo \"ERROR: .rej files detected\"; find . -type f -name \"*.rej\"; exit 1; } || exit 0'" + language: system + pass_filenames: false + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: 'v0.15.14' + hooks: + - id: ruff + args: [ --fix, --exit-non-zero-on-fix, --unsafe-fixes ] + + # Run the formatter + - id: ruff-format + + - repo: https://github.com/igorshubovych/markdownlint-cli + rev: v0.48.0 + hooks: + - id: markdownlint + args: ["--disable", "MD013"] + + - repo: https://github.com/python-jsonschema/check-jsonschema + rev: 0.37.2 + hooks: + - id: check-renovate + args: [ "--verbose" ] + + - id: check-github-workflows + args: ["--verbose"] + + - repo: https://github.com/rhysd/actionlint + rev: v1.7.12 + hooks: + - id: actionlint + + - repo: https://github.com/abravalheri/validate-pyproject + rev: v0.25 + hooks: + - id: validate-pyproject + + - repo: https://github.com/PyCQA/bandit + rev: 1.9.4 + hooks: + - id: bandit + args: ["--ini", ".bandit", "--exclude", ".venv,tests,.rhiza/tests,.git,.pytest_cache"] + + - repo: https://github.com/astral-sh/uv-pre-commit + rev: 0.11.16 + hooks: + - id: uv-lock + + - repo: https://github.com/econchick/interrogate + rev: 1.7.0 + hooks: + - id: interrogate + args: [--config=pyproject.toml] + files: ^src/ + + - repo: https://github.com/Jebel-Quant/rhiza-hooks + rev: v0.4.0 # Use the latest release + hooks: + # Migrated from rhiza + - id: check-rhiza-workflow-names + - id: update-readme-help + # Additional utility hooks + - id: check-rhiza-config + - id: check-makefile-targets + - id: check-python-version-consistency + # - id: check-template-bundles diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..09835a0 --- /dev/null +++ b/.python-version @@ -0,0 +1,2 @@ + +3.12 diff --git a/.rhiza/.cfg.toml b/.rhiza/.cfg.toml new file mode 100644 index 0000000..c96b1dd --- /dev/null +++ b/.rhiza/.cfg.toml @@ -0,0 +1,34 @@ +[tool.bumpversion] +parse = "(?P\\d+)\\.(?P\\d+)\\.(?P\\d+)(?:[-]?(?P[a-z]+)[\\.]?(?P\\d+))?(?:\\+build\\.(?P\\d+))?" +serialize = ["{major}.{minor}.{patch}-{release}.{pre_n}+build.{build_n}", "{major}.{minor}.{patch}+build.{build_n}", "{major}.{minor}.{patch}-{release}.{pre_n}", "{major}.{minor}.{patch}"] +search = "{current_version}" +replace = "{new_version}" +regex = false +ignore_missing_version = false +ignore_missing_files = false +tag = true +sign_tags = false +tag_name = "v{new_version}" +tag_message = "Bump version: {current_version} โ†’ {new_version}" +allow_dirty = false +commit = true +message = "Chore: bump version {current_version} โ†’ {new_version}" +commit_args = "" +pre_commit_hooks = ["uv sync", "git add uv.lock"] # Ensure uv.lock is updated + +[tool.bumpversion.parts.release] +optional_value = "prod" +values = [ + "dev", + "alpha", + "a", # PEP 440 short form for alpha + "beta", + "b", # PEP 440 short form for beta + "rc", + "prod" +] + +[[tool.bumpversion.files]] +filename = "pyproject.toml" +search = 'version = "{current_version}"' +replace = 'version = "{new_version}"' diff --git a/.rhiza/.env b/.rhiza/.env new file mode 100644 index 0000000..0fd6a6a --- /dev/null +++ b/.rhiza/.env @@ -0,0 +1,3 @@ +MARIMO_FOLDER=docs/notebooks +SOURCE_FOLDER=src +RHIZA_CI_OS_MATRIX=["ubuntu-latest","macos-latest","windows-latest"] diff --git a/.rhiza/.gitignore b/.rhiza/.gitignore new file mode 100644 index 0000000..f0ea93e --- /dev/null +++ b/.rhiza/.gitignore @@ -0,0 +1,2 @@ +# Dedicated rhiza ignore rules +!.env \ No newline at end of file diff --git a/.rhiza/.rhiza-version b/.rhiza/.rhiza-version new file mode 100644 index 0000000..2a0970c --- /dev/null +++ b/.rhiza/.rhiza-version @@ -0,0 +1 @@ +0.16.1 diff --git a/.rhiza/assets/rhiza-logo.svg b/.rhiza/assets/rhiza-logo.svg new file mode 100644 index 0000000..ff1c9f5 --- /dev/null +++ b/.rhiza/assets/rhiza-logo.svg @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.rhiza/completions/README.md b/.rhiza/completions/README.md new file mode 100644 index 0000000..b37cb15 --- /dev/null +++ b/.rhiza/completions/README.md @@ -0,0 +1,263 @@ +# Shell Completion for Rhiza Make Targets + +This directory contains shell completion scripts for Bash and Zsh that provide tab-completion for make targets in Rhiza-based projects. + +## Features + +- โœ… Tab-complete all available make targets +- โœ… Show target descriptions in Zsh +- โœ… Complete common make variables (DRY_RUN, BUMP, ENV, etc.) +- โœ… Works with any Rhiza-based project +- โœ… Auto-discovers targets from Makefile and included .mk files + +## Installation + +### Bash + +#### Method 1: Source in your shell config + +Add to your `~/.bashrc` or `~/.bash_profile`: + +```bash +# Rhiza make completion +if [ -f /path/to/project/.rhiza/completions/rhiza-completion.bash ]; then + source /path/to/project/.rhiza/completions/rhiza-completion.bash +fi +``` + +Replace `/path/to/project` with the actual path to your Rhiza project. + +#### Method 2: System-wide installation + +```bash +# Copy to bash completion directory +sudo cp .rhiza/completions/rhiza-completion.bash /etc/bash_completion.d/rhiza + +# Reload completions +source /etc/bash_completion.d/rhiza +``` + +#### Method 3: User-local installation + +```bash +# Create local completion directory +mkdir -p ~/.local/share/bash-completion/completions + +# Copy completion script +cp .rhiza/completions/rhiza-completion.bash ~/.local/share/bash-completion/completions/make + +# Reload bash +source ~/.bashrc +``` + +### Zsh + +#### Method 1: User-local installation (Recommended) + +```bash +# Create completion directory +mkdir -p ~/.zsh/completion + +# Copy completion script +cp .rhiza/completions/rhiza-completion.zsh ~/.zsh/completion/_make + +# Add to ~/.zshrc (if not already present) +echo 'fpath=(~/.zsh/completion $fpath)' >> ~/.zshrc +echo 'autoload -U compinit && compinit' >> ~/.zshrc + +# Reload zsh +source ~/.zshrc +``` + +#### Method 2: Source directly + +Add to your `~/.zshrc`: + +```zsh +# Rhiza make completion +if [ -f /path/to/project/.rhiza/completions/rhiza-completion.zsh ]; then + source /path/to/project/.rhiza/completions/rhiza-completion.zsh +fi +``` + +#### Method 3: System-wide installation + +```bash +# Copy to system completion directory +sudo cp .rhiza/completions/rhiza-completion.zsh /usr/local/share/zsh/site-functions/_make + +# Reload zsh +exec zsh +``` + +## Usage + +Once installed, you can tab-complete make targets: + +```bash +# Tab-complete targets +make + +# Complete with prefix +make te # Expands to: make test + +# Complete variables +make BUMP= # Shows: patch, minor, major + +# Works with any target +make doc # Shows: docs, docker-build, docker-run, etc. +``` + +### Zsh Benefits + +In Zsh, you'll also see descriptions for targets: + +```bash +make +# Shows: +# test -- run all tests +# fmt -- check the pre-commit hooks and the linting +# install -- install +# book -- build documentation site via zensical +# ... +``` + +## Common Variables + +The completion scripts understand these common variables: + +| Variable | Values | Description | +|----------|--------|-------------| +| `DRY_RUN` | `1` | Preview mode without making changes | +| `BUMP` | `patch`, `minor`, `major` | Version bump type | +| `ENV` | `dev`, `staging`, `prod` | Target environment | +| `COVERAGE_FAIL_UNDER` | (number) | Minimum coverage threshold | +| `PYTHON_VERSION` | (version) | Override Python version | + +Example usage: + +```bash +# Tab-complete after typing DRY_ +make DRY_ # Expands to: make DRY_RUN=1 + +# Tab-complete variable values +make BUMP= # Shows: patch minor major + +# Combine with targets +make bump BUMP= +``` + +## Troubleshooting + +### Bash: Completions not working + +1. Check if bash-completion is installed: + ```bash + # Debian/Ubuntu + sudo apt-get install bash-completion + + # macOS + brew install bash-completion@2 + ``` + +2. Ensure completion is enabled in your shell: + ```bash + # Add to ~/.bashrc if not present + if [ -f /etc/bash_completion ]; then + . /etc/bash_completion + fi + ``` + +3. Reload your shell configuration: + ```bash + source ~/.bashrc + ``` + +### Zsh: Completions not working + +1. Check if compinit is called in your `~/.zshrc`: + ```zsh + autoload -U compinit && compinit + ``` + +2. Clear the completion cache: + ```bash + rm -f ~/.zcompdump + compinit + ``` + +3. Ensure the script is in your fpath: + ```zsh + echo $fpath + ``` + +4. Reload your shell configuration: + ```zsh + source ~/.zshrc + ``` + +### No targets appearing + +1. Ensure you're in a directory with a Makefile: + ```bash + ls -la Makefile + ``` + +2. Test that make can parse the Makefile: + ```bash + make -qp 2>/dev/null | head + ``` + +3. Manually source the completion script to test: + ```bash + # Bash + source .rhiza/completions/rhiza-completion.bash + + # Zsh + source .rhiza/completions/rhiza-completion.zsh + ``` + +## Optional Aliases + +You can add shortcuts in your shell config: + +```bash +# Add to ~/.bashrc or ~/.zshrc +alias m='make' + +# For bash: +complete -F _rhiza_make_completion m + +# For zsh: +compdef _rhiza_make m +``` + +Then use: +```bash +m te # Expands to: m test +``` + +## Technical Details + +### How it works + +1. **Target Discovery**: Parses `make -qp` output to find all targets +2. **Description Extraction**: Looks for `##` comments after target names +3. **Variable Detection**: Includes common Makefile variables +4. **Dynamic Completion**: Regenerates list each time you tab + +### 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 + +## 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 new file mode 100644 index 0000000..0f860da --- /dev/null +++ b/.rhiza/completions/rhiza-completion.bash @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# Bash completion for Rhiza make targets +# +# Installation: +# Source this file in your ~/.bashrc or ~/.bash_profile: +# source /path/to/.rhiza/completions/rhiza-completion.bash +# +# Or copy to bash completion directory: +# sudo cp .rhiza/completions/rhiza-completion.bash /etc/bash_completion.d/rhiza +# + +_rhiza_make_completion() { + local cur prev opts + COMPREPLY=() + cur="${COMP_WORDS[COMP_CWORD]}" + prev="${COMP_WORDS[COMP_CWORD-1]}" + + # Check if we're in a directory with a Makefile + if [[ ! -f "Makefile" ]]; then + return 0 + fi + + # 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) + + # 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" + opts="$opts $vars" + + # Generate completions + COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) ) + return 0 +} + +# Register the completion function for make command +complete -F _rhiza_make_completion make + +# Also complete for direct make invocation with path +complete -F _rhiza_make_completion ./Makefile + +# Helpful aliases (optional - uncomment if desired) +# alias m='make' +# complete -F _rhiza_make_completion m diff --git a/.rhiza/completions/rhiza-completion.zsh b/.rhiza/completions/rhiza-completion.zsh new file mode 100644 index 0000000..03931c9 --- /dev/null +++ b/.rhiza/completions/rhiza-completion.zsh @@ -0,0 +1,88 @@ +#compdef make +# Zsh completion for Rhiza make targets +# +# Installation: +# Add this file to your fpath and ensure compinit is called: +# +# Method 1 (User-local): +# mkdir -p ~/.zsh/completion +# cp .rhiza/completions/rhiza-completion.zsh ~/.zsh/completion/_make +# Add to ~/.zshrc: +# fpath=(~/.zsh/completion $fpath) +# autoload -U compinit && compinit +# +# Method 2 (Source directly): +# Add to ~/.zshrc: +# source /path/to/.rhiza/completions/rhiza-completion.zsh +# +# Method 3 (System-wide): +# sudo cp .rhiza/completions/rhiza-completion.zsh /usr/local/share/zsh/site-functions/_make +# + +_rhiza_make() { + local -a targets variables + + # 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)"$( + make -qp 2>/dev/null | \ + awk -F':' ' + /^# Files/,/^# Finished Make data base/ { + if (/^[a-zA-Z0-9_-]+:.*##/) { + target=$1 + desc=$0 + sub(/^[^#]*## */, "", desc) + gsub(/^[ \t]+/, "", target) + print target ":" desc + } + } + ' | \ + grep -v '^Makefile:' | \ + sort -u + )"}) + + # Also get targets without descriptions + local -a plain_targets + 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 + )"}) + + # Common make variables + variables=( + 'DRY_RUN=1:preview mode without making changes' + 'BUMP=patch:bump patch version' + 'BUMP=minor:bump minor version' + 'BUMP=major:bump major version' + 'ENV=dev:development environment' + 'ENV=staging:staging environment' + 'ENV=prod:production environment' + 'COVERAGE_FAIL_UNDER=:minimum coverage threshold' + 'PYTHON_VERSION=:override Python version' + ) + + # Combine all completions + local -a all_completions + all_completions=($targets $plain_targets $variables) + + # Show completions with descriptions + _describe 'make targets' all_completions +} + +# Register the completion function +compdef _rhiza_make make + +# Optional: Add completion for common aliases +# Uncomment these if you use these aliases +# alias m='make' +# compdef _rhiza_make m diff --git a/.rhiza/make.d/book.mk b/.rhiza/make.d/book.mk new file mode 100644 index 0000000..6323b21 --- /dev/null +++ b/.rhiza/make.d/book.mk @@ -0,0 +1,58 @@ +## book.mk - Book-building targets (MkDocs-based) + +ROOT := $(shell git rev-parse --show-toplevel) + +.PHONY: book serve test benchmark stress hypothesis-test _book-reports _book-notebooks + +# No-op stubs โ€” overridden by test.mk / bench.mk when present +test:: ; @: +benchmark:: ; @: +stress:: ; @: +hypothesis-test:: ; @: + +BOOK_OUTPUT ?= _book + +MKDOCS_EXTRA_PACKAGES ?= +ZENSICAL_VERSION ?= >=0.0.36 + +##@ Book + +_book-reports: test benchmark stress hypothesis-test + @if [ -d "${ROOT}/_tests" ] && [ -n "$$(ls -A "${ROOT}/_tests" 2>/dev/null)" ]; then \ + printf "${BLUE}[INFO] Copying ${ROOT}/_tests -> docs/reports${RESET}\n"; \ + mkdir -p ${ROOT}/docs/reports; cp -r "${ROOT}/_tests/." "${ROOT}/docs/reports/"; \ + else \ + printf "${YELLOW}[WARN] ${ROOT}/_tests not found or empty, skipping${RESET}\n"; \ + fi + +# Export each Marimo notebook to a self-contained HTML file under docs/notebooks/. +# Skipped silently when MARIMO_FOLDER is not set or does not exist. +_book-notebooks: + @if [ -d "$(MARIMO_FOLDER)" ]; then \ + printf "${BLUE}[INFO] Exporting Marimo notebooks from $(MARIMO_FOLDER)${RESET}\n"; \ + for nb in $(MARIMO_FOLDER)/*.py; do \ + name=$$(basename "$$nb" .py); \ + printf "${BLUE}[INFO] Exporting $$nb -> ${ROOT}/docs/notebooks/$$name.html${RESET}\n"; \ + abs_output="${ROOT}/docs/notebooks/$$name.html"; \ + (cd "$$(dirname "$$nb")" && ${UV_BIN} run marimo export html --sandbox "$$(basename "$$nb")" -o "$$abs_output"); \ + done; \ + else \ + printf "${YELLOW}[WARN] MARIMO_FOLDER not set or missing, skipping notebook export${RESET}\n"; \ + fi + +# Serve the built book locally on port 8000. +# Uses Python's built-in HTTP server so the JetBrains built-in server (which +# refuses to serve gitignored directories like _book) is not needed. +serve: book ## build and serve the book at http://localhost:8000 + @printf "${BLUE}[INFO] Serving book at http://localhost:8000 (Ctrl-C to stop)${RESET}\n" + @cd $(BOOK_OUTPUT) && ${UV_BIN} run python -m http.server 8000 + +book:: _book-reports _book-notebooks ## compile the companion book via MkDocs + @rm -rf "$(BOOK_OUTPUT)" + @${UVX_BIN} $(MKDOCS_EXTRA_PACKAGES) 'zensical$(ZENSICAL_VERSION)' build -f "$(ROOT)/mkdocs.yml" + @mkdir -p "$(BOOK_OUTPUT)" && touch "$(BOOK_OUTPUT)/.nojekyll" + @if [ -f "${ROOT}/_tests/coverage.xml" ]; then \ + printf "${BLUE}[INFO] Generating coverage badge${RESET}\n"; \ + ${UVX_BIN} "genbadge[coverage]" coverage -i "${ROOT}/_tests/coverage.xml" -o "$(BOOK_OUTPUT)/coverage-badge.svg"; \ + fi + @printf "${GREEN}[SUCCESS] Book built at $(BOOK_OUTPUT)/${RESET}\n" diff --git a/.rhiza/make.d/bootstrap.mk b/.rhiza/make.d/bootstrap.mk new file mode 100644 index 0000000..2852697 --- /dev/null +++ b/.rhiza/make.d/bootstrap.mk @@ -0,0 +1,109 @@ +## .rhiza/make.d/bootstrap.mk - Bootstrap and Installation +# This file provides targets for setting up the development environment, +# installing dependencies, and cleaning project artifacts. + +# Declare phony targets (they don't produce files) +.PHONY: install-uv install clean pre-install post-install + +UV_SYNC_ARGS ?= --all-extras --all-groups + +# Hook targets (double-colon rules allow multiple definitions) +pre-install:: ; @: +post-install:: ; @: + +##@ Bootstrap +install-uv: ## ensure uv/uvx is installed + # Ensure the ${INSTALL_DIR} folder exists + @mkdir -p ${INSTALL_DIR} + + # Install uv/uvx only if they are not already present in PATH or in the install dir + @if command -v uv >/dev/null 2>&1 && command -v uvx >/dev/null 2>&1; then \ + :; \ + elif [ -x "${INSTALL_DIR}/uv" ] && [ -x "${INSTALL_DIR}/uvx" ]; then \ + printf "${BLUE}[INFO] uv and uvx already installed in ${INSTALL_DIR}, skipping.${RESET}\n"; \ + else \ + printf "${BLUE}[INFO] Installing uv and uvx into ${INSTALL_DIR}...${RESET}\n"; \ + if ! curl -LsSf https://astral.sh/uv/install.sh | UV_INSTALL_DIR="${INSTALL_DIR}" sh >/dev/null 2>&1; then \ + printf "${RED}[ERROR] Failed to install uv${RESET}\n"; \ + exit 1; \ + fi; \ + fi + +install: pre-install install-uv ## install + # Create the virtual environment only if it doesn't exist + @if [ ! -d "${VENV}" ]; then \ + ${UV_BIN} venv $(if $(PYTHON_VERSION),--python $(PYTHON_VERSION)) ${VENV} || { printf "${RED}[ERROR] Failed to create virtual environment${RESET}\n"; exit 1; }; \ + else \ + printf "${BLUE}[INFO] Using existing virtual environment at ${VENV}, skipping creation${RESET}\n"; \ + fi + + # Install the dependencies from pyproject.toml (if it exists) + @if [ -f "pyproject.toml" ]; then \ + if [ -f "uv.lock" ]; then \ + if ! ${UV_BIN} lock --check >/dev/null 2>&1; then \ + printf "${YELLOW}[WARN] uv.lock is out of sync with pyproject.toml${RESET}\n"; \ + printf "${YELLOW} Run 'uv sync' to update your lock file and environment${RESET}\n"; \ + printf "${YELLOW} Or run 'uv lock' to update only the lock file${RESET}\n"; \ + exit 1; \ + fi; \ + printf "${BLUE}[INFO] Installing dependencies from lock file${RESET}\n"; \ + ${UV_BIN} sync $(UV_SYNC_ARGS) --frozen || { printf "${RED}[ERROR] Failed to install dependencies${RESET}\n"; exit 1; }; \ + else \ + printf "${YELLOW}[WARN] uv.lock not found. Generating lock file and installing dependencies...${RESET}\n"; \ + ${UV_BIN} sync $(UV_SYNC_ARGS) || { printf "${RED}[ERROR] Failed to install dependencies${RESET}\n"; exit 1; }; \ + fi; \ + else \ + printf "${YELLOW}[WARN] No pyproject.toml found, skipping install${RESET}\n"; \ + fi + + # Install dev dependencies from .rhiza/requirements/*.txt files + @if [ -d ".rhiza/requirements" ] && ls .rhiza/requirements/*.txt >/dev/null 2>&1; then \ + for req_file in .rhiza/requirements/*.txt; do \ + if [ -f "$$req_file" ]; then \ + printf "${BLUE}[INFO] Installing requirements from $$req_file${RESET}\n"; \ + ${UV_BIN} pip install -r "$$req_file" || { printf "${RED}[ERROR] Failed to install requirements from $$req_file${RESET}\n"; exit 1; }; \ + fi; \ + done; \ + fi + + # Check if there is requirements.txt file in the tests folder (legacy support) + @if [ -f "tests/requirements.txt" ]; then \ + printf "${BLUE}[INFO] Installing requirements from tests/requirements.txt${RESET}\n"; \ + ${UV_BIN} pip install -r tests/requirements.txt || { printf "${RED}[ERROR] Failed to install test requirements${RESET}\n"; exit 1; }; \ + fi + + # Install pre-commit hooks + @if [ -f ".pre-commit-config.yaml" ]; then \ + printf "${BLUE}[INFO] Installing pre-commit hooks...${RESET}\n"; \ + ${UVX_BIN} -p ${PYTHON_VERSION} pre-commit install || { printf "${YELLOW}[WARN] Failed to install pre-commit hooks${RESET}\n"; }; \ + fi + + @$(MAKE) post-install + + # Display success message with activation instructions + @printf "\n${GREEN}[SUCCESS] Installation complete!${RESET}\n\n" + @printf "${BLUE}To activate the virtual environment, run:${RESET}\n" + @printf "${YELLOW} source ${VENV}/bin/activate${RESET}\n\n" + +clean: ## Clean project artifacts and stale local branches + @printf "%bCleaning project...%b\n" "$(BLUE)" "$(RESET)" + + # Remove ignored files/directories, but keep .env files, tested with futures project + @git clean -d -X -f \ + -e '!.env' \ + -e '!.env.*' + + # Remove build & test artifacts + @rm -rf \ + dist \ + build \ + *.egg-info \ + .coverage \ + .pytest_cache \ + .benchmarks + + @printf "%bRemoving local branches with no remote counterpart...%b\n" "$(BLUE)" "$(RESET)" + + @git fetch --prune + + @git branch -vv | awk '/: gone]/{print $$1}' | xargs -r git branch -D diff --git a/.rhiza/make.d/custom-env.mk b/.rhiza/make.d/custom-env.mk new file mode 100644 index 0000000..f5ec964 --- /dev/null +++ b/.rhiza/make.d/custom-env.mk @@ -0,0 +1,9 @@ +## .rhiza/make.d/custom-env.mk - Custom Environment Configuration +# This file example shows how to set variables for the project. + +# Custom variables for this repository +PROJECT_NAME_EXTRA := Rhiza Platform +LOG_LEVEL ?= INFO + +# Overriding core variables (be careful) +# VENV := .venv_custom diff --git a/.rhiza/make.d/custom-task.mk b/.rhiza/make.d/custom-task.mk new file mode 100644 index 0000000..086a8e7 --- /dev/null +++ b/.rhiza/make.d/custom-task.mk @@ -0,0 +1,12 @@ +## .rhiza/make.d/custom-task.mk - Custom Repository Tasks +# This file example shows how to add new targets. + +.PHONY: hello-rhiza + +##@ Custom Tasks +hello-rhiza: ## a custom greeting task + @printf "${GREEN}[INFO] Hello from the customised Rhiza project!${RESET}\n" + +# Adding logic to existing hooks +post-install:: ## run custom logic after core install + @printf "${BLUE}[INFO] Running custom post-install steps...${RESET}\n" diff --git a/.rhiza/make.d/doctor.mk b/.rhiza/make.d/doctor.mk new file mode 100644 index 0000000..398dca4 --- /dev/null +++ b/.rhiza/make.d/doctor.mk @@ -0,0 +1,60 @@ +## .rhiza/make.d/doctor.mk - Developer prerequisite diagnostics + +.PHONY: doctor + +##@ Dev +doctor: ## verify local prerequisites and print actionable guidance + @failed=0; \ + version_ge() { \ + awk -v a="$$1" -v b="$$2" 'BEGIN { \ + split(a, A, /[^0-9]+/); \ + split(b, B, /[^0-9]+/); \ + for (i = 1; i <= 3; i++) { \ + ai = A[i] + 0; \ + bi = B[i] + 0; \ + if (ai > bi) exit 0; \ + if (ai < bi) exit 1; \ + } \ + exit 0; \ + }'; \ + }; \ + check_tool() { \ + tool="$$1"; min="$$2"; install_url="$$3"; version_cmd="$$4"; gnu_required="$$5"; \ + if ! command -v "$$tool" >/dev/null 2>&1; then \ + printf "${RED}[โŒ]${RESET} %-9s missing โ€” install: %s\n" "$$tool" "$$install_url"; \ + failed=1; \ + return; \ + fi; \ + version="$$(eval "$$version_cmd" 2>/dev/null)"; \ + if [ -z "$$version" ]; then \ + printf "${RED}[โŒ]${RESET} %-9s unknown version (required โ‰ฅ %s)\n" "$$tool" "$$min"; \ + failed=1; \ + return; \ + fi; \ + extra=""; \ + if [ "$$gnu_required" = "gnu" ] && ! make --version 2>/dev/null | grep -q '^GNU Make'; then \ + extra=" (GNU required)"; \ + printf "${RED}[โŒ]${RESET} %-9s %-8s < %s%s\n" "$$tool" "$$version" "$$min" "$$extra"; \ + failed=1; \ + return; \ + fi; \ + if version_ge "$$version" "$$min"; then \ + if [ "$$gnu_required" = "gnu" ]; then \ + extra=" (GNU required)"; \ + fi; \ + printf "${GREEN}[โœ…]${RESET} %-9s %-8s โ‰ฅ %s%s\n" "$$tool" "$$version" "$$min" "$$extra"; \ + else \ + if [ "$$gnu_required" = "gnu" ]; then \ + extra=" (GNU required)"; \ + fi; \ + printf "${RED}[โŒ]${RESET} %-9s %-8s < %s%s\n" "$$tool" "$$version" "$$min" "$$extra"; \ + failed=1; \ + fi; \ + }; \ + check_tool "uv" "0.4.0" "https://docs.astral.sh/uv/getting-started/installation/" "uv --version | awk 'NR==1 {print \$$2}'" ""; \ + check_tool "make" "3.8.0" "https://www.gnu.org/software/make/" "make --version | awk 'NR==1 {for (i=1; i<=NF; i++) if (\$$i ~ /^[0-9]+(\\.[0-9]+)+$$/) {print \$$i; exit}}'" "gnu"; \ + check_tool "git" "2.0.0" "https://git-scm.com" "git --version | awk 'NR==1 {print \$$3}'" ""; \ + if [ "$$failed" -ne 0 ]; then \ + printf "\n${YELLOW}[WARN] One or more prerequisites are missing or below minimum version.${RESET}\n"; \ + exit 1; \ + fi diff --git a/.rhiza/make.d/marimo.mk b/.rhiza/make.d/marimo.mk new file mode 100644 index 0000000..36ae74c --- /dev/null +++ b/.rhiza/make.d/marimo.mk @@ -0,0 +1,42 @@ +## Makefile.marimo - Marimo notebook targets +# This file is included by the main Makefile + +# Declare phony targets (they don't produce files) +.PHONY: marimo-validate marimo + +##@ Marimo Notebooks +marimo-validate: install ## validate all Marimo notebooks can run + @printf "${BLUE}[INFO] Validating all notebooks in ${MARIMO_FOLDER}...${RESET}\n" + @if [ ! -d "${MARIMO_FOLDER}" ]; then \ + printf "${YELLOW}[WARN] Directory '${MARIMO_FOLDER}' does not exist. Skipping validation.${RESET}\n"; \ + else \ + failed=0; \ + for notebook in ${MARIMO_FOLDER}/*.py; do \ + if [ -f "$$notebook" ]; then \ + notebook_name=$$(basename "$$notebook"); \ + notebook_stem=$$(basename "$$notebook" .py); \ + artefact_folder="results/$$notebook_stem"; \ + mkdir -p "$$artefact_folder"; \ + printf "${BLUE}[INFO] Validating $$notebook_name (artefacts โ†’ $$artefact_folder)...${RESET}\n"; \ + if NOTEBOOK_OUTPUT_FOLDER="$$artefact_folder" ${UV_BIN} run "$$notebook" > /dev/null 2>&1; then \ + printf "${GREEN}[SUCCESS] $$notebook_name is valid${RESET}\n"; \ + else \ + printf "${RED}[ERROR] $$notebook_name failed validation${RESET}\n"; \ + failed=$$((failed + 1)); \ + fi; \ + fi; \ + done; \ + if [ $$failed -eq 0 ]; then \ + printf "${GREEN}[SUCCESS] All notebooks validated successfully${RESET}\n"; \ + else \ + printf "${RED}[ERROR] $$failed notebook(s) failed validation${RESET}\n"; \ + exit 1; \ + fi; \ + fi + +marimo: install ## fire up Marimo server + @if [ ! -d "${MARIMO_FOLDER}" ]; then \ + printf " ${YELLOW}[WARN] Marimo folder '${MARIMO_FOLDER}' not found, skipping start${RESET}\n"; \ + else \ + ${UV_BIN} run --no-project --with marimo --directory "${MARIMO_FOLDER}" marimo edit --no-token --headless; \ + fi diff --git a/.rhiza/make.d/quality.mk b/.rhiza/make.d/quality.mk new file mode 100644 index 0000000..7504828 --- /dev/null +++ b/.rhiza/make.d/quality.mk @@ -0,0 +1,59 @@ +## .rhiza/make.d/quality.mk - Quality and Formatting +# This file provides targets for code quality checks, linting, and formatting. + +# Configurable list of licenses that fail the compliance scan (semicolon-separated) +LICENSE_FAIL_ON ?= GPL;LGPL;AGPL + +# Declare phony targets (they don't produce files) +.PHONY: all deptry fmt license todos suppression-audit semgrep + +##@ Quality and Formatting +all: fmt deptry test docs-coverage security license typecheck rhiza-test ## run all CI targets locally + +deptry: install-uv ## Run deptry + @if [ -d ${SOURCE_FOLDER} ]; then \ + $(UVX_BIN) -p ${PYTHON_VERSION} deptry ${SOURCE_FOLDER}; \ + fi + + @if [ -d ${MARIMO_FOLDER} ]; then \ + if [ -d ${SOURCE_FOLDER} ]; then \ + $(UVX_BIN) -p ${PYTHON_VERSION} deptry ${MARIMO_FOLDER} ${SOURCE_FOLDER} --ignore DEP004; \ + else \ + $(UVX_BIN) -p ${PYTHON_VERSION} deptry ${MARIMO_FOLDER} --ignore DEP004; \ + fi \ + fi + +fmt: install-uv ## check the pre-commit hooks and the linting + @${UVX_BIN} -p ${PYTHON_VERSION} pre-commit run --all-files + +todos: ## search and report all TODO/FIXME/HACK comments in the codebase + @printf "${BLUE}[INFO] Searching for TODO, FIXME, and HACK comments...${RESET}\n" + @printf "${BOLD}Found the following items:${RESET}\n\n" + @find . -type f \( -name "*.py" -o -name "*.mk" -o -name "*.sh" -o -name "*.md" -o -name "*.yml" -o -name "*.yaml" \) \ + -not -path "./.venv/*" \ + -not -path "./.git/*" \ + -not -path "./node_modules/*" \ + -not -path "./.tox/*" \ + -not -path "./build/*" \ + -not -path "./dist/*" \ + -print0 | xargs -0 grep -nHE "(TODO|FIXME|HACK):" 2>/dev/null | \ + grep -v "make todos" | \ + awk -F: '{ printf "${YELLOW}%s${RESET}:${GREEN}%s${RESET}: %s\n", $$1, $$2, substr($$0, index($$0,$$3)) }' || \ + printf "${GREEN}[SUCCESS] No TODO/FIXME/HACK comments found!${RESET}\n" + @printf "\n${BLUE}[INFO] Search complete.${RESET}\n" + +suppression-audit: ## scan codebase for inline suppressions and report (grade, detail, histogram) + @printf "${BLUE}[INFO] Running suppression audit...${RESET}\n" + @${UV_BIN} run python .rhiza/utils/suppression_audit.py + +semgrep: install ## run Semgrep static analysis + @printf "${BLUE}[INFO] Running Semgrep...${RESET}\n" + @if [ -d ${SOURCE_FOLDER} ]; then \ + ${UVX_BIN} semgrep --config .rhiza/semgrep.yml ${SOURCE_FOLDER}; \ + else \ + printf "${YELLOW}[WARN] SOURCE_FOLDER '${SOURCE_FOLDER}' not found, skipping semgrep.${RESET}\n"; \ + fi + +license: install ## run license compliance scan (fail on GPL, LGPL, AGPL) + @printf "${BLUE}[INFO] Running license compliance scan...${RESET}\n" + @${UV_BIN} run --with pip-licenses pip-licenses --fail-on="${LICENSE_FAIL_ON}" diff --git a/.rhiza/make.d/releasing.mk b/.rhiza/make.d/releasing.mk new file mode 100644 index 0000000..5dee330 --- /dev/null +++ b/.rhiza/make.d/releasing.mk @@ -0,0 +1,50 @@ +## .rhiza/make.d/releasing.mk - Releasing and Versioning +# This file provides targets for version bumping and release management. + +# Declare phony targets (they don't produce files) +.PHONY: bump release publish release-status pre-bump post-bump pre-release post-release + +# Hook targets (double-colon rules allow multiple definitions) +pre-bump:: ; @: +post-bump:: ; @: +pre-release:: ; @: +post-release:: ; @: + +# DRY_RUN support: pass DRY_RUN=1 to preview changes without applying them +_DRY_RUN_FLAG := $(if $(DRY_RUN),--dry-run,) +_VERSION=0.5.1 + +##@ Releasing and Versioning +bump: pre-bump ## bump version of the project (supports DRY_RUN=1) + @if [ -f "pyproject.toml" ]; then \ + $(MAKE) install; \ + PATH="$(abspath ${VENV})/bin:$$PATH" ${UVX_BIN} "rhiza-tools>=$(_VERSION)" bump $(_DRY_RUN_FLAG); \ + if [ -z "$(DRY_RUN)" ]; then \ + printf "${BLUE}[INFO] Checking uv.lock file...${RESET}\n"; \ + ${UV_BIN} lock; \ + fi; \ + else \ + printf "${YELLOW}[WARN] No pyproject.toml found, skipping bump${RESET}\n"; \ + fi + @$(MAKE) post-bump + +release: pre-release install-uv ## create tag and push to remote repository triggering release workflow (supports DRY_RUN=1) + ${UVX_BIN} "rhiza-tools>=$(_VERSION)" release $(_DRY_RUN_FLAG); + @$(MAKE) post-release + +publish: pre-release install-uv ## bump version, create tag and push in one step (supports DRY_RUN=1) + ${UVX_BIN} "rhiza-tools>=$(_VERSION)" release --with-bump $(_DRY_RUN_FLAG); + @$(MAKE) post-release + +release-status: ## show release workflow status and latest release information +ifeq ($(FORGE_TYPE),github) + @{ $(MAKE) --no-print-directory workflow-status; printf "\n"; $(MAKE) --no-print-directory latest-release; } 2>&1 | $${PAGER:-less -R} +else ifeq ($(FORGE_TYPE),gitlab) + @printf "${YELLOW}[WARN] GitLab detected โ€” release-status is not yet supported for GitLab repositories.${RESET}\n" + @printf "${BLUE}[INFO] Please check your pipeline status in the GitLab UI.${RESET}\n" +else + @printf "${RED}[ERROR] Could not detect forge type (.github/workflows/ or .gitlab-ci.yml not found)${RESET}\n" +endif + + + diff --git a/.rhiza/make.d/test.mk b/.rhiza/make.d/test.mk new file mode 100644 index 0000000..3af2847 --- /dev/null +++ b/.rhiza/make.d/test.mk @@ -0,0 +1,172 @@ +## Makefile.tests - Testing and benchmarking targets +# This file is included by the main Makefile. +# It provides targets for running the test suite with coverage and +# executing performance benchmarks. + +# Declare phony targets (they don't produce files) +.PHONY: test benchmark typecheck security docs-coverage hypothesis-test coverage-badge stress test-pyproject mutation + +# Default directory for tests +TESTS_FOLDER := tests + +# Minimum coverage percent for tests to pass +# (Can be overridden in local.mk or via environment variable) +COVERAGE_FAIL_UNDER ?= 90 + +##@ Development and Testing + +# The 'test' target runs the complete test suite. +# 1. Cleans up any previous test results in _tests/. +# 2. Creates directories for HTML coverage and test reports. +# 3. Invokes pytest via the local virtual environment. +# 4. Generates terminal output, HTML coverage, JSON coverage, and HTML test reports. +test:: install ## run all tests + @rm -rf _tests; + + if [ -z "$$(find ${TESTS_FOLDER} -name 'test_*.py' -o -name '*_test.py' 2>/dev/null)" ]; then \ + printf "${YELLOW}[WARN] No test files found in ${TESTS_FOLDER}, skipping tests.${RESET}\n"; \ + exit 0; \ + fi; \ + mkdir -p _tests/html-coverage _tests/html-report; \ + if [ -d ${SOURCE_FOLDER} ]; then \ + ${UV_BIN} run pytest \ + -n auto \ + --ignore=${TESTS_FOLDER}/benchmarks \ + --ignore=${TESTS_FOLDER}/stress \ + --cov=${SOURCE_FOLDER} \ + --cov-report=term \ + --cov-report=html:_tests/html-coverage \ + --cov-fail-under=$(COVERAGE_FAIL_UNDER) \ + --cov-report=json:_tests/coverage.json \ + --cov-report=xml:_tests/coverage.xml \ + --html=_tests/html-report/report.html; \ + else \ + printf "${YELLOW}[WARN] Source folder ${SOURCE_FOLDER} not found, running tests without coverage${RESET}\n"; \ + ${UV_BIN} run pytest \ + -n auto \ + --ignore=${TESTS_FOLDER}/benchmarks \ + --ignore=${TESTS_FOLDER}/stress \ + --html=_tests/html-report/report.html; \ + fi + +# The 'typecheck' target runs static type analysis using ty. +# 1. Checks if the source directory exists. +# 2. Runs ty on the source folder. +typecheck: install ## run ty type checking + @if [ -d ${SOURCE_FOLDER} ]; then \ + printf "${BLUE}[INFO] Running ty type checking...${RESET}\n"; \ + ${UV_BIN} run ty check ${SOURCE_FOLDER}; \ + else \ + printf "${YELLOW}[WARN] Source folder ${SOURCE_FOLDER} not found, skipping typecheck${RESET}\n"; \ + fi + +# Extra flags forwarded to pip-audit (e.g. --ignore-vuln CVE-XXXX-YYYY) +PIP_AUDIT_ARGS ?= + +# The 'security' target performs security vulnerability scans. +# 1. Runs pip-audit via pip_audit_policy.py: fails on runtime dep CVEs, warns on tooling (pip/setuptools/wheel). +# 2. Runs bandit to find common security issues in the source code. +security: install ## run security scans (pip-audit and bandit) + @printf "${BLUE}[INFO] Running pip-audit for dependency vulnerabilities...${RESET}\n" + @${UV_BIN} run python .rhiza/utils/pip_audit_policy.py ${PIP_AUDIT_ARGS} + @printf "${BLUE}[INFO] Running bandit security scan...${RESET}\n" + @${UVX_BIN} bandit -r ${SOURCE_FOLDER} -ll -q --ini .bandit + +# The 'benchmark' target runs performance benchmarks using pytest-benchmark. +# 1. Installs benchmarking dependencies (pytest-benchmark, pygal). +# 2. Executes benchmarks found in the benchmarks/ subfolder. +# 3. Generates histograms and JSON results. +# 4. Runs a post-analysis script to process the results. +benchmark:: install ## run performance benchmarks + @if [ -d "${TESTS_FOLDER}/benchmarks" ]; then \ + printf "${BLUE}[INFO] Running performance benchmarks...${RESET}\n"; \ + ${UV_BIN} pip install pytest-benchmark==5.2.3 pygal==3.1.0; \ + mkdir -p _tests/benchmarks; \ + ${UV_BIN} run pytest "${TESTS_FOLDER}/benchmarks/" \ + --benchmark-only \ + --benchmark-histogram=_tests/benchmarks/histogram \ + --benchmark-json=_tests/benchmarks/results.json; \ + ${UVX_BIN} "rhiza-tools>=0.2.3" analyze-benchmarks --benchmarks-json _tests/benchmarks/results.json --output-html _tests/benchmarks/report.html; \ + else \ + printf "${YELLOW}[WARN] Benchmarks folder not found, skipping benchmarks${RESET}\n"; \ + fi + +# The 'docs-coverage' target checks documentation coverage using interrogate. +# 1. Checks if SOURCE_FOLDER exists. +# 2. Runs interrogate on the source folder with verbose output. +docs-coverage: install ## check documentation coverage with interrogate + @if [ -d "${SOURCE_FOLDER}" ]; then \ + printf "${BLUE}[INFO] Checking documentation coverage in ${SOURCE_FOLDER}...${RESET}\n"; \ + ${UV_BIN} run interrogate -vv --fail-under 100 --ignore-init-method --ignore-magic ${SOURCE_FOLDER}; \ + else \ + printf "${YELLOW}[WARN] Source folder ${SOURCE_FOLDER} not found, skipping docs-coverage${RESET}\n"; \ + fi + +# The 'hypothesis-test' target runs property-based tests using Hypothesis. +# 1. Checks if hypothesis tests exist in the tests directory. +# 2. Runs pytest with hypothesis-specific settings and statistics. +# 3. Generates detailed hypothesis examples and statistics. +hypothesis-test:: install ## run property-based tests with Hypothesis + @if [ -z "$$(find ${TESTS_FOLDER} -name 'test_*.py' -o -name '*_test.py' 2>/dev/null)" ]; then \ + printf "${YELLOW}[WARN] No test files found in ${TESTS_FOLDER}, skipping hypothesis tests.${RESET}\n"; \ + exit 0; \ + fi; \ + printf "${BLUE}[INFO] Running Hypothesis property-based tests...${RESET}\n"; \ + mkdir -p _tests/hypothesis; \ + PYTEST_HTML_TITLE="Hypothesis tests" ${UV_BIN} run pytest \ + --ignore=${TESTS_FOLDER}/benchmarks \ + -v \ + --hypothesis-show-statistics \ + --hypothesis-seed=0 \ + -m "hypothesis or property" \ + --tb=short \ + --html=_tests/hypothesis/report.html; \ + exit_code=$$?; \ + if [ $$exit_code -eq 5 ]; then \ + printf "${YELLOW}[WARN] No hypothesis/property tests collected, skipping.${RESET}\n"; \ + exit 0; \ + fi; \ + exit $$exit_code + +# The 'stress' target runs stress/load tests. +# 1. Checks if stress tests exist in the tests/stress directory. +# 2. Runs pytest with the stress marker to execute only stress tests. +# 3. Generates an HTML report of stress test results. +stress:: install ## run stress/load tests + @if [ ! -d "${TESTS_FOLDER}/stress" ]; then \ + printf "${YELLOW}[WARN] Stress tests folder not found, skipping stress tests.${RESET}\n"; \ + exit 0; \ + fi; \ + printf "${BLUE}[INFO] Running stress/load tests...${RESET}\n"; \ + mkdir -p _tests/stress; \ + ${UV_BIN} run pytest \ + -v \ + -m stress \ + --tb=short \ + --html=_tests/stress/report.html + +mutation: install ## run mutation tests with mutmut + @if [ ! -d ${SOURCE_FOLDER} ]; then \ + printf "${YELLOW}[WARN] Source folder ${SOURCE_FOLDER} not found, skipping mutation tests.${RESET}\n"; \ + exit 0; \ + fi; \ + printf "${BLUE}[INFO] Running mutation tests on ${SOURCE_FOLDER}...${RESET}\n"; \ + mkdir -p _tests/mutation; \ + run_status=0; \ + ${UV_BIN} run mutmut run \ + --paths-to-mutate="${SOURCE_FOLDER}" \ + --tests-dir="${TESTS_FOLDER}" || run_status=$$?; \ + ${UV_BIN} run mutmut html || exit $$?; \ + rm -rf _tests/mutation/html; \ + mv html _tests/mutation/html || exit $$?; \ + ${UV_BIN} run mutmut results || exit $$?; \ + exit $$run_status + +test-pyproject: install ## run pyproject.toml structure tests + @${UV_BIN} run pytest .rhiza/tests/structure/test_pyproject.py \ + -v \ + --tb=long \ + --showlocals \ + -rA \ + --durations=0 \ + --no-header diff --git a/.rhiza/requirements/README.md b/.rhiza/requirements/README.md new file mode 100644 index 0000000..b98278a --- /dev/null +++ b/.rhiza/requirements/README.md @@ -0,0 +1,27 @@ +# 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 new file mode 100644 index 0000000..bbded50 --- /dev/null +++ b/.rhiza/requirements/docs.txt @@ -0,0 +1,4 @@ +# 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 new file mode 100644 index 0000000..032c872 --- /dev/null +++ b/.rhiza/requirements/marimo.txt @@ -0,0 +1,2 @@ +# Marimo dependencies for rhiza +marimo>=0.18.0 diff --git a/.rhiza/requirements/tests.txt b/.rhiza/requirements/tests.txt new file mode 100644 index 0000000..96cc631 --- /dev/null +++ b/.rhiza/requirements/tests.txt @@ -0,0 +1,18 @@ +# 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 new file mode 100644 index 0000000..92bbda1 --- /dev/null +++ b/.rhiza/requirements/tools.txt @@ -0,0 +1,7 @@ +# 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 new file mode 100644 index 0000000..19c57dd --- /dev/null +++ b/.rhiza/rhiza.mk @@ -0,0 +1,178 @@ +## Makefile for jebel-quant/rhiza +# (https://github.com/jebel-quant/rhiza) +# +# Purpose: Developer tasks using uv/uvx (install, test, book). +# Lines with `##` after a target are parsed into help text, +# and lines starting with `##@` create section headers in the help output. +# +# Require GNU Make (MAKE_VERSION is unset in BSD make) +ifndef MAKE_VERSION +$(error GNU Make is required. macOS ships BSD make โ€” install GNU Make with: brew install make) +endif + +# Colours for pretty output in help messages +BLUE := \033[36m +BOLD := \033[1m +GREEN := \033[32m +RED := \033[31m +YELLOW := \033[33m +RESET := \033[0m + +# Default goal when running `make` with no target +.DEFAULT_GOAL := help + +# Declare phony targets (they don't produce files) +.PHONY: \ + help \ + post-bump \ + post-install \ + post-release \ + post-sync \ + post-validate \ + pre-bump \ + pre-install \ + pre-release \ + pre-sync \ + pre-validate \ + print-logo \ + readme \ + summarise-sync \ + sync \ + validate \ + version-matrix \ + ci-os-matrix + +# we need absolute paths! +INSTALL_DIR ?= $(abspath ./bin) +UV_BIN ?= $(shell command -v uv 2>/dev/null || echo ${INSTALL_DIR}/uv) +UVX_BIN ?= $(shell command -v uvx 2>/dev/null || echo ${INSTALL_DIR}/uvx) +VENV ?= .venv + +# Read Python version from .python-version (single source of truth) +PYTHON_VERSION ?= $(strip $(shell cat .python-version 2>/dev/null || echo "3.13")) +export PYTHON_VERSION + +# Read Rhiza version from .rhiza/.rhiza-version (single source of truth for rhiza-tools) +RHIZA_VERSION ?= $(shell cat .rhiza/.rhiza-version 2>/dev/null || echo "0.10.2") +export RHIZA_VERSION + +# Default sync schedule (cron expression for GitHub Actions sync workflow) +# Override in your root Makefile to customise when sync runs. +# Example: RHIZA_SYNC_SCHEDULE = 0 9 * * 1-5 (weekdays at 9 AM UTC) +RHIZA_SYNC_SCHEDULE ?= 0 0 * * 1 + +export UV_NO_MODIFY_PATH := 1 +export UV_VENV_CLEAR := 1 + +# Unset VIRTUAL_ENV to prevent uv from warning about path mismatches +# when a virtual environment is already activated in the shell +unexport VIRTUAL_ENV + +# Load .rhiza/.env (if present) and export its variables so recipes see them. +-include .rhiza/.env + +# ============================================================================== +# Rhiza Core +# ============================================================================== + +# RHIZA_LOGO definition +define RHIZA_LOGO + ____ _ _ + | _ \| |__ (_)______ _ + | |_) | '_ \| |_ / _\`| + | _ <| | | | |/ / (_| | + |_| \_\_| |_|_/___\__,_| + +endef +export RHIZA_LOGO + +# Declare phony targets for Rhiza Core +.PHONY: print-logo sync sync-experimental materialize validate readme pre-sync post-sync pre-validate post-validate _apply-sync-schedule + +# Hook targets (double-colon rules allow multiple definitions) +# Note: pre-install/post-install are defined in bootstrap.mk +# Note: pre-bump/post-bump/pre-release/post-release are defined in releasing.mk +pre-sync:: ; @: +post-sync:: ; @: +pre-validate:: ; @: +post-validate:: ; @: + +##@ Rhiza Workflows + +print-logo: + @printf "${BLUE}$$RHIZA_LOGO${RESET}\n" + + +sync: pre-sync ## sync with template repository as defined in .rhiza/template.yml + @if git remote get-url origin 2>/dev/null | grep -iqE 'jebel-quant/rhiza(\.git)?$$'; then \ + printf "${BLUE}[INFO] Skipping sync in rhiza repository (no template.yml by design)${RESET}\n"; \ + else \ + $(MAKE) install-uv && \ + ${UVX_BIN} "rhiza==$(RHIZA_VERSION)" sync . && \ + $(MAKE) _apply-sync-schedule; \ + fi + @$(MAKE) post-sync + +_apply-sync-schedule: ## (internal) apply RHIZA_SYNC_SCHEDULE override to GitHub Actions sync workflow + @if [ "$(RHIZA_SYNC_SCHEDULE)" != "0 0 * * 1" ] && [ -f .github/workflows/rhiza_sync.yml ]; then \ + sed -i.bak "s|cron: '[^']*'|cron: '$(RHIZA_SYNC_SCHEDULE)'|" .github/workflows/rhiza_sync.yml && rm -f .github/workflows/rhiza_sync.yml.bak; \ + printf "${BLUE}[INFO] Applied custom sync schedule: $(RHIZA_SYNC_SCHEDULE)${RESET}\n"; \ + fi + +materialize: ## [DEPRECATED] use 'make sync' instead โ€” materialize --force is now sync + @printf "${YELLOW}[WARN] 'make materialize' is deprecated and will be removed in a future release.${RESET}\n" + @printf "${YELLOW}[WARN] Please use 'make sync' instead (e.g. 'materialize --force' is now 'make sync').${RESET}\n" + @$(MAKE) sync + +summarise-sync: install-uv ## summarise differences created by sync with template repository + @if git remote get-url origin 2>/dev/null | grep -iqE 'jebel-quant/rhiza(\.git)?$$'; then \ + printf "${BLUE}[INFO] Skipping summarise-sync in rhiza repository (no template.yml by design)${RESET}\n"; \ + else \ + $(MAKE) install-uv; \ + ${UVX_BIN} "rhiza==$(RHIZA_VERSION)" summarise .; \ + fi + +rhiza-test: install ## run rhiza's own tests (if any) + @if [ -d ".rhiza/tests" ]; then \ + ${UV_BIN} run pytest .rhiza/tests; \ + else \ + printf "${YELLOW}[WARN] No .rhiza/tests directory found, skipping rhiza-tests${RESET}\n"; \ + fi + +validate: pre-validate rhiza-test ## validate project structure against template repository as defined in .rhiza/template.yml + @if git remote get-url origin 2>/dev/null | grep -iqE 'jebel-quant/rhiza(\.git)?$$'; then \ + printf "${BLUE}[INFO] Skipping validate in rhiza repository (no template.yml by design)${RESET}\n"; \ + else \ + $(MAKE) install-uv; \ + ${UVX_BIN} "rhiza==$(RHIZA_VERSION)" validate .; \ + fi + @$(MAKE) post-validate + +readme: install-uv ## update README.md with current Makefile help output + @${UVX_BIN} "rhiza-tools>=0.2.0" update-readme + +##@ Meta + +help: print-logo ## Display this help message + +@printf "$(BOLD)Usage:$(RESET)\n" + +@printf " make $(BLUE)$(RESET)\n\n" + +@printf "$(BOLD)Targets:$(RESET)\n" + +@awk 'BEGIN {FS = ":.*##"; printf ""} /^[a-zA-Z_-]+:.*?##/ { printf " $(BLUE)%-20s$(RESET) %s\n", $$1, $$2 } /^##@/ { printf "\n$(BOLD)%s$(RESET)\n", substr($$0, 5) }' $(MAKEFILE_LIST) + +@printf "\n" + +version-matrix: install-uv ## Emit the list of supported Python versions from pyproject.toml + @${UVX_BIN} "rhiza-tools>=0.2.2" version-matrix + +ci-os-matrix: ## Emit GitHub CI OSes (RHIZA_CI_OS_MATRIX as JSON array, default ["ubuntu-latest"]) + @printf '%s\n' '$(or $(RHIZA_CI_OS_MATRIX),["ubuntu-latest"])' + +print-% : ## print the value of a variable (usage: make print-VARIABLE) + @printf "${BLUE}[INFO] Printing value of variable '$*':${RESET}\n" + @printf "${BOLD}Value of $*:${RESET}\n" + @printf "${GREEN}" + @printf "%s\n" "$($*)" + @printf "${RESET}" + @printf "${BLUE}[INFO] End of value for '$*'${RESET}\n" + +# Optional: repo extensions (committed) +-include .rhiza/make.d/*.mk diff --git a/.rhiza/semgrep.yml b/.rhiza/semgrep.yml new file mode 100644 index 0000000..a5cc97c --- /dev/null +++ b/.rhiza/semgrep.yml @@ -0,0 +1,84 @@ +rules: + # ---------------------------- + # Security + # ---------------------------- + + - id: numpy-load-allow-pickle + patterns: + - pattern: np.load($PATH, ...) + - pattern-not: np.load($PATH, ..., allow_pickle=False, ...) + message: | + np.load() may deserialize arbitrary objects when allow_pickle=True + (default in older NumPy versions). This can execute malicious code. + Pass allow_pickle=False unless explicitly required. + languages: [python] + severity: ERROR + metadata: + category: security + cwe: "CWE-502: Deserialization of Untrusted Data" + + # ---------------------------- + # Randomness / Reproducibility + # ---------------------------- + + - id: numpy-random-seed + pattern: np.random.seed(...) + message: | + np.random.seed() sets global state and is not thread-safe. + Prefer a local Generator via np.random.default_rng(). + languages: [python] + severity: WARNING + metadata: + category: best-practice + + - id: numpy-global-random-state + patterns: + - pattern-either: + - pattern: np.random.rand(...) + - pattern: np.random.randn(...) + - pattern: np.random.randint(...) + - pattern: np.random.random(...) + - pattern: np.random.normal(...) + - pattern: np.random.uniform(...) + - pattern: np.random.choice(...) + - pattern: np.random.shuffle(...) + message: | + Global np.random.* usage is not thread-safe and can harm reproducibility. + Prefer a local Generator: + rng = np.random.default_rng(seed) + rng.method(...) + languages: [python] + severity: INFO + metadata: + category: best-practice + + # ---------------------------- + # Numerical Stability + # ---------------------------- + + - id: numpy-avoid-inv + pattern: np.linalg.inv($X) + message: | + Avoid explicit matrix inversion. Use np.linalg.solve(A, b) + for better numerical stability and performance. + languages: [python] + severity: WARNING + metadata: + category: numerical-stability + + # ---------------------------- + # Deprecated / Dangerous APIs + # ---------------------------- + + - id: numpy-matrix-deprecated + patterns: + - pattern-either: + - pattern: np.matrix(...) + - pattern: np.mat(...) + message: | + np.matrix is deprecated and scheduled for removal. Use np.array + or np.ndarray instead. It has different operator semantics (*). + languages: [python] + severity: WARNING + metadata: + category: maintainability diff --git a/.rhiza/template.lock b/.rhiza/template.lock new file mode 100644 index 0000000..71ce57c --- /dev/null +++ b/.rhiza/template.lock @@ -0,0 +1,89 @@ +sha: ae79c6f0729b21239655abe390269be9171f907d +repo: jebel-quant/rhiza +host: github +ref: v0.18.8 +include: [] +exclude: [] +templates: [] +files: +- .bandit +- .editorconfig +- .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/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_marimo.yml +- .github/workflows/rhiza_release.yml +- .github/workflows/rhiza_sync.yml +- .github/workflows/rhiza_weekly.yml +- .gitignore +- .pre-commit-config.yaml +- .python-version +- .rhiza/.cfg.toml +- .rhiza/.env +- .rhiza/.gitignore +- .rhiza/.rhiza-version +- .rhiza/assets/rhiza-logo.svg +- .rhiza/completions/README.md +- .rhiza/completions/rhiza-completion.bash +- .rhiza/completions/rhiza-completion.zsh +- .rhiza/make.d/book.mk +- .rhiza/make.d/bootstrap.mk +- .rhiza/make.d/custom-env.mk +- .rhiza/make.d/custom-task.mk +- .rhiza/make.d/doctor.mk +- .rhiza/make.d/marimo.mk +- .rhiza/make.d/quality.mk +- .rhiza/make.d/releasing.mk +- .rhiza/make.d/test.mk +- .rhiza/requirements/README.md +- .rhiza/requirements/docs.txt +- .rhiza/requirements/marimo.txt +- .rhiza/requirements/tests.txt +- .rhiza/requirements/tools.txt +- .rhiza/rhiza.mk +- .rhiza/semgrep.yml +- .rhiza/tests/README.md +- .rhiza/tests/api/conftest.py +- .rhiza/tests/api/test_github_targets.py +- .rhiza/tests/api/test_make_variable_overrides.py +- .rhiza/tests/api/test_makefile_api.py +- .rhiza/tests/api/test_makefile_targets.py +- .rhiza/tests/conftest.py +- .rhiza/tests/integration/test_book_targets.py +- .rhiza/tests/integration/test_docs_targets.py +- .rhiza/tests/integration/test_test_mk.py +- .rhiza/tests/integration/test_virtual_env_unexport.py +- .rhiza/tests/shell/test_scripts.sh +- .rhiza/tests/stress/README.md +- .rhiza/tests/stress/__init__.py +- .rhiza/tests/stress/conftest.py +- .rhiza/tests/structure/test_project_layout.py +- .rhiza/tests/structure/test_pyproject.py +- .rhiza/tests/structure/test_requirements.py +- .rhiza/tests/sync/conftest.py +- .rhiza/tests/sync/test_docstrings.py +- .rhiza/tests/sync/test_readme_validation.py +- .rhiza/tests/test_utils.py +- .rhiza/tests/utils/test_git_repo_fixture.py +- .rhiza/utils/pip_audit_policy.py +- .rhiza/utils/suppression_audit.py +- Makefile +- docs/assets/rhiza-logo.svg +- docs/development/MARIMO.md +- docs/development/TESTS.md +- docs/index.md +- docs/mkdocs-base.yml +- pytest.ini +- ruff.toml +profiles: +- github-project +synced_at: '2026-06-08T08:50:31Z' +strategy: merge diff --git a/.rhiza/tests/README.md b/.rhiza/tests/README.md new file mode 100644 index 0000000..1cf5c26 --- /dev/null +++ b/.rhiza/tests/README.md @@ -0,0 +1,169 @@ +# Rhiza Test Suite + +This directory contains the comprehensive test suite for the Rhiza project. + +## Test Organization + +Tests are organized into purpose-driven subdirectories: + +### `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. + +- `test_project_layout.py` โ€” Validates root-level files and directories +- `test_requirements.py` โ€” Validates `.rhiza/requirements/` structure + +### `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` + +By default, every `python` and `bash` code block in `README.md` is executed or +syntax-checked by `test_readme_validation.py`. To mark a block as intentionally +non-runnable (e.g. illustrative snippets or environment-specific commands), add +`+RHIZA_SKIP` to the opening fence line: + +~~~markdown +```python +RHIZA_SKIP +# This block will NOT be executed or syntax-checked +from my_env import some_function +some_function() +``` + +```bash +RHIZA_SKIP +# This bash block will NOT be syntax-checked +run-something --only-on-ci +``` +~~~ + +Markdown renderers (including GitHub) ignore everything after the first word on +a fence line, so the block still renders as a normal highlighted code block. +Blocks without `+RHIZA_SKIP` continue to be validated as before. + +### `utils/` +Tests for utility code and test infrastructure. These tests validate the testing framework itself and utility scripts. + +- `test_git_repo_fixture.py` โ€” Validates the `git_repo` fixture + +### `deps/` +Dependency validation tests. These tests ensure that project dependencies are correctly specified and healthy. + +- `test_dependency_health.py` โ€” Validates pyproject.toml and requirements files + +### `stress/` +Stress tests that verify Rhiza's stability under heavy load. These tests execute Rhiza-specific operations under concurrent load and repeated execution to detect race conditions, resource leaks, and performance degradation. + +- `test_makefile_stress.py` โ€” Makefile operations under concurrent/repeated load +- `test_git_stress.py` โ€” Git operations under concurrent load + +See [stress/README.md](stress/README.md) for detailed documentation. + +## Running Tests + +### Run all tests +```bash +uv run pytest .rhiza/tests/ +# or +make test +``` + +### Run tests from a specific category +```bash +uv run pytest .rhiza/tests/structure/ +uv run pytest .rhiza/tests/api/ +uv run pytest .rhiza/tests/integration/ +uv run pytest .rhiza/tests/sync/ +uv run pytest .rhiza/tests/utils/ +uv run pytest .rhiza/tests/deps/ +uv run pytest .rhiza/tests/stress/ +``` + +### Run stress tests with custom parameters +```bash +# Run all stress tests (default: 100 iterations, 10 workers) +uv run pytest .rhiza/tests/stress/ -v + +# Run with fewer iterations (faster) +uv run pytest .rhiza/tests/stress/ -v --iterations=10 + +# Skip stress tests when running full test suite +uv run pytest .rhiza/tests/ -v -m "not stress" +``` + +### Run a specific test file +```bash +uv run pytest .rhiza/tests/structure/test_project_layout.py +``` + +### Run with verbose output +```bash +uv run pytest .rhiza/tests/ -v +``` + +### Run with coverage +```bash +uv run pytest .rhiza/tests/ --cov +``` + +## Fixtures + +### Root-level fixtures (`conftest.py`) +- `root` โ€” Repository root path (session-scoped) +- `logger` โ€” Configured logger instance (session-scoped) +- `git_repo` โ€” Sandboxed git repository (function-scoped) + +### Category-specific fixtures +- `api/conftest.py` โ€” `setup_tmp_makefile`, `run_make`, `setup_rhiza_git_repo` +- `sync/conftest.py` โ€” `setup_sync_env` + +## Writing Tests + +### Conventions +- Use descriptive test names that explain what is being tested +- Group related tests in classes when appropriate +- Use appropriate fixtures for setup/teardown +- Add docstrings to test modules and complex test functions +- Use `pytest.mark.skip` for tests that depend on optional features + +### Import Patterns +```python +# Import shared helpers from test_utils +from test_utils import strip_ansi, run_make, setup_rhiza_git_repo + +# Import from local category conftest (for fixtures and category-specific helpers) +from api.conftest import SPLIT_MAKEFILES, setup_tmp_makefile + +# Note: Fixtures defined in conftest.py are automatically available in tests +# and don't need to be explicitly imported +``` + +## Test Coverage + +The test suite aims for high coverage across: +- Configuration validation (structure, dependencies) +- Makefile target correctness (api) +- End-to-end workflows (integration) +- Template synchronization (sync) +- Utility code (utils) + +## Notes + +- Benchmarks are located in `tests/benchmarks/` and run via `make benchmark` +- Integration tests use sandboxed git repositories to avoid affecting the working tree +- All Makefile tests use dry-run mode (`make -n`) to avoid side effects diff --git a/.rhiza/tests/api/conftest.py b/.rhiza/tests/api/conftest.py new file mode 100644 index 0000000..8733d6c --- /dev/null +++ b/.rhiza/tests/api/conftest.py @@ -0,0 +1,98 @@ +"""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 new file mode 100644 index 0000000..1008dee --- /dev/null +++ b/.rhiza/tests/api/test_github_targets.py @@ -0,0 +1,63 @@ +"""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 new file mode 100644 index 0000000..e5b5cfb --- /dev/null +++ b/.rhiza/tests/api/test_make_variable_overrides.py @@ -0,0 +1,161 @@ +"""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 new file mode 100644 index 0000000..8096006 --- /dev/null +++ b/.rhiza/tests/api/test_makefile_api.py @@ -0,0 +1,369 @@ +"""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 new file mode 100644 index 0000000..0137c32 --- /dev/null +++ b/.rhiza/tests/api/test_makefile_targets.py @@ -0,0 +1,363 @@ +"""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 new file mode 100644 index 0000000..419f6d6 --- /dev/null +++ b/.rhiza/tests/conftest.py @@ -0,0 +1,217 @@ +"""Pytest configuration and fixtures for setting up a mock git repository with versioning. + +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. + +Security Notes: +- S101 (assert usage): Asserts are appropriate in test code for validating conditions +- S603 (subprocess without shell=True): All subprocess calls use lists of known commands (git), + not user input, making them safe from shell injection +- S607 (subprocess with partial path): Using 'git' from PATH is acceptable in test fixtures + as the test environment is controlled and git is a required development dependency +""" + +import logging +import os +import pathlib +import shutil +import subprocess # nosec B404 - subprocess module needed for git operations in test fixtures +import sys + +import pytest + +tests_root = pathlib.Path(__file__).resolve().parent +if str(tests_root) not in sys.path: + sys.path.insert(0, str(tests_root)) + +from test_utils import GIT # noqa: E402 + +MOCK_MAKE_SCRIPT = """#!/usr/bin/env python3 +import sys + +if len(sys.argv) > 1 and sys.argv[1] == "help": + print("Mock Makefile Help") + print("target: ## Description") +""" + +MOCK_UV_SCRIPT = """#!/usr/bin/env python3 +import sys +import re + +try: + from packaging.version import parse, InvalidVersion + HAS_PACKAGING = True +except ImportError: + HAS_PACKAGING = False + +def get_version(): + with open("pyproject.toml", "r") as f: + content = f.read() + match = re.search(r'version = "(.*?)"', content) + return match.group(1) if match else "0.0.0" + +def set_version(new_version): + with open("pyproject.toml", "r") as f: + content = f.read() + new_content = re.sub(r'version = ".*?"', f'version = "{new_version}"', content) + with open("pyproject.toml", "w") as f: + f.write(new_content) + +def bump_version(current, bump_type): + major, minor, patch = map(int, current.split('.')) + if bump_type == "major": + return f"{major + 1}.0.0" + elif bump_type == "minor": + return f"{major}.{minor + 1}.0" + elif bump_type == "patch": + return f"{major}.{minor}.{patch + 1}" + return current + +def main(): + args = sys.argv[1:] + if not args: + sys.exit(1) + + if args[0] != "version": + # It might be a uvx call if we use the same script, but let's keep them separate or handle it here. + # For now, let's assume this is only for uv version commands as per original design. + sys.exit(1) + + # uv version --short + if "--short" in args and "--bump" not in args: + print(get_version()) + return + + # uv version --bump --dry-run --short + if "--bump" in args and "--dry-run" in args and "--short" in args: + bump_idx = args.index("--bump") + 1 + bump_type = args[bump_idx] + current = get_version() + print(bump_version(current, bump_type)) + return + + # uv version --bump (actual update) + if "--bump" in args and "--dry-run" not in args: + bump_idx = args.index("--bump") + 1 + bump_type = args[bump_idx] + current = get_version() + new_ver = bump_version(current, bump_type) + set_version(new_ver) + return + + # uv version --dry-run + if len(args) >= 2 and not args[1].startswith("-") and "--dry-run" in args: + version = args[1] + if HAS_PACKAGING: + try: + parse(version) + except InvalidVersion: + sys.exit(1) + else: + # Simple validation: must start with a digit + if not re.match(r"^\\d", version): + sys.exit(1) + # Just exit 0 if valid + return + + # uv version (actual update) + if len(args) == 2 and not args[1].startswith("-"): + set_version(args[1]) + return + +if __name__ == "__main__": + main() +""" + + +@pytest.fixture(scope="session") +def root(): + """Return the repository root directory as a pathlib.Path. + + Used by tests to locate files and scripts relative to the project root. + """ + return pathlib.Path(__file__).parent.parent.parent + + +@pytest.fixture(scope="session") +def logger(): + """Provide a session-scoped logger for tests. + + Returns: + logging.Logger: Logger configured for the test session. + """ + return logging.getLogger(__name__) + + +@pytest.fixture +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/integration/test_book_targets.py b/.rhiza/tests/integration/test_book_targets.py new file mode 100644 index 0000000..9c73666 --- /dev/null +++ b/.rhiza/tests/integration/test_book_targets.py @@ -0,0 +1,163 @@ +"""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 new file mode 100644 index 0000000..44f3e21 --- /dev/null +++ b/.rhiza/tests/integration/test_docs_targets.py @@ -0,0 +1,48 @@ +"""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 new file mode 100644 index 0000000..83102bd --- /dev/null +++ b/.rhiza/tests/integration/test_test_mk.py @@ -0,0 +1,53 @@ +"""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 new file mode 100644 index 0000000..fae30bb --- /dev/null +++ b/.rhiza/tests/integration/test_virtual_env_unexport.py @@ -0,0 +1,37 @@ +"""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 new file mode 100755 index 0000000..ee86799 --- /dev/null +++ b/.rhiza/tests/shell/test_scripts.sh @@ -0,0 +1,245 @@ +#!/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 new file mode 100644 index 0000000..06e4919 --- /dev/null +++ b/.rhiza/tests/stress/README.md @@ -0,0 +1,143 @@ +# 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 +- [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/stress/__init__.py b/.rhiza/tests/stress/__init__.py new file mode 100644 index 0000000..8ce3020 --- /dev/null +++ b/.rhiza/tests/stress/__init__.py @@ -0,0 +1,5 @@ +"""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 new file mode 100644 index 0000000..80bdc04 --- /dev/null +++ b/.rhiza/tests/stress/conftest.py @@ -0,0 +1,50 @@ +"""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/structure/test_project_layout.py b/.rhiza/tests/structure/test_project_layout.py new file mode 100644 index 0000000..5580598 --- /dev/null +++ b/.rhiza/tests/structure/test_project_layout.py @@ -0,0 +1,57 @@ +"""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_pyproject.py b/.rhiza/tests/structure/test_pyproject.py new file mode 100644 index 0000000..82f2b4d --- /dev/null +++ b/.rhiza/tests/structure/test_pyproject.py @@ -0,0 +1,217 @@ +"""Tests for pyproject.toml structure and required fields. + +This file and its associated tests flow down via a SYNC action from the +jebel-quant/rhiza repository (https://github.com/jebel-quant/rhiza). + +Validates that pyproject.toml: +- is syntactically valid TOML +- contains all required [project] fields +- declares a semver-compatible version +- specifies a minimum Python version via requires-python +- lists at least one named author +- provides [project.urls] with Homepage and Repository +- includes at least one Python version classifier +- declares a [dependency-groups] test group containing pytest +- declares a [dependency-groups] lint group +- version matches the latest git tag (vX.Y.Z โ†’ X.Y.Z) +""" + +from __future__ import annotations + +import re +import shutil +import subprocess # nosec B404 +import tomllib +from pathlib import Path + +import pytest +from packaging.version import Version + +_GIT = shutil.which("git") or "/usr/bin/git" + +_SEMVER_RE = re.compile(r"^\d+\.\d+\.\d+") +_REQUIRED_PROJECT_FIELDS = ("name", "version", "description", "readme", "requires-python", "license", "authors") + + +@pytest.fixture(scope="module") +def pyproject(root: Path) -> dict: + """Load and return pyproject.toml as a parsed dict.""" + path = root / "pyproject.toml" + if not path.exists(): + pytest.skip("pyproject.toml not found") + with path.open("rb") as f: + return tomllib.load(f) + + +@pytest.fixture(scope="module") +def project(pyproject: dict) -> dict: + """Return the [project] table from pyproject.toml.""" + table = pyproject.get("project") + if not isinstance(table, dict): + pytest.fail("pyproject.toml is missing a [project] table") + return table + + +class TestPyprojectToml: + """Tests for basic pyproject.toml existence and validity.""" + + def test_pyproject_toml_exists(self, root: Path) -> None: + """pyproject.toml must exist at the project root.""" + assert (root / "pyproject.toml").is_file(), "pyproject.toml not found at project root" + + def test_pyproject_toml_is_valid_toml(self, root: Path) -> None: + """pyproject.toml must be syntactically valid TOML.""" + with (root / "pyproject.toml").open("rb") as f: + data = tomllib.load(f) + assert isinstance(data, dict), "Parsed pyproject.toml must be a TOML table" + + def test_project_table_present(self, pyproject: dict) -> None: + """pyproject.toml must contain a [project] table.""" + assert "project" in pyproject, "pyproject.toml is missing a [project] table" + assert isinstance(pyproject["project"], dict), "[project] must be a TOML table" + + +class TestProjectFields: + """Tests for required fields within the [project] table.""" + + @pytest.mark.parametrize("field", _REQUIRED_PROJECT_FIELDS) + def test_required_field_present(self, project: dict, field: str) -> None: + """Each required [project] field must be present.""" + assert field in project, f"[project] is missing required field '{field}'" + + def test_name_is_non_empty_string(self, project: dict) -> None: + """[project].name must be a non-empty string.""" + name = project.get("name", "") + assert isinstance(name, str), "[project].name must be a string" + assert name.strip(), "[project].name must be a non-empty string" + + def test_version_follows_semver(self, project: dict) -> None: + """[project].version must follow semver (MAJOR.MINOR.PATCH).""" + version = project.get("version", "") + assert _SEMVER_RE.match(str(version)), ( + f"[project].version {version!r} does not follow semver (expected MAJOR.MINOR.PATCH)" + ) + + def test_requires_python_is_set(self, project: dict) -> None: + """[project].requires-python must be set to a non-empty constraint.""" + rp = project.get("requires-python", "") + assert isinstance(rp, str), "[project].requires-python must be a string" + assert rp.strip(), "[project].requires-python must be a non-empty version constraint" + + def test_authors_have_names(self, project: dict) -> None: + """[project].authors must contain at least one entry with a non-empty 'name'.""" + authors = project.get("authors", []) + assert isinstance(authors, list), "[project].authors must be a list" + assert len(authors) >= 1, "[project].authors must list at least one author" + named = [a for a in authors if isinstance(a, dict) and a.get("name", "").strip()] + assert len(named) >= 1, "At least one entry in [project].authors must have a non-empty 'name'" + + def test_description_is_non_empty_string(self, project: dict) -> None: + """[project].description must be a non-empty string.""" + desc = project.get("description", "") + assert isinstance(desc, str), "[project].description must be a string" + assert desc.strip(), "[project].description must be a non-empty string" + + +class TestProjectUrls: + """Tests for [project.urls] โ€” Homepage and Repository links.""" + + @pytest.fixture(scope="class") + def urls(self, project: dict) -> dict: + """Return the [project.urls] table.""" + table = project.get("urls") + if not isinstance(table, dict): + pytest.skip("[project.urls] not present") + return table + + def test_urls_table_present(self, project: dict) -> None: + """[project.urls] must be present.""" + assert "urls" in project, "pyproject.toml is missing a [project.urls] table" + + def test_homepage_configured(self, urls: dict) -> None: + """[project.urls] must include a Homepage entry.""" + assert "Homepage" in urls, "[project.urls] is missing a 'Homepage' entry" + assert urls["Homepage"].strip(), "[project.urls] 'Homepage' must be non-empty" + + def test_repository_configured(self, urls: dict) -> None: + """[project.urls] must include a Repository entry.""" + assert "Repository" in urls, "[project.urls] is missing a 'Repository' entry" + assert urls["Repository"].strip(), "[project.urls] 'Repository' must be non-empty" + + +class TestProjectClassifiers: + """Tests for [project].classifiers โ€” Python version and licence entries.""" + + @pytest.fixture(scope="class") + def classifiers(self, project: dict) -> list[str]: + """Return the classifiers list.""" + cl = project.get("classifiers", []) + if not cl: + pytest.skip("No classifiers declared in [project]") + return cl + + def test_python_version_classifier_present(self, classifiers: list[str]) -> None: + """At least one 'Programming Language :: Python :: 3.X' classifier must be present.""" + python_classifiers = [c for c in classifiers if re.match(r"Programming Language :: Python :: 3\.\d+", c)] + assert len(python_classifiers) >= 1, ( + "classifiers must include at least one 'Programming Language :: Python :: 3.X' entry" + ) + + def test_license_classifier_present(self, classifiers: list[str]) -> None: + """At least one 'License :: ' classifier must be present.""" + license_classifiers = [c for c in classifiers if c.startswith("License ::")] + assert len(license_classifiers) >= 1, "classifiers must include at least one 'License :: ' entry" + + +class TestDependencyGroups: + """Tests for [dependency-groups] โ€” ensures required groups are declared.""" + + @pytest.fixture(scope="class") + def dependency_groups(self, pyproject: dict) -> dict: + """Return the [dependency-groups] table.""" + dg = pyproject.get("dependency-groups") + if not isinstance(dg, dict): + pytest.skip("[dependency-groups] not present") + return dg + + def test_test_group_present(self, dependency_groups: dict) -> None: + """A 'test' dependency group must be declared.""" + assert "test" in dependency_groups, "[dependency-groups] must include a 'test' group" + + def test_test_group_includes_pytest(self, dependency_groups: dict) -> None: + """The 'test' dependency group must include pytest.""" + test_deps = dependency_groups.get("test", []) + assert any("pytest" in str(dep).lower() for dep in test_deps), ( + "[dependency-groups.test] must list pytest as a dependency" + ) + + def test_lint_group_present(self, dependency_groups: dict) -> None: + """A 'lint' dependency group must be declared.""" + assert "lint" in dependency_groups, "[dependency-groups] must include a 'lint' group" + + +class TestGitTagVersion: + """Tests for harmony between the latest git tag and pyproject.toml version.""" + + @pytest.fixture(scope="class") + def latest_tag(self, root: Path) -> str: + """Return the latest semver git tag, or skip if none exist.""" + result = subprocess.run( # nosec B603 + [_GIT, "tag", "--list", "v*", "--sort=-version:refname"], + capture_output=True, + text=True, + cwd=root, + ) + tags = [line.strip() for line in result.stdout.splitlines() if line.strip()] + if not tags: + pytest.skip("No version tags found in repository") + return tags[0] + + def test_latest_tag_matches_pyproject_version(self, latest_tag: str, project: dict) -> None: + """The latest git tag (vX.Y.Z) must match [project].version in pyproject.toml.""" + tag_version = str(Version(latest_tag.lstrip("v"))) + pyproject_version = str(Version(project.get("version", ""))) + assert tag_version == pyproject_version, ( + f"Latest git tag {latest_tag!r} (โ†’ {tag_version!r}) does not match " + f"[project].version {pyproject_version!r} in pyproject.toml" + ) diff --git a/.rhiza/tests/structure/test_requirements.py b/.rhiza/tests/structure/test_requirements.py new file mode 100644 index 0000000..1bf9d04 --- /dev/null +++ b/.rhiza/tests/structure/test_requirements.py @@ -0,0 +1,51 @@ +"""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 new file mode 100644 index 0000000..fafa07a --- /dev/null +++ b/.rhiza/tests/sync/conftest.py @@ -0,0 +1,100 @@ +"""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/sync/test_docstrings.py new file mode 100644 index 0000000..231e57b --- /dev/null +++ b/.rhiza/tests/sync/test_docstrings.py @@ -0,0 +1,123 @@ +"""Tests for module docstrings using doctest. + +This file and its associated tests flow down via a SYNC action from the jebel-quant/rhiza repository +(https://github.com/jebel-quant/rhiza). + +Automatically discovers all packages and runs doctests for each. +""" + +from __future__ import annotations + +import doctest +import importlib +import warnings +from pathlib import Path + +import pytest +from dotenv import dotenv_values + +# Read .rhiza/.env at collection time (no environment side-effects). +RHIZA_ENV_PATH = Path(".rhiza/.env") + + +def _iter_modules_from_path(logger, package_path: Path, src_path: Path): + """Recursively find all Python modules in a directory.""" + for path in package_path.rglob("*.py"): + if path.name == "__init__.py": + module_path = path.parent.relative_to(src_path) + else: + module_path = path.relative_to(src_path).with_suffix("") + + # Convert path to module name in an OS-independent way + module_name = ".".join(module_path.parts) + + try: + yield importlib.import_module(module_name) + except ImportError as e: + warnings.warn(f"Could not import {module_name}: {e}", stacklevel=2) + logger.warning("Could not import module %s: %s", module_name, e) + continue + + +def _find_packages(src_path: Path): + """Find all packages in the source path, including those nested under namespace packages.""" + for init_file in src_path.rglob("__init__.py"): + package_dir = init_file.parent + # Only yield top-level packages (those whose parent doesn't have __init__.py or is src_path) + parent = package_dir.parent + if parent == src_path or not (parent / "__init__.py").exists(): + yield package_dir + + +def test_doctests(logger, root, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]): + """Run doctests for each package directory.""" + values = dotenv_values(root / RHIZA_ENV_PATH) if (root / RHIZA_ENV_PATH).exists() else {} + source_folder = values.get("SOURCE_FOLDER", "src") + src_path = root / source_folder + + logger.info("Starting doctest discovery in: %s", src_path) + if not src_path.exists(): + logger.info("Source directory not found: %s โ€” skipping doctests", src_path) + pytest.skip(f"Source directory not found: {src_path}") + + # Add source path to sys.path with automatic cleanup + monkeypatch.syspath_prepend(str(src_path)) + logger.debug("Prepended to sys.path: %s", src_path) + + total_tests = 0 + total_failures = 0 + failed_modules = [] + + # Find all packages in the source path (supports namespace packages) + for package_dir in _find_packages(src_path): + if package_dir.is_dir() and (package_dir / "__init__.py").exists(): + # Import the package + package_name = package_dir.name + logger.info("Discovered package: %s", package_name) + try: + modules = list(_iter_modules_from_path(logger, package_dir, src_path)) + logger.debug("%d module(s) found in package %s", len(modules), package_name) + + for module in modules: + logger.debug("Running doctests for module: %s", module.__name__) + # Disable pytest's stdout capture during doctest to avoid interference + with capsys.disabled(): + results = doctest.testmod( + module, + verbose=False, + optionflags=(doctest.ELLIPSIS | doctest.NORMALIZE_WHITESPACE), + ) + total_tests += results.attempted + + if results.failed: + logger.warning( + "Doctests failed for %s: %d/%d failed", + module.__name__, + results.failed, + results.attempted, + ) + total_failures += results.failed + failed_modules.append((module.__name__, results.failed, results.attempted)) + else: + logger.debug("Doctests passed for %s (%d test(s))", module.__name__, results.attempted) + + except ImportError as e: + warnings.warn(f"Could not import package {package_name}: {e}", stacklevel=2) + logger.warning("Could not import package %s: %s", package_name, e) + continue + + if failed_modules: + formatted = "\n".join(f" {name}: {failed}/{attempted} failed" for name, failed, attempted in failed_modules) + msg = ( + f"Doctest summary: {total_tests} tests across {len(failed_modules)} module(s)\n" + f"Failures: {total_failures}\n" + f"Failed modules:\n{formatted}" + ) + logger.error("%s", msg) + assert total_failures == 0, msg + else: + logger.info("Doctest summary: %d tests, 0 failures", total_tests) + + if total_tests == 0: + logger.info("No doctests were found in any module โ€” skipping") + pytest.skip("No doctests were found in any module") diff --git a/.rhiza/tests/sync/test_readme_validation.py b/.rhiza/tests/sync/test_readme_validation.py new file mode 100644 index 0000000..b889166 --- /dev/null +++ b/.rhiza/tests/sync/test_readme_validation.py @@ -0,0 +1,197 @@ +"""Tests for README code examples. + +This file and its associated tests flow down via a SYNC action from the jebel-quant/rhiza repository +(https://github.com/jebel-quant/rhiza). + +This module extracts Python code and expected result blocks from README.md, +executes the code, and verifies the output matches the documented result. +""" + +import re +import subprocess # nosec B404 +import sys + +import pytest + +# Regex for Python code blocks โ€” captures optional flags (e.g. "+RHIZA_SKIP") and the code body. +CODE_BLOCK = re.compile(r"```python([^\n]*)\n(.*?)```", re.DOTALL) + +RESULT = re.compile(r"```result\n(.*?)```", re.DOTALL) + +# Regex for Bash code blocks โ€” captures optional flags and the code body. +BASH_BLOCK = re.compile(r"```bash([^\n]*)\n(.*?)```", re.DOTALL) + +# Bash executable used for syntax checking; subprocess.run below is trusted (noqa: S603). +BASH = "bash" + +# Flag that marks a code block as intentionally excluded from readme tests. +# Usage: add the flag after the language identifier on the opening fence line, +# e.g. ```python +RHIZA_SKIP or ```bash +RHIZA_SKIP +SKIP_FLAG = "+RHIZA_SKIP" + + +def _should_skip(flags: str) -> bool: + """Return True if the fence flags string contains the +RHIZA_SKIP marker.""" + return SKIP_FLAG in flags + + +def test_readme_runs(logger, root): + """Execute README code blocks and compare output to documented results.""" + readme = root / "README.md" + logger.info("Reading README from %s", readme) + readme_text = readme.read_text(encoding="utf-8") + all_code_blocks = CODE_BLOCK.findall(readme_text) + result_blocks = RESULT.findall(readme_text) + + code_blocks = [] + for i, (flags, code) in enumerate(all_code_blocks): + if _should_skip(flags): + logger.info("Skipping Python code block %d (%s flag)", i, SKIP_FLAG) + else: + code_blocks.append(code) + + logger.info( + "Found %d code block(s) (%d skipped) and %d result block(s) in README", + len(all_code_blocks), + len(all_code_blocks) - len(code_blocks), + len(result_blocks), + ) + + code = "".join(code_blocks) # merged code + expected = "".join(result_blocks) # merged results + + # Trust boundary: we execute Python snippets sourced from README.md in this repo. + # The README is part of the trusted repository content and reviewed in PRs. + logger.debug("Executing README code via %s -c ...", sys.executable) + result = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True, cwd=root) # nosec + + stdout = result.stdout + logger.debug("Execution finished with return code %d", result.returncode) + if result.stderr: + logger.debug("Stderr from README code:\n%s", result.stderr) + logger.debug("Stdout from README code:\n%s", stdout) + + assert result.returncode == 0, f"README code exited with {result.returncode}. Stderr:\n{result.stderr}" + logger.info("README code executed successfully; comparing output to expected result") + assert stdout.strip() == expected.strip() + logger.info("README code output matches expected result") + + +class TestReadmeTestEdgeCases: + """Edge cases for README code block testing.""" + + def test_readme_file_exists_at_root(self, root): + """README.md should exist at repository root.""" + readme = root / "README.md" + assert readme.exists() + assert readme.is_file() + + def test_readme_is_readable(self, root): + """README.md should be readable with UTF-8 encoding.""" + readme = root / "README.md" + content = readme.read_text(encoding="utf-8") + assert len(content) > 0 + assert isinstance(content, str) + + def test_readme_code_is_syntactically_valid(self, root): + """Python code blocks in README should be syntactically valid (skipped blocks are excluded).""" + readme = root / "README.md" + content = readme.read_text(encoding="utf-8") + all_code_blocks = CODE_BLOCK.findall(content) + + for i, (flags, code) in enumerate(all_code_blocks): + if _should_skip(flags): + continue + try: + compile(code, f"", "exec") + except SyntaxError as e: + pytest.fail(f"Code block {i} has syntax error: {e}") + + +class TestReadmeBashFragments: + """Tests for bash code fragments in README.""" + + def test_bash_blocks_basic_syntax(self, root, logger): + """Bash code blocks should have basic valid syntax (can be parsed by bash -n).""" + readme = root / "README.md" + content = readme.read_text(encoding="utf-8") + bash_blocks = BASH_BLOCK.findall(content) + + logger.info("Found %d bash code block(s) in README", len(bash_blocks)) + + for i, (flags, code) in enumerate(bash_blocks): + if _should_skip(flags): + logger.info("Skipping bash block %d (%s flag)", i, SKIP_FLAG) + continue + + # Skip directory tree representations and other non-executable blocks + if any(marker in code for marker in ["โ”œโ”€โ”€", "โ””โ”€โ”€", "โ”‚"]): + logger.info("Skipping bash block %d (directory tree representation)", i) + continue + + # Skip blocks that are primarily comments or documentation + lines = [line.strip() for line in code.split("\n") if line.strip()] + non_comment_lines = [line for line in lines if not line.startswith("#")] + if not non_comment_lines: + logger.info("Skipping bash block %d (only comments)", i) + continue + + logger.debug("Checking bash block %d:\n%s", i, code) + + # Use bash -n to check syntax without executing + # Trust boundary: we use bash -n which only parses without executing + result = subprocess.run( # nosec + [BASH, "-n"], + input=code, + capture_output=True, + text=True, + ) + + if result.returncode != 0: + pytest.fail(f"Bash block {i} has syntax errors:\nCode:\n{code}\nError:\n{result.stderr}") + + +class TestSkipFlag: + """Tests for the +RHIZA_SKIP flag that allows individual README code blocks to be excluded.""" + + def test_should_skip_returns_true_for_skip_flag(self): + """+RHIZA_SKIP in flags string should cause _should_skip to return True.""" + assert _should_skip(" +RHIZA_SKIP") is True + assert _should_skip("+RHIZA_SKIP") is True + assert _should_skip(" +RHIZA_SKIP other-flag") is True + + def test_should_skip_returns_false_without_flag(self): + """Absence of +RHIZA_SKIP should cause _should_skip to return False.""" + assert _should_skip("") is False + assert _should_skip(" ") is False + assert _should_skip("other-flag") is False + + def test_python_block_with_skip_flag_is_excluded(self, tmp_path): + """A ```python +RHIZA_SKIP block should not appear in the list of blocks to execute.""" + readme = tmp_path / "README.md" + readme.write_text( + '```python +RHIZA_SKIP\nraise RuntimeError("should not run")\n```\n' + "```python\nprint('hello')\n```\n" + "```result\nhello\n```\n", + encoding="utf-8", + ) + content = readme.read_text(encoding="utf-8") + all_blocks = CODE_BLOCK.findall(content) + assert len(all_blocks) == 2 + executed = [code for flags, code in all_blocks if not _should_skip(flags)] + assert len(executed) == 1 + assert "raise RuntimeError" not in executed[0] + + def test_bash_block_with_skip_flag_is_excluded(self, tmp_path): + """A ```bash +RHIZA_SKIP block should not be syntax-checked.""" + readme = tmp_path / "README.md" + readme.write_text( + "```bash +RHIZA_SKIP\nnot-valid-bash @@@@\n```\n```bash\necho hello\n```\n", + encoding="utf-8", + ) + content = readme.read_text(encoding="utf-8") + all_blocks = BASH_BLOCK.findall(content) + assert len(all_blocks) == 2 + checked = [code for flags, code in all_blocks if not _should_skip(flags)] + assert len(checked) == 1 + assert "not-valid-bash" not in checked[0] diff --git a/.rhiza/tests/test_utils.py b/.rhiza/tests/test_utils.py new file mode 100644 index 0000000..a7c1da4 --- /dev/null +++ b/.rhiza/tests/test_utils.py @@ -0,0 +1,70 @@ +"""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, + ) diff --git a/.rhiza/tests/utils/test_git_repo_fixture.py b/.rhiza/tests/utils/test_git_repo_fixture.py new file mode 100644 index 0000000..223cfee --- /dev/null +++ b/.rhiza/tests/utils/test_git_repo_fixture.py @@ -0,0 +1,132 @@ +"""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/utils/pip_audit_policy.py b/.rhiza/utils/pip_audit_policy.py new file mode 100644 index 0000000..4a5db0c --- /dev/null +++ b/.rhiza/utils/pip_audit_policy.py @@ -0,0 +1,67 @@ +"""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 new file mode 100644 index 0000000..069d2e7 --- /dev/null +++ b/.rhiza/utils/suppression_audit.py @@ -0,0 +1,369 @@ +"""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 cf50474..e0c6738 100644 --- a/Makefile +++ b/Makefile @@ -1,15 +1,15 @@ ## Makefile (repo-owned) # Keep this file small. It can be edited without breaking template sync. -# Bootstrap sync: active only before .rhiza/rhiza.mk is materialized by first sync -ifeq ($(wildcard .rhiza/rhiza.mk),) -.PHONY: sync -sync: ## Sync with template repository as defined in .rhiza/template.yml - uvx rhiza sync . -endif - -# Include the Rhiza API (template-managed, optional on first run) --include .rhiza/rhiza.mk +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]' + +# Always include the Rhiza API (template-managed) +include .rhiza/rhiza.mk # Optional: developer-local extensions (not committed) -include local.mk diff --git a/docs/assets/rhiza-logo.svg b/docs/assets/rhiza-logo.svg new file mode 100644 index 0000000..ff1c9f5 --- /dev/null +++ b/docs/assets/rhiza-logo.svg @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/development/MARIMO.md b/docs/development/MARIMO.md new file mode 100644 index 0000000..1aa87af --- /dev/null +++ b/docs/development/MARIMO.md @@ -0,0 +1,134 @@ +# Marimo Notebooks + +This directory contains interactive [Marimo](https://marimo.io/) notebooks for the Rhiza project. + +## Features + +Marimo notebooks support a wide range of features, including: + +- **Interactive UI Elements**: Sliders, dropdowns, text inputs, checkboxes, and multiselect +- **Reactive Programming**: Automatic cell updates when dependencies change +- **Data Visualisation**: Interactive plots using Plotly +- **DataFrames**: Working with Pandas data +- **Layout Components**: Columns, tabs, and accordions for organised content +- **Forms**: Dictionary-based forms for collecting user input +- **Rich Text**: Markdown and LaTeX support for documentation +- **Advanced Features**: Callouts, collapsible accordions, and more + +## Running the Notebooks + +### Using the Makefile + +From the repository root: + +```bash +make marimo +``` + +This will start the Marimo server and open all notebooks in the `docs/notebooks` directory. + +### Running a Specific Notebook + +To run a single notebook: + +```bash +marimo edit docs/notebooks/my_notebook.py +``` + +### Using uv (Recommended) + +The notebooks include inline dependency metadata, making them self-contained: + +```bash +uv run docs/notebooks/my_notebook.py +``` + +This will automatically install the required dependencies and run the notebook. + +## Notebook Structure + +Marimo notebooks are **pure Python files** (`.py`), not JSON. This means: + +- โœ… Easy version control with Git +- โœ… Standard code review workflows +- โœ… No hidden metadata +- โœ… Compatible with all Python tools + +Each notebook includes inline metadata that specifies its dependencies: + +```python +# /// script +# requires-python = ">=3.11" +# dependencies = [ +# "marimo==0.18.4", +# "numpy>=1.24.0", +# ] +# /// +``` + +## Configuration + +Marimo is configured in `pyproject.toml` to properly import the local package: + +```toml +[tool.marimo.runtime] +pythonpath = ["src"] +``` + +## CI/CD Integration + +The `.github/workflows/rhiza_marimo.yml` workflow automatically: + +1. Discovers all `.py` files in this directory +2. Runs each notebook in a fresh environment +3. Verifies that notebooks can bootstrap themselves +4. Ensures reproducibility + +This guarantees that all notebooks remain functional and up-to-date. + +## Creating New Notebooks + +To create a new Marimo notebook: + +1. Create a new `.py` file in this directory: + ```bash + marimo edit docs/notebooks/my_notebook.py + ``` + +2. Add inline metadata at the top: + ```python + # /// script + # requires-python = ">=3.11" + # dependencies = [ + # "marimo==0.18.4", + # # ... other dependencies + # ] + # /// + ``` + +3. Start building your notebook with cells + +4. Test it runs in a clean environment: + ```bash + uv run docs/notebooks/my_notebook.py + ``` + +5. Commit and push - the CI will validate it automatically + +## Learn More + +- **Marimo Documentation**: [https://docs.marimo.io/](https://docs.marimo.io/) +- **Example Gallery**: [https://marimo.io/examples](https://marimo.io/examples) +- **Community Discord**: [https://discord.gg/JE7nhX6mD8](https://discord.gg/JE7nhX6mD8) + +## Tips + +- **Reactivity**: Remember that cells automatically re-run when their dependencies change +- **Pure Python**: Edit notebooks in any text editor, not just Marimo's UI +- **Git-Friendly**: Notebooks diff and merge like regular Python files +- **Self-Contained**: Use inline metadata to make notebooks reproducible +- **Interactive**: Take advantage of Marimo's rich UI components for better user experience + +--- + +*Happy exploring with Marimo! ๐Ÿš€* diff --git a/docs/development/TESTS.md b/docs/development/TESTS.md new file mode 100644 index 0000000..f77b134 --- /dev/null +++ b/docs/development/TESTS.md @@ -0,0 +1,288 @@ +# Property-Based and Load/Stress Testing + +This document describes the property-based testing and load/stress testing infrastructure added to the Rhiza project. + +## Overview + +Rhiza now includes two additional types of testing: + +1. **Property-Based Testing** (using Hypothesis) - Tests that verify properties hold across a wide range of generated inputs +2. **Load/Stress Testing** (using pytest-benchmark) - Tests that measure performance and verify stability under load + +## README Code Block Testing + +The test file `.rhiza/tests/sync/test_readme_validation.py` automatically executes every `python` +code block in `README.md` and checks that its output matches the adjacent ` ```result ` block. It +also syntax-checks every `bash` block using `bash -n`. + +### Skipping individual blocks with `+RHIZA_SKIP` + +To exclude a specific code block from being executed or syntax-checked, append `+RHIZA_SKIP` to +the opening fence line: + +~~~markdown +```python +RHIZA_SKIP +# This block will NOT be executed or syntax-checked by the readme tests. +# Use it for illustrative examples, environment-specific code, or incomplete snippets. +from my_env import some_function +some_function() +``` + +```bash +RHIZA_SKIP +# This bash block will NOT be syntax-checked. +run-something --only-on-ci +``` +~~~ + +Markdown renderers (including GitHub) ignore everything after the first word on a fence line, so +the block still renders as a normal syntax-highlighted code block. All blocks that do **not** carry +`+RHIZA_SKIP` continue to be validated as before. + +## Property-Based Testing + +Property-based tests use the [Hypothesis](https://hypothesis.readthedocs.io/) library to automatically generate test cases that verify certain properties always hold true. + +### Locations + +In a standard Rhiza project, there are two relevant locations for property-based tests: + +- **Project property-based tests** live in `tests/property/`. These are part of your normal test suite and are discovered by `pytest` via `pytest.ini:testpaths = tests`. +- **Rhiza's own template/internal property-based tests** (if present) live in `.rhiza/tests/property/`. These are not part of your project's main test suite by default. + +### Running Property-Based Tests + +In a typical setup, the Make targets map to these suites as follows: + +- `make test`: runs `pytest` over everything under `tests/` (including `tests/property/`). +- `make rhiza-test`: runs Rhiza's internal tests under `.rhiza/tests/` (including `.rhiza/tests/property/` if any exist). + +You can also invoke the corresponding `pytest` commands directly: + +```bash +# Run all project property-based tests (what make test covers) +uv run pytest tests/property/ -v + +# Run Rhiza's internal/template property-based tests (if you have any in .rhiza) +uv run pytest .rhiza/tests/property/ -v + +# Run project property-based tests with more examples (increase coverage) +uv run pytest tests/property/ -v --hypothesis-max-examples=1000 + +# Run project property-based tests with verbose Hypothesis output +uv run pytest tests/property/ -v --hypothesis-verbosity=verbose +``` + +### Example Tests + +The following property-based tests are included as examples: + +#### Generic Property Tests +- **test_sort_correctness_using_properties**: Verifies that sorted() correctly orders lists and preserves all elements including duplicates + +## Load/Stress Testing + +Load and stress tests use [pytest-benchmark](https://pytest-benchmark.readthedocs.io/) to measure performance and verify system stability under load. + +### Location + +Benchmark and stress tests are located in `tests/benchmarks/` (if present) + +### Running Benchmark Tests + +```bash +# Run all benchmarks +make benchmark + +# Or with pytest directly +uv run pytest tests/benchmarks/ -v + +# Run benchmarks and generate histogram +uv run pytest tests/benchmarks/ --benchmark-histogram=_tests/benchmarks/histogram + +# Run benchmarks and save results +uv run pytest tests/benchmarks/ --benchmark-json=_tests/benchmarks/results.json + +# Skip benchmarks (for CI) +uv run pytest tests/benchmarks/ --benchmark-skip + +# Run only stress tests (note: these don't run with make benchmark by default) +uv run pytest tests/benchmarks/ -m stress -v + +# Skip stress tests (run only performance benchmarks) +uv run pytest tests/benchmarks/ -m "not stress" -v +``` + +**Note**: The `make benchmark` target runs with `--benchmark-only`, which means stress tests (that don't use the `benchmark` fixture) will be skipped. To run stress tests explicitly, use `uv run pytest tests/benchmarks/ -m stress -v`. + +### Benchmark Test Categories + +#### 1. Makefile Performance +Tests that measure the performance of common Makefile operations: +- `test_help_target_performance` - Measures help target execution time +- `test_print_variable_performance` - Measures variable printing performance +- `test_dry_run_install_performance` - Measures dry-run install performance +- `test_makefile_parsing_overhead` - Measures Makefile parsing overhead + +#### 2. File System Operations +Tests that benchmark file system operations: +- `test_directory_traversal_performance` - Measures directory traversal speed +- `test_file_reading_performance` - Measures file reading performance +- `test_multiple_file_checks_performance` - Measures file existence checking + +#### 3. Subprocess Overhead +Tests that measure subprocess creation overhead: +- `test_subprocess_creation_overhead` - Measures subprocess creation time +- `test_git_command_performance` - Measures git command execution time + +#### 4. Stress Scenarios +Tests that verify stability under load (marked with `@pytest.mark.stress`): +- `test_repeated_help_invocations` - Stress tests repeated help invocations (100 iterations) +- `test_concurrent_print_variable_stress` - Tests concurrent Makefile invocations (deterministic) +- `test_file_system_stress` - Tests rapid file creation/deletion (100 iterations) + +**Note**: Stress tests can be slow and are marked with the `stress` marker. They don't use the `benchmark` fixture, so they won't run with `make benchmark` (which uses `--benchmark-only`). Use `uv run pytest tests/benchmarks/ -m stress -v` to run them explicitly. + +### Understanding Benchmark Results + +Benchmark output includes: +- **Min/Max**: Minimum and maximum execution times +- **Mean**: Average execution time +- **StdDev**: Standard deviation (consistency) +- **Median**: Median execution time +- **IQR**: Interquartile range +- **Outliers**: Number of outlier measurements +- **OPS**: Operations per second (1/Mean) + +Example output: +``` +--------------------------------------------------- benchmark: 1 tests --------------------------------------------------- +Name (time in ms) Min Max Mean StdDev Median IQR Outliers OPS Rounds Iterations +-------------------------------------------------------------------------------------------------------------------------- +test_help_target_performance 16.5255 18.0592 16.9294 0.3194 16.8354 0.4791 15;1 59.0689 55 1 +-------------------------------------------------------------------------------------------------------------------------- +``` + +## Integration with CI/CD + +### Running in CI + +The property-based tests run as part of the regular test suite: + +```bash +# Run all tests including property-based tests +make test +``` + +Benchmarks can be run separately or as part of validation: + +```bash +# Run benchmarks +make benchmark +``` + +## Best Practices + +### Writing Property-Based Tests + +1. **Focus on invariants**: Test properties that should always hold true +2. **Use appropriate strategies**: Choose Hypothesis strategies that generate realistic inputs +3. **Keep tests fast**: Property tests run multiple times, so keep them quick +4. **Test edge cases**: Use `@example` decorator to test specific known edge cases + +Example: +```python +from hypothesis import given, strategies as st, example + +@given(version=st.from_regex(r"^\d+\.\d+\.\d+$", fullmatch=True)) +@example(version="0.0.0") # Test specific edge case +def test_version_parsing(version): + parts = version.split(".") + assert len(parts) == 3 + assert all(p.isdigit() for p in parts) +``` + +### Writing Benchmark Tests + +1. **Benchmark real operations**: Test actual operations users will perform +2. **Use fixtures wisely**: Use module or session-scoped fixtures for expensive setup +3. **Test multiple scenarios**: Benchmark best case, average case, and worst case +4. **Monitor trends**: Track benchmark results over time to detect regressions + +Example: +```python +def test_operation_performance(benchmark): + def run_operation(): + # Operation to benchmark + return perform_operation() + + result = benchmark(run_operation) + assert result is not None +``` + +### Writing Stress Tests + +1. **Test realistic scenarios**: Simulate real-world usage patterns +2. **Set reasonable thresholds**: Allow small failure rates for resource contention +3. **Test concurrency**: Use ThreadPoolExecutor or ProcessPoolExecutor for concurrent tests +4. **Monitor resource usage**: Consider memory, CPU, and I/O in addition to time + +Example: +```python +def test_concurrent_operations(root): + import concurrent.futures + + def operation(): + # Operation to stress test + return perform_operation() + + with concurrent.futures.ThreadPoolExecutor(max_workers=10) as executor: + futures = [executor.submit(operation) for _ in range(100)] + results = [f.result() for f in concurrent.futures.as_completed(futures)] + + success_rate = sum(results) / len(results) + assert success_rate == 1.0 # Rhiza template stress tests require 100% success +``` + +## Dependencies + +The following dependencies are required for property-based and load/stress testing: + +``` +# Property-based testing +hypothesis>=6.150.0 + +# Benchmarking and performance testing +pytest-benchmark>=5.2.3 +pygal>=3.1.0 +``` + +These are automatically installed when running `make install` or by installing from `.rhiza/requirements/tests.txt`. + +## Troubleshooting + +### Hypothesis Hangs or Times Out + +If Hypothesis tests hang, you can: +1. Reduce the number of examples: `pytest --hypothesis-max-examples=10` +2. Set a deadline: `pytest --hypothesis-deadline=1000` +3. Use the CI profile: `pytest --hypothesis-profile=ci` + +### Benchmarks Vary Too Much + +If benchmark results have high variance: +1. Close other applications to reduce system load +2. Increase the number of rounds: `pytest --benchmark-min-rounds=10` +3. Run on a consistent environment (CI is preferred for accurate benchmarks) + +### Stress Tests Fail + +If stress tests fail occasionally: +1. Check system resources (memory, CPU) +2. Increase acceptable failure rate if resource contention is expected +3. Reduce iteration count for local development + +## References + +- [Hypothesis Documentation](https://hypothesis.readthedocs.io/) +- [pytest-benchmark Documentation](https://pytest-benchmark.readthedocs.io/) +- [GitHub Actions Benchmark Action](https://github.com/benchmark-action/github-action-benchmark) diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..0e0b6b4 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,2 @@ +--8<-- "README.md" + diff --git a/docs/mkdocs-base.yml b/docs/mkdocs-base.yml new file mode 100644 index 0000000..095ba11 --- /dev/null +++ b/docs/mkdocs-base.yml @@ -0,0 +1,120 @@ +# docs/mkdocs-base.yml โ€” Base MkDocs configuration for rhiza-based projects. +# +# USAGE (standalone) +# Build or serve this file directly if you have no root-level mkdocs.yml: +# uvx --with mkdocs-material mkdocs serve -f docs/mkdocs-base.yml +# The rhiza build system (make book) will pick this file up automatically +# as a fallback when no root-level mkdocs.yml is found. +# +# USAGE (with INHERIT) +# To extend this config from a root-level mkdocs.yml, add the following +# at the top of your mkdocs.yml and override only what you need: +# +# INHERIT: docs/mkdocs-base.yml +# +# site_name: My Project +# site_url: https://example.github.io/my-project/ +# repo_url: https://github.com/example/my-project +# repo_name: example/my-project +# +# docs_dir: docs # always set this explicitly โ€” base uses docs_dir: . which +# # resolves relative to docs/mkdocs-base.yml, not mkdocs.yml +# +# nav: # nav is fully replaced โ€” not merged โ€” by the child config +# - Home: index.md +# - Guide: guide.md +# +# Any key you omit in mkdocs.yml is inherited from this file. +# The 'nav' key is always fully replaced when defined in the child. + +docs_dir: docs +site_dir: _book + +# ---------------------------------------------------------------------------- +# Theme +# ---------------------------------------------------------------------------- +theme: + name: material + language: en + features: + - announce.dismiss + - content.code.annotate + - content.code.copy + - content.code.select + - content.footnote.tooltips + - content.tabs.link + - content.tooltips + - navigation.footer + - navigation.indexes + - navigation.instant + - navigation.instant.prefetch + - navigation.path + - navigation.top + - navigation.tracking + - search.highlight + palette: + - scheme: default + toggle: + icon: material/weather-sunny + name: Switch to dark mode + - scheme: slate + toggle: + icon: material/weather-night + name: Switch to light mode + +# ---------------------------------------------------------------------------- +# Markdown extensions +# ---------------------------------------------------------------------------- +markdown_extensions: + - abbr + - admonition + - attr_list + - def_list + - footnotes + - md_in_html + - toc: + permalink: true + toc_depth: 3 + - pymdownx.betterem + - pymdownx.caret + - pymdownx.details + - pymdownx.emoji: + emoji_generator: !!python/name:material.extensions.emoji.to_svg + emoji_index: !!python/name:material.extensions.emoji.twemoji + - pymdownx.highlight: + anchor_linenums: true + line_spans: __span + pygments_lang_class: true + - pymdownx.inlinehilite + - pymdownx.keys + - pymdownx.mark + - pymdownx.smartsymbols + - pymdownx.superfences: + custom_fences: + - name: mermaid + class: mermaid + format: !!python/name:pymdownx.superfences.fence_code_format + - pymdownx.tabbed: + alternate_style: true + combine_header_slug: true + - pymdownx.tasklist: + custom_checkbox: true + - pymdownx.tilde + - pymdownx.snippets: + base_path: ["."] + +# ---------------------------------------------------------------------------- +# Plugins +# ---------------------------------------------------------------------------- +plugins: + - search + - mkdocstrings: + handlers: + python: + options: + docstring_style: google + show_source: true + show_root_heading: true + show_symbol_type_heading: true + show_symbol_type_toc: true + diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..da5b41e --- /dev/null +++ b/pytest.ini @@ -0,0 +1,16 @@ +[pytest] +testpaths = tests +# Enable live logs on console +log_cli = true +# Show DEBUG+ messages +log_cli_level = DEBUG +log_cli_format = %(asctime)s %(levelname)s %(name)s: %(message)s +log_cli_date_format = %H:%M:%S +# Show extra summary info for skipped/failed tests +addopts = -ra +timeout = 60 +# Register custom markers +markers = + stress: marks tests as stress tests (deselect with '-m "not stress"') + property: marks tests as property-based tests + kaleido: marks tests that require the kaleido package for static image export diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000..4563178 --- /dev/null +++ b/ruff.toml @@ -0,0 +1,131 @@ +# This file is part of the jebel-quant/rhiza repository +# (https://github.com/jebel-quant/rhiza). +# +# Maximum line length for the entire project +line-length = 120 +# Target Python version +target-version = "py311" + +# Exclude directories with Jinja template variables in their names +exclude = ["**/[{][{]*/", "**/*[}][}]*/"] + +[lint] +# Available rule sets in Ruff: +# A: flake8-builtins - Check for python builtins being used as variables or parameters +# B: flake8-bugbear - Find likely bugs and design problems +# C4: flake8-comprehensions - Helps write better list/set/dict comprehensions +# D: pydocstyle - Check docstring style +# E: pycodestyle errors - PEP 8 style guide +# ERA: eradicate - Find commented out code +# F: pyflakes - Detect logical errors +# I: isort - Sort imports +# N: pep8-naming - Check PEP 8 naming conventions +# PT: flake8-pytest-style - Check pytest best practices +# RUF: Ruff-specific rules +# S: flake8-bandit - Find security issues +# SIM: flake8-simplify - Simplify code +# T10: flake8-debugger - Check for debugger imports and calls +# UP: pyupgrade - Upgrade syntax for newer Python +# W: pycodestyle warnings - PEP 8 style guide warnings +# ANN: flake8-annotations - Type annotation checks +# ARG: flake8-unused-arguments - Unused arguments +# BLE: flake8-blind-except - Check for blind except statements +# COM: flake8-commas - Trailing comma enforcement +# DTZ: flake8-datetimez - Ensure timezone-aware datetime objects +# EM: flake8-errmsg - Check error message strings +# FBT: flake8-boolean-trap - Boolean argument checks +# ICN: flake8-import-conventions - Import convention enforcement +# ISC: flake8-implicit-str-concat - Implicit string concatenation +# NPY: NumPy-specific rules +# PD: pandas-specific rules +# PGH: pygrep-hooks - Grep-based checks +# PIE: flake8-pie - Miscellaneous rules +# PL: Pylint rules +# Q: flake8-quotes - Quotation style enforcement +# RSE: flake8-raise - Raise statement checks +# RET: flake8-return - Return statement checks +# SLF: flake8-self - Check for self references +# TCH: flake8-type-checking - Type checking imports +# TID: flake8-tidy-imports - Import tidying +# TRY: flake8-try-except-raise - Try/except/raise checks +# YTT: flake8-2020 - Python 2020+ compatibility + +select = [ + "D", # pydocstyle - Check docstring style + "E", # pycodestyle errors - PEP 8 style guide + "F", # pyflakes - Detect logical errors + "I", # isort - Sort imports + "N", # pep8-naming - Check PEP 8 naming conventions + "W", # pycodestyle warnings - PEP 8 style guide warnings + "UP", # pyupgrade - Upgrade syntax for newer Python +] + +# Ensure docstrings are required for magic methods (e.g., __init__) and +# private/underscored modules by explicitly selecting the relevant pydocstyle +# rules in addition to the D set. This makes the intent clear and future-proof +# if the default pydocstyle selection changes. +extend-select = [ + "D105", # pydocstyle - Require docstrings for magic methods + "D107", # pydocstyle - Require docstrings for __init__ + "B", # flake8-bugbear - Find likely bugs and design problems + "C4", # flake8-comprehensions - Better list/set/dict comprehensions + "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 +] + +# Resolve incompatible pydocstyle rules: prefer D211 and D212 over D203 and D213 +ignore = [ + "D203", # one-blank-line-before-class (conflicts with D211) + "D213", # multi-line-summary-second-line (conflicts with D212) +] + +[lint.pydocstyle] +convention = "google" + +# Formatting configuration +[format] +# Use double quotes for strings +quote-style = "double" +# Use spaces for indentation +indent-style = "space" +# Automatically detect and use the appropriate line ending +line-ending = "auto" + +# File-specific rule exceptions +[lint.per-file-ignores] +# Test files - allow assert statements and subprocess calls for testing +"**/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 +] +"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 +] +# Marimo notebooks - allow flexible coding patterns for interactive exploration +"**/notebooks/*.py" = [ + "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 + "PLC0415", # Allow imports not at top-level in notebooks + "B018", # Allow useless expressions in notebooks + "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 +]