From b33ebe52f6fb0711875d54e92a67773e202b0598 Mon Sep 17 00:00:00 2001 From: Thomas Schmelzer Date: Sat, 6 Jun 2026 09:14:29 +0400 Subject: [PATCH 1/2] chore: bump rhiza to v0.18.8 --- .rhiza/template.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.rhiza/template.yml b/.rhiza/template.yml index ad1bde3d..b7469cb3 100644 --- a/.rhiza/template.yml +++ b/.rhiza/template.yml @@ -1,5 +1,5 @@ repository: "jebel-quant/rhiza" -ref: "v0.18.4" +ref: "v0.18.8" profiles: - github-project From cca900726d62942850ee778dab1330500843b90e Mon Sep 17 00:00:00 2001 From: Thomas Schmelzer Date: Mon, 8 Jun 2026 09:41:11 +0400 Subject: [PATCH 2/2] chore: bump rhiza to v0.18.8 Co-Authored-By: Claude Sonnet 4.6 --- .github/actions/configure-git-auth/README.md | 80 -------- .github/actions/configure-git-auth/action.yml | 21 --- .github/workflows/rhiza_benchmark.yml | 2 +- .github/workflows/rhiza_book.yml | 2 +- .github/workflows/rhiza_ci.yml | 2 +- .github/workflows/rhiza_codeql.yml | 2 +- .github/workflows/rhiza_marimo.yml | 2 +- .github/workflows/rhiza_release.yml | 139 +++++++++++++- .github/workflows/rhiza_sync.yml | 2 +- .github/workflows/rhiza_weekly.yml | 2 +- .rhiza/make.d/releasing.mk | 2 +- .rhiza/make.d/test.mk | 19 +- .rhiza/requirements/tests.txt | 1 + .rhiza/template.lock | 12 +- .rhiza/tests/api/test_github_targets.py | 63 +++++++ .../tests/api/test_make_variable_overrides.py | 161 ++++++++++++++++ .rhiza/tests/api/test_makefile_targets.py | 4 +- .rhiza/tests/integration/test_sbom.py | 172 ------------------ .rhiza/tests/structure/test_pyproject.py | 33 ++++ pyproject.toml.template | 12 -- 20 files changed, 424 insertions(+), 309 deletions(-) delete mode 100644 .github/actions/configure-git-auth/README.md delete mode 100644 .github/actions/configure-git-auth/action.yml create mode 100644 .rhiza/tests/api/test_github_targets.py create mode 100644 .rhiza/tests/api/test_make_variable_overrides.py delete mode 100644 .rhiza/tests/integration/test_sbom.py delete mode 100644 pyproject.toml.template diff --git a/.github/actions/configure-git-auth/README.md b/.github/actions/configure-git-auth/README.md deleted file mode 100644 index 4b6faeb7..00000000 --- a/.github/actions/configure-git-auth/README.md +++ /dev/null @@ -1,80 +0,0 @@ -# Configure Git Auth for Private Packages - -This composite action configures git to use token authentication for private GitHub packages. - -## Usage - -Add this step before installing dependencies that include private GitHub packages: - -```yaml -- name: Configure git auth for private packages - uses: ./.github/actions/configure-git-auth - with: - token: ${{ secrets.GH_PAT }} -``` - -The `GH_PAT` secret should be a Personal Access Token with `repo` scope. - -## What It Does - -This action runs: - -```bash -git config --global url."https://@github.com/".insteadOf "https://github.com/" -``` - -This tells git to automatically inject the token into all HTTPS GitHub URLs, enabling access to private repositories. - -## When to Use - -Use this action when your project has dependencies defined in `pyproject.toml` like: - -```toml -[tool.uv.sources] -private-package = { git = "https://github.com/your-org/private-package.git", rev = "v1.0.0" } -``` - -## Token Requirements - -By default, this action will use the workflow’s built-in `GITHUB_TOKEN` (`github.token`) if no `token` input is provided or if the provided value is empty (it uses `inputs.token || github.token` internally). - -The `GITHUB_TOKEN` is usually sufficient when: - -- installing dependencies hosted in the **same repository** as the workflow, or -- accessing **public** repositories. - -The default `GITHUB_TOKEN` typically does **not** have permission to read other private repositories, even within the same organization. For that scenario, you should create a Personal Access Token (PAT) with `repo` scope and store it as `secrets.GH_PAT`, then pass it to the action via the `token` input. - -If you configure the step as in the example (`token: ${{ secrets.GH_PAT }}`) and `secrets.GH_PAT` is not defined, GitHub Actions passes an empty string to the action. The composite action then falls back to `github.token`, so the configuration step itself still succeeds. However, any subsequent step that tries to access private repositories that are not covered by the permissions of `GITHUB_TOKEN` will fail with an authentication error. -## Example Workflow - -```yaml -name: CI - -on: [push, pull_request] - -jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - - name: Install uv - uses: astral-sh/setup-uv@v7 - - - name: Configure git auth for private packages - uses: ./.github/actions/configure-git-auth - with: - token: ${{ secrets.GH_PAT }} - - - name: Install dependencies - run: uv sync --frozen - - - name: Run tests - run: uv run pytest -``` - -## See Also - -- [PRIVATE_PACKAGES.md](../../../.rhiza/docs/PRIVATE_PACKAGES.md) - Complete guide to using private packages -- [TOKEN_SETUP.md](../../../.rhiza/docs/TOKEN_SETUP.md) - Setting up Personal Access Tokens diff --git a/.github/actions/configure-git-auth/action.yml b/.github/actions/configure-git-auth/action.yml deleted file mode 100644 index d4d898fb..00000000 --- a/.github/actions/configure-git-auth/action.yml +++ /dev/null @@ -1,21 +0,0 @@ -name: 'Configure Git Auth for Private Packages' -description: 'Configure git to use token authentication for private GitHub packages' - -inputs: - token: - description: 'GitHub token to use for authentication' - required: false - -runs: - using: composite - steps: - - name: Configure git authentication - shell: bash - env: - GH_TOKEN: ${{ inputs.token || github.token }} - run: | - # Configure git to use token authentication for GitHub URLs - # This allows uv/pip to install private packages from GitHub - git config --global url."https://${GH_TOKEN}@github.com/".insteadOf "https://github.com/" - - echo "βœ“ Git configured to use token authentication for GitHub" diff --git a/.github/workflows/rhiza_benchmark.yml b/.github/workflows/rhiza_benchmark.yml index ed08b778..48b1c302 100644 --- a/.github/workflows/rhiza_benchmark.yml +++ b/.github/workflows/rhiza_benchmark.yml @@ -20,5 +20,5 @@ on: jobs: benchmark: - uses: jebel-quant/rhiza/.github/workflows/rhiza_benchmark.yml@v0.18.5 + 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 index f91bf10e..368ec167 100644 --- a/.github/workflows/rhiza_book.yml +++ b/.github/workflows/rhiza_book.yml @@ -24,7 +24,7 @@ on: jobs: book: - uses: jebel-quant/rhiza/.github/workflows/rhiza_book.yml@v0.18.5 + uses: jebel-quant/rhiza/.github/workflows/rhiza_book.yml@v0.18.7 secrets: inherit permissions: contents: read diff --git a/.github/workflows/rhiza_ci.yml b/.github/workflows/rhiza_ci.yml index 700a1d23..af5753a7 100644 --- a/.github/workflows/rhiza_ci.yml +++ b/.github/workflows/rhiza_ci.yml @@ -21,5 +21,5 @@ on: jobs: ci: - uses: jebel-quant/rhiza/.github/workflows/rhiza_ci.yml@v0.18.5 + 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 index 179ea17f..5fd8ffcc 100644 --- a/.github/workflows/rhiza_codeql.yml +++ b/.github/workflows/rhiza_codeql.yml @@ -42,5 +42,5 @@ on: jobs: codeql: - uses: jebel-quant/rhiza/.github/workflows/rhiza_codeql.yml@v0.18.5 + 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 index bf0ad1fd..0083ab6f 100644 --- a/.github/workflows/rhiza_marimo.yml +++ b/.github/workflows/rhiza_marimo.yml @@ -28,5 +28,5 @@ on: jobs: marimo: - uses: jebel-quant/rhiza/.github/workflows/rhiza_marimo.yml@v0.18.5 + 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 index f5909892..b4dc4b55 100644 --- a/.github/workflows/rhiza_release.yml +++ b/.github/workflows/rhiza_release.yml @@ -23,8 +23,9 @@ # 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. 🐳 Publish Devcontainer - Build and publish devcontainer image (conditional) -# 8. βœ… Finalize Release - Publish the GitHub release with links +# 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) @@ -49,6 +50,11 @@ # - 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__ @@ -59,6 +65,7 @@ # πŸ“„ 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) # @@ -138,6 +145,52 @@ jobs: 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 @@ -358,6 +411,68 @@ jobs: 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 @@ -449,8 +564,8 @@ jobs: finalise-release: name: Finalise Release runs-on: ubuntu-latest - needs: [tag, pypi, devcontainer] - if: needs.pypi.result == 'success' || needs.devcontainer.result == 'success' + 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.3 @@ -510,12 +625,25 @@ jobs: echo "EOF" } >> "$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 "") @@ -525,8 +653,7 @@ jobs: 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 index 837d1b90..f3eb45c4 100644 --- a/.github/workflows/rhiza_sync.yml +++ b/.github/workflows/rhiza_sync.yml @@ -28,7 +28,7 @@ on: jobs: sync: - uses: jebel-quant/rhiza/.github/workflows/rhiza_sync.yml@v0.18.5 + 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) }} diff --git a/.github/workflows/rhiza_weekly.yml b/.github/workflows/rhiza_weekly.yml index 6a46f85c..27801ade 100644 --- a/.github/workflows/rhiza_weekly.yml +++ b/.github/workflows/rhiza_weekly.yml @@ -25,5 +25,5 @@ on: jobs: weekly: - uses: jebel-quant/rhiza/.github/workflows/rhiza_weekly.yml@v0.18.5 + uses: jebel-quant/rhiza/.github/workflows/rhiza_weekly.yml@v0.18.4 secrets: inherit diff --git a/.rhiza/make.d/releasing.mk b/.rhiza/make.d/releasing.mk index fc6d57a6..5dee330d 100644 --- a/.rhiza/make.d/releasing.mk +++ b/.rhiza/make.d/releasing.mk @@ -12,7 +12,7 @@ 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.3.3 +_VERSION=0.5.1 ##@ Releasing and Versioning bump: pre-bump ## bump version of the project (supports DRY_RUN=1) diff --git a/.rhiza/make.d/test.mk b/.rhiza/make.d/test.mk index cdf52e34..3af28479 100644 --- a/.rhiza/make.d/test.mk +++ b/.rhiza/make.d/test.mk @@ -4,7 +4,7 @@ # executing performance benchmarks. # Declare phony targets (they don't produce files) -.PHONY: test benchmark typecheck security docs-coverage hypothesis-test coverage-badge stress test-pyproject +.PHONY: test benchmark typecheck security docs-coverage hypothesis-test coverage-badge stress test-pyproject mutation # Default directory for tests TESTS_FOLDER := tests @@ -145,6 +145,23 @@ stress:: install ## run stress/load tests --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 \ diff --git a/.rhiza/requirements/tests.txt b/.rhiza/requirements/tests.txt index e07b5300..34962874 100644 --- a/.rhiza/requirements/tests.txt +++ b/.rhiza/requirements/tests.txt @@ -8,6 +8,7 @@ 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 diff --git a/.rhiza/template.lock b/.rhiza/template.lock index 3fd53e67..781cb8a5 100644 --- a/.rhiza/template.lock +++ b/.rhiza/template.lock @@ -1,7 +1,7 @@ -sha: 2d95f598ddcb0bf5ff1cdf753d7165c96150237f +sha: ae79c6f0729b21239655abe390269be9171f907d repo: jebel-quant/rhiza host: github -ref: v0.18.4 +ref: v0.18.8 include: [] exclude: [] templates: @@ -12,8 +12,6 @@ files: - .github/DISCUSSION_TEMPLATE/q-and-a.yml - .github/ISSUE_TEMPLATE/bug_report.yml - .github/ISSUE_TEMPLATE/feature_request.yml -- .github/actions/configure-git-auth/README.md -- .github/actions/configure-git-auth/action.yml - .github/dependabot.yml - .github/pull_request_template.md - .github/release.yml @@ -57,12 +55,13 @@ files: - .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_sbom.py - .rhiza/tests/integration/test_test_mk.py - .rhiza/tests/integration/test_virtual_env_unexport.py - .rhiza/tests/shell/test_scripts.sh @@ -87,10 +86,9 @@ files: - docs/development/TESTS.md - docs/index.md - docs/mkdocs-base.yml -- pyproject.toml.template - pytest.ini - ruff.toml profiles: - github-project -synced_at: '2026-05-28T16:45:32Z' +synced_at: '2026-06-06T05:14:42Z' strategy: merge diff --git a/.rhiza/tests/api/test_github_targets.py b/.rhiza/tests/api/test_github_targets.py new file mode 100644 index 00000000..1008dee6 --- /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 00000000..e5b5cfbf --- /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_targets.py b/.rhiza/tests/api/test_makefile_targets.py index 78222281..0137c321 100644 --- a/.rhiza/tests/api/test_makefile_targets.py +++ b/.rhiza/tests/api/test_makefile_targets.py @@ -303,7 +303,7 @@ def mock_bin(self, tmp_path): args = sys.argv[1:] print(f"[MOCK] uvx {' '.join(args)}") -# Check if this is the bump command: "rhiza-tools>=0.3.3" bump +# 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") @@ -333,7 +333,7 @@ def test_bump_execution(self, logger, mock_bin, tmp_path): 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.3.3 bump" in result.stdout + 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) diff --git a/.rhiza/tests/integration/test_sbom.py b/.rhiza/tests/integration/test_sbom.py deleted file mode 100644 index 0235c22d..00000000 --- a/.rhiza/tests/integration/test_sbom.py +++ /dev/null @@ -1,172 +0,0 @@ -"""Integration test for SBOM generation using cyclonedx-bom. - -This file and its associated tests flow down via a SYNC action from the jebel-quant/rhiza repository -(https://github.com/jebel-quant/rhiza). - -Tests the SBOM (Software Bill of Materials) generation workflow to ensure -the cyclonedx-bom tool works correctly with uvx. -""" - -import subprocess # nosec B404 - - -def test_sbom_generation_json(git_repo, logger): - """Test that SBOM generation works in JSON format.""" - # Run the SBOM generation command for JSON - result = subprocess.run( # nosec B603 B607 - [ - "uvx", - "--from", - "cyclonedx-bom>=7.0.0", - "cyclonedx-py", - "environment", - "--pyproject", - "pyproject.toml", - "--of", - "JSON", - "-o", - "sbom.cdx.json", - ], - cwd=git_repo, - capture_output=True, - text=True, - check=False, - ) - - logger.info("SBOM JSON stdout: %s", result.stdout) - logger.info("SBOM JSON stderr: %s", result.stderr) - - # Verify command succeeded - assert result.returncode == 0, f"SBOM JSON generation failed: {result.stderr}" - - # Verify output file exists - sbom_file = git_repo / "sbom.cdx.json" - assert sbom_file.exists(), "SBOM JSON file was not created" - assert sbom_file.stat().st_size > 0, "SBOM JSON file is empty" - - # Verify it's valid JSON - import json - - with open(sbom_file) as f: - sbom_data = json.load(f) - - # Basic CycloneDX structure validation - assert "bomFormat" in sbom_data, "SBOM missing bomFormat field" - assert sbom_data["bomFormat"] == "CycloneDX", "SBOM has incorrect bomFormat" - assert "components" in sbom_data, "SBOM missing components field" - - # Verify primary component (metadata.component) is present for NTIA compliance - assert "metadata" in sbom_data, "SBOM missing metadata field" - assert "component" in sbom_data["metadata"], "SBOM missing primary component (metadata.component)" - primary = sbom_data["metadata"]["component"] - assert primary.get("name"), "Primary component missing name" - assert primary.get("version"), "Primary component missing version" - - -def test_sbom_generation_xml(git_repo, logger): - """Test that SBOM generation works in XML format.""" - # Run the SBOM generation command for XML - result = subprocess.run( # nosec B603 B607 - [ - "uvx", - "--from", - "cyclonedx-bom>=7.0.0", - "cyclonedx-py", - "environment", - "--pyproject", - "pyproject.toml", - "--of", - "XML", - "-o", - "sbom.cdx.xml", - ], - cwd=git_repo, - capture_output=True, - text=True, - check=False, - ) - - logger.info("SBOM XML stdout: %s", result.stdout) - logger.info("SBOM XML stderr: %s", result.stderr) - - # Verify command succeeded - assert result.returncode == 0, f"SBOM XML generation failed: {result.stderr}" - - # Verify output file exists - sbom_file = git_repo / "sbom.cdx.xml" - assert sbom_file.exists(), "SBOM XML file was not created" - assert sbom_file.stat().st_size > 0, "SBOM XML file is empty" - - # Verify it's valid XML with CycloneDX structure - import defusedxml.ElementTree - - tree = defusedxml.ElementTree.parse(sbom_file) - root = tree.getroot() - - # Check for CycloneDX namespace - assert "cyclonedx" in root.tag.lower(), "SBOM XML root is not CycloneDX" - # Check for components element - components = root.find(".//{*}components") - assert components is not None, "SBOM XML missing components element" - - -def test_sbom_command_syntax(git_repo, logger): - """Test that the uvx command syntax is correct (no npm-style @^version).""" - # This test verifies that we're using the correct syntax - # Bad: uvx cyclonedx-bom@^7.0.0 - # Good: uvx --from 'cyclonedx-bom>=7.0.0' cyclonedx-py - - # Try the old (incorrect) syntax - should fail - result_bad = subprocess.run( # nosec B603 B607 - [ - "uvx", - "cyclonedx-bom@^7.0.0", - "environment", - "--of", - "JSON", - "-o", - "sbom.test.json", - ], - cwd=git_repo, - capture_output=True, - text=True, - check=False, - ) - - logger.info("Bad syntax stdout: %s", result_bad.stdout) - logger.info("Bad syntax stderr: %s", result_bad.stderr) - - # Old syntax should fail - assert result_bad.returncode != 0, "Old npm-style syntax should not work" - - # Try the new (correct) syntax - should succeed - result_good = subprocess.run( # nosec B603 B607 - [ - "uvx", - "--from", - "cyclonedx-bom>=7.0.0", - "cyclonedx-py", - "environment", - "--pyproject", - "pyproject.toml", - "--of", - "JSON", - "-o", - "sbom.test.json", - ], - cwd=git_repo, - capture_output=True, - text=True, - check=False, - ) - - logger.info("Good syntax stdout: %s", result_good.stdout) - logger.info("Good syntax stderr: %s", result_good.stderr) - - # New syntax should succeed - assert result_good.returncode == 0, f"Correct syntax failed: {result_good.stderr}" - - # Cleanup - test_file = git_repo / "sbom.test.json" - if test_file.exists(): - test_file.unlink() diff --git a/.rhiza/tests/structure/test_pyproject.py b/.rhiza/tests/structure/test_pyproject.py index c2b3bf87..82f2b4d7 100644 --- a/.rhiza/tests/structure/test_pyproject.py +++ b/.rhiza/tests/structure/test_pyproject.py @@ -13,15 +13,21 @@ - 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") @@ -182,3 +188,30 @@ def test_test_group_includes_pytest(self, dependency_groups: dict) -> None: 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/pyproject.toml.template b/pyproject.toml.template deleted file mode 100644 index 64c7a48b..00000000 --- a/pyproject.toml.template +++ /dev/null @@ -1,12 +0,0 @@ -[project] -name = "your-project" -version = "0.1.0" -description = "A Rhiza-based Python project" -readme = "README.md" -requires-python = ">=3.11" -dependencies = [] - -[dependency-groups] -lint = [] -test = [] -docs = []