diff --git a/.github/workflows/rhiza_sync.yml b/.github/workflows/rhiza_sync.yml deleted file mode 100644 index 45d0bae..0000000 --- a/.github/workflows/rhiza_sync.yml +++ /dev/null @@ -1,43 +0,0 @@ -# This file is part of the jebel-quant/rhiza repository -# (https://github.com/jebel-quant/rhiza). -# -# Workflow: Sync -# -# Purpose: Synchronizes the repository with its upstream rhiza template. -# On Renovate/rhiza branch push: auto-commits synced files directly -# to the branch. On schedule/dispatch: opens a pull request. -# -# IMPORTANT: A PAT with 'workflow' scope (PAT_TOKEN) is required when workflow -# files are modified. See .github/CONFIG.md for setup instructions. -# -# Trigger: On Renovate/rhiza branch push, weekly schedule, and manual dispatch. - -name: "(RHIZA) SYNC" - -permissions: - contents: write - pull-requests: write - -on: - push: - branches: - - 'renovate/jebel-quant-rhiza-**' - - 'rhiza/**' - paths: - - '.rhiza/template.yml' - schedule: - - cron: '0 0 * * 1' # Weekly on Monday - workflow_dispatch: - inputs: - create-pr: - description: "Create a pull request" - type: boolean - default: true - -jobs: - sync: - uses: jebel-quant/rhiza/.github/workflows/rhiza_sync.yml@v0.19.9 - with: - direct: ${{ github.event_name == 'push' }} - create-pr: ${{ github.event_name != 'push' && (github.event_name == 'schedule' || inputs.create-pr == true) }} - secrets: inherit diff --git a/.rhiza/.rhiza-version b/.rhiza/.rhiza-version deleted file mode 100644 index 2a0970c..0000000 --- a/.rhiza/.rhiza-version +++ /dev/null @@ -1 +0,0 @@ -0.16.1 diff --git a/.rhiza/make.d/releasing.mk b/.rhiza/make.d/releasing.mk deleted file mode 100644 index 5dee330..0000000 --- a/.rhiza/make.d/releasing.mk +++ /dev/null @@ -1,50 +0,0 @@ -## .rhiza/make.d/releasing.mk - Releasing and Versioning -# This file provides targets for version bumping and release management. - -# Declare phony targets (they don't produce files) -.PHONY: bump release publish release-status pre-bump post-bump pre-release post-release - -# Hook targets (double-colon rules allow multiple definitions) -pre-bump:: ; @: -post-bump:: ; @: -pre-release:: ; @: -post-release:: ; @: - -# DRY_RUN support: pass DRY_RUN=1 to preview changes without applying them -_DRY_RUN_FLAG := $(if $(DRY_RUN),--dry-run,) -_VERSION=0.5.1 - -##@ Releasing and Versioning -bump: pre-bump ## bump version of the project (supports DRY_RUN=1) - @if [ -f "pyproject.toml" ]; then \ - $(MAKE) install; \ - PATH="$(abspath ${VENV})/bin:$$PATH" ${UVX_BIN} "rhiza-tools>=$(_VERSION)" bump $(_DRY_RUN_FLAG); \ - if [ -z "$(DRY_RUN)" ]; then \ - printf "${BLUE}[INFO] Checking uv.lock file...${RESET}\n"; \ - ${UV_BIN} lock; \ - fi; \ - else \ - printf "${YELLOW}[WARN] No pyproject.toml found, skipping bump${RESET}\n"; \ - fi - @$(MAKE) post-bump - -release: pre-release install-uv ## create tag and push to remote repository triggering release workflow (supports DRY_RUN=1) - ${UVX_BIN} "rhiza-tools>=$(_VERSION)" release $(_DRY_RUN_FLAG); - @$(MAKE) post-release - -publish: pre-release install-uv ## bump version, create tag and push in one step (supports DRY_RUN=1) - ${UVX_BIN} "rhiza-tools>=$(_VERSION)" release --with-bump $(_DRY_RUN_FLAG); - @$(MAKE) post-release - -release-status: ## show release workflow status and latest release information -ifeq ($(FORGE_TYPE),github) - @{ $(MAKE) --no-print-directory workflow-status; printf "\n"; $(MAKE) --no-print-directory latest-release; } 2>&1 | $${PAGER:-less -R} -else ifeq ($(FORGE_TYPE),gitlab) - @printf "${YELLOW}[WARN] GitLab detected — release-status is not yet supported for GitLab repositories.${RESET}\n" - @printf "${BLUE}[INFO] Please check your pipeline status in the GitLab UI.${RESET}\n" -else - @printf "${RED}[ERROR] Could not detect forge type (.github/workflows/ or .gitlab-ci.yml not found)${RESET}\n" -endif - - - diff --git a/.rhiza/requirements/README.md b/.rhiza/requirements/README.md deleted file mode 100644 index b98278a..0000000 --- a/.rhiza/requirements/README.md +++ /dev/null @@ -1,27 +0,0 @@ -# Requirements Folder - -This folder contains the development dependencies for the Rhiza project, organized by purpose. - -## Files - -- **tests.txt** - Testing dependencies (pytest, pytest-cov, pytest-html, pytest-mock, PyYAML, defusedxml, hypothesis, pytest-benchmark, pygal) -- **marimo.txt** - Marimo notebook dependencies -- **docs.txt** - Documentation generation dependencies (interrogate, mkdocs, mkdocs-material, mkdocstrings) -- **tools.txt** - Development tools (pre-commit, python-dotenv, typer, ty) - -## Usage - -These requirements files are automatically installed by the `make install` command. - -To install specific requirement files manually: - -```bash -uv pip install -r .rhiza/requirements/tests.txt -uv pip install -r .rhiza/requirements/marimo.txt -uv pip install -r .rhiza/requirements/docs.txt -uv pip install -r .rhiza/requirements/tools.txt -``` - -## CI/CD - -GitHub Actions workflows automatically install these requirements as needed. diff --git a/.rhiza/requirements/docs.txt b/.rhiza/requirements/docs.txt deleted file mode 100644 index bbded50..0000000 --- a/.rhiza/requirements/docs.txt +++ /dev/null @@ -1,4 +0,0 @@ -# Documentation dependencies for rhiza -interrogate>=1.7.0 -mike>=2.2.0 -zensical>=0.0.33 diff --git a/.rhiza/requirements/marimo.txt b/.rhiza/requirements/marimo.txt deleted file mode 100644 index 032c872..0000000 --- a/.rhiza/requirements/marimo.txt +++ /dev/null @@ -1,2 +0,0 @@ -# Marimo dependencies for rhiza -marimo>=0.18.0 diff --git a/.rhiza/requirements/tests.txt b/.rhiza/requirements/tests.txt deleted file mode 100644 index 96cc631..0000000 --- a/.rhiza/requirements/tests.txt +++ /dev/null @@ -1,18 +0,0 @@ -# Test dependencies for rhiza -pytest>=8.0 -python-dotenv>=1.0 -pytest-cov>=6.0 -pytest-html>=4.0 -pytest-mock>=3.0 -pytest-xdist>=3.0 -pytest-timeout>=2.0 -PyYAML>=6.0 -defusedxml>=0.7.0 -mutmut>=2.0,<3.0 - -# For property-based testing -hypothesis>=6.150.0 - -# For benchmarks -pytest-benchmark>=5.2.3 -pygal>=3.1.0 diff --git a/.rhiza/requirements/tools.txt b/.rhiza/requirements/tools.txt deleted file mode 100644 index 92bbda1..0000000 --- a/.rhiza/requirements/tools.txt +++ /dev/null @@ -1,7 +0,0 @@ -# Development tool dependencies for rhiza -pre-commit==4.5.1 -python-dotenv==1.2.2 - -# for now needed until rhiza-tools is finished -typer==0.21.1 -ty>=0.0.30 diff --git a/.rhiza/tests/api/conftest.py b/.rhiza/tests/api/conftest.py deleted file mode 100644 index 8733d6c..0000000 --- a/.rhiza/tests/api/conftest.py +++ /dev/null @@ -1,98 +0,0 @@ -"""Shared fixtures for Makefile API tests. - -This conftest provides: -- setup_tmp_makefile: Copies Makefile and split files to temp dir for isolated testing -- run_make: Helper to execute make commands with dry-run support (imported from test_utils) -- setup_rhiza_git_repo: Initialize a git repo configured as rhiza origin (imported from test_utils) -- SPLIT_MAKEFILES: List of split Makefile paths - -Security Notes: -- S101 (assert usage): Asserts are used in pytest tests to validate conditions -- S603/S607 (subprocess usage): Any subprocess calls (via run_make) are for testing - Makefile targets in isolated environments with controlled inputs -- Test code operates in a controlled environment with trusted inputs -""" - -from __future__ import annotations - -import os -import shutil -import sys -from pathlib import Path - -import pytest - -tests_root = Path(__file__).resolve().parents[1] -if str(tests_root) not in sys.path: - sys.path.insert(0, str(tests_root)) - -from test_utils import run_make, setup_rhiza_git_repo, strip_ansi # noqa: E402, F401 - -# Split Makefile paths that are included in the main Makefile -# These are now located in .rhiza/make.d/ directory -SPLIT_MAKEFILES = [ - ".rhiza/rhiza.mk", - ".rhiza/make.d/bootstrap.mk", - ".rhiza/make.d/quality.mk", - ".rhiza/make.d/releasing.mk", - ".rhiza/make.d/doctor.mk", - ".rhiza/make.d/test.mk", - ".rhiza/make.d/book.mk", - ".rhiza/make.d/marimo.mk", - ".rhiza/make.d/presentation.mk", - ".rhiza/make.d/github.mk", - ".rhiza/make.d/agentic.mk", - ".rhiza/make.d/gh-aw.mk", - ".rhiza/make.d/docker.mk", -] - - -@pytest.fixture(autouse=True) -def setup_tmp_makefile(logger, root, tmp_path: Path): - """Copy the Makefile and split Makefiles into a temp directory and chdir there. - - We rely on `make -n` so that no real commands are executed. - This fixture consolidates setup for both basic Makefile tests and GitHub targets. - """ - logger.debug("Setting up temporary Makefile test dir: %s", tmp_path) - - # Copy the main Makefile into the temporary working directory - shutil.copy(root / "Makefile", tmp_path / "Makefile") - - # Copy core Rhiza Makefiles - (tmp_path / ".rhiza").mkdir(exist_ok=True) - shutil.copy(root / ".rhiza" / "rhiza.mk", tmp_path / ".rhiza" / "rhiza.mk") - - # Copy .python-version file for PYTHON_VERSION variable - if (root / ".python-version").exists(): - shutil.copy(root / ".python-version", tmp_path / ".python-version") - - # Copy .rhiza/.env if it exists (needed for GitHub targets and other configuration) - if (root / ".rhiza" / ".env").exists(): - shutil.copy(root / ".rhiza" / ".env", tmp_path / ".rhiza" / ".env") - else: - # Create a minimal, deterministic .rhiza/.env for tests so they don't - # depend on the developer's local configuration which may vary. - env_content = "CUSTOM_SCRIPTS_FOLDER=.rhiza/customisations/scripts\n" - (tmp_path / ".rhiza" / ".env").write_text(env_content) - - logger.debug("Copied Makefile from %s to %s", root / "Makefile", tmp_path / "Makefile") - - # Copy split Makefiles if they exist (maintaining directory structure) - for split_file in SPLIT_MAKEFILES: - source_path = root / split_file - if source_path.exists(): - dest_path = tmp_path / split_file - dest_path.parent.mkdir(parents=True, exist_ok=True) - shutil.copy(source_path, dest_path) - logger.debug("Copied %s to %s", source_path, dest_path) - - # Move into tmp directory for isolation - old_cwd = Path.cwd() - os.chdir(tmp_path) - logger.debug("Changed working directory to %s", tmp_path) - try: - yield - finally: - os.chdir(old_cwd) - logger.debug("Restored working directory to %s", old_cwd) diff --git a/.rhiza/tests/api/test_github_targets.py b/.rhiza/tests/api/test_github_targets.py deleted file mode 100644 index 1008dee..0000000 --- a/.rhiza/tests/api/test_github_targets.py +++ /dev/null @@ -1,63 +0,0 @@ -"""Tests for the GitHub Makefile targets using safe dry-runs. - -These tests validate that the .github/github.mk targets are correctly exposed -and emit the expected commands without actually executing them. -""" - -from __future__ import annotations - -from pathlib import Path - -import pytest - -# Import run_make from local conftest (setup_tmp_makefile is autouse) -from api.conftest import run_make - -_GITHUB_MK = Path(__file__).resolve().parents[3] / ".rhiza" / "make.d" / "github.mk" -if not _GITHUB_MK.exists(): - pytest.skip("github.mk not found, skipping github targets tests", allow_module_level=True) - - -def test_gh_targets_exist(logger): - """Verify that GitHub targets are listed in help.""" - result = run_make(logger, ["help"], dry_run=False) - output = result.stdout - - expected_targets = ["gh-install", "view-prs", "view-issues", "failed-workflows", "whoami"] - - for target in expected_targets: - assert target in output, f"Target {target} not found in help output" - - -def test_gh_install_dry_run(logger): - """Verify gh-install target dry-run.""" - result = run_make(logger, ["gh-install"]) - # In dry-run, we expect to see the shell commands that would be executed. - # Since the recipe uses @if, make -n might verify the syntax or show the command if not silenced. - # However, with -s (silent), make -n might not show much for @ commands unless they are echoed. - # But we mainly want to ensure it runs without error. - assert result.returncode == 0 - - -def test_view_prs_dry_run(logger): - """Verify view-prs target dry-run.""" - result = run_make(logger, ["view-prs"]) - assert result.returncode == 0 - - -def test_view_issues_dry_run(logger): - """Verify view-issues target dry-run.""" - result = run_make(logger, ["view-issues"]) - assert result.returncode == 0 - - -def test_failed_workflows_dry_run(logger): - """Verify failed-workflows target dry-run.""" - result = run_make(logger, ["failed-workflows"]) - assert result.returncode == 0 - - -def test_whoami_dry_run(logger): - """Verify whoami target dry-run.""" - result = run_make(logger, ["whoami"]) - assert result.returncode == 0 diff --git a/.rhiza/tests/api/test_make_variable_overrides.py b/.rhiza/tests/api/test_make_variable_overrides.py deleted file mode 100644 index e5b5cfb..0000000 --- a/.rhiza/tests/api/test_make_variable_overrides.py +++ /dev/null @@ -1,161 +0,0 @@ -"""Tests for Makefile variable override behaviour. - -This file and its associated tests flow down via a SYNC action from the -jebel-quant/rhiza repository (https://github.com/jebel-quant/rhiza). - -Validates that key Makefile variables behave correctly when overridden on -the command line, ensuring downstream projects can customise coverage -thresholds, license checks, and Python tooling without modifying the -shared Makefile infrastructure. - -All tests use `make -n` (dry-run) to observe what commands *would* be -executed without running them — keeping the suite fast and side-effect-free. -""" - -from __future__ import annotations - -import os - -from api.conftest import run_make, strip_ansi - - -class TestCoverageFailUnder: - """COVERAGE_FAIL_UNDER controls the pytest --cov-fail-under threshold.""" - - def test_default_threshold_is_90(self, logger) -> None: - """Default COVERAGE_FAIL_UNDER value must be 90.""" - proc = run_make(logger, ["test"]) - assert "--cov-fail-under=90" in proc.stdout, ( - "Default coverage threshold should be 90; got:\n" + proc.stdout[:500] - ) - - def test_threshold_override_to_100(self, logger) -> None: - """COVERAGE_FAIL_UNDER=100 must propagate to pytest invocation.""" - proc = run_make(logger, ["test", "COVERAGE_FAIL_UNDER=100"]) - assert "--cov-fail-under=100" in proc.stdout - - def test_threshold_override_to_0(self, logger) -> None: - """COVERAGE_FAIL_UNDER=0 must propagate (useful for bootstrapping new projects).""" - proc = run_make(logger, ["test", "COVERAGE_FAIL_UNDER=0"]) - assert "--cov-fail-under=0" in proc.stdout - - def test_threshold_override_to_arbitrary_value(self, logger) -> None: - """Any integer override for COVERAGE_FAIL_UNDER must appear verbatim in the command.""" - proc = run_make(logger, ["test", "COVERAGE_FAIL_UNDER=73"]) - assert "--cov-fail-under=73" in proc.stdout - - -class TestLicenseFailOn: - """LICENSE_FAIL_ON controls which SPDX license identifiers cause a build failure.""" - - def test_default_fails_on_gpl(self, logger) -> None: - """Default LICENSE_FAIL_ON must include GPL to block copyleft licenses.""" - proc = run_make(logger, ["license"]) - assert "GPL" in proc.stdout, "Default license check should fail on GPL; got:\n" + proc.stdout[:500] - - def test_fail_on_override_single_license(self, logger) -> None: - """Custom single-license override must appear in the make license command.""" - proc = run_make(logger, ["license", "LICENSE_FAIL_ON=MIT"]) - assert "MIT" in proc.stdout - - def test_fail_on_override_multiple_licenses(self, logger) -> None: - """Semicolon-separated multi-license override must appear verbatim.""" - proc = run_make(logger, ["license", "LICENSE_FAIL_ON=AGPL-3.0;GPL-2.0;LGPL-2.1"]) - assert "AGPL-3.0" in proc.stdout - assert "GPL-2.0" in proc.stdout - - def test_fail_on_override_quoted_correctly(self, logger) -> None: - """LICENSE_FAIL_ON value must be quoted in the underlying pip-licenses call.""" - proc = run_make(logger, ["license", "LICENSE_FAIL_ON=MIT;Apache"]) - # The Makefile must quote the value to handle semicolons properly - assert '--fail-on="MIT;Apache"' in proc.stdout - - -class TestPythonVersionVariable: - """PYTHON_VERSION drives uvx -p ... in quality and formatting targets.""" - - def test_python_version_read_from_python_version_file(self, logger, tmp_path) -> None: - """When .python-version exists, PYTHON_VERSION should reflect its contents.""" - python_version_file = tmp_path / ".python-version" - if python_version_file.exists(): - version = python_version_file.read_text().strip() - proc = run_make(logger, ["print-PYTHON_VERSION"], dry_run=False) - out = strip_ansi(proc.stdout) - assert version in out, f"Expected {version} in PYTHON_VERSION output; got: {out}" - - def test_python_version_default_when_file_missing(self, logger, tmp_path) -> None: - """When .python-version is absent and PYTHON_VERSION env var is unset, default to 3.13.""" - pv_file = tmp_path / ".python-version" - if pv_file.exists(): - pv_file.unlink() - - env = os.environ.copy() - env.pop("PYTHON_VERSION", None) - - proc = run_make(logger, ["print-PYTHON_VERSION"], dry_run=False, env=env) - out = strip_ansi(proc.stdout) - assert "3.13" in out, f"Expected default 3.13; got: {out}" - - def test_python_version_used_in_fmt_target(self, logger, tmp_path) -> None: - """The fmt target must pass -p to uvx.""" - env = os.environ.copy() - env.pop("PYTHON_VERSION", None) - - proc = run_make(logger, ["fmt"], env=env) - assert "uvx -p" in proc.stdout, "fmt target should use uvx -p " - - -class TestSourceFolderVariable: - """SOURCE_FOLDER drives coverage collection and static analysis targets.""" - - def test_typecheck_uses_source_folder(self, logger, tmp_path) -> None: - """The typecheck target must check the directory set by SOURCE_FOLDER.""" - src_dir = tmp_path / "mypackage" - src_dir.mkdir(exist_ok=True) - - env_file = tmp_path / ".rhiza" / ".env" - if env_file.exists(): - env_file.write_text(env_file.read_text() + "\nSOURCE_FOLDER=mypackage\n") - - proc = run_make(logger, ["typecheck", "SOURCE_FOLDER=mypackage"]) - assert "mypackage" in proc.stdout, "typecheck should reference SOURCE_FOLDER; got:\n" + proc.stdout[:400] - - def test_deptry_uses_source_folder(self, logger, tmp_path) -> None: - """The deptry target must scan the directory set by SOURCE_FOLDER.""" - src_dir = tmp_path / "mypackage" - src_dir.mkdir(exist_ok=True) - - proc = run_make(logger, ["deptry", "SOURCE_FOLDER=mypackage"]) - assert "mypackage" in proc.stdout, "deptry should reference SOURCE_FOLDER; got:\n" + proc.stdout[:400] - - -class TestUvNoModifyPath: - """UV_NO_MODIFY_PATH must always be exported to 1 to avoid uv touching PATH.""" - - def test_uv_no_modify_path_is_1(self, logger) -> None: - """UV_NO_MODIFY_PATH must be exported as 1 in the Makefile.""" - proc = run_make(logger, ["print-UV_NO_MODIFY_PATH"], dry_run=False) - out = strip_ansi(proc.stdout) - assert "1" in out, f"UV_NO_MODIFY_PATH should be 1; got: {out}" - - def test_uv_no_modify_path_cannot_be_overridden_to_empty(self, logger) -> None: - """UV_NO_MODIFY_PATH must still appear in the printed value when queried.""" - proc = run_make(logger, ["print-UV_NO_MODIFY_PATH"], dry_run=False) - out = strip_ansi(proc.stdout) - assert "UV_NO_MODIFY_PATH" in out - - -class TestTestsFolder: - """TESTS_FOLDER defaults to 'tests' but can be overridden.""" - - def test_default_tests_folder_is_tests(self, logger) -> None: - """Default TESTS_FOLDER must be 'tests'.""" - proc = run_make(logger, ["print-TESTS_FOLDER"], dry_run=False) - out = strip_ansi(proc.stdout) - assert "tests" in out, f"Default TESTS_FOLDER should be 'tests'; got: {out}" - - def test_pytest_uses_tests_folder(self, logger) -> None: - """The test target must invoke pytest with the TESTS_FOLDER path.""" - proc = run_make(logger, ["test"]) - # The default tests folder must appear somewhere in the pytest invocation - assert "pytest" in proc.stdout diff --git a/.rhiza/tests/api/test_makefile_api.py b/.rhiza/tests/api/test_makefile_api.py deleted file mode 100644 index 8096006..0000000 --- a/.rhiza/tests/api/test_makefile_api.py +++ /dev/null @@ -1,369 +0,0 @@ -"""Tests for the new Makefile API structure (Wrapper + Makefile.rhiza).""" - -import os -import shutil -import subprocess # nosec -from pathlib import Path - -import pytest - -# Get absolute paths for executables to avoid S607 warnings from CodeFactor/Bandit -GIT = shutil.which("git") or "/usr/bin/git" - -# Files required for the API test environment -REQUIRED_FILES = [ - "Makefile", - "pyproject.toml", - "README.md", # is needed to do uv sync, etc. -] - -# Folders to copy recursively -REQUIRED_FOLDERS = [ - ".rhiza", -] - -OPTIONAL_FOLDERS = [ - "tests", # for tests/tests.mk - "docker", # for docker/docker.mk, if referenced - "book", - "presentation", -] - - -@pytest.fixture -def setup_api_env(logger, root, tmp_path: Path): - """Set up the Makefile API test environment in a temp folder.""" - logger.debug("Setting up Makefile API test env in: %s", tmp_path) - - # Copy files - for filename in REQUIRED_FILES: - src = root / filename - if src.exists(): - shutil.copy(src, tmp_path / filename) - else: - pytest.fail(f"Required file {filename} not found in root") - - # Copy required directories - for folder in REQUIRED_FOLDERS: - src = root / folder - if src.exists(): - dest = tmp_path / folder - if dest.exists(): - shutil.rmtree(dest) - shutil.copytree(src, dest) - else: - pytest.fail(f"Required folder {folder} not found in root") - - # Copy optional directories - for folder in OPTIONAL_FOLDERS: - src = root / folder - if src.exists(): - dest = tmp_path / folder - if dest.exists(): - shutil.rmtree(dest) - shutil.copytree(src, dest) - - # Create .rhiza/make.d and ensure no local.mk exists initially - (tmp_path / ".rhiza" / "make.d").mkdir(parents=True, exist_ok=True) - if (tmp_path / "local.mk").exists(): - (tmp_path / "local.mk").unlink() - - # Initialize git repo for rhiza tools (required for sync/validate) - subprocess.run([GIT, "init"], cwd=tmp_path, check=True, capture_output=True) # nosec - # Configure git user for commits if needed (some rhiza checks might need commits) - subprocess.run([GIT, "config", "user.email", "you@example.com"], cwd=tmp_path, check=True, capture_output=True) # nosec - subprocess.run([GIT, "config", "user.name", "Rhiza Test"], cwd=tmp_path, check=True, capture_output=True) # nosec - # Add origin remote to simulate being in the rhiza repo (triggers the skip logic in rhiza.mk) - subprocess.run( - [GIT, "remote", "add", "origin", "https://github.com/jebel-quant/rhiza.git"], - cwd=tmp_path, - check=True, - capture_output=True, - ) # nosec - - # Move to tmp dir - old_cwd = Path.cwd() - os.chdir(tmp_path) - try: - yield tmp_path - finally: - os.chdir(old_cwd) - - -# Import run_make from local conftest -from api.conftest import run_make # noqa: E402 - - -def test_api_delegation(logger, setup_api_env): - """Test that 'make help' works and delegates to .rhiza/rhiza.mk.""" - result = run_make(logger, ["help"], dry_run=False) - assert result.returncode == 0 - # "Rhiza Workflows" is a section in .rhiza/rhiza.mk - assert "Rhiza Workflows" in result.stdout - - # Core targets from .rhiza/make.d/ should be available - assert "test" in result.stdout or "install" in result.stdout - - -def test_minimal_setup_works(logger, setup_api_env): - """Test that make works even if optional folders (tests, docker, etc.) are missing.""" - # Remove optional folders - for folder in OPTIONAL_FOLDERS: - p = setup_api_env / folder - if p.exists(): - shutil.rmtree(p) - - # Also remove files that might be copied if they were in the root? - # Just mainly folders. - - # Run make help - result = run_make(logger, ["help"], dry_run=False) - assert result.returncode == 0 - - # Check that core rhiza targets exist - assert "Rhiza Workflows" in result.stdout - assert "sync" in result.stdout - - # Note: docker-build and other targets from .rhiza/make.d/ are always present - # but they gracefully skip if their respective folders/files don't exist. - # This is by design - targets are always available but handle missing resources. - - -def test_extension_mechanism(logger, setup_api_env): - """Test that custom targets can be added in the root Makefile.""" - # Add a custom target to the root Makefile (before include line) - makefile = setup_api_env / "Makefile" - original = makefile.read_text() - # Insert custom target before the include line - new_content = ( - """.PHONY: custom-target -custom-target: - @echo "Running custom target" - -""" - + original - ) - makefile.write_text(new_content) - - result = run_make(logger, ["custom-target"], dry_run=False) - assert result.returncode == 0 - assert "Running custom target" in result.stdout - - -def test_local_override(logger, setup_api_env): - """Test that local.mk is included and can match targets.""" - local_file = setup_api_env / "local.mk" - local_file.write_text(""" -.PHONY: local-target -local-target: - @echo "Running local target" -""") - - result = run_make(logger, ["local-target"], dry_run=False) - assert result.returncode == 0 - assert "Running local target" in result.stdout - - -def test_local_override_pre_hook(logger, setup_api_env): - """Test using local.mk to override a pre-hook.""" - local_file = setup_api_env / "local.mk" - # We override pre-sync to print a marker (using double-colon to match rhiza.mk) - local_file.write_text(""" -pre-sync:: - @echo "[[LOCAL_PRE_SYNC]]" -""") - - # Run sync in dry-run. - # Note: Makefile.rhiza defines pre-sync as empty rule (or with @:). - # Make warns if we redefine a target unless it's a double-colon rule or we are careful. - # But usually the last one loaded wins or they merge if double-colon. - # The current definition in Makefile.rhiza is `pre-sync: ; @echo ...` or similar. - # Wait, I defined it as `pre-sync: ; @:` (single colon). - # So redefining it in local.mk (which is included AFTER) might trigger a warning but should work. - - result = run_make(logger, ["sync"], dry_run=False) - # We might expect a warning about overriding commands for target `pre-sync` - # checking stdout/stderr for the marker - - assert "[[LOCAL_PRE_SYNC]]" in result.stdout - - -def test_hook_execution_order(logger, setup_api_env): - """Define hooks in root Makefile and verify execution order.""" - # Add hooks to root Makefile (before include line) - makefile = setup_api_env / "Makefile" - original = makefile.read_text() - new_content = ( - """pre-sync:: - @echo "STARTING_SYNC" - -post-sync:: - @echo "FINISHED_SYNC" - -""" - + original - ) - makefile.write_text(new_content) - - result = run_make(logger, ["sync"], dry_run=False) - assert result.returncode == 0 - output = result.stdout - - # Check that markers are present - assert "STARTING_SYNC" in output - assert "FINISHED_SYNC" in output - - # Check order: STARTING_SYNC comes before FINISHED_SYNC - start_index = output.find("STARTING_SYNC") - finish_index = output.find("FINISHED_SYNC") - assert start_index < finish_index - - -def test_override_core_target(logger, setup_api_env): - """Verify that the root Makefile can override a core target (with warning).""" - # Override 'fmt' which is defined in quality.mk - # Add override AFTER the include line so it takes precedence - makefile = setup_api_env / "Makefile" - original = makefile.read_text() - new_content = ( - original - + """ -fmt: - @echo "CUSTOM_FMT" -""" - ) - makefile.write_text(new_content) - - result = run_make(logger, ["fmt"], dry_run=False) - assert result.returncode == 0 - # It should run the custom one because it's defined after the include - assert "CUSTOM_FMT" in result.stdout - - # We expect a warning on stderr about overriding - assert "warning: overriding" in result.stderr.lower() - assert "fmt" in result.stderr.lower() - - -def test_global_variable_override(logger, setup_api_env): - """Test that global variables can be overridden in the root Makefile. - - This tests the pattern documented in CUSTOMIZATION.md: - Set variables before the include line to override defaults. - """ - # Add variable override to root Makefile (before include line) - makefile = setup_api_env / "Makefile" - original = makefile.read_text() - new_content = ( - """# Override default coverage threshold (defaults to 90) -COVERAGE_FAIL_UNDER := 42 -export COVERAGE_FAIL_UNDER - -""" - + original - ) - makefile.write_text(new_content) - - result = run_make(logger, ["print-COVERAGE_FAIL_UNDER"], dry_run=False) - assert result.returncode == 0 - assert "42" in result.stdout - - -def test_pre_install_hook(logger, setup_api_env): - """Test that pre-install hooks are executed before install. - - This tests the hook pattern documented in CUSTOMIZATION.md. - """ - makefile = setup_api_env / "Makefile" - original = makefile.read_text() - new_content = ( - """pre-install:: - @echo "[[PRE_INSTALL_HOOK]]" - -""" - + original - ) - makefile.write_text(new_content) - - # Run install in dry-run mode to avoid actual installation - result = run_make(logger, ["install"], dry_run=True) - assert result.returncode == 0 - # In dry-run mode, the echo command is printed (not executed) - assert "PRE_INSTALL_HOOK" in result.stdout - - -def test_post_install_hook(logger, setup_api_env): - """Test that post-install hooks are executed after install. - - This tests the hook pattern documented in CUSTOMIZATION.md. - """ - makefile = setup_api_env / "Makefile" - original = makefile.read_text() - new_content = ( - """post-install:: - @echo "[[POST_INSTALL_HOOK]]" - -""" - + original - ) - makefile.write_text(new_content) - - # Run install in dry-run mode - result = run_make(logger, ["install"], dry_run=True) - assert result.returncode == 0 - assert "POST_INSTALL_HOOK" in result.stdout - - -def test_multiple_hooks_accumulate(logger, setup_api_env): - """Test that multiple hook definitions accumulate rather than override. - - This is a key feature of double-colon rules: the root Makefile and - local.mk can both add to the same hook without conflicts. - """ - # Add hook in root Makefile - makefile = setup_api_env / "Makefile" - original = makefile.read_text() - new_content = ( - """pre-sync:: - @echo "[[HOOK_A]]" - -""" - + original - ) - makefile.write_text(new_content) - - # Add another hook in local.mk - (setup_api_env / "local.mk").write_text("""pre-sync:: - @echo "[[HOOK_B]]" -""") - - result = run_make(logger, ["sync"], dry_run=False) - assert result.returncode == 0 - # Both hooks should be present - assert "[[HOOK_A]]" in result.stdout - assert "[[HOOK_B]]" in result.stdout - - -def test_variable_override_before_include(logger, setup_api_env): - """Test that variables set before include take precedence. - - Variables defined in the root Makefile before the include line - should be available throughout the build. - """ - # Set a variable and use it in a target (before include) - makefile = setup_api_env / "Makefile" - original = makefile.read_text() - new_content = ( - """MY_CUSTOM_VAR := hello - -.PHONY: show-var -show-var: - @echo "MY_VAR=$(MY_CUSTOM_VAR)" - -""" - + original - ) - makefile.write_text(new_content) - - result = run_make(logger, ["show-var"], dry_run=False) - assert result.returncode == 0 - assert "MY_VAR=hello" in result.stdout diff --git a/.rhiza/tests/api/test_makefile_targets.py b/.rhiza/tests/api/test_makefile_targets.py deleted file mode 100644 index 0137c32..0000000 --- a/.rhiza/tests/api/test_makefile_targets.py +++ /dev/null @@ -1,363 +0,0 @@ -"""Tests for the Makefile targets and help output using safe dry-runs. - -This file and its associated tests flow down via a SYNC action from the jebel-quant/rhiza repository -(https://github.com/jebel-quant/rhiza). - -These tests validate that the Makefile exposes expected targets and emits -the correct commands without actually executing them, by invoking `make -n` -(dry-run). We also pass `-s` to reduce noise in CI logs. This approach keeps -tests fast, portable, and free of side effects like network or environment -changes. -""" - -from __future__ import annotations - -import os - -import pytest -from api.conftest import SPLIT_MAKEFILES, run_make, setup_rhiza_git_repo, strip_ansi - - -def assert_uvx_command_uses_version(output: str, tmp_path, command_fragment: str): - """Assert uvx command uses .python-version when present, else fallback checks.""" - python_version_file = tmp_path / ".python-version" - if python_version_file.exists(): - python_version = python_version_file.read_text().strip() - assert f"uvx -p {python_version} {command_fragment}" in output - else: - assert "uvx -p" in output - assert command_fragment in output - - -class TestMakefile: - """Smoke tests for Makefile help and common targets using make -n.""" - - def test_default_goal_is_help(self, logger): - """Default goal should render the help index with known targets.""" - proc = run_make(logger) - out = proc.stdout - assert "Usage:" in out - assert "Targets:" in out - # ensure a few known targets appear in the help index - for target in ["install", "fmt", "deptry", "test", "help"]: - assert target in out - - def test_help_target(self, logger): - """Explicit `make help` prints usage, targets, and section headers.""" - proc = run_make(logger, ["help"]) - out = proc.stdout - assert "Usage:" in out - assert "Targets:" in out - assert "Bootstrap" in out or "Meta" in out # section headers - - def test_doctor_target_appears_in_help(self, logger): - """Doctor target should appear in help under the Dev section.""" - proc = run_make(logger, ["help"]) - out = proc.stdout - assert "Dev" in out - assert "doctor" in out - - def test_doctor_fails_when_minimum_version_is_not_met(self, logger, tmp_path): - """Doctor should exit non-zero when a prerequisite version is below the minimum.""" - fake_bin = tmp_path / "fake-bin" - fake_bin.mkdir(exist_ok=True) - - for name, content in { - "uv": "#!/usr/bin/env sh\necho 'uv 0.3.0'\n", - "python": "#!/usr/bin/env sh\necho 'Python 3.12.2'\n", - "make": "#!/usr/bin/env sh\necho 'GNU Make 4.4.1'\n", - "git": "#!/usr/bin/env sh\necho 'git version 2.44.0'\n", - }.items(): - script = fake_bin / name - script.write_text(content) - script.chmod(0o755) - - env = os.environ.copy() - env["PATH"] = f"{fake_bin}:{env.get('PATH', '')}" - - proc = run_make(logger, ["doctor"], dry_run=False, check=False, env=env) - out = strip_ansi(proc.stdout) - assert proc.returncode != 0 - assert "[❌] uv" in out - assert "0.3.0" in out - assert "0.4.0" in out - - def test_fmt_target_dry_run(self, logger, tmp_path): - """Fmt target should invoke pre-commit via uvx with Python version in dry-run output.""" - # Create clean environment without PYTHON_VERSION so Makefile reads from .python-version - env = os.environ.copy() - env.pop("PYTHON_VERSION", None) - - proc = run_make(logger, ["fmt"], env=env) - out = proc.stdout - assert_uvx_command_uses_version(out, tmp_path, "pre-commit run --all-files") - - def test_deptry_target_dry_run(self, logger, tmp_path): - """Deptry target should invoke deptry via uvx with Python version in dry-run output.""" - # Create a mock SOURCE_FOLDER directory so the deptry command runs - source_folder = tmp_path / "src" - source_folder.mkdir(exist_ok=True) - - # Update .env to set SOURCE_FOLDER - env_file = tmp_path / ".rhiza" / ".env" - env_content = env_file.read_text() - env_content += "\nSOURCE_FOLDER=src\n" - env_file.write_text(env_content) - - # Create clean environment without PYTHON_VERSION so Makefile reads from .python-version - env = os.environ.copy() - env.pop("PYTHON_VERSION", None) - - proc = run_make(logger, ["deptry"], env=env) - - out = proc.stdout - assert_uvx_command_uses_version(out, tmp_path, "deptry src") - - def test_typecheck_target_dry_run(self, logger, tmp_path): - """Typecheck target should invoke ty via uv run in dry-run output.""" - # Create a mock SOURCE_FOLDER directory so the typecheck command runs - source_folder = tmp_path / "src" - source_folder.mkdir(exist_ok=True) - - # Update .env to set SOURCE_FOLDER - env_file = tmp_path / ".rhiza" / ".env" - env_content = env_file.read_text() - env_content += "\nSOURCE_FOLDER=src\n" - env_file.write_text(env_content) - - proc = run_make(logger, ["typecheck"]) - out = proc.stdout - # Check for uv run command - assert "uv run ty check src" in out - - def test_test_target_dry_run(self, logger): - """Test target should invoke pytest via uv with coverage and HTML outputs in dry-run output.""" - proc = run_make(logger, ["test"]) - out = proc.stdout - # Expect key steps - assert "mkdir -p _tests/html-coverage _tests/html-report" in out - # Check for uv command running pytest - assert "uv run pytest" in out - # Check for XML coverage report - assert "--cov-report=xml:_tests/coverage.xml" in out - - def test_test_target_without_source_folder(self, logger, tmp_path): - """Test target should run without coverage when SOURCE_FOLDER doesn't exist.""" - # Update .env to set SOURCE_FOLDER to a non-existent directory - env_file = tmp_path / ".rhiza" / ".env" - env_content = env_file.read_text() - env_content += "\nSOURCE_FOLDER=nonexistent_src\n" - env_file.write_text(env_content) - - # Create tests folder - tests_folder = tmp_path / "tests" - tests_folder.mkdir(exist_ok=True) - - proc = run_make(logger, ["test"]) - out = proc.stdout - # Should see warning about missing source folder - assert "if [ -d nonexistent_src ]" in out - # Should still run pytest but without coverage flags - assert "uv run pytest" in out - assert "--html=_tests/html-report/report.html" in out - - def test_python_version_defaults_to_3_13_if_missing(self, logger, tmp_path): - """`PYTHON_VERSION` should default to `3.13` if .python-version is missing.""" - # Ensure .python-version does not exist - python_version_file = tmp_path / ".python-version" - if python_version_file.exists(): - python_version_file.unlink() - - # Create clean environment without PYTHON_VERSION - env = os.environ.copy() - env.pop("PYTHON_VERSION", None) - - proc = run_make(logger, ["print-PYTHON_VERSION"], dry_run=False, env=env) - out = strip_ansi(proc.stdout) - assert "Value of PYTHON_VERSION:\n3.13" in out - - def test_uv_no_modify_path_is_exported(self, logger): - """`UV_NO_MODIFY_PATH` should be set to `1` in the Makefile.""" - proc = run_make(logger, ["print-UV_NO_MODIFY_PATH"], dry_run=False) - out = strip_ansi(proc.stdout) - assert "Value of UV_NO_MODIFY_PATH:\n1" in out - - def test_that_target_coverage_is_configurable(self, logger): - """Test target should respond to COVERAGE_FAIL_UNDER variable.""" - # Default case: ensure the flag is present - proc = run_make(logger, ["test"]) - assert "--cov-fail-under=" in proc.stdout - - # Override case: ensure the flag takes the specific value - proc_override = run_make(logger, ["test", "COVERAGE_FAIL_UNDER=42"]) - assert "--cov-fail-under=42" in proc_override.stdout - - def test_suppression_audit_target_dry_run(self, logger): - """Suppression-audit target should invoke the Python audit script via uv run in dry-run output.""" - proc = run_make(logger, ["suppression-audit"]) - out = proc.stdout - assert "uv run python" in out - assert "suppression_audit.py" in out - - def test_license_target_dry_run(self, logger): - """License target should invoke pip-licenses via uv run --with in dry-run output.""" - proc = run_make(logger, ["license"]) - out = proc.stdout - assert "uv run --with pip-licenses pip-licenses" in out - assert "--fail-on=" in out - assert "GPL" in out - - def test_license_fail_on_is_configurable(self, logger): - """License target should use the LICENSE_FAIL_ON variable for the fail-on list.""" - proc = run_make(logger, ["license", "LICENSE_FAIL_ON=MIT;Apache"]) - out = proc.stdout - assert '--fail-on="MIT;Apache"' in out - - def test_serve_target_uses_uv_run_python_http_server(self, logger): - """Serve target should use uv run instead of directly calling python3.""" - proc = run_make(logger, ["serve"]) - out = proc.stdout - assert "uv run python -m http.server 8000" in out - - -class TestMakefileRootFixture: - """Tests for root fixture usage in Makefile tests.""" - - def test_makefile_exists_at_root(self, root): - """Makefile should exist at repository root.""" - makefile = root / "Makefile" - assert makefile.exists() - assert makefile.is_file() - - def test_makefile_contains_targets(self, root): - """Makefile should contain expected targets (including split files).""" - makefile = root / "Makefile" - content = makefile.read_text() - - # Read split Makefiles as well - for split_file in SPLIT_MAKEFILES: - split_path = root / split_file - if split_path.exists(): - content += "\n" + split_path.read_text() - - expected_targets = ["install", "fmt", "test", "deptry", "help"] - for target in expected_targets: - assert f"{target}:" in content or f".PHONY: {target}" in content - - def test_validate_target_skips_in_rhiza_repo(self, logger): - """Validate target should skip execution in rhiza repository.""" - setup_rhiza_git_repo() - - proc = run_make(logger, ["validate"], dry_run=False) - # out = strip_ansi(proc.stdout) - # assert "[INFO] Skipping validate in rhiza repository" in out - assert proc.returncode == 0 - - def test_sync_target_skips_in_rhiza_repo(self, logger): - """Sync target should skip execution in rhiza repository.""" - setup_rhiza_git_repo() - - proc = run_make(logger, ["sync"], dry_run=False) - # out = strip_ansi(proc.stdout) - # assert "[INFO] Skipping sync in rhiza repository" in out - assert proc.returncode == 0 - - def test_sync_experimental_target_skips_in_rhiza_repo(self, logger): - """Sync-experimental target should skip execution in rhiza repository.""" - setup_rhiza_git_repo() - - proc = run_make(logger, ["sync-experimental"], dry_run=False) - assert proc.returncode == 0 - - def test_materialize_target_is_deprecated(self, logger): - """Materialize target should print a deprecation warning and delegate to sync.""" - setup_rhiza_git_repo() - - proc = run_make(logger, ["materialize"], dry_run=False) - out = strip_ansi(proc.stdout) - assert proc.returncode == 0 - assert "deprecated" in out.lower() - assert "sync" in out - - -class TestMakeBump: - """Tests for the 'make bump' target.""" - - @pytest.fixture - def mock_bin(self, tmp_path): - """Create mock uv and uvx scripts in ./bin.""" - bin_dir = tmp_path / "bin" - bin_dir.mkdir(exist_ok=True) - - uv = bin_dir / "uv" - uv.write_text('#!/bin/sh\necho "[MOCK] uv $@"\n') - uv.chmod(0o755) - - # Mock uvx to simulate version bump if arguments match - uvx = bin_dir / "uvx" - uvx_script = """#!/usr/bin/env python3 -import sys -import re -from pathlib import Path - -args = sys.argv[1:] -print(f"[MOCK] uvx {' '.join(args)}") - -# Check if this is the bump command: "rhiza-tools>=0.5.1" bump -if "bump" in args: - # Simulate bumping version in pyproject.toml - pyproject = Path("pyproject.toml") - if pyproject.exists(): - content = pyproject.read_text() - # Simple regex replacement for version - # Assuming version = "0.1.0" -> "0.1.1" - new_content = re.sub(r'version = "([0-9.]+)"', lambda m: f'version = "{m.group(1)[:-1]}{int(m.group(1)[-1]) + 1}"', content) - pyproject.write_text(new_content) - print(f"[MOCK] Bumped version in {pyproject}") -""" # noqa: E501 - uvx.write_text(uvx_script) - uvx.chmod(0o755) - - return bin_dir - - def test_bump_execution(self, logger, mock_bin, tmp_path): - """Test 'make bump' execution with mocked tools and verify version change.""" - # Create dummy pyproject.toml with initial version - pyproject = tmp_path / "pyproject.toml" - pyproject.write_text('version = "0.1.0"\n[project]\nname = "test"\n') - - uv_bin = mock_bin / "uv" - uvx_bin = mock_bin / "uvx" - - # Run make bump with dry_run=False to actually execute the shell commands - result = run_make(logger, ["bump", f"UV_BIN={uv_bin}", f"UVX_BIN={uvx_bin}"], dry_run=False) - - # Verify that the mock tools were called - assert "[MOCK] uvx rhiza-tools>=0.5.1 bump" in result.stdout - assert "[MOCK] uv lock" in result.stdout - - # Verify that 'make install' was called (which calls uv sync) - assert "[MOCK] uv sync" in result.stdout - - # Verify that the version was actually bumped by our mock - new_content = pyproject.read_text() - assert 'version = "0.1.1"' in new_content - - def test_bump_no_pyproject(self, logger, mock_bin, tmp_path): - """Test 'make bump' execution without pyproject.toml.""" - # Ensure pyproject.toml does not exist - pyproject = tmp_path / "pyproject.toml" - if pyproject.exists(): - pyproject.unlink() - - uv_bin = mock_bin / "uv" - uvx_bin = mock_bin / "uvx" - - result = run_make(logger, ["bump", f"UV_BIN={uv_bin}", f"UVX_BIN={uvx_bin}"], dry_run=False) - - # Check for warning message - assert "No pyproject.toml found, skipping bump" in result.stdout - - # Ensure bump commands are NOT executed - assert "[MOCK] uvx" not in result.stdout - assert "[MOCK] uv lock" not in result.stdout diff --git a/.rhiza/tests/integration/test_book_targets.py b/.rhiza/tests/integration/test_book_targets.py deleted file mode 100644 index 9c73666..0000000 --- a/.rhiza/tests/integration/test_book_targets.py +++ /dev/null @@ -1,163 +0,0 @@ -"""Tests for book-related Makefile targets and their resilience.""" - -import shutil -import subprocess # nosec - -import pytest - -MAKE = shutil.which("make") or "/usr/bin/make" - - -@pytest.fixture -def book_makefile(git_repo): - """Return the book.mk path or skip tests if missing.""" - makefile = git_repo / ".rhiza" / "make.d" / "book.mk" - if not makefile.exists(): - pytest.skip("book.mk not found, skipping test") - return makefile - - -def test_no_book_folder(git_repo, book_makefile): - """Test that make targets work gracefully when book folder is missing. - - Now that book-related targets are defined in .rhiza/make.d/, they are always - available but check internally for the existence of the book folder. - Using dry-run (-n) to test the target logic without actually executing. - """ - if (git_repo / "book").exists(): - shutil.rmtree(git_repo / "book") - assert not (git_repo / "book").exists() - - # Targets are now always defined via .rhiza/make.d/ - # Use dry-run to verify they exist and can be parsed - for target in ["book"]: - result = subprocess.run([MAKE, "-n", target], cwd=git_repo, capture_output=True, text=True) # nosec - # Target should exist (not "no rule to make target") - assert "no rule to make target" not in result.stderr.lower(), ( - f"Target {target} should be defined in .rhiza/make.d/" - ) - - -def test_book_folder_but_no_mk(git_repo, book_makefile): - """Test behavior when book folder exists but is empty. - - With the new architecture, targets are always defined in .rhiza/make.d/book.mk, - so they should exist regardless of the book folder contents. - """ - # ensure book folder exists but is empty - if (git_repo / "book").exists(): - shutil.rmtree(git_repo / "book") - # create an empty book folder - (git_repo / "book").mkdir() - - # assert the book folder exists - assert (git_repo / "book").exists() - # assert the git_repo / "book" folder is empty - assert not list((git_repo / "book").iterdir()) - - # Targets are now always defined via .rhiza/make.d/ - # Use dry-run to verify they exist and can be parsed - for target in ["book"]: - result = subprocess.run([MAKE, "-n", target], cwd=git_repo, capture_output=True, text=True) # nosec - # Target should exist (not "no rule to make target") - assert "no rule to make target" not in result.stderr.lower(), ( - f"Target {target} should be defined in .rhiza/make.d/" - ) - - -def test_book_folder(git_repo, book_makefile): - """Test that .rhiza/make.d/book.mk defines the expected phony targets.""" - content = book_makefile.read_text() - - # get the list of phony targets from the Makefile - phony_targets = [line.strip() for line in content.splitlines() if line.startswith(".PHONY:")] - if not phony_targets: - pytest.skip("No .PHONY targets found in book.mk") - - # Collect all targets from all .PHONY lines - all_targets = set() - for phony_line in phony_targets: - targets = phony_line.split(":")[1].strip().split() - all_targets.update(targets) - - expected_targets = {"book", "test", "benchmark", "stress", "hypothesis-test"} - assert expected_targets.issubset(all_targets), ( - f"Expected phony targets to include {expected_targets}, got {all_targets}" - ) - - -def test_book_noop_targets_defined(book_makefile): - """Test that book.mk defines no-op fallback targets for build resilience. - - These no-op double-colon rules ensure 'make book' succeeds even when - test.mk is not available or tests are not installed. - """ - content = book_makefile.read_text() - for target in ["test", "benchmark", "stress", "hypothesis-test"]: - assert f"{target}::" in content, ( - f"book.mk should define a no-op '::' fallback for '{target}' to ensure build resilience" - ) - - -def test_book_without_logo_file(git_repo, book_makefile): - """Test that book target works when LOGO_FILE is not set or empty. - - The build should succeed gracefully without a logo, and the generated - HTML template should hide the logo element via onerror handler. - """ - makefile = git_repo / "Makefile" - if not makefile.exists(): - pytest.skip("Makefile not found") - - # Read current Makefile content - content = makefile.read_text() - - # Remove or comment out LOGO_FILE if present - lines = content.splitlines() - new_lines = [] - for line in lines: - if line.strip().startswith("LOGO_FILE"): - # Comment out the line - new_lines.append(f"# {line}") - else: - new_lines.append(line) - makefile.write_text("\n".join(new_lines)) - - # Dry-run the book target - it should still be valid - result = subprocess.run([MAKE, "-n", "book"], cwd=git_repo, capture_output=True, text=True) # nosec - assert "no rule to make target" not in result.stderr.lower(), "book target should work without LOGO_FILE" - # Should not have errors about missing logo variable - assert result.returncode == 0, f"Dry-run failed: {result.stderr}" - - -def test_book_with_missing_logo_file(git_repo, book_makefile): - """Test that book target warns when LOGO_FILE points to non-existent file. - - The build should succeed but emit a warning about the missing logo. - """ - makefile = git_repo / "Makefile" - if not makefile.exists(): - pytest.skip("Makefile not found") - - # Read current Makefile content and set LOGO_FILE to non-existent path - content = makefile.read_text() - lines = content.splitlines() - new_lines = [] - logo_set = False - for line in lines: - if line.strip().startswith("LOGO_FILE"): - new_lines.append("LOGO_FILE=nonexistent/path/logo.svg") - logo_set = True - else: - new_lines.append(line) - if not logo_set: - # Insert LOGO_FILE before the include line - for i, line in enumerate(new_lines): - if line.strip().startswith("include"): - new_lines.insert(i, "LOGO_FILE=nonexistent/path/logo.svg") - break - makefile.write_text("\n".join(new_lines)) - - # Dry-run should still succeed - result = subprocess.run([MAKE, "-n", "book"], cwd=git_repo, capture_output=True, text=True) # nosec - assert result.returncode == 0, f"Dry-run failed with missing logo: {result.stderr}" diff --git a/.rhiza/tests/integration/test_docs_targets.py b/.rhiza/tests/integration/test_docs_targets.py deleted file mode 100644 index 44f3e21..0000000 --- a/.rhiza/tests/integration/test_docs_targets.py +++ /dev/null @@ -1,48 +0,0 @@ -"""Tests for book.mk Makefile targets and the MKDOCS_EXTRA_PACKAGES variable.""" - -import shutil -import subprocess # nosec - -import pytest - -MAKE = shutil.which("make") or "/usr/bin/make" - - -@pytest.fixture -def book_makefile(git_repo): - """Return the book.mk path or skip tests if missing.""" - makefile = git_repo / ".rhiza" / "make.d" / "book.mk" - if not makefile.exists(): - pytest.skip("book.mk not found, skipping test") - return makefile - - -def test_mkdocs_extra_packages_variable_defined(book_makefile): - """Test that MKDOCS_EXTRA_PACKAGES is declared with a default-empty value.""" - content = book_makefile.read_text() - assert "MKDOCS_EXTRA_PACKAGES ?=" in content, "book.mk should declare MKDOCS_EXTRA_PACKAGES with a ?= default" - - -def test_mkdocs_build_dry_run_with_extra_packages(git_repo, book_makefile): - """Test that passing MKDOCS_EXTRA_PACKAGES on the command line is accepted by make. - - Validates both a single package and multiple packages to confirm the variable - correctly extends the uvx invocation in all cases. - """ - for extra in [ - "--with mkdocs-graphviz", - "--with mkdocs-graphviz --with mkdocs-mermaid2", - ]: - result = subprocess.run( # nosec - [MAKE, "-n", "book", f"MKDOCS_EXTRA_PACKAGES={extra}"], - cwd=git_repo, - capture_output=True, - text=True, - ) - assert "no rule to make target" not in result.stderr.lower(), "book should be a defined target" - assert result.returncode == 0, f"Dry-run failed: {result.stderr}" - # Each extra package flag should appear in the dry-run output - for pkg in ["mkdocs-graphviz", "mkdocs-mermaid2"][: len(extra.split("--with")) - 1]: - assert pkg in result.stdout, ( - f"MKDOCS_EXTRA_PACKAGES package '{pkg}' should be visible in the dry-run command" - ) diff --git a/.rhiza/tests/integration/test_test_mk.py b/.rhiza/tests/integration/test_test_mk.py deleted file mode 100644 index 83102bd..0000000 --- a/.rhiza/tests/integration/test_test_mk.py +++ /dev/null @@ -1,53 +0,0 @@ -"""Integration test for .rhiza/make.d/test.mk to verify that it handles the case of missing test files correctly.""" - -from test_utils import run_make - - -def test_missing_tests_warning(git_repo, logger): - """Test that missing tests trigger a warning but do not fail (exit 0).""" - # 1. Setup a minimal Makefile in the test repo - # We include .rhiza/make.d/test.mk but mock the 'install' dependency - # and provide color variables used in the script. - makefile_content = r""" -YELLOW := \033[33m -RED := \033[31m -RESET := \033[0m - -# Define folders expected by test.mk -TESTS_FOLDER := tests -SOURCE_FOLDER := src -VENV := .venv - -# Mock install to avoid actual installation in test -install: - @echo "Mock install" - -# Include the target under test -include .rhiza/make.d/test.mk -""" - (git_repo / "Makefile").write_text(makefile_content, encoding="utf-8") - - # 2. Ensure 'tests' folder exists but is empty/has no python test files - tests_dir = git_repo / "tests" - if tests_dir.exists(): - import shutil - - shutil.rmtree(tests_dir) - tests_dir.mkdir() - - # 3. Run 'make test' - # We use dry_run=False so the shell commands in the recipe actually execute. - # The 'check=False' allows us to assert the return code ourselves, - # though we expect 0 now. - result = run_make(logger, ["test"], check=False, dry_run=False) - - # 4. output for debugging - logger.info("make stdout: %s", result.stdout) - logger.info("make stderr: %s", result.stderr) - - # 5. Verify results - assert result.returncode == 0, "make test should exit with 0 when no tests found" - - # The warning message matches what we put in test.mk - # "No test files found in {TESTS_FOLDER}, skipping tests" - assert "No test files found in tests, skipping tests" in result.stdout diff --git a/.rhiza/tests/integration/test_virtual_env_unexport.py b/.rhiza/tests/integration/test_virtual_env_unexport.py deleted file mode 100644 index fae30bb..0000000 --- a/.rhiza/tests/integration/test_virtual_env_unexport.py +++ /dev/null @@ -1,37 +0,0 @@ -"""Integration test to verify VIRTUAL_ENV is unset for uv commands.""" - -import os - -from test_utils import run_make - - -def test_virtual_env_not_exported(git_repo, logger): - """Test that VIRTUAL_ENV is not exported to child processes when set in the environment.""" - # 1. Setup a minimal Makefile that includes rhiza.mk - makefile_content = r""" -# Include rhiza.mk which has 'unexport VIRTUAL_ENV' -include .rhiza/rhiza.mk - -# Create a test target that checks if VIRTUAL_ENV is exported -.PHONY: test-env -test-env: - @echo "VIRTUAL_ENV in shell: '$$VIRTUAL_ENV'" -""" - (git_repo / "Makefile").write_text(makefile_content, encoding="utf-8") - - # 2. Set VIRTUAL_ENV in the environment (simulating an activated venv) - env = os.environ.copy() - env["VIRTUAL_ENV"] = "/some/absolute/path/.venv" - - # 3. Run 'make test-env' with VIRTUAL_ENV set - result = run_make(logger, ["test-env"], check=True, dry_run=False, env=env) - - # 4. Output for debugging - logger.info("make stdout: %s", result.stdout) - logger.info("make stderr: %s", result.stderr) - - # 5. Verify that VIRTUAL_ENV is empty in the shell (not exported) - # The output should contain "VIRTUAL_ENV in shell: ''" - assert "VIRTUAL_ENV in shell: ''" in result.stdout, ( - f"VIRTUAL_ENV should be empty in shell commands, but got: {result.stdout}" - ) diff --git a/.rhiza/tests/shell/test_scripts.sh b/.rhiza/tests/shell/test_scripts.sh deleted file mode 100755 index ee86799..0000000 --- a/.rhiza/tests/shell/test_scripts.sh +++ /dev/null @@ -1,245 +0,0 @@ -#!/bin/bash -# Shell script test suite -# Tests shell scripts in the repository for correctness and error handling -# -# Usage: ./test_scripts.sh [--verbose] -# --verbose: Show detailed output for each test - -set -euo pipefail - -# Test counters -TESTS_RUN=0 -TESTS_PASSED=0 -TESTS_FAILED=0 - -# Color output -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' # No Color - -VERBOSE=false -if [ "${1:-}" = "--verbose" ]; then - VERBOSE=true -fi - -# Test helper functions -assert_equal() { - local expected="$1" - local actual="$2" - local test_name="$3" - - TESTS_RUN=$((TESTS_RUN + 1)) - - if [ "$expected" = "$actual" ]; then - TESTS_PASSED=$((TESTS_PASSED + 1)) - if [ "$VERBOSE" = true ]; then - echo -e "${GREEN}✓${NC} PASS: $test_name" - fi - else - TESTS_FAILED=$((TESTS_FAILED + 1)) - echo -e "${RED}✗${NC} FAIL: $test_name" - echo " Expected: $expected" - echo " Got: $actual" - fi -} - -assert_contains() { - local haystack="$1" - local needle="$2" - local test_name="$3" - - TESTS_RUN=$((TESTS_RUN + 1)) - - if grep -q "$needle" <<< "$haystack"; then - TESTS_PASSED=$((TESTS_PASSED + 1)) - if [ "$VERBOSE" = true ]; then - echo -e "${GREEN}✓${NC} PASS: $test_name" - fi - else - TESTS_FAILED=$((TESTS_FAILED + 1)) - echo -e "${RED}✗${NC} FAIL: $test_name" - echo " Expected to find: $needle" - echo " In output: $haystack" - fi -} - -assert_exit_code() { - local expected_code="$1" - local actual_code="$2" - local test_name="$3" - - TESTS_RUN=$((TESTS_RUN + 1)) - - if [ "$expected_code" -eq "$actual_code" ]; then - TESTS_PASSED=$((TESTS_PASSED + 1)) - if [ "$VERBOSE" = true ]; then - echo -e "${GREEN}✓${NC} PASS: $test_name" - fi - else - TESTS_FAILED=$((TESTS_FAILED + 1)) - echo -e "${RED}✗${NC} FAIL: $test_name" - echo " Expected exit code: $expected_code" - echo " Got exit code: $actual_code" - fi -} - -# Find repository root (script is in .rhiza/tests/shell/) -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" - -echo -e "${BLUE}=== Shell Script Test Suite ===${NC}" -echo "Repository: $REPO_ROOT" -echo "" - -# ============================================================================ -# Test Suite: session-start.sh -# ============================================================================ -echo -e "${YELLOW}Testing: session-start.sh${NC}" - -# Test 1: Script has proper shebang -first_line=$(head -n 1 "$REPO_ROOT/.github/hooks/session-start.sh") -assert_equal "#!/bin/bash" "$first_line" "session-start.sh has bash shebang" - -# Test 2: Script uses strict mode -if grep -q "set -euo pipefail" "$REPO_ROOT/.github/hooks/session-start.sh"; then - TESTS_RUN=$((TESTS_RUN + 1)) - TESTS_PASSED=$((TESTS_PASSED + 1)) - if [ "$VERBOSE" = true ]; then - echo -e "${GREEN}✓${NC} PASS: session-start.sh uses strict error handling" - fi -else - TESTS_RUN=$((TESTS_RUN + 1)) - TESTS_FAILED=$((TESTS_FAILED + 1)) - echo -e "${RED}✗${NC} FAIL: session-start.sh missing 'set -euo pipefail'" -fi - -# Test 3: Normal mode with valid environment -if [ -d "$REPO_ROOT/.venv" ] && command -v uv >/dev/null 2>&1; then - output=$(bash "$REPO_ROOT/.github/hooks/session-start.sh" 2>&1) || true - assert_contains "$output" "Validating environment" "session-start.sh normal mode runs validation" -fi - -# ============================================================================ -# Test Suite: session-end.sh -# ============================================================================ -echo -e "${YELLOW}Testing: session-end.sh${NC}" - -# Test 4: Script has proper shebang -first_line=$(head -n 1 "$REPO_ROOT/.github/hooks/session-end.sh") -assert_equal "#!/bin/bash" "$first_line" "session-end.sh has bash shebang" - -# Test 5: Script uses strict mode -if grep -q "set -euo pipefail" "$REPO_ROOT/.github/hooks/session-end.sh"; then - TESTS_RUN=$((TESTS_RUN + 1)) - TESTS_PASSED=$((TESTS_PASSED + 1)) - if [ "$VERBOSE" = true ]; then - echo -e "${GREEN}✓${NC} PASS: session-end.sh uses strict error handling" - fi -else - TESTS_RUN=$((TESTS_RUN + 1)) - TESTS_FAILED=$((TESTS_FAILED + 1)) - echo -e "${RED}✗${NC} FAIL: session-end.sh missing 'set -euo pipefail'" -fi - -# ============================================================================ -# Test Suite: bootstrap.sh -# ============================================================================ -echo -e "${YELLOW}Testing: bootstrap.sh${NC}" - -# Test 6: Script has proper shebang -first_line=$(head -n 1 "$REPO_ROOT/.devcontainer/bootstrap.sh") -assert_equal "#!/bin/bash" "$first_line" "bootstrap.sh has bash shebang" - -# Test 7: Script uses strict mode -if grep -q "set -euo pipefail" "$REPO_ROOT/.devcontainer/bootstrap.sh"; then - TESTS_RUN=$((TESTS_RUN + 1)) - TESTS_PASSED=$((TESTS_PASSED + 1)) - if [ "$VERBOSE" = true ]; then - echo -e "${GREEN}✓${NC} PASS: bootstrap.sh uses strict error handling" - fi -else - TESTS_RUN=$((TESTS_RUN + 1)) - TESTS_FAILED=$((TESTS_FAILED + 1)) - echo -e "${RED}✗${NC} FAIL: bootstrap.sh missing 'set -euo pipefail'" -fi - -# Test 8: Script has error handler function -if grep -q "error_with_recovery" "$REPO_ROOT/.devcontainer/bootstrap.sh"; then - TESTS_RUN=$((TESTS_RUN + 1)) - TESTS_PASSED=$((TESTS_PASSED + 1)) - if [ "$VERBOSE" = true ]; then - echo -e "${GREEN}✓${NC} PASS: bootstrap.sh has error_with_recovery function" - fi -else - TESTS_RUN=$((TESTS_RUN + 1)) - TESTS_FAILED=$((TESTS_FAILED + 1)) - echo -e "${RED}✗${NC} FAIL: bootstrap.sh missing error_with_recovery function" -fi - -# Test 9: Script includes remediation messages -if grep -q "Remediation\|Suggested fix" "$REPO_ROOT/.devcontainer/bootstrap.sh"; then - TESTS_RUN=$((TESTS_RUN + 1)) - TESTS_PASSED=$((TESTS_PASSED + 1)) - if [ "$VERBOSE" = true ]; then - echo -e "${GREEN}✓${NC} PASS: bootstrap.sh includes remediation messages" - fi -else - TESTS_RUN=$((TESTS_RUN + 1)) - TESTS_FAILED=$((TESTS_FAILED + 1)) - echo -e "${RED}✗${NC} FAIL: bootstrap.sh missing remediation messages" -fi - -# Test 10: Script handles .python-version file -if grep -q ".python-version" "$REPO_ROOT/.devcontainer/bootstrap.sh"; then - TESTS_RUN=$((TESTS_RUN + 1)) - TESTS_PASSED=$((TESTS_PASSED + 1)) - if [ "$VERBOSE" = true ]; then - echo -e "${GREEN}✓${NC} PASS: bootstrap.sh checks for .python-version" - fi -else - TESTS_RUN=$((TESTS_RUN + 1)) - TESTS_FAILED=$((TESTS_FAILED + 1)) - echo -e "${RED}✗${NC} FAIL: bootstrap.sh doesn't check .python-version" -fi - -# ============================================================================ -# Test Suite: Shell script syntax validation -# ============================================================================ -echo -e "${YELLOW}Testing: Syntax validation${NC}" - -# Test 11-13: Validate syntax of all shell scripts -for script in \ - "$REPO_ROOT/.devcontainer/bootstrap.sh" \ - "$REPO_ROOT/.github/hooks/session-start.sh" \ - "$REPO_ROOT/.github/hooks/session-end.sh" -do - script_name=$(basename "$script") - if bash -n "$script" 2>/dev/null; then - TESTS_RUN=$((TESTS_RUN + 1)) - TESTS_PASSED=$((TESTS_PASSED + 1)) - if [ "$VERBOSE" = true ]; then - echo -e "${GREEN}✓${NC} PASS: $script_name has valid bash syntax" - fi - else - TESTS_RUN=$((TESTS_RUN + 1)) - TESTS_FAILED=$((TESTS_FAILED + 1)) - echo -e "${RED}✗${NC} FAIL: $script_name has syntax errors" - fi -done - -# ============================================================================ -# Test Summary -# ============================================================================ -echo "" -echo -e "${BLUE}=== Test Summary ===${NC}" -echo "Tests run: $TESTS_RUN" -echo -e "${GREEN}Tests passed: $TESTS_PASSED${NC}" -if [ $TESTS_FAILED -gt 0 ]; then - echo -e "${RED}Tests failed: $TESTS_FAILED${NC}" - exit 1 -else - echo -e "${GREEN}All tests passed!${NC}" - exit 0 -fi diff --git a/.rhiza/tests/stress/README.md b/.rhiza/tests/stress/README.md deleted file mode 100644 index 06e4919..0000000 --- a/.rhiza/tests/stress/README.md +++ /dev/null @@ -1,143 +0,0 @@ -# Stress Tests for Rhiza Framework - -This directory contains stress tests that verify the stability and performance of the Rhiza framework under heavy load conditions. - -## Overview - -Stress tests differ from regular integration tests and benchmarks: -- **Integration tests** verify that workflows execute correctly -- **Benchmarks** measure performance of individual operations -- **Stress tests** verify system stability under concurrent load and repeated operations - -These tests focus specifically on Rhiza's core operations: Makefile execution and Git operations used by release and sync workflows. - -## Test Categories - -### 1. Makefile Stress Tests (`test_makefile_stress.py`) - -Tests Rhiza's Makefile operations under stress: -- Concurrent invocations of targets (help, dry-run) -- Repeated executions to detect resource leaks -- Parallel variable printing and Makefile parsing - -### 2. Git Operations Stress Tests (`test_git_stress.py`) - -Tests Git operations used by Rhiza (release scripts, sync) under concurrent load: -- Concurrent git status/log/diff/show commands -- Repeated git operations (status, log, branch, rev-parse) -- Rapid git rev-parse (used in release script) - -## Running Stress Tests - -### Run all stress tests -```bash -uv run pytest .rhiza/tests/stress/ -v -``` - -### Run specific stress test category -```bash -uv run pytest .rhiza/tests/stress/test_makefile_stress.py -v -uv run pytest .rhiza/tests/stress/test_git_stress.py -v -``` - -### Run with custom iteration count -```bash -# Reduce iterations for faster testing -uv run pytest .rhiza/tests/stress/ -v --iterations=10 - -# Increase iterations for more thorough testing -uv run pytest .rhiza/tests/stress/ -v --iterations=500 -``` - -### Run with custom worker count -```bash -# Test with more concurrent workers -uv run pytest .rhiza/tests/stress/ -v --workers=20 -``` - -### Skip stress tests (when running full test suite) -```bash -uv run pytest .rhiza/tests/ -v -m "not stress" -``` - -## Test Markers - -All tests in this directory are marked with `@pytest.mark.stress` to allow selective execution: - -```python -@pytest.mark.stress -def test_concurrent_operations(): - # Test concurrent operations - pass -``` - -## Expected Behavior - -Stress tests should: -1. **Pass consistently** - No flakiness or race conditions -2. **Complete in reasonable time** - Generally < 60 seconds per test -3. **Clean up resources** - No leaked file handles, processes, or temporary files -4. **Report clear failures** - When failures occur, provide actionable error messages - -## Acceptance Criteria - -For Rhiza framework stress tests, we aim for: -- **100% success rate** - All operations should complete successfully -- **No resource leaks** - Memory and file handles should be cleaned up -- **Deterministic behavior** - Tests should produce consistent results -- **Reasonable performance** - Operations should complete within expected time bounds - -## Troubleshooting - -### Tests timeout -- Reduce iteration count: `pytest --iterations=10` -- Reduce worker count: `pytest --workers=5` -- Check system resources (CPU, memory, disk) - -### Intermittent failures -- Run with verbose output: `pytest -vv` -- Check for resource contention with other processes -- Verify git configuration (may affect git operations) - -### Out of memory errors -- Reduce concurrent workers -- Check for memory leaks in test code -- Ensure proper cleanup in fixtures - -## Contributing - -When adding new stress tests: -1. Use the `@pytest.mark.stress` decorator -2. Use provided fixtures (`stress_iterations`, `concurrent_workers`) -3. Ensure proper cleanup (use context managers or fixtures) -4. Document expected behavior and acceptance criteria -5. Keep tests focused on one stress scenario -6. Provide clear assertion messages - -Example: -```python -import pytest -import concurrent.futures - -@pytest.mark.stress -def test_concurrent_operation(stress_iterations, concurrent_workers): - """Test concurrent execution of operation X.""" - - def perform_operation(): - # Operation to stress test - return True - - with concurrent.futures.ThreadPoolExecutor(max_workers=concurrent_workers) as executor: - futures = [executor.submit(perform_operation) for _ in range(stress_iterations)] - results = [f.result() for f in concurrent.futures.as_completed(futures)] - - success_rate = sum(results) / len(results) - assert success_rate == 1.0, f"Expected 100% success rate, got {success_rate * 100:.1f}%" -``` - -## See Also - -- [Main Test README](../README.md) - Overview of all test categories -- [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 deleted file mode 100644 index 8ce3020..0000000 --- a/.rhiza/tests/stress/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Stress tests for Rhiza framework. - -This module contains stress tests that verify system stability and performance -under heavy load conditions. -""" diff --git a/.rhiza/tests/stress/conftest.py b/.rhiza/tests/stress/conftest.py deleted file mode 100644 index 80bdc04..0000000 --- a/.rhiza/tests/stress/conftest.py +++ /dev/null @@ -1,50 +0,0 @@ -"""Pytest configuration for stress tests. - -Provides fixtures and utilities specific to stress testing scenarios. - -Security Note: -- S101 (assert usage): Safe in test code - asserts are expected in pytest tests -- S603/S607 (subprocess usage): Not used in this file, but documented for completeness - Any subprocess calls in stress tests are for testing make/git commands in isolated - temporary environments with controlled inputs -""" - -from __future__ import annotations - -import pytest - - -def pytest_addoption(parser): - """Add custom command-line options for stress tests.""" - parser.addoption( - "--iterations", - action="store", - default=100, - type=int, - help="Number of iterations for stress tests (default: 100)", - ) - parser.addoption( - "--workers", - action="store", - default=10, - type=int, - help="Number of concurrent workers for stress tests (default: 10)", - ) - - -@pytest.fixture -def stress_iterations(request): - """Return the number of iterations for stress tests. - - Default is 100 iterations. Can be overridden via --iterations command line option. - """ - return request.config.getoption("--iterations") - - -@pytest.fixture -def concurrent_workers(request): - """Return the number of concurrent workers for stress tests. - - Default is 10 workers. Can be overridden via --workers command line option. - """ - return request.config.getoption("--workers") diff --git a/.rhiza/tests/structure/test_project_layout.py b/.rhiza/tests/structure/test_project_layout.py deleted file mode 100644 index 5580598..0000000 --- a/.rhiza/tests/structure/test_project_layout.py +++ /dev/null @@ -1,57 +0,0 @@ -"""Tests for the root pytest fixture that yields the repository root Path. - -This file and its associated tests flow down via a SYNC action from the jebel-quant/rhiza repository -(https://github.com/jebel-quant/rhiza). - -This module ensures the fixture resolves to the true project root and that -expected files/directories exist, enabling other tests to locate resources -reliably. -""" - -import pytest - - -class TestRootFixture: - """Tests for the root fixture that provides repository root path.""" - - def test_root_resolves_correctly_from_nested_location(self, root): - """Root should correctly resolve to repository root from .rhiza/tests/.""" - conftest_path = root / ".rhiza" / "tests" / "conftest.py" - assert conftest_path.exists() - - def test_root_contains_expected_directories(self, root): - """Root should contain all expected project directories.""" - required_dirs = [".rhiza"] - # optional_dirs = ["src", "tests", "book"] # src/ is optional (rhiza itself doesn't have one) - - for dirname in required_dirs: - assert (root / dirname).exists(), f"Required directory {dirname} not found" - - # Check that at least one CI directory exists (.github or .gitlab) - ci_dirs = [".github", ".gitlab"] - if not any((root / ci_dir).exists() for ci_dir in ci_dirs): - pytest.fail(f"At least one CI directory from {ci_dirs} must exist") - - # for dirname in optional_dirs: - # if not (root / dirname).exists(): - # pytest.skip(f"Optional directory {dirname} not present in this project") - - def test_root_contains_expected_files(self, root): - """Root should contain all expected configuration files.""" - required_files = [ - "pyproject.toml", - "README.md", - "Makefile", - ] - optional_files = [ - "ruff.toml", - ".gitignore", - ".editorconfig", - ] - - for filename in required_files: - assert (root / filename).exists(), f"Required file {filename} not found" - - for filename in optional_files: - if not (root / filename).exists(): - pytest.skip(f"Optional file {filename} not present in this project") diff --git a/.rhiza/tests/structure/test_pyproject.py b/.rhiza/tests/structure/test_pyproject.py deleted file mode 100644 index 82f2b4d..0000000 --- a/.rhiza/tests/structure/test_pyproject.py +++ /dev/null @@ -1,217 +0,0 @@ -"""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 deleted file mode 100644 index 1bf9d04..0000000 --- a/.rhiza/tests/structure/test_requirements.py +++ /dev/null @@ -1,51 +0,0 @@ -"""Tests for the .rhiza/requirements folder structure. - -This test ensures that the requirements folder exists and contains the expected -requirement files for development dependencies. -""" - -from typing import ClassVar - - -class TestRequirementsFolder: - """Tests for the .rhiza/requirements folder structure.""" - - # Expected requirements files - EXPECTED_REQUIREMENTS_FILES: ClassVar[list[str]] = [ - # "tests.txt", # may not be present in all repositories - # "marimo.txt", # may not be present in all repositories - "docs.txt", - "tools.txt", - ] - - def test_requirements_folder_exists(self, root): - """Requirements folder should exist in .rhiza directory.""" - requirements_dir = root / ".rhiza" / "requirements" - assert requirements_dir.exists(), ".rhiza/requirements directory should exist" - assert requirements_dir.is_dir(), ".rhiza/requirements should be a directory" - - def test_requirements_files_exist(self, root): - """All expected requirements files should exist.""" - requirements_dir = root / ".rhiza" / "requirements" - for filename in self.EXPECTED_REQUIREMENTS_FILES: - filepath = requirements_dir / filename - assert filepath.exists(), f"{filename} should exist in requirements folder" - assert filepath.is_file(), f"{filename} should be a file" - - def test_requirements_files_not_empty(self, root): - """Requirements files should not be empty.""" - requirements_dir = root / ".rhiza" / "requirements" - for filename in self.EXPECTED_REQUIREMENTS_FILES: - filepath = requirements_dir / filename - content = filepath.read_text() - # Filter out comments and empty lines - lines = [line.strip() for line in content.splitlines() if line.strip() and not line.strip().startswith("#")] - assert len(lines) > 0, f"{filename} should contain at least one dependency" - - def test_readme_exists_in_requirements_folder(self, root): - """README.md should exist in requirements folder.""" - readme_path = root / ".rhiza" / "requirements" / "README.md" - assert readme_path.exists(), "README.md should exist in requirements folder" - assert readme_path.is_file(), "README.md should be a file" - content = readme_path.read_text() - assert len(content) > 0, "README.md should not be empty" diff --git a/.rhiza/tests/sync/conftest.py b/.rhiza/tests/sync/conftest.py deleted file mode 100644 index fafa07a..0000000 --- a/.rhiza/tests/sync/conftest.py +++ /dev/null @@ -1,100 +0,0 @@ -"""Shared fixtures and helpers for sync tests. - -Provides environment setup for template sync, workflow versioning, -and content validation tests. - -Security Notes: -- S101 (assert usage): Asserts are used in pytest tests to validate conditions -- S603/S607 (subprocess usage): Any subprocess calls are for testing sync targets - in isolated environments with controlled inputs -- Test code operates in a controlled environment with trusted inputs -""" - -from __future__ import annotations - -import os -import shutil -import sys -from pathlib import Path - -import pytest - -tests_root = Path(__file__).resolve().parents[1] -if str(tests_root) not in sys.path: - sys.path.insert(0, str(tests_root)) - -from test_utils import run_make, setup_rhiza_git_repo, strip_ansi # noqa: E402, F401 - - -@pytest.fixture(autouse=True) -def setup_sync_env(logger, root, tmp_path: Path): - """Set up a temporary environment for sync tests with Makefile, templates, and git. - - This fixture creates a complete test environment with: - - Makefile and rhiza.mk configuration - - .rhiza-version file and .env configuration - - template.yml and pyproject.toml - - Initialized git repository (configured as rhiza origin) - - src/ and tests/ directories to satisfy validate target - """ - logger.debug("Setting up sync test environment: %s", tmp_path) - - # Copy the main Makefile into the temporary working directory - shutil.copy(root / "Makefile", tmp_path / "Makefile") - - # Copy core Rhiza Makefiles and version file - (tmp_path / ".rhiza").mkdir(exist_ok=True) - shutil.copy(root / ".rhiza" / "rhiza.mk", tmp_path / ".rhiza" / "rhiza.mk") - - # Copy split Makefiles from make.d directory - split_makefiles = [ - "bootstrap.mk", - "quality.mk", - "releasing.mk", - "test.mk", - "book.mk", - "marimo.mk", - "presentation.mk", - "github.mk", - "agentic.mk", - "docker.mk", - ] - (tmp_path / ".rhiza" / "make.d").mkdir(parents=True, exist_ok=True) - for mk_file in split_makefiles: - source_path = root / ".rhiza" / "make.d" / mk_file - if source_path.exists(): - shutil.copy(source_path, tmp_path / ".rhiza" / "make.d" / mk_file) - - # Copy .rhiza-version if it exists - if (root / ".rhiza" / ".rhiza-version").exists(): - shutil.copy(root / ".rhiza" / ".rhiza-version", tmp_path / ".rhiza" / ".rhiza-version") - - # Create a minimal, deterministic .rhiza/.env for tests - env_content = "CUSTOM_SCRIPTS_FOLDER=.rhiza/customisations/scripts\n" - (tmp_path / ".rhiza" / ".env").write_text(env_content) - - logger.debug("Copied Makefile from %s to %s", root / "Makefile", tmp_path / "Makefile") - - # Create a minimal .rhiza/template.yml - (tmp_path / ".rhiza" / "template.yml").write_text("repository: Jebel-Quant/rhiza\nref: v0.7.1\n") - - # Sort out pyproject.toml - (tmp_path / "pyproject.toml").write_text('[project]\nname = "test-project"\nversion = "0.1.0"\n') - - # Move into tmp directory for isolation - old_cwd = Path.cwd() - os.chdir(tmp_path) - logger.debug("Changed working directory to %s", tmp_path) - - # Initialize a git repo so that commands checking for it (like sync) don't fail validation - setup_rhiza_git_repo() - - # Create src and tests directories to satisfy validate - (tmp_path / "src").mkdir(exist_ok=True) - (tmp_path / "tests").mkdir(exist_ok=True) - - try: - yield - finally: - os.chdir(old_cwd) - logger.debug("Restored working directory to %s", old_cwd) diff --git a/.rhiza/tests/sync/test_docstrings.py b/.rhiza/tests/sync/test_docstrings.py deleted file mode 100644 index 231e57b..0000000 --- a/.rhiza/tests/sync/test_docstrings.py +++ /dev/null @@ -1,123 +0,0 @@ -"""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 deleted file mode 100644 index b889166..0000000 --- a/.rhiza/tests/sync/test_readme_validation.py +++ /dev/null @@ -1,197 +0,0 @@ -"""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 deleted file mode 100644 index a7c1da4..0000000 --- a/.rhiza/tests/test_utils.py +++ /dev/null @@ -1,70 +0,0 @@ -"""Shared test utilities. - -Helper functions used across the test suite. Extracted from conftest.py to avoid -relative imports and __init__.py requirements in test directories. - -This file and its associated utilities flow down via a SYNC action from the -jebel-quant/rhiza repository (https://github.com/jebel-quant/rhiza). - -Security Notes: -- S101 (assert usage): Asserts are used in test utilities to validate test setup conditions -- S603 (subprocess without shell=True): All subprocess calls use command lists with known - executables (git, make), not user input, preventing shell injection -- S607 (subprocess with partial path): Git and make are resolved from PATH via shutil.which() - with fallbacks, which is safe in controlled test environments -""" - -import re -import shutil -import subprocess # nosec B404 - subprocess module needed for git/make operations in test utilities - -# Get absolute paths for executables to avoid S607 warnings -GIT = shutil.which("git") or "/usr/bin/git" -MAKE = shutil.which("make") or "/usr/bin/make" - - -def strip_ansi(text: str) -> str: - """Strip ANSI escape sequences from text.""" - ansi_escape = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])") - return ansi_escape.sub("", text) - - -def run_make( - logger, args: list[str] | None = None, check: bool = True, dry_run: bool = True, env: dict[str, str] | None = None -) -> subprocess.CompletedProcess: - """Run `make` with optional arguments and return the completed process. - - Args: - logger: Logger used to emit diagnostic messages during the run - args: Additional arguments for make - check: If True, raise on non-zero return code - dry_run: If True, use -n to avoid executing commands - env: Optional environment variables to pass to the subprocess - """ - cmd = [MAKE] - if args: - cmd.extend(args) - # Use -s to reduce noise, -n to avoid executing commands - flags = "-sn" if dry_run else "-s" - cmd.insert(1, flags) - logger.info("Running command: %s", " ".join(cmd)) - result = subprocess.run(cmd, capture_output=True, text=True, env=env) # nosec B603 - logger.debug("make exited with code %d", result.returncode) - if result.stdout: - logger.debug("make stdout (truncated to 500 chars):\n%s", result.stdout[:500]) - if result.stderr: - logger.debug("make stderr (truncated to 500 chars):\n%s", result.stderr[:500]) - if check and result.returncode != 0: - msg = f"make failed with code {result.returncode}:\nSTDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}" - raise AssertionError(msg) - return result - - -def setup_rhiza_git_repo(): - """Initialize a git repository and set remote to rhiza.""" - subprocess.run([GIT, "init"], check=True, capture_output=True) # nosec B603 - subprocess.run( # nosec B603 - [GIT, "remote", "add", "origin", "https://github.com/jebel-quant/rhiza"], - check=True, - capture_output=True, - ) diff --git a/.rhiza/tests/utils/test_git_repo_fixture.py b/.rhiza/tests/utils/test_git_repo_fixture.py deleted file mode 100644 index 223cfee..0000000 --- a/.rhiza/tests/utils/test_git_repo_fixture.py +++ /dev/null @@ -1,132 +0,0 @@ -"""Tests for the git_repo pytest fixture that creates a mock Git repository. - -This file and its associated tests flow down via a SYNC action from the jebel-quant/rhiza repository -(https://github.com/jebel-quant/rhiza). - -This module validates the temporary repository structure, git initialization, -mocked tool executables, environment variables, and basic git configuration the -fixture is expected to provide for integration-style tests. -""" - -import os -import shutil -import subprocess # nosec B404 -from pathlib import Path - -# Get absolute path for git to avoid S607 warnings -GIT = shutil.which("git") or "/usr/bin/git" - - -class TestGitRepoFixture: - """Tests for the git_repo fixture that sets up a mock git repository.""" - - def test_git_repo_creates_temporary_directory(self, git_repo): - """Git repo fixture should create a temporary directory.""" - assert git_repo.exists() - assert git_repo.is_dir() - - def test_git_repo_contains_pyproject_toml(self, git_repo): - """Git repo should contain a pyproject.toml file.""" - pyproject = git_repo / "pyproject.toml" - assert pyproject.exists() - content = pyproject.read_text() - assert 'name = "test-project"' in content - assert 'version = "0.1.0"' in content - - def test_git_repo_contains_uv_lock(self, git_repo): - """Git repo should contain a uv.lock file.""" - assert (git_repo / "uv.lock").exists() - - def test_git_repo_has_bin_directory_with_mocks(self, git_repo): - """Git repo should have bin directory with mock tools.""" - bin_dir = git_repo / "bin" - assert bin_dir.exists() - assert (bin_dir / "uv").exists() - - def test_git_repo_mock_tools_are_executable(self, git_repo): - """Mock tools should be executable.""" - for tool in ["uv"]: - tool_path = git_repo / "bin" / tool - assert os.access(tool_path, os.X_OK), f"{tool} is not executable" - - def test_git_repo_is_initialized(self, git_repo): - """Git repo should be properly initialized.""" - result = subprocess.run( # nosec B603 - [GIT, "rev-parse", "--git-dir"], - cwd=git_repo, - capture_output=True, - text=True, - ) - assert result.returncode == 0 - assert ".git" in result.stdout - - def test_git_repo_has_master_branch(self, git_repo): - """Git repo should be on master branch.""" - result = subprocess.run( # nosec B603 - [GIT, "branch", "--show-current"], - cwd=git_repo, - capture_output=True, - text=True, - ) - assert result.returncode == 0 - assert result.stdout.strip() == "master" - - def test_git_repo_has_initial_commit(self, git_repo): - """Git repo should have an initial commit.""" - result = subprocess.run( # nosec B603 - [GIT, "log", "--oneline"], - cwd=git_repo, - capture_output=True, - text=True, - ) - assert result.returncode == 0 - assert "Initial commit" in result.stdout - - def test_git_repo_has_remote_configured(self, git_repo): - """Git repo should have origin remote configured.""" - result = subprocess.run( # nosec B603 - [GIT, "remote", "-v"], - cwd=git_repo, - capture_output=True, - text=True, - ) - assert result.returncode == 0 - assert "origin" in result.stdout - - def test_git_repo_user_config_is_set(self, git_repo): - """Git repo should have user.email and user.name configured.""" - email = subprocess.check_output( # nosec B603 - [GIT, "config", "user.email"], - cwd=git_repo, - text=True, - ).strip() - name = subprocess.check_output( # nosec B603 - [GIT, "config", "user.name"], - cwd=git_repo, - text=True, - ).strip() - assert email == "test@example.com" - assert name == "Test User" - - def test_git_repo_working_tree_is_clean(self, git_repo): - """Git repo should start with a clean working tree.""" - result = subprocess.run( # nosec B603 - [GIT, "status", "--porcelain"], - cwd=git_repo, - capture_output=True, - text=True, - ) - assert result.returncode == 0 - assert result.stdout.strip() == "" - - def test_git_repo_changes_current_directory(self, git_repo): - """Git repo fixture should change to the temporary directory.""" - current_dir = Path.cwd() - assert current_dir == git_repo - - def test_git_repo_modifies_path_environment(self, git_repo): - """Git repo fixture should prepend bin directory to PATH.""" - path_env = os.environ.get("PATH", "") - bin_dir = str(git_repo / "bin") - assert bin_dir in path_env - assert path_env.startswith(bin_dir) diff --git a/.rhiza/utils/pip_audit_policy.py b/.rhiza/utils/pip_audit_policy.py deleted file mode 100644 index 4a5db0c..0000000 --- a/.rhiza/utils/pip_audit_policy.py +++ /dev/null @@ -1,67 +0,0 @@ -"""Run pip-audit with a tiered vulnerability policy. - -Fails the build for vulnerabilities in runtime dependencies. -Warns (without failing) for tooling packages: pip, setuptools, wheel, distribute. -Any extra arguments are forwarded to pip-audit (e.g. ``--ignore-vuln CVE-XXXX-YYYY``). -""" - -from __future__ import annotations - -import json -import shutil -import subprocess # nosec B404 -import sys - -_RESET = "\033[0m" -_RED = "\033[31m" -_YELLOW = "\033[33m" -_GREEN = "\033[32m" - -# Packages treated as build tooling — CVEs warn but do not fail CI. -_TOOLING: frozenset[str] = frozenset({"pip", "setuptools", "wheel", "distribute"}) - - -def _vuln_ids(vuln: dict) -> str: # type: ignore[type-arg] - """Return a human-readable string of all IDs for a vulnerability entry.""" - ids = [vuln["id"]] + [a for a in vuln.get("aliases", []) if a != vuln["id"]] - return ", ".join(ids) - - -def main() -> int: - """Run pip-audit and apply tiered vulnerability policy.""" - uvx = shutil.which("uvx") or "uvx" - cmd = [uvx, "pip-audit", "--format", "json", *sys.argv[1:]] - proc = subprocess.run(cmd, capture_output=True, text=True) # noqa: S603 # nosec B603 - - if proc.returncode == 0: - print(f"{_GREEN}[OK] pip-audit: no vulnerabilities found{_RESET}") - return 0 - - try: - data = json.loads(proc.stdout) - except json.JSONDecodeError: - sys.stdout.write(proc.stdout) - sys.stderr.write(proc.stderr) - return proc.returncode - - deps = data.get("dependencies", []) - tooling_vulns = [d for d in deps if d.get("vulns") and d["name"].lower() in _TOOLING] - runtime_vulns = [d for d in deps if d.get("vulns") and d["name"].lower() not in _TOOLING] - - for dep in tooling_vulns: - for v in dep["vulns"]: - print( - f"{_YELLOW}[WARN] {dep['name']}=={dep['version']}: {_vuln_ids(v)} (tooling — not failing build){_RESET}" - ) - - if not runtime_vulns: - return 0 - - for dep in runtime_vulns: - for v in dep["vulns"]: - print(f"{_RED}[FAIL] {dep['name']}=={dep['version']}: {_vuln_ids(v)}{_RESET}") - return 1 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/.rhiza/utils/suppression_audit.py b/.rhiza/utils/suppression_audit.py deleted file mode 100644 index 069d2e7..0000000 --- a/.rhiza/utils/suppression_audit.py +++ /dev/null @@ -1,369 +0,0 @@ -"""Suppression audit: scan codebase for inline suppressions of security, coverage, docs, and linting. - -Detects and reports on inline suppression comments such as: -- ``# noqa`` / ``# noqa: CODE`` (ruff/flake8 linting suppressions) -- ``# nosec`` / ``# nosec: CODE`` (bandit security suppressions) -- ``# type: ignore`` / ``# type: ignore[CODE]`` (mypy/pyright type-checking suppressions) -- ``# pragma: no cover`` (coverage suppressions) -- ``# noinspection CODE`` (PyCharm/IDE suppressions) - -Outputs a detailed per-file report, an ASCII histogram, and a letter grade. -""" - -from __future__ import annotations - -import argparse -import io -import json -import re -import shutil -import subprocess # nosec B404 -import sys -import tokenize -from collections import Counter -from dataclasses import dataclass, field -from pathlib import Path - -# --------------------------------------------------------------------------- -# Suppression patterns -# --------------------------------------------------------------------------- - -# Each entry: (kind_label, compiled_regex). -# The first capture group (if any) captures the comma-separated rule codes. -SUPPRESSION_PATTERNS: list[tuple[str, re.Pattern[str]]] = [ - ( - "noqa", - re.compile(r"#\s*noqa(?:\s*:\s*([A-Z0-9]+(?:\s*,\s*[A-Z0-9]+)*))?", re.IGNORECASE), - ), - ( - "nosec", - re.compile(r"#\s*nosec(?:\s*:?\s*([A-Z0-9]+(?:\s*,\s*[A-Z0-9]+)*))?", re.IGNORECASE), - ), - ( - "type:ignore", - re.compile(r"#\s*type\s*:\s*ignore(?:\[([^\]]+)\])?", re.IGNORECASE), - ), - ( - "no cover", - re.compile(r"#\s*pragma\s*:\s*no\s+cover", re.IGNORECASE), - ), - ( - "noinspection", - re.compile(r"#\s*noinspection\s+(\w+)", re.IGNORECASE), - ), -] - -# Directories to skip during the scan -_SKIP_DIRS = {".venv", ".git", "node_modules", ".tox", "build", "dist", "__pycache__", "tests"} - - -# --------------------------------------------------------------------------- -# Data model -# --------------------------------------------------------------------------- - - -@dataclass -class Suppression: - """Represents a single suppression comment found in the codebase.""" - - file: str - line_no: int - kind: str - codes: list[str] = field(default_factory=list) - raw: str = "" - - -# --------------------------------------------------------------------------- -# Scanning helpers -# --------------------------------------------------------------------------- - - -def _should_skip(path: Path) -> bool: - """Return True if any path component is in the skip-list.""" - return bool(_SKIP_DIRS.intersection(path.parts)) - - -def _is_rhiza_repo(root: Path) -> bool: - """Return True if *root* is the rhiza framework repo itself. - - Consumer repos have a ``.rhiza/template.yml`` file that records the upstream - rhiza repository reference. The rhiza repo itself never has this file — its - absence is the reliable signal that we are running inside the framework repo. - """ - return not (root / ".rhiza" / "template.yml").exists() - - -def scan_file(path: Path) -> list[Suppression]: - """Scan a single Python file and return all suppressions found. - - Uses Python's ``tokenize`` module so that only actual comment tokens are - inspected — patterns that appear inside string literals or docstrings are - correctly ignored. - """ - suppressions: list[Suppression] = [] - try: - source = path.read_text(encoding="utf-8", errors="replace") - except OSError: - return suppressions - - try: - tokens = tokenize.generate_tokens(io.StringIO(source).readline) - for tok_type, tok_string, tok_start, _tok_end, _line in tokens: - if tok_type != tokenize.COMMENT: - continue - line_no = tok_start[0] - for kind, pattern in SUPPRESSION_PATTERNS: - match = pattern.search(tok_string) - if match: - codes_raw = match.group(1) if match.lastindex and match.group(1) else "" - codes = [c.strip() for c in codes_raw.split(",") if c.strip()] if codes_raw else [] - suppressions.append( - Suppression( - file=str(path), - line_no=line_no, - kind=kind, - codes=codes, - raw=tok_string.strip(), - ) - ) - break # count each comment line once - except tokenize.TokenError: - pass # skip files with tokenization errors (e.g. incomplete source) - - return suppressions - - -def count_non_empty_lines(path: Path) -> int: - """Count non-empty lines in a file.""" - try: - return sum(1 for line in path.read_text(encoding="utf-8", errors="replace").splitlines() if line.strip()) - except OSError: - return 0 - - -# --------------------------------------------------------------------------- -# Grading -# --------------------------------------------------------------------------- - -# Grade thresholds: suppressions per 100 lines of code -_GRADE_THRESHOLDS: list[tuple[float, str]] = [ - (0.0, "A+"), - (0.5, "A"), - (1.0, "B"), - (2.0, "C"), - (3.0, "D"), -] - - -def compute_grade(density: float) -> str: - """Return a letter grade based on suppression density (count per 100 lines).""" - grade = "F" - for threshold, letter in _GRADE_THRESHOLDS: - if density <= threshold: - grade = letter - break - return grade - - -# --------------------------------------------------------------------------- -# Rendering helpers -# --------------------------------------------------------------------------- - -_BAR_WIDTH = 24 - - -def _bar(count: int, max_count: int) -> str: - """Render a fixed-width ASCII progress bar.""" - if max_count == 0: - return "░" * _BAR_WIDTH - filled = round(count / max_count * _BAR_WIDTH) - return "█" * filled + "░" * (_BAR_WIDTH - filled) - - -_GRADE_COLOURS = { - "A+": "\033[92m", # bright green - "A": "\033[32m", # green - "B": "\033[32m", # green - "C": "\033[33m", # yellow - "D": "\033[33m", # yellow - "F": "\033[31m", # red -} -_RESET = "\033[0m" -_BOLD = "\033[1m" -_BLUE = "\033[36m" -_YELLOW = "\033[33m" -_GREEN = "\033[32m" -_RED = "\033[31m" -_CVE_RE = re.compile(r"\bCVE-\d{4}-\d+\b", re.IGNORECASE) - - -# --------------------------------------------------------------------------- -# Main -# --------------------------------------------------------------------------- - - -def _active_pip_audit_ids(extra_args: list[str]) -> set[str]: - """Return vulnerability IDs currently reported by pip-audit.""" - uvx = shutil.which("uvx") or "uvx" - cmd = [uvx, "pip-audit", "--format", "json", *extra_args] - proc = subprocess.run(cmd, capture_output=True, text=True) # noqa: S603 # nosec B603 - - if proc.returncode not in {0, 1}: - sys.stdout.write(proc.stdout) - sys.stderr.write(proc.stderr) - raise RuntimeError("pip-audit execution failed") - - try: - data = json.loads(proc.stdout or "{}") - except json.JSONDecodeError as exc: - raise RuntimeError("pip-audit did not return valid JSON") from exc - - ids: set[str] = set() - for dep in data.get("dependencies", []): - for vuln in dep.get("vulns", []): - vuln_id = vuln.get("id") - if vuln_id: - ids.add(str(vuln_id).upper()) - for alias in vuln.get("aliases", []): - ids.add(str(alias).upper()) - return ids - - -def _nosec_cves(suppressions: list[Suppression]) -> set[str]: - """Extract CVE identifiers referenced by # nosec suppressions.""" - cves: set[str] = set() - for sup in suppressions: - if sup.kind != "nosec": - continue - cves.update(match.upper() for match in _CVE_RE.findall(sup.raw)) - return cves - - -def _collect_suppressions(root: Path) -> tuple[list[Path], list[Suppression], int]: - """Collect Python files, suppressions, and non-empty line counts.""" - in_rhiza_repo = _is_rhiza_repo(root) - - def _include(p: Path) -> bool: - if _should_skip(p): - return False - # In consumer repos, skip the .rhiza/ framework directory entirely - return not (not in_rhiza_repo and ".rhiza" in p.parts) - - py_files = sorted(p for p in root.rglob("*.py") if _include(p)) - - all_suppressions: list[Suppression] = [] - total_lines = 0 - for py_file in py_files: - all_suppressions.extend(scan_file(py_file)) - total_lines += count_non_empty_lines(py_file) - - return py_files, all_suppressions, total_lines - - -def _print_report(py_files: list[Path], all_suppressions: list[Suppression], total_lines: int) -> None: - """Print the suppression audit report.""" - # ----------------------------------------------------------------------- - # Header - # ----------------------------------------------------------------------- - print() - print(f"{_BOLD}{'=' * 62}{_RESET}") - print(f"{_BOLD} Suppression Audit Report{_RESET}") - print(f"{_BOLD}{'=' * 62}{_RESET}") - print() - - # ----------------------------------------------------------------------- - # Detailed per-file report - # ----------------------------------------------------------------------- - print(f"{_BOLD}Detailed Report:{_RESET}") - if all_suppressions: - for sup in all_suppressions: - codes_str = f"[{', '.join(sup.codes)}]" if sup.codes else "" - print(f" {_YELLOW}{sup.file}{_RESET}:{_GREEN}{sup.line_no}{_RESET}: # {sup.kind}{codes_str}") - else: - print(f" {_GREEN}No inline suppressions found.{_RESET}") - print() - - # ----------------------------------------------------------------------- - # Histogram by code - # ----------------------------------------------------------------------- - print(f"{_BOLD}Histogram (by suppression code):{_RESET}") - code_counter: Counter[str] = Counter() - for sup in all_suppressions: - if sup.codes: - for code in sup.codes: - code_counter[f"{sup.kind}[{code}]"] += 1 - else: - code_counter[f"{sup.kind}"] += 1 - if code_counter: - max_code_count = max(code_counter.values()) - total_code_count = sum(code_counter.values()) - for label, count in code_counter.most_common(): - pct = count / total_code_count * 100 - print(f" {label:<20} {_BLUE}{_bar(count, max_code_count)}{_RESET} {count:>3} ({pct:.0f}%)") - else: - print(" (none)") - print() - - # ----------------------------------------------------------------------- - # Summary + Grade - # ----------------------------------------------------------------------- - density = (len(all_suppressions) / total_lines * 100) if total_lines > 0 else 0.0 - grade = compute_grade(density) - grade_colour = _GRADE_COLOURS.get(grade, _RESET) - - print(f"{_BOLD}Summary:{_RESET}") - print(f" Files scanned : {len(py_files)}") - print(f" Lines scanned : {total_lines:,}") - print(f" Suppressions : {len(all_suppressions)}") - print(f" Density : {density:.2f} per 100 lines") - print() - print(f" Grade : {grade_colour}{_BOLD}{grade}{_RESET}") - print() - - -def _check_stale_nosec_cves(suppressions: list[Suppression], pip_audit_args: list[str]) -> int: - """Validate CVE-tagged # nosec suppressions against active pip-audit findings.""" - suppressed_cves = _nosec_cves(suppressions) - if not suppressed_cves: - print(f"{_GREEN}[OK]{_RESET} No CVE-tagged # nosec suppressions found.") - return 0 - - try: - active_cves = _active_pip_audit_ids(pip_audit_args) - except RuntimeError as exc: - print(f"{_RED}[FAIL]{_RESET} {exc}") - return 2 - - stale = sorted(cve for cve in suppressed_cves if cve not in active_cves) - if stale: - print(f"{_RED}[FAIL]{_RESET} Stale # nosec CVE suppressions detected:") - for cve in stale: - print(f" - {cve}") - return 1 - - print(f"{_GREEN}[OK]{_RESET} All CVE-tagged # nosec suppressions match active pip-audit findings.") - return 0 - - -def main(argv: list[str] | None = None) -> int: - """Run the suppression audit and print a structured report.""" - parser = argparse.ArgumentParser(add_help=True) - parser.add_argument( - "--fail-stale-nosec-cve", - action="store_true", - help="Fail when # nosec comments reference CVEs that pip-audit no longer reports.", - ) - args, pip_audit_args = parser.parse_known_args(argv) - - root = Path(".") - py_files, all_suppressions, total_lines = _collect_suppressions(root) - _print_report(py_files, all_suppressions, total_lines) - - if args.fail_stale_nosec_cve: - return _check_stale_nosec_cves(all_suppressions, pip_audit_args) - - return 0 - - -if __name__ == "__main__": - sys.exit(main(sys.argv[1:]))