From c7ccd6e694363b80dbf33848812d92aa3c123473 Mon Sep 17 00:00:00 2001 From: Thomas Schmelzer Date: Tue, 18 Aug 2026 00:11:52 +0400 Subject: [PATCH 1/3] chore: restructure .rhiza/tests to the rhiza v1.3.3 flat layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v1.3.3 replaces the nested .rhiza/tests/{api,integration,shell,stress, structure,sync,utils} tree with five flat modules. test_docstrings.py moves unchanged; test_pyproject.py and test_readme_validation.py move and are rewritten; test_readme.py and test_release_tags.py are new. The rest of the old tree goes, along with test_utils.py, whose helpers the flat modules no longer import. Isolated from #27 on request so the test-suite churn — 445 insertions against 2627 deletions — is reviewable on its own. Note that it does not stand up alone: quality.mk's rhiza-test provisions this layout's dependencies (pytest-timeout, python-dotenv, packaging), python.mk reads .rhiza/tests/test_pyproject.py, and pytest.ini supplies the pythonpath these modules import through. All three land in #27, which is stacked on this branch. Expect CI to be red here until both have merged. Co-Authored-By: Claude Opus 5 (1M context) --- .rhiza/tests/README.md | 168 ++------ .rhiza/tests/api/conftest.py | 98 ----- .rhiza/tests/api/test_github_targets.py | 63 --- .../tests/api/test_make_variable_overrides.py | 161 -------- .rhiza/tests/api/test_makefile_api.py | 369 ------------------ .rhiza/tests/api/test_makefile_targets.py | 363 ----------------- .rhiza/tests/conftest.py | 217 ++-------- .rhiza/tests/integration/test_book_targets.py | 163 -------- .rhiza/tests/integration/test_docs_targets.py | 48 --- .rhiza/tests/integration/test_test_mk.py | 53 --- .../integration/test_virtual_env_unexport.py | 37 -- .rhiza/tests/shell/test_scripts.sh | 245 ------------ .rhiza/tests/stress/README.md | 143 ------- .rhiza/tests/stress/__init__.py | 5 - .rhiza/tests/stress/conftest.py | 50 --- .rhiza/tests/structure/test_project_layout.py | 57 --- .rhiza/tests/structure/test_requirements.py | 51 --- .rhiza/tests/sync/conftest.py | 100 ----- .rhiza/tests/{sync => }/test_docstrings.py | 0 .../tests/{structure => }/test_pyproject.py | 187 +++++++-- .rhiza/tests/test_readme.py | 140 +++++++ .../{sync => }/test_readme_validation.py | 89 +---- .rhiza/tests/test_release_tags.py | 63 +++ .rhiza/tests/test_utils.py | 70 ---- .rhiza/tests/utils/test_git_repo_fixture.py | 132 ------- 25 files changed, 445 insertions(+), 2627 deletions(-) delete mode 100644 .rhiza/tests/api/conftest.py delete mode 100644 .rhiza/tests/api/test_github_targets.py delete mode 100644 .rhiza/tests/api/test_make_variable_overrides.py delete mode 100644 .rhiza/tests/api/test_makefile_api.py delete mode 100644 .rhiza/tests/api/test_makefile_targets.py delete mode 100644 .rhiza/tests/integration/test_book_targets.py delete mode 100644 .rhiza/tests/integration/test_docs_targets.py delete mode 100644 .rhiza/tests/integration/test_test_mk.py delete mode 100644 .rhiza/tests/integration/test_virtual_env_unexport.py delete mode 100755 .rhiza/tests/shell/test_scripts.sh delete mode 100644 .rhiza/tests/stress/README.md delete mode 100644 .rhiza/tests/stress/__init__.py delete mode 100644 .rhiza/tests/stress/conftest.py delete mode 100644 .rhiza/tests/structure/test_project_layout.py delete mode 100644 .rhiza/tests/structure/test_requirements.py delete mode 100644 .rhiza/tests/sync/conftest.py rename .rhiza/tests/{sync => }/test_docstrings.py (100%) rename .rhiza/tests/{structure => }/test_pyproject.py (51%) create mode 100644 .rhiza/tests/test_readme.py rename .rhiza/tests/{sync => }/test_readme_validation.py (59%) create mode 100644 .rhiza/tests/test_release_tags.py delete mode 100644 .rhiza/tests/test_utils.py delete mode 100644 .rhiza/tests/utils/test_git_repo_fixture.py diff --git a/.rhiza/tests/README.md b/.rhiza/tests/README.md index 1cf5c26..b79c24b 100644 --- a/.rhiza/tests/README.md +++ b/.rhiza/tests/README.md @@ -1,43 +1,41 @@ -# Rhiza Test Suite +# Rhiza Test Suite (`.rhiza/tests/`) -This directory contains the comprehensive test suite for the Rhiza project. +This directory is **synced from [jebel-quant/rhiza](https://github.com/jebel-quant/rhiza)** +and runs in your project with `make rhiza-test`. Its job is to validate the parts of *your* +repository that Rhiza cares about — the metadata, release config, docs and docstrings that +vary per project — using the shared fixtures below. -## Test Organization +> Tests that only exercise Rhiza's *own* template files (Makefile targets, workflow stubs, +> the project skeleton) live in Rhiza's mother-repo `tests/` suite and are **not** synced +> here — they would be identical in every consumer and can't be changed downstream. Put +> your project's own tests under your `tests/` directory, not here. -Tests are organized into purpose-driven subdirectories: +## Layout -### `structure/` -Static assertions about file and directory presence. These tests verify that the repository contains the expected files, directories, and configuration structure without executing any subprocesses. +The suite is flat — one file per concern — but **which files you get depends on the +bundles you sync**. Each is owned by whichever bundle the assertion belongs to, so a Rust +project gets the Rust manifest checks and none of the Python ones: -- `test_project_layout.py` — Validates root-level files and directories -- `test_requirements.py` — Validates `.rhiza/requirements/` structure +| file | owned by | checks | +| --- | --- | --- | +| `conftest.py` | `core` | shared fixtures (`root`, `logger`, `latest_tag`) | +| `test_release_tags.py` | `core` | the newest tag is reachable from a branch | +| `test_readme.py` | `core` | README exists; every `bash` fence parses | +| `test_pyproject.py` | `python-core` | `pyproject.toml` structure, and its `[tool.bumpversion]` block | +| `test_docstrings.py` | `python-core` | doctests across the modules in your source folder | +| `test_readme_validation.py` | `tests` | executes `python` fences and diffs them against `result` (see below) | +| `test_cargo_toml.py` | `rust-core` | `Cargo.toml` structure and the `.bumpversion.toml` wiring | +| `test_go_module.py` | `go-core` | `go.mod`, the `Version` constant, and the same wiring | -### `api/` -Makefile target validation via dry-runs. These tests verify that Makefile targets are properly defined and would execute the expected commands. +Every profile pairs `core` with exactly one language layer, so `conftest.py` is always +present alongside whichever layer's modules arrived. -- `test_makefile_targets.py` — Core Makefile targets (install, test, fmt, etc.) -- `test_makefile_api.py` — Makefile API (delegation, extension, hooks, overrides) -- `test_github_targets.py` — GitHub-specific Makefile targets +### Skipping README code blocks with `+RHIZA_SKIP` -### `integration/` -Tests requiring sandboxed git repositories or subprocess execution. These tests verify end-to-end workflows. - -- `test_release.py` — Release script functionality -- `test_book_targets.py` — Documentation book build targets - -### `sync/` -Template sync, workflows, versioning, and content validation tests. These tests ensure that template synchronization and content validation work correctly. - -- `test_rhiza_version.py` — Version reading and workflow validation -- `test_readme_validation.py` — README code block execution and validation -- `test_docstrings.py` — Doctest validation across source modules - -#### Skipping README code blocks with `+RHIZA_SKIP` - -By default, every `python` and `bash` code block in `README.md` is executed or -syntax-checked by `test_readme_validation.py`. To mark a block as intentionally -non-runnable (e.g. illustrative snippets or environment-specific commands), add -`+RHIZA_SKIP` to the opening fence line: +By default, every `bash` fence in `README.md` is syntax-checked (`test_readme.py`, any +language) and every `python` fence is executed (`test_readme_validation.py`, Python +projects). To mark a block as intentionally non-runnable — an illustrative snippet, an +environment-specific command — add `+RHIZA_SKIP` to the opening fence line: ~~~markdown ```python +RHIZA_SKIP @@ -56,114 +54,28 @@ Markdown renderers (including GitHub) ignore everything after the first word on a fence line, so the block still renders as a normal highlighted code block. Blocks without `+RHIZA_SKIP` continue to be validated as before. -### `utils/` -Tests for utility code and test infrastructure. These tests validate the testing framework itself and utility scripts. - -- `test_git_repo_fixture.py` — Validates the `git_repo` fixture - -### `deps/` -Dependency validation tests. These tests ensure that project dependencies are correctly specified and healthy. - -- `test_dependency_health.py` — Validates pyproject.toml and requirements files - -### `stress/` -Stress tests that verify Rhiza's stability under heavy load. These tests execute Rhiza-specific operations under concurrent load and repeated execution to detect race conditions, resource leaks, and performance degradation. - -- `test_makefile_stress.py` — Makefile operations under concurrent/repeated load -- `test_git_stress.py` — Git operations under concurrent load - -See [stress/README.md](stress/README.md) for detailed documentation. - ## Running Tests -### Run all tests -```bash -uv run pytest .rhiza/tests/ -# or -make test -``` - -### Run tests from a specific category -```bash -uv run pytest .rhiza/tests/structure/ -uv run pytest .rhiza/tests/api/ -uv run pytest .rhiza/tests/integration/ -uv run pytest .rhiza/tests/sync/ -uv run pytest .rhiza/tests/utils/ -uv run pytest .rhiza/tests/deps/ -uv run pytest .rhiza/tests/stress/ -``` - -### Run stress tests with custom parameters -```bash -# Run all stress tests (default: 100 iterations, 10 workers) -uv run pytest .rhiza/tests/stress/ -v - -# Run with fewer iterations (faster) -uv run pytest .rhiza/tests/stress/ -v --iterations=10 - -# Skip stress tests when running full test suite -uv run pytest .rhiza/tests/ -v -m "not stress" -``` - -### Run a specific test file -```bash -uv run pytest .rhiza/tests/structure/test_project_layout.py -``` - -### Run with verbose output -```bash -uv run pytest .rhiza/tests/ -v -``` - -### Run with coverage ```bash -uv run pytest .rhiza/tests/ --cov +make rhiza-test # run this suite (the usual entry point) +uv run pytest .rhiza/tests/ # equivalent, direct invocation +uv run pytest .rhiza/tests/test_pyproject.py # a single file +uv run pytest .rhiza/tests/ -v # verbose ``` ## Fixtures -### Root-level fixtures (`conftest.py`) -- `root` — Repository root path (session-scoped) -- `logger` — Configured logger instance (session-scoped) -- `git_repo` — Sandboxed git repository (function-scoped) +Defined in `conftest.py` and available to every test without import: -### Category-specific fixtures -- `api/conftest.py` — `setup_tmp_makefile`, `run_make`, `setup_rhiza_git_repo` -- `sync/conftest.py` — `setup_sync_env` +- `root` — repository root path (session-scoped) +- `logger` — configured logger instance (session-scoped) + +`.rhiza/tests` is on `pythonpath` (see `pytest.ini`), so intra-suite imports resolve +without any `sys.path` manipulation. ## Writing Tests -### Conventions - Use descriptive test names that explain what is being tested - Group related tests in classes when appropriate -- Use appropriate fixtures for setup/teardown - Add docstrings to test modules and complex test functions - Use `pytest.mark.skip` for tests that depend on optional features - -### Import Patterns -```python -# Import shared helpers from test_utils -from test_utils import strip_ansi, run_make, setup_rhiza_git_repo - -# Import from local category conftest (for fixtures and category-specific helpers) -from api.conftest import SPLIT_MAKEFILES, setup_tmp_makefile - -# Note: Fixtures defined in conftest.py are automatically available in tests -# and don't need to be explicitly imported -``` - -## Test Coverage - -The test suite aims for high coverage across: -- Configuration validation (structure, dependencies) -- Makefile target correctness (api) -- End-to-end workflows (integration) -- Template synchronization (sync) -- Utility code (utils) - -## Notes - -- Benchmarks are located in `tests/benchmarks/` and run via `make benchmark` -- Integration tests use sandboxed git repositories to avoid affecting the working tree -- All Makefile tests use dry-run mode (`make -n`) to avoid side effects diff --git a/.rhiza/tests/api/conftest.py b/.rhiza/tests/api/conftest.py deleted file mode 100644 index 8733d6c..0000000 --- a/.rhiza/tests/api/conftest.py +++ /dev/null @@ -1,98 +0,0 @@ -"""Shared fixtures for Makefile API tests. - -This conftest provides: -- setup_tmp_makefile: Copies Makefile and split files to temp dir for isolated testing -- run_make: Helper to execute make commands with dry-run support (imported from test_utils) -- setup_rhiza_git_repo: Initialize a git repo configured as rhiza origin (imported from test_utils) -- SPLIT_MAKEFILES: List of split Makefile paths - -Security Notes: -- S101 (assert usage): Asserts are used in pytest tests to validate conditions -- S603/S607 (subprocess usage): Any subprocess calls (via run_make) are for testing - Makefile targets in isolated environments with controlled inputs -- Test code operates in a controlled environment with trusted inputs -""" - -from __future__ import annotations - -import os -import shutil -import sys -from pathlib import Path - -import pytest - -tests_root = Path(__file__).resolve().parents[1] -if str(tests_root) not in sys.path: - sys.path.insert(0, str(tests_root)) - -from test_utils import run_make, setup_rhiza_git_repo, strip_ansi # noqa: E402, F401 - -# Split Makefile paths that are included in the main Makefile -# These are now located in .rhiza/make.d/ directory -SPLIT_MAKEFILES = [ - ".rhiza/rhiza.mk", - ".rhiza/make.d/bootstrap.mk", - ".rhiza/make.d/quality.mk", - ".rhiza/make.d/releasing.mk", - ".rhiza/make.d/doctor.mk", - ".rhiza/make.d/test.mk", - ".rhiza/make.d/book.mk", - ".rhiza/make.d/marimo.mk", - ".rhiza/make.d/presentation.mk", - ".rhiza/make.d/github.mk", - ".rhiza/make.d/agentic.mk", - ".rhiza/make.d/gh-aw.mk", - ".rhiza/make.d/docker.mk", -] - - -@pytest.fixture(autouse=True) -def setup_tmp_makefile(logger, root, tmp_path: Path): - """Copy the Makefile and split Makefiles into a temp directory and chdir there. - - We rely on `make -n` so that no real commands are executed. - This fixture consolidates setup for both basic Makefile tests and GitHub targets. - """ - logger.debug("Setting up temporary Makefile test dir: %s", tmp_path) - - # Copy the main Makefile into the temporary working directory - shutil.copy(root / "Makefile", tmp_path / "Makefile") - - # Copy core Rhiza Makefiles - (tmp_path / ".rhiza").mkdir(exist_ok=True) - shutil.copy(root / ".rhiza" / "rhiza.mk", tmp_path / ".rhiza" / "rhiza.mk") - - # Copy .python-version file for PYTHON_VERSION variable - if (root / ".python-version").exists(): - shutil.copy(root / ".python-version", tmp_path / ".python-version") - - # Copy .rhiza/.env if it exists (needed for GitHub targets and other configuration) - if (root / ".rhiza" / ".env").exists(): - shutil.copy(root / ".rhiza" / ".env", tmp_path / ".rhiza" / ".env") - else: - # Create a minimal, deterministic .rhiza/.env for tests so they don't - # depend on the developer's local configuration which may vary. - env_content = "CUSTOM_SCRIPTS_FOLDER=.rhiza/customisations/scripts\n" - (tmp_path / ".rhiza" / ".env").write_text(env_content) - - logger.debug("Copied Makefile from %s to %s", root / "Makefile", tmp_path / "Makefile") - - # Copy split Makefiles if they exist (maintaining directory structure) - for split_file in SPLIT_MAKEFILES: - source_path = root / split_file - if source_path.exists(): - dest_path = tmp_path / split_file - dest_path.parent.mkdir(parents=True, exist_ok=True) - shutil.copy(source_path, dest_path) - logger.debug("Copied %s to %s", source_path, dest_path) - - # Move into tmp directory for isolation - old_cwd = Path.cwd() - os.chdir(tmp_path) - logger.debug("Changed working directory to %s", tmp_path) - try: - yield - finally: - os.chdir(old_cwd) - logger.debug("Restored working directory to %s", old_cwd) diff --git a/.rhiza/tests/api/test_github_targets.py b/.rhiza/tests/api/test_github_targets.py deleted file mode 100644 index 1008dee..0000000 --- a/.rhiza/tests/api/test_github_targets.py +++ /dev/null @@ -1,63 +0,0 @@ -"""Tests for the GitHub Makefile targets using safe dry-runs. - -These tests validate that the .github/github.mk targets are correctly exposed -and emit the expected commands without actually executing them. -""" - -from __future__ import annotations - -from pathlib import Path - -import pytest - -# Import run_make from local conftest (setup_tmp_makefile is autouse) -from api.conftest import run_make - -_GITHUB_MK = Path(__file__).resolve().parents[3] / ".rhiza" / "make.d" / "github.mk" -if not _GITHUB_MK.exists(): - pytest.skip("github.mk not found, skipping github targets tests", allow_module_level=True) - - -def test_gh_targets_exist(logger): - """Verify that GitHub targets are listed in help.""" - result = run_make(logger, ["help"], dry_run=False) - output = result.stdout - - expected_targets = ["gh-install", "view-prs", "view-issues", "failed-workflows", "whoami"] - - for target in expected_targets: - assert target in output, f"Target {target} not found in help output" - - -def test_gh_install_dry_run(logger): - """Verify gh-install target dry-run.""" - result = run_make(logger, ["gh-install"]) - # In dry-run, we expect to see the shell commands that would be executed. - # Since the recipe uses @if, make -n might verify the syntax or show the command if not silenced. - # However, with -s (silent), make -n might not show much for @ commands unless they are echoed. - # But we mainly want to ensure it runs without error. - assert result.returncode == 0 - - -def test_view_prs_dry_run(logger): - """Verify view-prs target dry-run.""" - result = run_make(logger, ["view-prs"]) - assert result.returncode == 0 - - -def test_view_issues_dry_run(logger): - """Verify view-issues target dry-run.""" - result = run_make(logger, ["view-issues"]) - assert result.returncode == 0 - - -def test_failed_workflows_dry_run(logger): - """Verify failed-workflows target dry-run.""" - result = run_make(logger, ["failed-workflows"]) - assert result.returncode == 0 - - -def test_whoami_dry_run(logger): - """Verify whoami target dry-run.""" - result = run_make(logger, ["whoami"]) - assert result.returncode == 0 diff --git a/.rhiza/tests/api/test_make_variable_overrides.py b/.rhiza/tests/api/test_make_variable_overrides.py deleted file mode 100644 index e5b5cfb..0000000 --- a/.rhiza/tests/api/test_make_variable_overrides.py +++ /dev/null @@ -1,161 +0,0 @@ -"""Tests for Makefile variable override behaviour. - -This file and its associated tests flow down via a SYNC action from the -jebel-quant/rhiza repository (https://github.com/jebel-quant/rhiza). - -Validates that key Makefile variables behave correctly when overridden on -the command line, ensuring downstream projects can customise coverage -thresholds, license checks, and Python tooling without modifying the -shared Makefile infrastructure. - -All tests use `make -n` (dry-run) to observe what commands *would* be -executed without running them — keeping the suite fast and side-effect-free. -""" - -from __future__ import annotations - -import os - -from api.conftest import run_make, strip_ansi - - -class TestCoverageFailUnder: - """COVERAGE_FAIL_UNDER controls the pytest --cov-fail-under threshold.""" - - def test_default_threshold_is_90(self, logger) -> None: - """Default COVERAGE_FAIL_UNDER value must be 90.""" - proc = run_make(logger, ["test"]) - assert "--cov-fail-under=90" in proc.stdout, ( - "Default coverage threshold should be 90; got:\n" + proc.stdout[:500] - ) - - def test_threshold_override_to_100(self, logger) -> None: - """COVERAGE_FAIL_UNDER=100 must propagate to pytest invocation.""" - proc = run_make(logger, ["test", "COVERAGE_FAIL_UNDER=100"]) - assert "--cov-fail-under=100" in proc.stdout - - def test_threshold_override_to_0(self, logger) -> None: - """COVERAGE_FAIL_UNDER=0 must propagate (useful for bootstrapping new projects).""" - proc = run_make(logger, ["test", "COVERAGE_FAIL_UNDER=0"]) - assert "--cov-fail-under=0" in proc.stdout - - def test_threshold_override_to_arbitrary_value(self, logger) -> None: - """Any integer override for COVERAGE_FAIL_UNDER must appear verbatim in the command.""" - proc = run_make(logger, ["test", "COVERAGE_FAIL_UNDER=73"]) - assert "--cov-fail-under=73" in proc.stdout - - -class TestLicenseFailOn: - """LICENSE_FAIL_ON controls which SPDX license identifiers cause a build failure.""" - - def test_default_fails_on_gpl(self, logger) -> None: - """Default LICENSE_FAIL_ON must include GPL to block copyleft licenses.""" - proc = run_make(logger, ["license"]) - assert "GPL" in proc.stdout, "Default license check should fail on GPL; got:\n" + proc.stdout[:500] - - def test_fail_on_override_single_license(self, logger) -> None: - """Custom single-license override must appear in the make license command.""" - proc = run_make(logger, ["license", "LICENSE_FAIL_ON=MIT"]) - assert "MIT" in proc.stdout - - def test_fail_on_override_multiple_licenses(self, logger) -> None: - """Semicolon-separated multi-license override must appear verbatim.""" - proc = run_make(logger, ["license", "LICENSE_FAIL_ON=AGPL-3.0;GPL-2.0;LGPL-2.1"]) - assert "AGPL-3.0" in proc.stdout - assert "GPL-2.0" in proc.stdout - - def test_fail_on_override_quoted_correctly(self, logger) -> None: - """LICENSE_FAIL_ON value must be quoted in the underlying pip-licenses call.""" - proc = run_make(logger, ["license", "LICENSE_FAIL_ON=MIT;Apache"]) - # The Makefile must quote the value to handle semicolons properly - assert '--fail-on="MIT;Apache"' in proc.stdout - - -class TestPythonVersionVariable: - """PYTHON_VERSION drives uvx -p ... in quality and formatting targets.""" - - def test_python_version_read_from_python_version_file(self, logger, tmp_path) -> None: - """When .python-version exists, PYTHON_VERSION should reflect its contents.""" - python_version_file = tmp_path / ".python-version" - if python_version_file.exists(): - version = python_version_file.read_text().strip() - proc = run_make(logger, ["print-PYTHON_VERSION"], dry_run=False) - out = strip_ansi(proc.stdout) - assert version in out, f"Expected {version} in PYTHON_VERSION output; got: {out}" - - def test_python_version_default_when_file_missing(self, logger, tmp_path) -> None: - """When .python-version is absent and PYTHON_VERSION env var is unset, default to 3.13.""" - pv_file = tmp_path / ".python-version" - if pv_file.exists(): - pv_file.unlink() - - env = os.environ.copy() - env.pop("PYTHON_VERSION", None) - - proc = run_make(logger, ["print-PYTHON_VERSION"], dry_run=False, env=env) - out = strip_ansi(proc.stdout) - assert "3.13" in out, f"Expected default 3.13; got: {out}" - - def test_python_version_used_in_fmt_target(self, logger, tmp_path) -> None: - """The fmt target must pass -p to uvx.""" - env = os.environ.copy() - env.pop("PYTHON_VERSION", None) - - proc = run_make(logger, ["fmt"], env=env) - assert "uvx -p" in proc.stdout, "fmt target should use uvx -p " - - -class TestSourceFolderVariable: - """SOURCE_FOLDER drives coverage collection and static analysis targets.""" - - def test_typecheck_uses_source_folder(self, logger, tmp_path) -> None: - """The typecheck target must check the directory set by SOURCE_FOLDER.""" - src_dir = tmp_path / "mypackage" - src_dir.mkdir(exist_ok=True) - - env_file = tmp_path / ".rhiza" / ".env" - if env_file.exists(): - env_file.write_text(env_file.read_text() + "\nSOURCE_FOLDER=mypackage\n") - - proc = run_make(logger, ["typecheck", "SOURCE_FOLDER=mypackage"]) - assert "mypackage" in proc.stdout, "typecheck should reference SOURCE_FOLDER; got:\n" + proc.stdout[:400] - - def test_deptry_uses_source_folder(self, logger, tmp_path) -> None: - """The deptry target must scan the directory set by SOURCE_FOLDER.""" - src_dir = tmp_path / "mypackage" - src_dir.mkdir(exist_ok=True) - - proc = run_make(logger, ["deptry", "SOURCE_FOLDER=mypackage"]) - assert "mypackage" in proc.stdout, "deptry should reference SOURCE_FOLDER; got:\n" + proc.stdout[:400] - - -class TestUvNoModifyPath: - """UV_NO_MODIFY_PATH must always be exported to 1 to avoid uv touching PATH.""" - - def test_uv_no_modify_path_is_1(self, logger) -> None: - """UV_NO_MODIFY_PATH must be exported as 1 in the Makefile.""" - proc = run_make(logger, ["print-UV_NO_MODIFY_PATH"], dry_run=False) - out = strip_ansi(proc.stdout) - assert "1" in out, f"UV_NO_MODIFY_PATH should be 1; got: {out}" - - def test_uv_no_modify_path_cannot_be_overridden_to_empty(self, logger) -> None: - """UV_NO_MODIFY_PATH must still appear in the printed value when queried.""" - proc = run_make(logger, ["print-UV_NO_MODIFY_PATH"], dry_run=False) - out = strip_ansi(proc.stdout) - assert "UV_NO_MODIFY_PATH" in out - - -class TestTestsFolder: - """TESTS_FOLDER defaults to 'tests' but can be overridden.""" - - def test_default_tests_folder_is_tests(self, logger) -> None: - """Default TESTS_FOLDER must be 'tests'.""" - proc = run_make(logger, ["print-TESTS_FOLDER"], dry_run=False) - out = strip_ansi(proc.stdout) - assert "tests" in out, f"Default TESTS_FOLDER should be 'tests'; got: {out}" - - def test_pytest_uses_tests_folder(self, logger) -> None: - """The test target must invoke pytest with the TESTS_FOLDER path.""" - proc = run_make(logger, ["test"]) - # The default tests folder must appear somewhere in the pytest invocation - assert "pytest" in proc.stdout diff --git a/.rhiza/tests/api/test_makefile_api.py b/.rhiza/tests/api/test_makefile_api.py deleted file mode 100644 index 8096006..0000000 --- a/.rhiza/tests/api/test_makefile_api.py +++ /dev/null @@ -1,369 +0,0 @@ -"""Tests for the new Makefile API structure (Wrapper + Makefile.rhiza).""" - -import os -import shutil -import subprocess # nosec -from pathlib import Path - -import pytest - -# Get absolute paths for executables to avoid S607 warnings from CodeFactor/Bandit -GIT = shutil.which("git") or "/usr/bin/git" - -# Files required for the API test environment -REQUIRED_FILES = [ - "Makefile", - "pyproject.toml", - "README.md", # is needed to do uv sync, etc. -] - -# Folders to copy recursively -REQUIRED_FOLDERS = [ - ".rhiza", -] - -OPTIONAL_FOLDERS = [ - "tests", # for tests/tests.mk - "docker", # for docker/docker.mk, if referenced - "book", - "presentation", -] - - -@pytest.fixture -def setup_api_env(logger, root, tmp_path: Path): - """Set up the Makefile API test environment in a temp folder.""" - logger.debug("Setting up Makefile API test env in: %s", tmp_path) - - # Copy files - for filename in REQUIRED_FILES: - src = root / filename - if src.exists(): - shutil.copy(src, tmp_path / filename) - else: - pytest.fail(f"Required file {filename} not found in root") - - # Copy required directories - for folder in REQUIRED_FOLDERS: - src = root / folder - if src.exists(): - dest = tmp_path / folder - if dest.exists(): - shutil.rmtree(dest) - shutil.copytree(src, dest) - else: - pytest.fail(f"Required folder {folder} not found in root") - - # Copy optional directories - for folder in OPTIONAL_FOLDERS: - src = root / folder - if src.exists(): - dest = tmp_path / folder - if dest.exists(): - shutil.rmtree(dest) - shutil.copytree(src, dest) - - # Create .rhiza/make.d and ensure no local.mk exists initially - (tmp_path / ".rhiza" / "make.d").mkdir(parents=True, exist_ok=True) - if (tmp_path / "local.mk").exists(): - (tmp_path / "local.mk").unlink() - - # Initialize git repo for rhiza tools (required for sync/validate) - subprocess.run([GIT, "init"], cwd=tmp_path, check=True, capture_output=True) # nosec - # Configure git user for commits if needed (some rhiza checks might need commits) - subprocess.run([GIT, "config", "user.email", "you@example.com"], cwd=tmp_path, check=True, capture_output=True) # nosec - subprocess.run([GIT, "config", "user.name", "Rhiza Test"], cwd=tmp_path, check=True, capture_output=True) # nosec - # Add origin remote to simulate being in the rhiza repo (triggers the skip logic in rhiza.mk) - subprocess.run( - [GIT, "remote", "add", "origin", "https://github.com/jebel-quant/rhiza.git"], - cwd=tmp_path, - check=True, - capture_output=True, - ) # nosec - - # Move to tmp dir - old_cwd = Path.cwd() - os.chdir(tmp_path) - try: - yield tmp_path - finally: - os.chdir(old_cwd) - - -# Import run_make from local conftest -from api.conftest import run_make # noqa: E402 - - -def test_api_delegation(logger, setup_api_env): - """Test that 'make help' works and delegates to .rhiza/rhiza.mk.""" - result = run_make(logger, ["help"], dry_run=False) - assert result.returncode == 0 - # "Rhiza Workflows" is a section in .rhiza/rhiza.mk - assert "Rhiza Workflows" in result.stdout - - # Core targets from .rhiza/make.d/ should be available - assert "test" in result.stdout or "install" in result.stdout - - -def test_minimal_setup_works(logger, setup_api_env): - """Test that make works even if optional folders (tests, docker, etc.) are missing.""" - # Remove optional folders - for folder in OPTIONAL_FOLDERS: - p = setup_api_env / folder - if p.exists(): - shutil.rmtree(p) - - # Also remove files that might be copied if they were in the root? - # Just mainly folders. - - # Run make help - result = run_make(logger, ["help"], dry_run=False) - assert result.returncode == 0 - - # Check that core rhiza targets exist - assert "Rhiza Workflows" in result.stdout - assert "sync" in result.stdout - - # Note: docker-build and other targets from .rhiza/make.d/ are always present - # but they gracefully skip if their respective folders/files don't exist. - # This is by design - targets are always available but handle missing resources. - - -def test_extension_mechanism(logger, setup_api_env): - """Test that custom targets can be added in the root Makefile.""" - # Add a custom target to the root Makefile (before include line) - makefile = setup_api_env / "Makefile" - original = makefile.read_text() - # Insert custom target before the include line - new_content = ( - """.PHONY: custom-target -custom-target: - @echo "Running custom target" - -""" - + original - ) - makefile.write_text(new_content) - - result = run_make(logger, ["custom-target"], dry_run=False) - assert result.returncode == 0 - assert "Running custom target" in result.stdout - - -def test_local_override(logger, setup_api_env): - """Test that local.mk is included and can match targets.""" - local_file = setup_api_env / "local.mk" - local_file.write_text(""" -.PHONY: local-target -local-target: - @echo "Running local target" -""") - - result = run_make(logger, ["local-target"], dry_run=False) - assert result.returncode == 0 - assert "Running local target" in result.stdout - - -def test_local_override_pre_hook(logger, setup_api_env): - """Test using local.mk to override a pre-hook.""" - local_file = setup_api_env / "local.mk" - # We override pre-sync to print a marker (using double-colon to match rhiza.mk) - local_file.write_text(""" -pre-sync:: - @echo "[[LOCAL_PRE_SYNC]]" -""") - - # Run sync in dry-run. - # Note: Makefile.rhiza defines pre-sync as empty rule (or with @:). - # Make warns if we redefine a target unless it's a double-colon rule or we are careful. - # But usually the last one loaded wins or they merge if double-colon. - # The current definition in Makefile.rhiza is `pre-sync: ; @echo ...` or similar. - # Wait, I defined it as `pre-sync: ; @:` (single colon). - # So redefining it in local.mk (which is included AFTER) might trigger a warning but should work. - - result = run_make(logger, ["sync"], dry_run=False) - # We might expect a warning about overriding commands for target `pre-sync` - # checking stdout/stderr for the marker - - assert "[[LOCAL_PRE_SYNC]]" in result.stdout - - -def test_hook_execution_order(logger, setup_api_env): - """Define hooks in root Makefile and verify execution order.""" - # Add hooks to root Makefile (before include line) - makefile = setup_api_env / "Makefile" - original = makefile.read_text() - new_content = ( - """pre-sync:: - @echo "STARTING_SYNC" - -post-sync:: - @echo "FINISHED_SYNC" - -""" - + original - ) - makefile.write_text(new_content) - - result = run_make(logger, ["sync"], dry_run=False) - assert result.returncode == 0 - output = result.stdout - - # Check that markers are present - assert "STARTING_SYNC" in output - assert "FINISHED_SYNC" in output - - # Check order: STARTING_SYNC comes before FINISHED_SYNC - start_index = output.find("STARTING_SYNC") - finish_index = output.find("FINISHED_SYNC") - assert start_index < finish_index - - -def test_override_core_target(logger, setup_api_env): - """Verify that the root Makefile can override a core target (with warning).""" - # Override 'fmt' which is defined in quality.mk - # Add override AFTER the include line so it takes precedence - makefile = setup_api_env / "Makefile" - original = makefile.read_text() - new_content = ( - original - + """ -fmt: - @echo "CUSTOM_FMT" -""" - ) - makefile.write_text(new_content) - - result = run_make(logger, ["fmt"], dry_run=False) - assert result.returncode == 0 - # It should run the custom one because it's defined after the include - assert "CUSTOM_FMT" in result.stdout - - # We expect a warning on stderr about overriding - assert "warning: overriding" in result.stderr.lower() - assert "fmt" in result.stderr.lower() - - -def test_global_variable_override(logger, setup_api_env): - """Test that global variables can be overridden in the root Makefile. - - This tests the pattern documented in CUSTOMIZATION.md: - Set variables before the include line to override defaults. - """ - # Add variable override to root Makefile (before include line) - makefile = setup_api_env / "Makefile" - original = makefile.read_text() - new_content = ( - """# Override default coverage threshold (defaults to 90) -COVERAGE_FAIL_UNDER := 42 -export COVERAGE_FAIL_UNDER - -""" - + original - ) - makefile.write_text(new_content) - - result = run_make(logger, ["print-COVERAGE_FAIL_UNDER"], dry_run=False) - assert result.returncode == 0 - assert "42" in result.stdout - - -def test_pre_install_hook(logger, setup_api_env): - """Test that pre-install hooks are executed before install. - - This tests the hook pattern documented in CUSTOMIZATION.md. - """ - makefile = setup_api_env / "Makefile" - original = makefile.read_text() - new_content = ( - """pre-install:: - @echo "[[PRE_INSTALL_HOOK]]" - -""" - + original - ) - makefile.write_text(new_content) - - # Run install in dry-run mode to avoid actual installation - result = run_make(logger, ["install"], dry_run=True) - assert result.returncode == 0 - # In dry-run mode, the echo command is printed (not executed) - assert "PRE_INSTALL_HOOK" in result.stdout - - -def test_post_install_hook(logger, setup_api_env): - """Test that post-install hooks are executed after install. - - This tests the hook pattern documented in CUSTOMIZATION.md. - """ - makefile = setup_api_env / "Makefile" - original = makefile.read_text() - new_content = ( - """post-install:: - @echo "[[POST_INSTALL_HOOK]]" - -""" - + original - ) - makefile.write_text(new_content) - - # Run install in dry-run mode - result = run_make(logger, ["install"], dry_run=True) - assert result.returncode == 0 - assert "POST_INSTALL_HOOK" in result.stdout - - -def test_multiple_hooks_accumulate(logger, setup_api_env): - """Test that multiple hook definitions accumulate rather than override. - - This is a key feature of double-colon rules: the root Makefile and - local.mk can both add to the same hook without conflicts. - """ - # Add hook in root Makefile - makefile = setup_api_env / "Makefile" - original = makefile.read_text() - new_content = ( - """pre-sync:: - @echo "[[HOOK_A]]" - -""" - + original - ) - makefile.write_text(new_content) - - # Add another hook in local.mk - (setup_api_env / "local.mk").write_text("""pre-sync:: - @echo "[[HOOK_B]]" -""") - - result = run_make(logger, ["sync"], dry_run=False) - assert result.returncode == 0 - # Both hooks should be present - assert "[[HOOK_A]]" in result.stdout - assert "[[HOOK_B]]" in result.stdout - - -def test_variable_override_before_include(logger, setup_api_env): - """Test that variables set before include take precedence. - - Variables defined in the root Makefile before the include line - should be available throughout the build. - """ - # Set a variable and use it in a target (before include) - makefile = setup_api_env / "Makefile" - original = makefile.read_text() - new_content = ( - """MY_CUSTOM_VAR := hello - -.PHONY: show-var -show-var: - @echo "MY_VAR=$(MY_CUSTOM_VAR)" - -""" - + original - ) - makefile.write_text(new_content) - - result = run_make(logger, ["show-var"], dry_run=False) - assert result.returncode == 0 - assert "MY_VAR=hello" in result.stdout diff --git a/.rhiza/tests/api/test_makefile_targets.py b/.rhiza/tests/api/test_makefile_targets.py deleted file mode 100644 index 0137c32..0000000 --- a/.rhiza/tests/api/test_makefile_targets.py +++ /dev/null @@ -1,363 +0,0 @@ -"""Tests for the Makefile targets and help output using safe dry-runs. - -This file and its associated tests flow down via a SYNC action from the jebel-quant/rhiza repository -(https://github.com/jebel-quant/rhiza). - -These tests validate that the Makefile exposes expected targets and emits -the correct commands without actually executing them, by invoking `make -n` -(dry-run). We also pass `-s` to reduce noise in CI logs. This approach keeps -tests fast, portable, and free of side effects like network or environment -changes. -""" - -from __future__ import annotations - -import os - -import pytest -from api.conftest import SPLIT_MAKEFILES, run_make, setup_rhiza_git_repo, strip_ansi - - -def assert_uvx_command_uses_version(output: str, tmp_path, command_fragment: str): - """Assert uvx command uses .python-version when present, else fallback checks.""" - python_version_file = tmp_path / ".python-version" - if python_version_file.exists(): - python_version = python_version_file.read_text().strip() - assert f"uvx -p {python_version} {command_fragment}" in output - else: - assert "uvx -p" in output - assert command_fragment in output - - -class TestMakefile: - """Smoke tests for Makefile help and common targets using make -n.""" - - def test_default_goal_is_help(self, logger): - """Default goal should render the help index with known targets.""" - proc = run_make(logger) - out = proc.stdout - assert "Usage:" in out - assert "Targets:" in out - # ensure a few known targets appear in the help index - for target in ["install", "fmt", "deptry", "test", "help"]: - assert target in out - - def test_help_target(self, logger): - """Explicit `make help` prints usage, targets, and section headers.""" - proc = run_make(logger, ["help"]) - out = proc.stdout - assert "Usage:" in out - assert "Targets:" in out - assert "Bootstrap" in out or "Meta" in out # section headers - - def test_doctor_target_appears_in_help(self, logger): - """Doctor target should appear in help under the Dev section.""" - proc = run_make(logger, ["help"]) - out = proc.stdout - assert "Dev" in out - assert "doctor" in out - - def test_doctor_fails_when_minimum_version_is_not_met(self, logger, tmp_path): - """Doctor should exit non-zero when a prerequisite version is below the minimum.""" - fake_bin = tmp_path / "fake-bin" - fake_bin.mkdir(exist_ok=True) - - for name, content in { - "uv": "#!/usr/bin/env sh\necho 'uv 0.3.0'\n", - "python": "#!/usr/bin/env sh\necho 'Python 3.12.2'\n", - "make": "#!/usr/bin/env sh\necho 'GNU Make 4.4.1'\n", - "git": "#!/usr/bin/env sh\necho 'git version 2.44.0'\n", - }.items(): - script = fake_bin / name - script.write_text(content) - script.chmod(0o755) - - env = os.environ.copy() - env["PATH"] = f"{fake_bin}:{env.get('PATH', '')}" - - proc = run_make(logger, ["doctor"], dry_run=False, check=False, env=env) - out = strip_ansi(proc.stdout) - assert proc.returncode != 0 - assert "[❌] uv" in out - assert "0.3.0" in out - assert "0.4.0" in out - - def test_fmt_target_dry_run(self, logger, tmp_path): - """Fmt target should invoke pre-commit via uvx with Python version in dry-run output.""" - # Create clean environment without PYTHON_VERSION so Makefile reads from .python-version - env = os.environ.copy() - env.pop("PYTHON_VERSION", None) - - proc = run_make(logger, ["fmt"], env=env) - out = proc.stdout - assert_uvx_command_uses_version(out, tmp_path, "pre-commit run --all-files") - - def test_deptry_target_dry_run(self, logger, tmp_path): - """Deptry target should invoke deptry via uvx with Python version in dry-run output.""" - # Create a mock SOURCE_FOLDER directory so the deptry command runs - source_folder = tmp_path / "src" - source_folder.mkdir(exist_ok=True) - - # Update .env to set SOURCE_FOLDER - env_file = tmp_path / ".rhiza" / ".env" - env_content = env_file.read_text() - env_content += "\nSOURCE_FOLDER=src\n" - env_file.write_text(env_content) - - # Create clean environment without PYTHON_VERSION so Makefile reads from .python-version - env = os.environ.copy() - env.pop("PYTHON_VERSION", None) - - proc = run_make(logger, ["deptry"], env=env) - - out = proc.stdout - assert_uvx_command_uses_version(out, tmp_path, "deptry src") - - def test_typecheck_target_dry_run(self, logger, tmp_path): - """Typecheck target should invoke ty via uv run in dry-run output.""" - # Create a mock SOURCE_FOLDER directory so the typecheck command runs - source_folder = tmp_path / "src" - source_folder.mkdir(exist_ok=True) - - # Update .env to set SOURCE_FOLDER - env_file = tmp_path / ".rhiza" / ".env" - env_content = env_file.read_text() - env_content += "\nSOURCE_FOLDER=src\n" - env_file.write_text(env_content) - - proc = run_make(logger, ["typecheck"]) - out = proc.stdout - # Check for uv run command - assert "uv run ty check src" in out - - def test_test_target_dry_run(self, logger): - """Test target should invoke pytest via uv with coverage and HTML outputs in dry-run output.""" - proc = run_make(logger, ["test"]) - out = proc.stdout - # Expect key steps - assert "mkdir -p _tests/html-coverage _tests/html-report" in out - # Check for uv command running pytest - assert "uv run pytest" in out - # Check for XML coverage report - assert "--cov-report=xml:_tests/coverage.xml" in out - - def test_test_target_without_source_folder(self, logger, tmp_path): - """Test target should run without coverage when SOURCE_FOLDER doesn't exist.""" - # Update .env to set SOURCE_FOLDER to a non-existent directory - env_file = tmp_path / ".rhiza" / ".env" - env_content = env_file.read_text() - env_content += "\nSOURCE_FOLDER=nonexistent_src\n" - env_file.write_text(env_content) - - # Create tests folder - tests_folder = tmp_path / "tests" - tests_folder.mkdir(exist_ok=True) - - proc = run_make(logger, ["test"]) - out = proc.stdout - # Should see warning about missing source folder - assert "if [ -d nonexistent_src ]" in out - # Should still run pytest but without coverage flags - assert "uv run pytest" in out - assert "--html=_tests/html-report/report.html" in out - - def test_python_version_defaults_to_3_13_if_missing(self, logger, tmp_path): - """`PYTHON_VERSION` should default to `3.13` if .python-version is missing.""" - # Ensure .python-version does not exist - python_version_file = tmp_path / ".python-version" - if python_version_file.exists(): - python_version_file.unlink() - - # Create clean environment without PYTHON_VERSION - env = os.environ.copy() - env.pop("PYTHON_VERSION", None) - - proc = run_make(logger, ["print-PYTHON_VERSION"], dry_run=False, env=env) - out = strip_ansi(proc.stdout) - assert "Value of PYTHON_VERSION:\n3.13" in out - - def test_uv_no_modify_path_is_exported(self, logger): - """`UV_NO_MODIFY_PATH` should be set to `1` in the Makefile.""" - proc = run_make(logger, ["print-UV_NO_MODIFY_PATH"], dry_run=False) - out = strip_ansi(proc.stdout) - assert "Value of UV_NO_MODIFY_PATH:\n1" in out - - def test_that_target_coverage_is_configurable(self, logger): - """Test target should respond to COVERAGE_FAIL_UNDER variable.""" - # Default case: ensure the flag is present - proc = run_make(logger, ["test"]) - assert "--cov-fail-under=" in proc.stdout - - # Override case: ensure the flag takes the specific value - proc_override = run_make(logger, ["test", "COVERAGE_FAIL_UNDER=42"]) - assert "--cov-fail-under=42" in proc_override.stdout - - def test_suppression_audit_target_dry_run(self, logger): - """Suppression-audit target should invoke the Python audit script via uv run in dry-run output.""" - proc = run_make(logger, ["suppression-audit"]) - out = proc.stdout - assert "uv run python" in out - assert "suppression_audit.py" in out - - def test_license_target_dry_run(self, logger): - """License target should invoke pip-licenses via uv run --with in dry-run output.""" - proc = run_make(logger, ["license"]) - out = proc.stdout - assert "uv run --with pip-licenses pip-licenses" in out - assert "--fail-on=" in out - assert "GPL" in out - - def test_license_fail_on_is_configurable(self, logger): - """License target should use the LICENSE_FAIL_ON variable for the fail-on list.""" - proc = run_make(logger, ["license", "LICENSE_FAIL_ON=MIT;Apache"]) - out = proc.stdout - assert '--fail-on="MIT;Apache"' in out - - def test_serve_target_uses_uv_run_python_http_server(self, logger): - """Serve target should use uv run instead of directly calling python3.""" - proc = run_make(logger, ["serve"]) - out = proc.stdout - assert "uv run python -m http.server 8000" in out - - -class TestMakefileRootFixture: - """Tests for root fixture usage in Makefile tests.""" - - def test_makefile_exists_at_root(self, root): - """Makefile should exist at repository root.""" - makefile = root / "Makefile" - assert makefile.exists() - assert makefile.is_file() - - def test_makefile_contains_targets(self, root): - """Makefile should contain expected targets (including split files).""" - makefile = root / "Makefile" - content = makefile.read_text() - - # Read split Makefiles as well - for split_file in SPLIT_MAKEFILES: - split_path = root / split_file - if split_path.exists(): - content += "\n" + split_path.read_text() - - expected_targets = ["install", "fmt", "test", "deptry", "help"] - for target in expected_targets: - assert f"{target}:" in content or f".PHONY: {target}" in content - - def test_validate_target_skips_in_rhiza_repo(self, logger): - """Validate target should skip execution in rhiza repository.""" - setup_rhiza_git_repo() - - proc = run_make(logger, ["validate"], dry_run=False) - # out = strip_ansi(proc.stdout) - # assert "[INFO] Skipping validate in rhiza repository" in out - assert proc.returncode == 0 - - def test_sync_target_skips_in_rhiza_repo(self, logger): - """Sync target should skip execution in rhiza repository.""" - setup_rhiza_git_repo() - - proc = run_make(logger, ["sync"], dry_run=False) - # out = strip_ansi(proc.stdout) - # assert "[INFO] Skipping sync in rhiza repository" in out - assert proc.returncode == 0 - - def test_sync_experimental_target_skips_in_rhiza_repo(self, logger): - """Sync-experimental target should skip execution in rhiza repository.""" - setup_rhiza_git_repo() - - proc = run_make(logger, ["sync-experimental"], dry_run=False) - assert proc.returncode == 0 - - def test_materialize_target_is_deprecated(self, logger): - """Materialize target should print a deprecation warning and delegate to sync.""" - setup_rhiza_git_repo() - - proc = run_make(logger, ["materialize"], dry_run=False) - out = strip_ansi(proc.stdout) - assert proc.returncode == 0 - assert "deprecated" in out.lower() - assert "sync" in out - - -class TestMakeBump: - """Tests for the 'make bump' target.""" - - @pytest.fixture - def mock_bin(self, tmp_path): - """Create mock uv and uvx scripts in ./bin.""" - bin_dir = tmp_path / "bin" - bin_dir.mkdir(exist_ok=True) - - uv = bin_dir / "uv" - uv.write_text('#!/bin/sh\necho "[MOCK] uv $@"\n') - uv.chmod(0o755) - - # Mock uvx to simulate version bump if arguments match - uvx = bin_dir / "uvx" - uvx_script = """#!/usr/bin/env python3 -import sys -import re -from pathlib import Path - -args = sys.argv[1:] -print(f"[MOCK] uvx {' '.join(args)}") - -# Check if this is the bump command: "rhiza-tools>=0.5.1" bump -if "bump" in args: - # Simulate bumping version in pyproject.toml - pyproject = Path("pyproject.toml") - if pyproject.exists(): - content = pyproject.read_text() - # Simple regex replacement for version - # Assuming version = "0.1.0" -> "0.1.1" - new_content = re.sub(r'version = "([0-9.]+)"', lambda m: f'version = "{m.group(1)[:-1]}{int(m.group(1)[-1]) + 1}"', content) - pyproject.write_text(new_content) - print(f"[MOCK] Bumped version in {pyproject}") -""" # noqa: E501 - uvx.write_text(uvx_script) - uvx.chmod(0o755) - - return bin_dir - - def test_bump_execution(self, logger, mock_bin, tmp_path): - """Test 'make bump' execution with mocked tools and verify version change.""" - # Create dummy pyproject.toml with initial version - pyproject = tmp_path / "pyproject.toml" - pyproject.write_text('version = "0.1.0"\n[project]\nname = "test"\n') - - uv_bin = mock_bin / "uv" - uvx_bin = mock_bin / "uvx" - - # Run make bump with dry_run=False to actually execute the shell commands - result = run_make(logger, ["bump", f"UV_BIN={uv_bin}", f"UVX_BIN={uvx_bin}"], dry_run=False) - - # Verify that the mock tools were called - assert "[MOCK] uvx rhiza-tools>=0.5.1 bump" in result.stdout - assert "[MOCK] uv lock" in result.stdout - - # Verify that 'make install' was called (which calls uv sync) - assert "[MOCK] uv sync" in result.stdout - - # Verify that the version was actually bumped by our mock - new_content = pyproject.read_text() - assert 'version = "0.1.1"' in new_content - - def test_bump_no_pyproject(self, logger, mock_bin, tmp_path): - """Test 'make bump' execution without pyproject.toml.""" - # Ensure pyproject.toml does not exist - pyproject = tmp_path / "pyproject.toml" - if pyproject.exists(): - pyproject.unlink() - - uv_bin = mock_bin / "uv" - uvx_bin = mock_bin / "uvx" - - result = run_make(logger, ["bump", f"UV_BIN={uv_bin}", f"UVX_BIN={uvx_bin}"], dry_run=False) - - # Check for warning message - assert "No pyproject.toml found, skipping bump" in result.stdout - - # Ensure bump commands are NOT executed - assert "[MOCK] uvx" not in result.stdout - assert "[MOCK] uv lock" not in result.stdout diff --git a/.rhiza/tests/conftest.py b/.rhiza/tests/conftest.py index 419f6d6..7005ded 100644 --- a/.rhiza/tests/conftest.py +++ b/.rhiza/tests/conftest.py @@ -1,129 +1,29 @@ -"""Pytest configuration and fixtures for setting up a mock git repository with versioning. +"""Pytest configuration and fixtures for the rhiza test suite. This file and its associated tests flow down via a SYNC action from the jebel-quant/rhiza repository (https://github.com/jebel-quant/rhiza). -Provides test fixtures for testing git-based workflows and version management. +Provides shared session-scoped fixtures (``root``, ``logger`` and ``latest_tag``) used +across the test modules. + +Owned by ``core`` rather than by a language layer: the fixtures resolve paths and read +git, neither of which depends on what the project is written in. That is what lets the +Rust and Go layers ship their own ``.rhiza/tests`` modules without shipping a conftest +each — every profile pairs ``core`` with exactly one language layer, so this file is +always present alongside them. Security Notes: - S101 (assert usage): Asserts are appropriate in test code for validating conditions -- S603 (subprocess without shell=True): All subprocess calls use lists of known commands (git), - not user input, making them safe from shell injection -- S607 (subprocess with partial path): Using 'git' from PATH is acceptable in test fixtures - as the test environment is controlled and git is a required development dependency """ import logging -import os import pathlib import shutil -import subprocess # nosec B404 - subprocess module needed for git operations in test fixtures -import sys +import subprocess # nosec B404 import pytest -tests_root = pathlib.Path(__file__).resolve().parent -if str(tests_root) not in sys.path: - sys.path.insert(0, str(tests_root)) - -from test_utils import GIT # noqa: E402 - -MOCK_MAKE_SCRIPT = """#!/usr/bin/env python3 -import sys - -if len(sys.argv) > 1 and sys.argv[1] == "help": - print("Mock Makefile Help") - print("target: ## Description") -""" - -MOCK_UV_SCRIPT = """#!/usr/bin/env python3 -import sys -import re - -try: - from packaging.version import parse, InvalidVersion - HAS_PACKAGING = True -except ImportError: - HAS_PACKAGING = False - -def get_version(): - with open("pyproject.toml", "r") as f: - content = f.read() - match = re.search(r'version = "(.*?)"', content) - return match.group(1) if match else "0.0.0" - -def set_version(new_version): - with open("pyproject.toml", "r") as f: - content = f.read() - new_content = re.sub(r'version = ".*?"', f'version = "{new_version}"', content) - with open("pyproject.toml", "w") as f: - f.write(new_content) - -def bump_version(current, bump_type): - major, minor, patch = map(int, current.split('.')) - if bump_type == "major": - return f"{major + 1}.0.0" - elif bump_type == "minor": - return f"{major}.{minor + 1}.0" - elif bump_type == "patch": - return f"{major}.{minor}.{patch + 1}" - return current - -def main(): - args = sys.argv[1:] - if not args: - sys.exit(1) - - if args[0] != "version": - # It might be a uvx call if we use the same script, but let's keep them separate or handle it here. - # For now, let's assume this is only for uv version commands as per original design. - sys.exit(1) - - # uv version --short - if "--short" in args and "--bump" not in args: - print(get_version()) - return - - # uv version --bump --dry-run --short - if "--bump" in args and "--dry-run" in args and "--short" in args: - bump_idx = args.index("--bump") + 1 - bump_type = args[bump_idx] - current = get_version() - print(bump_version(current, bump_type)) - return - - # uv version --bump (actual update) - if "--bump" in args and "--dry-run" not in args: - bump_idx = args.index("--bump") + 1 - bump_type = args[bump_idx] - current = get_version() - new_ver = bump_version(current, bump_type) - set_version(new_ver) - return - - # uv version --dry-run - if len(args) >= 2 and not args[1].startswith("-") and "--dry-run" in args: - version = args[1] - if HAS_PACKAGING: - try: - parse(version) - except InvalidVersion: - sys.exit(1) - else: - # Simple validation: must start with a digit - if not re.match(r"^\\d", version): - sys.exit(1) - # Just exit 0 if valid - return - - # uv version (actual update) - if len(args) == 2 and not args[1].startswith("-"): - set_version(args[1]) - return - -if __name__ == "__main__": - main() -""" +_GIT = shutil.which("git") or "/usr/bin/git" @pytest.fixture(scope="session") @@ -145,73 +45,28 @@ def logger(): return logging.getLogger(__name__) -@pytest.fixture -def git_repo(root, tmp_path, monkeypatch): - """Sets up a remote bare repo and a local clone with necessary files.""" - remote_dir = tmp_path / "remote.git" - local_dir = tmp_path / "local" - - # 1. Create bare remote - remote_dir.mkdir() - subprocess.run([GIT, "init", "--bare", str(remote_dir)], check=True) # nosec B603 - # Ensure the remote's default HEAD points to master for predictable behavior - subprocess.run([GIT, "symbolic-ref", "HEAD", "refs/heads/master"], cwd=remote_dir, check=True) # nosec B603 - - # 2. Clone to local - subprocess.run([GIT, "clone", str(remote_dir), str(local_dir)], check=True) # nosec B603 - - # Use monkeypatch to safely change cwd for the duration of the test - monkeypatch.chdir(local_dir) - - # Ensure local default branch is 'master' to match test expectations - subprocess.run([GIT, "checkout", "-b", "master"], check=True) # nosec B603 - - # Create pyproject.toml - with open("pyproject.toml", "w") as f: - f.write('[project]\nname = "test-project"\nversion = "0.1.0"\n') - - # Create dummy uv.lock - with open("uv.lock", "w") as f: - f.write("") - - # Create bin/uv mock - bin_dir = local_dir / "bin" - bin_dir.mkdir() - - uv_path = bin_dir / "uv" - with open(uv_path, "w") as f: - f.write(MOCK_UV_SCRIPT) - uv_path.chmod(0o755) - - make_path = bin_dir / "make" - with open(make_path, "w") as f: - f.write(MOCK_MAKE_SCRIPT) - make_path.chmod(0o755) - - # Ensure our bin comes first on PATH so 'uv' resolves to mock - monkeypatch.setenv("PATH", f"{bin_dir}:{os.environ.get('PATH', '')}") - - # Copy core Rhiza Makefiles - (local_dir / ".rhiza").mkdir(parents=True, exist_ok=True) - shutil.copy(root / ".rhiza" / "rhiza.mk", local_dir / ".rhiza" / "rhiza.mk") - shutil.copy(root / "Makefile", local_dir / "Makefile") - - # Copy .rhiza/make.d/ directory (contains split makefiles) - make_d_src = root / ".rhiza" / "make.d" - if make_d_src.is_dir(): - make_d_dst = local_dir / ".rhiza" / "make.d" - shutil.copytree(make_d_src, make_d_dst, dirs_exist_ok=True) - - book_src = root / "book" - book_dst = local_dir / "book" - if book_src.is_dir(): - shutil.copytree(book_src, book_dst, dirs_exist_ok=True) - - # Commit and push initial state - subprocess.run([GIT, "config", "user.email", "test@example.com"], check=True) # nosec B603 - subprocess.run([GIT, "config", "user.name", "Test User"], check=True) # nosec B603 - subprocess.run([GIT, "add", "."], check=True) # nosec B603 - subprocess.run([GIT, "commit", "-m", "Initial commit"], check=True) # nosec B603 - subprocess.run([GIT, "push", "origin", "master"], check=True) # nosec B603 - - return local_dir +@pytest.fixture(scope="session") +def latest_tag(root): + """Return the newest ``vX.Y.Z`` git tag, skipping when the repo has none. + + Shared rather than per-module because each language layer asserts the same thing + against a different file — ``[project].version``, ``[package].version``, or Go's + ``Version`` constant — and because every layer's release config derives its current + version from this tag. + + Args: + root: Repository root, from the ``root`` fixture. + + Returns: + str: The highest version tag, e.g. ``v1.3.1``. + """ + result = subprocess.run( # nosec B603 + [_GIT, "tag", "--list", "v*", "--sort=-version:refname"], + capture_output=True, + text=True, + cwd=root, + ) + tags = [line.strip() for line in result.stdout.splitlines() if line.strip()] + if not tags: + pytest.skip("No version tags found in repository") + return tags[0] diff --git a/.rhiza/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_requirements.py b/.rhiza/tests/structure/test_requirements.py deleted file mode 100644 index 1bf9d04..0000000 --- a/.rhiza/tests/structure/test_requirements.py +++ /dev/null @@ -1,51 +0,0 @@ -"""Tests for the .rhiza/requirements folder structure. - -This test ensures that the requirements folder exists and contains the expected -requirement files for development dependencies. -""" - -from typing import ClassVar - - -class TestRequirementsFolder: - """Tests for the .rhiza/requirements folder structure.""" - - # Expected requirements files - EXPECTED_REQUIREMENTS_FILES: ClassVar[list[str]] = [ - # "tests.txt", # may not be present in all repositories - # "marimo.txt", # may not be present in all repositories - "docs.txt", - "tools.txt", - ] - - def test_requirements_folder_exists(self, root): - """Requirements folder should exist in .rhiza directory.""" - requirements_dir = root / ".rhiza" / "requirements" - assert requirements_dir.exists(), ".rhiza/requirements directory should exist" - assert requirements_dir.is_dir(), ".rhiza/requirements should be a directory" - - def test_requirements_files_exist(self, root): - """All expected requirements files should exist.""" - requirements_dir = root / ".rhiza" / "requirements" - for filename in self.EXPECTED_REQUIREMENTS_FILES: - filepath = requirements_dir / filename - assert filepath.exists(), f"{filename} should exist in requirements folder" - assert filepath.is_file(), f"{filename} should be a file" - - def test_requirements_files_not_empty(self, root): - """Requirements files should not be empty.""" - requirements_dir = root / ".rhiza" / "requirements" - for filename in self.EXPECTED_REQUIREMENTS_FILES: - filepath = requirements_dir / filename - content = filepath.read_text() - # Filter out comments and empty lines - lines = [line.strip() for line in content.splitlines() if line.strip() and not line.strip().startswith("#")] - assert len(lines) > 0, f"{filename} should contain at least one dependency" - - def test_readme_exists_in_requirements_folder(self, root): - """README.md should exist in requirements folder.""" - readme_path = root / ".rhiza" / "requirements" / "README.md" - assert readme_path.exists(), "README.md should exist in requirements folder" - assert readme_path.is_file(), "README.md should be a file" - content = readme_path.read_text() - assert len(content) > 0, "README.md should not be empty" diff --git a/.rhiza/tests/sync/conftest.py b/.rhiza/tests/sync/conftest.py deleted file mode 100644 index fafa07a..0000000 --- a/.rhiza/tests/sync/conftest.py +++ /dev/null @@ -1,100 +0,0 @@ -"""Shared fixtures and helpers for sync tests. - -Provides environment setup for template sync, workflow versioning, -and content validation tests. - -Security Notes: -- S101 (assert usage): Asserts are used in pytest tests to validate conditions -- S603/S607 (subprocess usage): Any subprocess calls are for testing sync targets - in isolated environments with controlled inputs -- Test code operates in a controlled environment with trusted inputs -""" - -from __future__ import annotations - -import os -import shutil -import sys -from pathlib import Path - -import pytest - -tests_root = Path(__file__).resolve().parents[1] -if str(tests_root) not in sys.path: - sys.path.insert(0, str(tests_root)) - -from test_utils import run_make, setup_rhiza_git_repo, strip_ansi # noqa: E402, F401 - - -@pytest.fixture(autouse=True) -def setup_sync_env(logger, root, tmp_path: Path): - """Set up a temporary environment for sync tests with Makefile, templates, and git. - - This fixture creates a complete test environment with: - - Makefile and rhiza.mk configuration - - .rhiza-version file and .env configuration - - template.yml and pyproject.toml - - Initialized git repository (configured as rhiza origin) - - src/ and tests/ directories to satisfy validate target - """ - logger.debug("Setting up sync test environment: %s", tmp_path) - - # Copy the main Makefile into the temporary working directory - shutil.copy(root / "Makefile", tmp_path / "Makefile") - - # Copy core Rhiza Makefiles and version file - (tmp_path / ".rhiza").mkdir(exist_ok=True) - shutil.copy(root / ".rhiza" / "rhiza.mk", tmp_path / ".rhiza" / "rhiza.mk") - - # Copy split Makefiles from make.d directory - split_makefiles = [ - "bootstrap.mk", - "quality.mk", - "releasing.mk", - "test.mk", - "book.mk", - "marimo.mk", - "presentation.mk", - "github.mk", - "agentic.mk", - "docker.mk", - ] - (tmp_path / ".rhiza" / "make.d").mkdir(parents=True, exist_ok=True) - for mk_file in split_makefiles: - source_path = root / ".rhiza" / "make.d" / mk_file - if source_path.exists(): - shutil.copy(source_path, tmp_path / ".rhiza" / "make.d" / mk_file) - - # Copy .rhiza-version if it exists - if (root / ".rhiza" / ".rhiza-version").exists(): - shutil.copy(root / ".rhiza" / ".rhiza-version", tmp_path / ".rhiza" / ".rhiza-version") - - # Create a minimal, deterministic .rhiza/.env for tests - env_content = "CUSTOM_SCRIPTS_FOLDER=.rhiza/customisations/scripts\n" - (tmp_path / ".rhiza" / ".env").write_text(env_content) - - logger.debug("Copied Makefile from %s to %s", root / "Makefile", tmp_path / "Makefile") - - # Create a minimal .rhiza/template.yml - (tmp_path / ".rhiza" / "template.yml").write_text("repository: Jebel-Quant/rhiza\nref: v0.7.1\n") - - # Sort out pyproject.toml - (tmp_path / "pyproject.toml").write_text('[project]\nname = "test-project"\nversion = "0.1.0"\n') - - # Move into tmp directory for isolation - old_cwd = Path.cwd() - os.chdir(tmp_path) - logger.debug("Changed working directory to %s", tmp_path) - - # Initialize a git repo so that commands checking for it (like sync) don't fail validation - setup_rhiza_git_repo() - - # Create src and tests directories to satisfy validate - (tmp_path / "src").mkdir(exist_ok=True) - (tmp_path / "tests").mkdir(exist_ok=True) - - try: - yield - finally: - os.chdir(old_cwd) - logger.debug("Restored working directory to %s", old_cwd) diff --git a/.rhiza/tests/sync/test_docstrings.py b/.rhiza/tests/test_docstrings.py similarity index 100% rename from .rhiza/tests/sync/test_docstrings.py rename to .rhiza/tests/test_docstrings.py diff --git a/.rhiza/tests/structure/test_pyproject.py b/.rhiza/tests/test_pyproject.py similarity index 51% rename from .rhiza/tests/structure/test_pyproject.py rename to .rhiza/tests/test_pyproject.py index 82f2b4d..68bf13a 100644 --- a/.rhiza/tests/structure/test_pyproject.py +++ b/.rhiza/tests/test_pyproject.py @@ -12,26 +12,53 @@ - 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 +- carries a [tool.bumpversion] table bump-my-version can actually discover - version matches the latest git tag (vX.Y.Z → X.Y.Z) + +Reachability of that tag lives in ``test_release_tags.py``, shipped by ``core``: the +invariant holds for every language layer, not just this one. """ 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") +# The only filenames bump-my-version auto-discovers. Anything else — including the +# `.rhiza/.cfg.toml` older template versions shipped — is read solely when passed +# with --config-file, which nothing in this template does. +_DISCOVERABLE_CONFIGS = (".bumpversion.toml", ".bumpversion.cfg", "setup.cfg", "pyproject.toml") + + +def _has_bumpversion_section(path: Path) -> bool: + """Report whether a config file carries a bumpversion section at all. + + Args: + path: Candidate config file; a missing or malformed file counts as absent. + + Returns: + True when the file declares ``[tool.bumpversion]`` (TOML) or ``[bumpversion]`` + (INI). ``.bumpversion.toml`` nests the table under ``[tool]`` just as + pyproject.toml does. + """ + if not path.is_file(): + return False + if path.suffix == ".cfg": + return "[bumpversion]" in path.read_text(encoding="utf-8") + try: + with path.open("rb") as handle: + data = tomllib.load(handle) + except tomllib.TOMLDecodeError: + return False + return isinstance(data.get("tool", {}).get("bumpversion"), dict) + @pytest.fixture(scope="module") def pyproject(root: Path) -> dict: @@ -116,7 +143,7 @@ def test_description_is_non_empty_string(self, project: dict) -> None: class TestProjectUrls: """Tests for [project.urls] — Homepage and Repository links.""" - @pytest.fixture(scope="class") + @pytest.fixture def urls(self, project: dict) -> dict: """Return the [project.urls] table.""" table = project.get("urls") @@ -140,9 +167,9 @@ def test_repository_configured(self, urls: dict) -> None: class TestProjectClassifiers: - """Tests for [project].classifiers — Python version and licence entries.""" + """Tests for [project].classifiers — Python version entries.""" - @pytest.fixture(scope="class") + @pytest.fixture def classifiers(self, project: dict) -> list[str]: """Return the classifiers list.""" cl = project.get("classifiers", []) @@ -157,16 +184,33 @@ def test_python_version_classifier_present(self, classifiers: list[str]) -> None "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.""" + def test_no_license_classifier(self, project: dict) -> None: + """No deprecated 'License :: ' classifier may be present. + + PyPI has deprecated the ``License ::`` trove classifiers in favor of the SPDX + ``license`` expression field, so the shipped pyproject must not declare one. + """ + classifiers = project.get("classifiers", []) license_classifiers = [c for c in classifiers if c.startswith("License ::")] - assert len(license_classifiers) >= 1, "classifiers must include at least one 'License :: ' entry" + assert not license_classifiers, ( + f"classifiers must not include any deprecated 'License :: ' entry; found {license_classifiers}" + ) class TestDependencyGroups: - """Tests for [dependency-groups] — ensures required groups are declared.""" - - @pytest.fixture(scope="class") + """Tests for [dependency-groups] — ensures required groups are declared. + + Only ``test`` is required, and only because ``make test`` has to have somewhere to + find pytest. There was a ``test_lint_group_present`` here until #1484, and it is + worth saying why it went: rhiza provisions every linter through prek/uvx, so the + group it demanded had nothing legitimate to hold, and the mother repo satisfied it + with a literal ``lint = []``. A required-group check that the reference + implementation can only pass by declaring an empty list is testing a convention + rather than a working project, so a project may still declare ``lint`` — nothing + reads it. + """ + + @pytest.fixture def dependency_groups(self, pyproject: dict) -> dict: """Return the [dependency-groups] table.""" dg = pyproject.get("dependency-groups") @@ -185,27 +229,106 @@ def test_test_group_includes_pytest(self, dependency_groups: dict) -> None: "[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 TestBumpversionConfigIsDiscoverable: + """The release flow must find a version config, not silently invent one (#1453). + + bump-my-version searches four filenames and stops. When it finds none it does + **not** fail — it falls back to ``git describe`` and reports the last reachable + tag as the current version. Release tooling then computes bump candidates from + that number rather than the project's, which is how a repo at 0.7.0 with a + newest reachable tag of v0.6.4 gets offered "minor → v0.7.0", a version it has + already published. + + Once a ``[tool.bumpversion]`` table exists in pyproject.toml, bump-my-version + reads and rewrites PEP 621 ``[project].version`` natively, so the minimum + workable config is three lines and duplicates the version string nowhere:: + + [tool.bumpversion] + allow_dirty = false + # /rhiza:release commits and tags itself so the changelog lands in the + # bump commit. + commit = false + tag = false + + Add a ``[[tool.bumpversion.files]]`` entry per *additional* location (a plugin + manifest, a self-referencing CI stub pin) — never for ``[project].version`` + itself. + """ + + @pytest.fixture + def declared_version(self, project: dict) -> str: + """The statically declared project version, or skip when it is dynamic.""" + version = project.get("version") + if not isinstance(version, str): + pytest.skip("[project].version is dynamic — no static location to bump") + return version + + def test_a_discoverable_config_exists(self, root: Path, pyproject: dict, declared_version: str) -> None: + """A bumpversion section must live in a file bump-my-version actually reads.""" + found = [name for name in _DISCOVERABLE_CONFIGS if _has_bumpversion_section(root / name)] + hint = "" + if (root / ".rhiza" / ".cfg.toml").is_file(): + hint = ( + " A leftover .rhiza/.cfg.toml is present: that path is never auto-discovered " + "(it predates the fix for issue #1453) and can be deleted." + ) + assert found, ( + f"pyproject.toml declares version {declared_version!r} but no bumpversion config " + f"was found in any file bump-my-version searches ({', '.join(_DISCOVERABLE_CONFIGS)}). " + f"It will silently fall back to `git describe`, so a release can be cut at a version " + f"that already exists. Add a [tool.bumpversion] table to pyproject.toml.{hint}" + ) -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, + def test_pyproject_is_the_config_that_wins(self, root: Path, declared_version: str) -> None: + """No earlier-searched file may shadow pyproject.toml's table. + + Search order is significant: a ``.bumpversion.toml`` beats pyproject.toml and + takes ``[project].version`` out of the picture, so the two version numbers can + then drift apart unnoticed. A Python project keeps its version in one place. + """ + shadowing = [ + name for name in _DISCOVERABLE_CONFIGS if name != "pyproject.toml" and _has_bumpversion_section(root / name) + ] + assert not shadowing, ( + f"{shadowing} is searched before pyproject.toml and would shadow its " + f"[tool.bumpversion] table, detaching the bump from [project].version " + f"({declared_version!r})" ) - 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_config_does_not_duplicate_the_version(self, pyproject: dict, declared_version: str) -> None: + """``current_version`` is redundant in pyproject.toml, and drifts once stale.""" + section = pyproject.get("tool", {}).get("bumpversion") + if not isinstance(section, dict): + pytest.skip("no [tool.bumpversion] table — reported by test_a_discoverable_config_exists") + declared_in_config = section.get("current_version") + assert declared_in_config in (None, declared_version), ( + f"[tool.bumpversion].current_version is {declared_in_config!r} but " + f"[project].version is {declared_version!r}; bumping from the stale value cannot " + f"match the version in the file. Drop current_version — bump-my-version reads " + f"[project].version natively." + ) + + def test_the_release_flow_owns_the_commit_and_the_tag(self, pyproject: dict) -> None: + """``/rhiza:release`` folds the changelog into the bump commit and tags it itself.""" + section = pyproject.get("tool", {}).get("bumpversion") + if not isinstance(section, dict): + pytest.skip("no [tool.bumpversion] table — reported by test_a_discoverable_config_exists") + for key in ("commit", "tag"): + assert section.get(key, False) is False, ( + f"[tool.bumpversion].{key} must be false: the release flow commits and tags " + f"itself so the changelog lands in the bump commit, and a bare " + f"`bump-my-version bump` would otherwise add a second commit and a duplicate tag" + ) + + +class TestGitTagVersion: + """Tests for harmony between the latest git tag and pyproject.toml version. + + Reachability of that tag is asserted by ``test_release_tags.py``, which ``core`` + ships: the invariant is about git rather than about Python, and all three language + layers need it. + """ 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.""" diff --git a/.rhiza/tests/test_readme.py b/.rhiza/tests/test_readme.py new file mode 100644 index 0000000..99c07ae --- /dev/null +++ b/.rhiza/tests/test_readme.py @@ -0,0 +1,140 @@ +"""Tests for the README that hold whatever the project is written in. + +This file and its associated tests flow down via a SYNC action from the +jebel-quant/rhiza repository (https://github.com/jebel-quant/rhiza). + +Owned by ``core`` because none of it is language-specific: every synced README documents +its gates in ``bash`` fences — ``make install``, ``make test``, ``make all`` — and a +fence with a syntax error is broken the same way in a Rust, Go or Python project. Before +this split (#1472) all of it lived in the ``tests`` bundle, which requires +``python-core``, so a Rust or Go repo had no README coverage at all. + +The Python-block half stays behind in ``tests`` as ``test_readme_validation.py``: it +executes ``python`` fences and diffs them against a ``result`` block, which only means +something where the project *is* Python. + +Note the split of labour with the fence flags: ``SKIP_FLAG`` and ``_should_skip`` are +duplicated across the two modules rather than shared. Bundles are copied +independently — a Rust project receives this file and not the other — so a shared helper +would need a third home that both bundles ship, which is a worse trade for four lines. +""" + +from __future__ import annotations + +import re +import subprocess # nosec B404 +from pathlib import Path + +import pytest + +# Bash code blocks — captures optional flags (e.g. "+RHIZA_SKIP") and the code body. +BASH_BLOCK = re.compile(r"```bash([^\n]*)\n(.*?)```", re.DOTALL) + +# Bash executable used for syntax checking; `bash -n` parses without executing. +BASH = "bash" + +# Flag marking a fence as intentionally excluded. Usage: add it after the language +# identifier on the opening fence line, e.g. ```bash +RHIZA_SKIP +SKIP_FLAG = "+RHIZA_SKIP" + +# Box-drawing characters mean the fence is a directory tree, not runnable shell. +_TREE_MARKERS = ("├──", "└──", "│") + + +def _should_skip(flags: str) -> bool: + """Return True if the fence flags string contains the +RHIZA_SKIP marker. + + Args: + flags: Text following the language identifier on the opening fence line. + + Returns: + True when the block is intentionally excluded. + """ + return SKIP_FLAG in flags + + +class TestReadmeExists: + """The README has to be there and be readable before anything else applies.""" + + def test_readme_file_exists_at_root(self, root: Path) -> None: + """README.md should exist at repository root.""" + readme = root / "README.md" + assert readme.exists(), "README.md not found at project root" + assert readme.is_file(), "README.md is not a regular file" + + def test_readme_is_readable(self, root: Path) -> None: + """README.md should be readable with UTF-8 encoding and non-empty.""" + content = (root / "README.md").read_text(encoding="utf-8") + assert content.strip(), "README.md is empty" + + +class TestReadmeBashFragments: + """Bash fences must parse, in any language's project. + + Only ``bash -n`` — the blocks are parsed, never executed. A README's shell examples + are usually destructive-adjacent (`make clean`, `git push`) and running them is not + what this is for; a fence that cannot even parse is a documentation bug regardless. + """ + + def test_bash_blocks_basic_syntax(self, root: Path, logger) -> None: + """Every non-skipped bash block should parse under `bash -n`.""" + content = (root / "README.md").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 + + if any(marker in code for marker in _TREE_MARKERS): + logger.info("Skipping bash block %d (directory tree representation)", i) + continue + + # A block that is only comments has nothing to parse and no way to be wrong. + lines = [line.strip() for line in code.split("\n") if line.strip()] + if not [line for line in lines if not line.startswith("#")]: + logger.info("Skipping bash block %d (only comments)", i) + continue + + logger.debug("Checking bash block %d:\n%s", i, code) + + result = subprocess.run( # nosec B603 B607 - `bash -n` parses without executing + [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 excludes an individual fence.""" + + def test_should_skip_returns_true_for_skip_flag(self) -> None: + """+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) -> None: + """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_bash_block_with_skip_flag_is_excluded(self, tmp_path: Path) -> None: + """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", + ) + all_blocks = BASH_BLOCK.findall(readme.read_text(encoding="utf-8")) + 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/sync/test_readme_validation.py b/.rhiza/tests/test_readme_validation.py similarity index 59% rename from .rhiza/tests/sync/test_readme_validation.py rename to .rhiza/tests/test_readme_validation.py index b889166..3c935ee 100644 --- a/.rhiza/tests/sync/test_readme_validation.py +++ b/.rhiza/tests/test_readme_validation.py @@ -1,10 +1,19 @@ -"""Tests for README code examples. +"""Tests for executable Python examples in the README. 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. + +The language-neutral half — that the README exists, and that its ``bash`` fences +parse — moved to ``core``'s ``test_readme.py`` in #1472, so a Rust or Go project gets it +too. What stays here only means something where the project itself is Python: running a +``python`` fence and diffing it against the following ``result`` block. + +``SKIP_FLAG`` and ``_should_skip`` are duplicated with that module rather than shared. +Bundles are copied independently, so a shared helper would need a third home both bundles +ship — a worse trade for four lines. """ import re @@ -18,12 +27,6 @@ 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 @@ -80,19 +83,6 @@ def test_readme_runs(logger, root): 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" @@ -108,51 +98,8 @@ def test_readme_code_is_syntactically_valid(self, root): 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.""" + """Tests for the +RHIZA_SKIP flag as it applies to Python fences.""" def test_should_skip_returns_true_for_skip_flag(self): """+RHIZA_SKIP in flags string should cause _should_skip to return True.""" @@ -181,17 +128,3 @@ def test_python_block_with_skip_flag_is_excluded(self, tmp_path): 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_release_tags.py b/.rhiza/tests/test_release_tags.py new file mode 100644 index 0000000..5fce672 --- /dev/null +++ b/.rhiza/tests/test_release_tags.py @@ -0,0 +1,63 @@ +"""Tests for the release tags every language layer's version config derives from. + +This file and its associated tests flow down via a SYNC action from the +jebel-quant/rhiza repository (https://github.com/jebel-quant/rhiza). + +Owned by ``core`` because the invariant is about git, not about a language. All three +layers depend on it for the same reason: ``python-core``'s ``[tool.bumpversion]`` table +and the root ``.bumpversion.toml`` that ``rust-core`` and ``go-core`` ship both fall back +to reading the newest tag when they cannot read a version from a file, and git-cliff +places changelog boundaries at tags. An unreachable tag breaks both. +""" + +from __future__ import annotations + +import shutil +import subprocess # nosec B404 +from pathlib import Path + +import pytest + +_GIT = shutil.which("git") or "/usr/bin/git" + + +def test_latest_tag_is_reachable_from_a_branch(latest_tag: str, root: Path) -> None: + """The newest tag must sit on a commit some branch contains (#1454). + + ``git tag --list`` happily reports an orphaned tag, which is how a repo can stay + green while ``git describe`` disagrees with it. A release cut on a branch that is + then squash-merged leaves its tag on the pre-squash commit while the content lands + on the default branch under a new SHA; no branch contains the tagged commit any + more. + + The consequence is not cosmetic. git-cliff cannot place a boundary at an + unreachable tag, so regenerating CHANGELOG.md deletes that version's section and + folds its commits into the next release. Bump tooling reading the version from + ``git describe`` skips the release for the same reason. + """ + if ( + subprocess.run( # nosec B603 + [_GIT, "rev-parse", "--is-shallow-repository"], capture_output=True, text=True, cwd=root + ).stdout.strip() + == "true" + ): + pytest.skip("shallow clone — the commit graph is incomplete") + + commit = subprocess.run( # nosec B603 + [_GIT, "rev-parse", f"{latest_tag}^{{commit}}"], capture_output=True, text=True, cwd=root + ) + if commit.returncode != 0: + pytest.skip(f"tagged commit for {latest_tag} is not present locally") + + contains = subprocess.run( # nosec B603 + [_GIT, "branch", "-a", "--contains", commit.stdout.strip(), "--format=%(refname:short)"], + capture_output=True, + text=True, + cwd=root, + ) + assert contains.stdout.strip(), ( + f"Tag {latest_tag} points at {commit.stdout.strip()[:12]}, which no branch contains. " + f"It is most likely the pre-squash commit of a squash-merged release branch: " + f"`git describe` skips this release and regenerating CHANGELOG.md will delete its " + f"section. Re-tag the merged commit and delete the orphaned tag." + ) 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) From da3690af31bd49bf88387714b49c7937174a05ef Mon Sep 17 00:00:00 2001 From: Thomas Schmelzer Date: Tue, 18 Aug 2026 00:19:36 +0400 Subject: [PATCH 2/3] fix: satisfy the two checks the v1.3.3 test layout adds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test_no_license_classifier: PyPI deprecated the `License ::` trove classifiers in favour of the license field, so the MIT one is dropped. `license = {text = "MIT"}` still carries it — the built wheel reports License: MIT with no license classifiers. test_a_discoverable_config_exists: the bumpversion config lived in .rhiza/.cfg.toml, which bump-my-version never searches. It searches .bumpversion.toml, .bumpversion.cfg, setup.cfg and pyproject.toml, and on finding none does not fail — it falls back to `git describe` and treats the newest reachable tag as the current version, so a release can be cut at a number already published. A [tool.bumpversion] table now lives in pyproject.toml. The table is deliberately three settings and no current_version: bump-my-version rewrites PEP 621 [project].version natively, and commit/tag stay false because the release flow makes its own commit and tag. The stale .rhiza/.cfg.toml is removed in the stacked PR, where the rest of the paths v1.3.3 dropped are removed; until then it is inert, since nothing auto-discovers that path. .rhiza/tests now reports 37 passed, 3 skipped. Co-Authored-By: Claude Opus 5 (1M context) --- pyproject.toml | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 088ef84..93cbe41 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,6 @@ classifiers = [ "Development Status :: 3 - Alpha", "Intended Audience :: Developers", "Intended Audience :: Financial and Insurance Industry", - "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", @@ -60,3 +59,20 @@ build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] packages = ["src/pycharting"] + +[tool.bumpversion] +# bump-my-version searches .bumpversion.toml, .bumpversion.cfg, setup.cfg and +# pyproject.toml, in that order, and stops at the first hit. Without a table in +# one of them it does not fail — it falls back to `git describe` and treats the +# newest reachable tag as the current version, which is how a release gets cut +# at a number that already exists. +# +# No current_version here: bump-my-version reads and rewrites PEP 621 +# [project].version natively, so repeating it only creates a second copy to +# drift. Add [[tool.bumpversion.files]] entries for *additional* locations only. +allow_dirty = false +# The release flow commits and tags itself, so the changelog lands in the bump +# commit; letting bump-my-version do either would add a second commit and a +# duplicate tag. +commit = false +tag = false From 91e5d06999a0ad07a774df6b5e6aeab493c0c038 Mon Sep 17 00:00:00 2001 From: Thomas Schmelzer Date: Tue, 18 Aug 2026 07:17:34 +0400 Subject: [PATCH 3/3] chore: move the make layer to rhiza v1.3.3 and drop .rhiza/requirements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit .rhiza/requirements/*.txt was a second dependency manifest: `make install` pip-installed every file in it into the project venv up front. v1.3.3 drops the directory and has each target provision what it needs on the fly instead (`uv run --with pytest --with pytest-timeout …`), so the venv holds the project's own dependencies and nothing else. Removing the lists alone would not work, which is why the make layer comes with them: - the v1.3.3 bootstrap.mk no longer defines `install` at all — that moves to the new python.mk, which also takes over test, typecheck, security, license and docs-coverage from the old test.mk and quality.mk, so mixing old and new files gives duplicate target definitions; - the old .rhiza/tests suite asserts against the old layout (it copies make.d files into a temp dir and inspects `make -sn test`), so it fails the moment `test` moves to python.mk. The v1.3.3 suite this branch is stacked on replaces it. Two knock-on effects worth naming: - `make license` now passes. The old tests.txt pulled in pygal, which is LGPLv3+, and the license gate fails on LGPL — the pinned list was itself the violation. - v1.3.3 changes the `typecheck` default from ty to `both`, adding `mypy --strict`. That reports 43 errors in src/ that pre-date this branch, so custom-env.mk pins TYPECHECKER to ty, holding the gate exactly where master has it rather than turning a template bump into a source rewrite. `make typecheck TYPECHECKER=mypy` still runs it. Verified against a venv pruned to the locked set, so it matches a fresh CI checkout: install, test (172 passed, 100% coverage), rhiza-test (37 passed, 3 skipped), docs-coverage (100%), security, deps, typecheck and license all pass. Co-Authored-By: Claude Opus 5 (1M context) --- .rhiza/.env | 10 ++ .rhiza/make.d/book.mk | 2 +- .rhiza/make.d/bootstrap.mk | 70 +------- .rhiza/make.d/completions.mk | 42 +++++ .rhiza/make.d/custom-env.mk | 11 ++ .rhiza/make.d/doctor.mk | 21 ++- .rhiza/make.d/github.mk | 70 ++++++++ .rhiza/make.d/marimo.mk | 18 ++ .rhiza/make.d/python.mk | 312 +++++++++++++++++++++++++++++++++ .rhiza/make.d/quality.mk | 76 +++++--- .rhiza/make.d/releasing.mk | 50 ------ .rhiza/make.d/test.mk | 123 +++---------- .rhiza/requirements/README.md | 27 --- .rhiza/requirements/docs.txt | 4 - .rhiza/requirements/marimo.txt | 2 - .rhiza/requirements/tests.txt | 18 -- .rhiza/requirements/tools.txt | 7 - .rhiza/rhiza.mk | 149 +++++++--------- Makefile | 6 +- 19 files changed, 626 insertions(+), 392 deletions(-) create mode 100644 .rhiza/make.d/completions.mk create mode 100644 .rhiza/make.d/github.mk create mode 100644 .rhiza/make.d/python.mk delete mode 100644 .rhiza/make.d/releasing.mk delete mode 100644 .rhiza/requirements/README.md delete mode 100644 .rhiza/requirements/docs.txt delete mode 100644 .rhiza/requirements/marimo.txt delete mode 100644 .rhiza/requirements/tests.txt delete mode 100644 .rhiza/requirements/tools.txt diff --git a/.rhiza/.env b/.rhiza/.env index 0fd6a6a..7d92a35 100644 --- a/.rhiza/.env +++ b/.rhiza/.env @@ -1,3 +1,13 @@ +# .rhiza/.env — project-local environment overrides (optional) +# +# This file is optional. If absent, rhiza.mk falls back to built-in defaults: +# SOURCE_FOLDER = src +# MARIMO_FOLDER = docs/notebooks +# RHIZA_CI_OS_MATRIX = ["ubuntu-latest"] +# +# Add or uncomment only the variables you want to override. +# Variables defined here are exported to all Make recipes. + MARIMO_FOLDER=docs/notebooks SOURCE_FOLDER=src RHIZA_CI_OS_MATRIX=["ubuntu-latest","macos-latest","windows-latest"] diff --git a/.rhiza/make.d/book.mk b/.rhiza/make.d/book.mk index 6323b21..95791bf 100644 --- a/.rhiza/make.d/book.mk +++ b/.rhiza/make.d/book.mk @@ -34,7 +34,7 @@ _book-notebooks: name=$$(basename "$$nb" .py); \ printf "${BLUE}[INFO] Exporting $$nb -> ${ROOT}/docs/notebooks/$$name.html${RESET}\n"; \ abs_output="${ROOT}/docs/notebooks/$$name.html"; \ - (cd "$$(dirname "$$nb")" && ${UV_BIN} run marimo export html --sandbox "$$(basename "$$nb")" -o "$$abs_output"); \ + (cd "$$(dirname "$$nb")" && ${UV_BIN} run --with marimo marimo export html --sandbox "$$(basename "$$nb")" -o "$$abs_output"); \ done; \ else \ printf "${YELLOW}[WARN] MARIMO_FOLDER not set or missing, skipping notebook export${RESET}\n"; \ diff --git a/.rhiza/make.d/bootstrap.mk b/.rhiza/make.d/bootstrap.mk index 2852697..9bf67e2 100644 --- a/.rhiza/make.d/bootstrap.mk +++ b/.rhiza/make.d/bootstrap.mk @@ -1,11 +1,13 @@ ## .rhiza/make.d/bootstrap.mk - Bootstrap and Installation -# This file provides targets for setting up the development environment, -# installing dependencies, and cleaning project artifacts. +# This file provides the language-neutral half of setup: it puts uv/uvx on the +# path (rhiza runs pre-commit, mkdocs and semgrep through uvx whatever the +# project is written in), declares the install hooks, and cleans artifacts. +# +# `install` itself belongs to the language layer, because what it means differs: +# python.mk (bundle python-core) creates a virtualenv and runs `uv sync`. # Declare phony targets (they don't produce files) -.PHONY: install-uv install clean pre-install post-install - -UV_SYNC_ARGS ?= --all-extras --all-groups +.PHONY: install-uv clean pre-install post-install # Hook targets (double-colon rules allow multiple definitions) pre-install:: ; @: @@ -29,62 +31,6 @@ install-uv: ## ensure uv/uvx is installed fi; \ fi -install: pre-install install-uv ## install - # Create the virtual environment only if it doesn't exist - @if [ ! -d "${VENV}" ]; then \ - ${UV_BIN} venv $(if $(PYTHON_VERSION),--python $(PYTHON_VERSION)) ${VENV} || { printf "${RED}[ERROR] Failed to create virtual environment${RESET}\n"; exit 1; }; \ - else \ - printf "${BLUE}[INFO] Using existing virtual environment at ${VENV}, skipping creation${RESET}\n"; \ - fi - - # Install the dependencies from pyproject.toml (if it exists) - @if [ -f "pyproject.toml" ]; then \ - if [ -f "uv.lock" ]; then \ - if ! ${UV_BIN} lock --check >/dev/null 2>&1; then \ - printf "${YELLOW}[WARN] uv.lock is out of sync with pyproject.toml${RESET}\n"; \ - printf "${YELLOW} Run 'uv sync' to update your lock file and environment${RESET}\n"; \ - printf "${YELLOW} Or run 'uv lock' to update only the lock file${RESET}\n"; \ - exit 1; \ - fi; \ - printf "${BLUE}[INFO] Installing dependencies from lock file${RESET}\n"; \ - ${UV_BIN} sync $(UV_SYNC_ARGS) --frozen || { printf "${RED}[ERROR] Failed to install dependencies${RESET}\n"; exit 1; }; \ - else \ - printf "${YELLOW}[WARN] uv.lock not found. Generating lock file and installing dependencies...${RESET}\n"; \ - ${UV_BIN} sync $(UV_SYNC_ARGS) || { printf "${RED}[ERROR] Failed to install dependencies${RESET}\n"; exit 1; }; \ - fi; \ - else \ - printf "${YELLOW}[WARN] No pyproject.toml found, skipping install${RESET}\n"; \ - fi - - # Install dev dependencies from .rhiza/requirements/*.txt files - @if [ -d ".rhiza/requirements" ] && ls .rhiza/requirements/*.txt >/dev/null 2>&1; then \ - for req_file in .rhiza/requirements/*.txt; do \ - if [ -f "$$req_file" ]; then \ - printf "${BLUE}[INFO] Installing requirements from $$req_file${RESET}\n"; \ - ${UV_BIN} pip install -r "$$req_file" || { printf "${RED}[ERROR] Failed to install requirements from $$req_file${RESET}\n"; exit 1; }; \ - fi; \ - done; \ - fi - - # Check if there is requirements.txt file in the tests folder (legacy support) - @if [ -f "tests/requirements.txt" ]; then \ - printf "${BLUE}[INFO] Installing requirements from tests/requirements.txt${RESET}\n"; \ - ${UV_BIN} pip install -r tests/requirements.txt || { printf "${RED}[ERROR] Failed to install test requirements${RESET}\n"; exit 1; }; \ - fi - - # Install pre-commit hooks - @if [ -f ".pre-commit-config.yaml" ]; then \ - printf "${BLUE}[INFO] Installing pre-commit hooks...${RESET}\n"; \ - ${UVX_BIN} -p ${PYTHON_VERSION} pre-commit install || { printf "${YELLOW}[WARN] Failed to install pre-commit hooks${RESET}\n"; }; \ - fi - - @$(MAKE) post-install - - # Display success message with activation instructions - @printf "\n${GREEN}[SUCCESS] Installation complete!${RESET}\n\n" - @printf "${BLUE}To activate the virtual environment, run:${RESET}\n" - @printf "${YELLOW} source ${VENV}/bin/activate${RESET}\n\n" - clean: ## Clean project artifacts and stale local branches @printf "%bCleaning project...%b\n" "$(BLUE)" "$(RESET)" @@ -106,4 +52,4 @@ clean: ## Clean project artifacts and stale local branches @git fetch --prune - @git branch -vv | awk '/: gone]/{print $$1}' | xargs -r git branch -D + @git branch -vv | awk '/: gone]/ && $$1 != "*" && $$1 != "+" {print $$1}' | xargs -r git branch -D diff --git a/.rhiza/make.d/completions.mk b/.rhiza/make.d/completions.mk new file mode 100644 index 0000000..59c2239 --- /dev/null +++ b/.rhiza/make.d/completions.mk @@ -0,0 +1,42 @@ +## .rhiza/make.d/completions.mk - Install shell tab-completion for make targets +# Copies the bundled completion scripts (.rhiza/completions/) into the user's +# completion directory so `make ` completes Rhiza targets. See +# .rhiza/completions/README.md for manual setup, aliases, and troubleshooting. + +.PHONY: install-completions + +# Which shell(s) to install completion for: bash, zsh, or both. +# make cannot reliably detect the login shell (it forces $SHELL to /bin/sh for +# recipes), so this defaults to `both` — installing an unused script is harmless. +# Narrow it with e.g. `make install-completions SHELL_KIND=zsh`. +SHELL_KIND ?= both + +##@ Shell Completion +install-completions: ## install shell tab-completion for make targets (SHELL_KIND=bash|zsh|both) + @src=".rhiza/completions"; \ + if [ ! -d "$$src" ]; then \ + printf "${RED}[❌]${RESET} $$src not found — run from the project root.\n"; \ + exit 1; \ + fi; \ + install_bash() { \ + dest="$${XDG_DATA_HOME:-$$HOME/.local/share}/bash-completion/completions"; \ + mkdir -p "$$dest"; \ + cp "$$src/rhiza-completion.bash" "$$dest/make"; \ + printf "${GREEN}[✓]${RESET} bash: installed to %s\n" "$$dest/make"; \ + printf "${BLUE}[INFO]${RESET} start a new shell, or run: ${BOLD}source %s${RESET}\n" "$$dest/make"; \ + }; \ + install_zsh() { \ + dest="$${XDG_DATA_HOME:-$$HOME/.local/share}/zsh/site-functions"; \ + mkdir -p "$$dest"; \ + cp "$$src/rhiza-completion.zsh" "$$dest/_make"; \ + printf "${GREEN}[✓]${RESET} zsh: installed to %s\n" "$$dest/_make"; \ + printf "${BLUE}[INFO]${RESET} if completion does not activate, add to ~/.zshrc:\n"; \ + printf " ${BOLD}fpath=(%s \$$fpath); autoload -U compinit && compinit${RESET}\n" "$$dest"; \ + }; \ + case "$(SHELL_KIND)" in \ + bash) install_bash ;; \ + zsh) install_zsh ;; \ + both) install_bash; install_zsh ;; \ + *) printf "${YELLOW}[WARN]${RESET} unknown SHELL_KIND='%s'; installing both (use bash|zsh|both).\n" "$(SHELL_KIND)"; \ + install_bash; install_zsh ;; \ + esac diff --git a/.rhiza/make.d/custom-env.mk b/.rhiza/make.d/custom-env.mk index f5ec964..64811a1 100644 --- a/.rhiza/make.d/custom-env.mk +++ b/.rhiza/make.d/custom-env.mk @@ -5,5 +5,16 @@ PROJECT_NAME_EXTRA := Rhiza Platform LOG_LEVEL ?= INFO +# rhiza v1.3.3 changed the `typecheck` default from ty to `both`, which adds +# `mypy --strict`. That reports 43 errors in src/ today — missing generic +# parameters, Any returns, no pandas stubs — none of them introduced here, and +# all of them pre-dating this repo's move to v1.3.3. Pinning ty keeps the gate +# exactly as strict as it is on master rather than turning the upgrade into a +# 43-error source change. +# +# `make typecheck TYPECHECKER=mypy` still runs it on demand; drop this line once +# the source is strict-clean. +TYPECHECKER ?= ty + # Overriding core variables (be careful) # VENV := .venv_custom diff --git a/.rhiza/make.d/doctor.mk b/.rhiza/make.d/doctor.mk index 398dca4..738db1b 100644 --- a/.rhiza/make.d/doctor.mk +++ b/.rhiza/make.d/doctor.mk @@ -1,9 +1,18 @@ ## .rhiza/make.d/doctor.mk - Developer prerequisite diagnostics +# +# Core checks only the tools it needs whatever the language: uv, make, git. A language +# layer appends its own diagnostics with a second `doctor::` rule — double-colon, like +# `pre-install::` and `test::`, so each rule runs in turn and a layer never has to know +# what core already checked. rust.mk uses this to catch a cargo that is not +# rustup-managed, which no version check would notice. +# +# Each rule runs in its own shell, so `failed` below is local to this one: a layer's rule +# reports and exits on its own account, and `make doctor` fails if any rule does. .PHONY: doctor ##@ Dev -doctor: ## verify local prerequisites and print actionable guidance +doctor:: ## verify local prerequisites and print actionable guidance @failed=0; \ version_ge() { \ awk -v a="$$1" -v b="$$2" 'BEGIN { \ @@ -21,20 +30,20 @@ doctor: ## verify local prerequisites and print actionable guidance check_tool() { \ tool="$$1"; min="$$2"; install_url="$$3"; version_cmd="$$4"; gnu_required="$$5"; \ if ! command -v "$$tool" >/dev/null 2>&1; then \ - printf "${RED}[❌]${RESET} %-9s missing — install: %s\n" "$$tool" "$$install_url"; \ + printf "${RED}[FAIL]${RESET} %-11s missing - install: %s\n" "$$tool" "$$install_url"; \ failed=1; \ return; \ fi; \ version="$$(eval "$$version_cmd" 2>/dev/null)"; \ if [ -z "$$version" ]; then \ - printf "${RED}[❌]${RESET} %-9s unknown version (required ≥ %s)\n" "$$tool" "$$min"; \ + printf "${RED}[FAIL]${RESET} %-11s unknown version (required >= %s)\n" "$$tool" "$$min"; \ failed=1; \ return; \ fi; \ extra=""; \ if [ "$$gnu_required" = "gnu" ] && ! make --version 2>/dev/null | grep -q '^GNU Make'; then \ extra=" (GNU required)"; \ - printf "${RED}[❌]${RESET} %-9s %-8s < %s%s\n" "$$tool" "$$version" "$$min" "$$extra"; \ + printf "${RED}[FAIL]${RESET} %-11s %-8s < %s%s\n" "$$tool" "$$version" "$$min" "$$extra"; \ failed=1; \ return; \ fi; \ @@ -42,12 +51,12 @@ doctor: ## verify local prerequisites and print actionable guidance if [ "$$gnu_required" = "gnu" ]; then \ extra=" (GNU required)"; \ fi; \ - printf "${GREEN}[✅]${RESET} %-9s %-8s ≥ %s%s\n" "$$tool" "$$version" "$$min" "$$extra"; \ + printf "${GREEN}[ OK ]${RESET} %-11s %-8s >= %s%s\n" "$$tool" "$$version" "$$min" "$$extra"; \ else \ if [ "$$gnu_required" = "gnu" ]; then \ extra=" (GNU required)"; \ fi; \ - printf "${RED}[❌]${RESET} %-9s %-8s < %s%s\n" "$$tool" "$$version" "$$min" "$$extra"; \ + printf "${RED}[FAIL]${RESET} %-11s %-8s < %s%s\n" "$$tool" "$$version" "$$min" "$$extra"; \ failed=1; \ fi; \ }; \ diff --git a/.rhiza/make.d/github.mk b/.rhiza/make.d/github.mk new file mode 100644 index 0000000..346b617 --- /dev/null +++ b/.rhiza/make.d/github.mk @@ -0,0 +1,70 @@ +## github.mk - github repo maintenance and helpers +# This file is included by the main Makefile + +# ── Forge Detection ────────────────────────────────────────────────────────── +# FORGE_TYPE is set once and reused by any target that needs to know the forge. +# Priority: .github/workflows/ → .gitlab-ci.yml / .gitlab/ → unknown +FORGE_TYPE := $(if $(wildcard .github/workflows/),github,$(if $(or $(wildcard .gitlab-ci.yml),$(wildcard .gitlab/)),gitlab,unknown)) + +# Declare phony targets +.PHONY: gh-install require-gh view-prs view-issues failed-workflows workflow-status latest-release whoami print-logo + +# ── Internal guard ─────────────────────────────────────────────────────────── +# Require the gh CLI; hard-fail if missing so downstream targets can depend on it. +require-gh: + @if ! command -v gh >/dev/null 2>&1; then \ + printf "${RED}[ERROR] gh cli not found. Install from: https://github.com/cli/cli?tab=readme-ov-file#installation${RESET}\n"; \ + exit 1; \ + fi + +##@ GitHub Helpers +gh-install: ## check for gh cli existence and install extensions + @if ! command -v gh >/dev/null 2>&1; then \ + printf "${YELLOW}[WARN] gh cli not found.${RESET}\n"; \ + printf "${BLUE}[INFO] Please install it from: https://github.com/cli/cli?tab=readme-ov-file#installation${RESET}\n"; \ + else \ + printf "${GREEN}[INFO] gh cli is installed.${RESET}\n"; \ + fi + +view-prs: gh-install ## list open pull requests + @printf "${BLUE}[INFO] Open Pull Requests:${RESET}\n" + @gh pr list --json number,title,author,headRefName,updatedAt --template \ + '{{tablerow (printf "NUM" | color "bold") (printf "TITLE" | color "bold") (printf "AUTHOR" | color "bold") (printf "BRANCH" | color "bold") (printf "UPDATED" | color "bold")}}{{range .}}{{tablerow (printf "#%v" .number | color "green") .title (.author.login | color "cyan") (.headRefName | color "yellow") (timeago .updatedAt | color "white")}}{{end}}' + +view-issues: gh-install ## list open issues + @printf "${BLUE}[INFO] Open Issues:${RESET}\n" + @gh issue list --json number,title,author,labels,updatedAt --template \ + '{{tablerow (printf "NUM" | color "bold") (printf "TITLE" | color "bold") (printf "AUTHOR" | color "bold") (printf "LABELS" | color "bold") (printf "UPDATED" | color "bold")}}{{range .}}{{tablerow (printf "#%v" .number | color "green") .title (.author.login | color "cyan") (pluck "name" .labels | join ", " | color "yellow") (timeago .updatedAt | color "white")}}{{end}}' + +failed-workflows: gh-install ## list recent failing workflow runs + @printf "${BLUE}[INFO] Recent Failing Workflow Runs:${RESET}\n" + @gh run list --limit 10 --status failure --json conclusion,name,headBranch,event,createdAt --template \ + '{{tablerow (printf "STATUS" | color "bold") (printf "NAME" | color "bold") (printf "BRANCH" | color "bold") (printf "EVENT" | color "bold") (printf "TIME" | color "bold")}}{{range .}}{{tablerow (printf "%s" .conclusion | color "red") .name (.headBranch | color "cyan") (.event | color "yellow") (timeago .createdAt | color "white")}}{{end}}' + +whoami: gh-install ## check github auth status + @printf "${BLUE}[INFO] GitHub Authentication Status:${RESET}\n" + @gh auth status --hostname github.com --json hosts --template \ + '{{range $$host, $$accounts := .hosts}}{{range $$accounts}}{{if .active}} {{printf "✓" | color "green"}} Logged in to {{$$host}} account {{.login | color "bold"}} ({{.tokenSource}}){{"\n"}} Active account: {{printf "true" | color "green"}}{{"\n"}} Git operations protocol: {{.gitProtocol | color "yellow"}}{{"\n"}} Token scopes: {{.scopes | color "yellow"}}{{"\n"}}{{end}}{{end}}{{end}}' + +workflow-status: require-gh ## show recent runs for the release workflow + @printf "${BOLD}Release Workflow Status${RESET}\n" + @printf "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}\n" + @RELEASE_WF=$$(gh workflow list --json name,id --jq '.[] | select(.name | test("release";"i")) | .name' 2>/dev/null | head -1); \ + if [ -n "$$RELEASE_WF" ]; then \ + printf "${BLUE}[INFO] Workflow: ${GREEN}$$RELEASE_WF${RESET}\n\n"; \ + gh run list --workflow "$$RELEASE_WF" --limit 5 \ + --json status,conclusion,headBranch,event,createdAt,displayTitle,url \ + --template '{{tablerow (printf "STATUS" | color "bold") (printf "CONCLUSION" | color "bold") (printf "TITLE" | color "bold") (printf "EVENT" | color "bold") (printf "TIME" | color "bold")}}{{range .}}{{tablerow (printf "%s" .status | color "cyan") (printf "%s" (or .conclusion "—") | color (or (and (eq .conclusion "success") "green") (and (eq .conclusion "failure") "red") "yellow")) .displayTitle (.event | color "yellow") (timeago .createdAt | color "white")}}{{end}}'; \ + else \ + printf "${YELLOW}[WARN] No release workflow found in this repository${RESET}\n"; \ + fi + +latest-release: require-gh ## show information about the latest GitHub release + @printf "${BOLD}Latest Release${RESET}\n" + @printf "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}\n" + @if gh release view --json tagName --jq '.tagName' >/dev/null 2>&1; then \ + gh release view --json tagName,name,publishedAt,url,isDraft,isPrerelease,author \ + --template ' Tag: {{.tagName | color "green"}}{{"\n"}} Name: {{.name}}{{"\n"}} Author: {{.author.login}}{{"\n"}} Published: {{timeago .publishedAt}}{{"\n"}} Status: {{if .isDraft}}{{printf "Draft" | color "yellow"}}{{else if .isPrerelease}}{{printf "Pre-release" | color "yellow"}}{{else}}{{printf "Published" | color "green"}}{{end}}{{"\n"}} URL: {{.url}}{{"\n"}}'; \ + else \ + printf "${YELLOW}[WARN] No releases found in this repository${RESET}\n"; \ + fi diff --git a/.rhiza/make.d/marimo.mk b/.rhiza/make.d/marimo.mk index 36ae74c..37dcc77 100644 --- a/.rhiza/make.d/marimo.mk +++ b/.rhiza/make.d/marimo.mk @@ -1,6 +1,24 @@ ## Makefile.marimo - Marimo notebook targets # This file is included by the main Makefile +# Contribute the marimo notebook folder to the shared deptry scan defined in +# quality.mk. DEP004 (misplaced development dependency) is ignored because +# notebooks legitimately import packages declared as development dependencies. +ifneq ($(wildcard $(MARIMO_FOLDER)),) +DEPTRY_FOLDERS += $(MARIMO_FOLDER) +DEPTRY_IGNORE += --ignore DEP004 +endif + +# Exempt docutils from the licence gate in python.mk, on the same accumulator principle: +# the bundle that brings a dependency in owns the exemption for it, rather than every +# consumer rediscovering it. marimo depends on docutils, which is offered under a choice +# of licences and reports all of them as one string -- "BSD License; GNU General Public +# License (GPL); Public Domain". pip-licenses matches --fail-on against that string with +# no notion of *or*, so now that the gate actually matches, `GPL` hits a package every +# consumer of this bundle takes under BSD. Unconditional, unlike the deptry lines above: +# the dependency arrives with marimo itself, not with a notebook folder. +LICENSE_IGNORE_PACKAGES += docutils + # Declare phony targets (they don't produce files) .PHONY: marimo-validate marimo diff --git a/.rhiza/make.d/python.mk b/.rhiza/make.d/python.mk new file mode 100644 index 0000000..90f4917 --- /dev/null +++ b/.rhiza/make.d/python.mk @@ -0,0 +1,312 @@ +## .rhiza/make.d/python.mk - the Python language layer (bundle: python-core) +# +# Everything in rhiza that only makes sense because the project is written in +# Python. `core` provides the make framework and uv/uvx as a tool runner; this +# file turns that into a Python project: the virtualenv, the `install` that syncs +# it, dependency and licence analysis of the declared dependencies, and the `all` +# aggregate naming the gates. +# +# A sibling language layer (rust.mk, from a rust-core bundle) ships the same +# *target names* — install, all — with different recipes. That contract is the +# reason book.mk and the CI workflows can call `make install` without knowing the +# language. Only one language layer is ever synced into a repo. +# +# Every gate named in `all` below is defined *here*, which is what makes this file +# comparable to rust.mk and go.mk. Until #1475 four of them — test, typecheck, +# security, docs-coverage — lived in the `tests` bundle's test.mk while `all` named +# them, so a project syncing `core + python-core` without `tests` had an `all` that +# could not run. The `tests` bundle now carries only the genuinely optional extras +# (benchmark, hypothesis-test, stress, mutation). + +# Declare phony targets (they don't produce files) +.PHONY: all deps deptry docs-coverage install license security test test-pyproject typecheck + +# The project virtualenv, and the interpreter that fills it. PYTHON_VERSION is +# declared in rhiza.mk (core needs a Python to run its own tooling on); here +# `.python-version` — which this bundle ships — makes it the project's version too. +VENV ?= .venv +UV_SYNC_ARGS ?= --all-extras --all-groups + +export UV_VENV_CLEAR := 1 + +# Configurable list of licenses that fail the compliance scan (semicolon-separated). +# +# These are matched as *substrings* of the reported licence -- see `--partial-match` +# in the `license` recipe. Without that flag pip-licenses compares against the whole +# licence string, and `GPL` never equals a real classifier such as "GNU General +# Public License v2 or later (GPLv2+)", so the gate passed with a GPL package +# installed. +LICENSE_FAIL_ON ?= GPL;LGPL;AGPL + +# Packages exempted from the scan by name, space-separated. Empty by default: an +# exemption should be a deliberate, reviewable act -- in a project's own Makefile, or +# in the bundle that owns the dependency -- not something this layer grants globally. +# Two cases are legitimate, and `?=` plus `+=` makes it an accumulator for both: +# +# 1. A copyleft *development* dependency -- a reference implementation a differential +# test runs against -- never imported by the shipped package and never redistributed. +# 2. A package offered under several licences at the reader's choice. pip-licenses +# reports the classifier list joined with "; " and has no notion of *or*, so +# `--partial-match` fires on the copyleft option even where a permissive one is +# taken. marimo.mk exempts docutils ("BSD License; GNU General Public License +# (GPL); Public Domain") for exactly this reason. +# +# Case 2 is the one to be careful with: check that a permissive option really is on +# offer, rather than that the string merely looks long. +LICENSE_IGNORE_PACKAGES ?= + +# Default directory for tests. Also read by the `tests` bundle's test.mk, which +# requires this layer, so the two never disagree about where tests live. +TESTS_FOLDER := tests + +# Minimum coverage percent for tests to pass +# (Can be overridden in local.mk or via environment variable) +COVERAGE_FAIL_UNDER ?= 90 + +# Which static type checker(s) the 'typecheck' target runs: ty, mypy, or both. +# Running both is the default for backward compatibility, but ty and mypy +# occasionally disagree (e.g. one accepts a suppression the other still flags), +# forcing duplicate `# type: ignore` / `# ty: ignore` comments. Set this to +# 'ty' or 'mypy' in local.mk or .rhiza/.env to run a single checker instead. +TYPECHECKER ?= both + +##@ Python +install: pre-install install-uv ## install + # Create the virtual environment only if it doesn't exist + @if [ ! -d "${VENV}" ]; then \ + ${UV_BIN} venv $(if $(PYTHON_VERSION),--python $(PYTHON_VERSION)) ${VENV} || { printf "${RED}[ERROR] Failed to create virtual environment${RESET}\n"; exit 1; }; \ + else \ + printf "${BLUE}[INFO] Using existing virtual environment at ${VENV}, skipping creation${RESET}\n"; \ + fi + + # Install the dependencies from pyproject.toml (if it exists). + # --inexact leaves packages uv did not manage in place instead of pruning them each + # run, so repeated 'make' targets don't churn the environment. Per-target tooling + # (pytest, interrogate, mutmut, ...) is provisioned on the fly via `uv run --with` + # in the individual targets, so there is no separate dependency-install step here. + @if [ -f "pyproject.toml" ]; then \ + if [ -f "uv.lock" ]; then \ + if ! ${UV_BIN} lock --check >/dev/null 2>&1; then \ + printf "${YELLOW}[WARN] uv.lock is out of sync with pyproject.toml${RESET}\n"; \ + printf "${YELLOW} Run 'uv sync' to update your lock file and environment${RESET}\n"; \ + printf "${YELLOW} Or run 'uv lock' to update only the lock file${RESET}\n"; \ + exit 1; \ + fi; \ + printf "${BLUE}[INFO] Installing dependencies from lock file${RESET}\n"; \ + ${UV_BIN} sync $(UV_SYNC_ARGS) --inexact --frozen || { printf "${RED}[ERROR] Failed to install dependencies${RESET}\n"; exit 1; }; \ + else \ + printf "${YELLOW}[WARN] uv.lock not found. Generating lock file and installing dependencies...${RESET}\n"; \ + ${UV_BIN} sync $(UV_SYNC_ARGS) --inexact || { printf "${RED}[ERROR] Failed to install dependencies${RESET}\n"; exit 1; }; \ + fi; \ + else \ + printf "${YELLOW}[WARN] No pyproject.toml found, skipping install${RESET}\n"; \ + fi + + # Install pre-commit hooks (skip when core.hooksPath is set, e.g. by an + # external hook manager — pre-commit refuses to install in that case) + # + # `-c` for the reason quality.mk's `fmt` passes `--config`, and it must be repeated + # here: prek bakes the flag into the generated shim, so without it the commit-time + # gate rediscovers nested projects and stops meaning what `make fmt` means. A + # consumer who wants prek's monorepo behaviour drops the flag from both places. + @if [ -f ".pre-commit-config.yaml" ]; then \ + if [ -n "$$(git config --get core.hooksPath 2>/dev/null)" ]; then \ + printf "${BLUE}[INFO] Skipping pre-commit hook install: core.hooksPath is set${RESET}\n"; \ + else \ + printf "${BLUE}[INFO] Installing pre-commit hooks...${RESET}\n"; \ + ${UVX_BIN} prek install -c .pre-commit-config.yaml || { printf "${YELLOW}[WARN] Failed to install pre-commit hooks${RESET}\n"; }; \ + fi; \ + fi + + @$(MAKE) post-install + + # Display success message with activation instructions + @printf "\n${GREEN}[SUCCESS] Installation complete!${RESET}\n\n" + @printf "${BLUE}To activate the virtual environment, run:${RESET}\n" + @printf "${YELLOW} source ${VENV}/bin/activate${RESET}\n\n" + +all: fmt deps test docs-coverage security license typecheck rhiza-test ## run all CI targets locally + +# deptry scans one or more folders for dependency issues. Each feature bundle +# contributes the folders it owns to DEPTRY_FOLDERS (and any per-folder ignores +# to DEPTRY_IGNORE), so this target never needs to know which bundles are +# present. The language layer itself contributes SOURCE_FOLDER when it exists; see e.g. +# marimo.mk for a bundle that appends its own folder. Rhiza's own test folder +# (.rhiza/tests) is deliberately excluded: its tooling is provisioned on the fly +# via `uv run --with` in the individual targets, not declared in the project's +# pyproject, so deptry (which validates against pyproject) would only emit noise +# for it. +DEPTRY_FOLDERS ?= +DEPTRY_IGNORE ?= +ifneq ($(wildcard $(SOURCE_FOLDER)),) +DEPTRY_FOLDERS += $(SOURCE_FOLDER) +endif + +# Named `deps`, matching rust.mk and go.mk. This was the one gate whose *target name* +# differed by language (#1474): `deptry` names the tool, which nothing else in the +# contract does — pytest is `test`, mypy is `typecheck`, bandit is `security`. So +# `make deps` used to fail on a Python project and `make deptry` on the other two, and +# nothing language-neutral could invoke the gate without knowing the language first. +# +# The variables stay `DEPTRY_*`: they name the tool's *arguments*, honestly so — +# marimo.mk appends `--ignore DEP004`, a deptry rule code — and they are the accumulator +# interface a downstream local.mk writes to, so renaming them would break consumers for +# nothing. +deps: install-uv ## Run deptry over the folders contributed by each bundle + @if [ -n "$(strip $(DEPTRY_FOLDERS))" ]; then \ + printf "${BLUE}[INFO] Running deptry on:${RESET} $(strip $(DEPTRY_FOLDERS))\n"; \ + $(UVX_BIN) -p ${PYTHON_VERSION} deptry $(strip $(DEPTRY_FOLDERS) $(DEPTRY_IGNORE)); \ + else \ + printf "${YELLOW}[WARN] no deptry folders found, skipping.${RESET}\n"; \ + fi + +# Deprecated alias. A downstream local.mk, a hand-written CI job or a contributor's +# muscle memory all still call `make deptry`; a hard rename would break them at the +# point where the gate simply stops existing. Warns rather than failing, and can go in +# a future release once consumers have moved. +deptry: ## deprecated alias for `deps` + @printf "${YELLOW}[WARN] \`make deptry\` is deprecated and will be removed; use \`make deps\`.${RESET}\n" + @$(MAKE) --no-print-directory deps + +# --ignore-packages takes one or more names and errors on a bare flag, so it is +# omitted entirely when nothing is exempted. +LICENSE_IGNORE_FLAG = $(if $(strip $(LICENSE_IGNORE_PACKAGES)),--ignore-packages $(strip $(LICENSE_IGNORE_PACKAGES)),) + +license: install ## run license compliance scan (fail on GPL, LGPL, AGPL) + @printf "${BLUE}[INFO] Running license compliance scan...${RESET}\n" + @${UV_BIN} run --with pip-licenses pip-licenses \ + --fail-on="${LICENSE_FAIL_ON}" --partial-match ${LICENSE_IGNORE_FLAG} + +##@ Development and Testing + +# The 'test' target runs the complete test suite. +# 1. Cleans up any previous test results in _tests/ and stale coverage data. +# 2. Creates directories for HTML coverage and test reports. +# 3. Invokes pytest via the local virtual environment. +# 4. Generates terminal output, HTML coverage, JSON coverage, and HTML test reports. +# +# Parallel (pytest-xdist) runs occasionally crash *during worker/session +# teardown* even though every test passed — e.g. the xdist +# `worker_workerfinished` KeyError or a pytest-html report-write race. pytest +# signals these runner-internal crashes with exit code 3 (INTERNALERROR), +# which is distinct from real test failures (1), interruptions (2) and usage +# errors (4). We therefore retry the suite once on exit code 3 only, so a +# teardown race no longer flips a green run red, while genuine failures still +# fail immediately. Stale `.coverage*` data is removed before each attempt so a +# previously crashed run cannot leave a corrupt data file that reports a false +# 0% coverage on the next run. +test:: install ## run all tests + @rm -rf _tests + @if [ -z "$$(find ${TESTS_FOLDER} -name 'test_*.py' -o -name '*_test.py' 2>/dev/null)" ]; then \ + printf "${YELLOW}[WARN] No test files found in ${TESTS_FOLDER}, skipping tests.${RESET}\n"; \ + exit 0; \ + fi; \ + if [ -d ${SOURCE_FOLDER} ]; then \ + set -- -n auto \ + --ignore=${TESTS_FOLDER}/benchmarks \ + --ignore=${TESTS_FOLDER}/stress \ + --cov=${SOURCE_FOLDER} \ + --cov-report=term \ + --cov-report=html:_tests/html-coverage \ + --cov-fail-under=$(COVERAGE_FAIL_UNDER) \ + --cov-report=json:_tests/coverage.json \ + --cov-report=xml:_tests/coverage.xml \ + --html=_tests/html-report/report.html; \ + else \ + printf "${YELLOW}[WARN] Source folder ${SOURCE_FOLDER} not found, running tests without coverage${RESET}\n"; \ + set -- -n auto \ + --ignore=${TESTS_FOLDER}/benchmarks \ + --ignore=${TESTS_FOLDER}/stress \ + --html=_tests/html-report/report.html; \ + fi; \ + attempt=1; max_attempts=2; \ + while :; do \ + rm -f .coverage .coverage.* _tests/coverage.xml _tests/coverage.json 2>/dev/null || true; \ + mkdir -p _tests/html-coverage _tests/html-report; \ + ${UV_BIN} run --with pytest --with pytest-cov --with pytest-xdist --with pytest-html --with pytest-timeout --with pytest-mock pytest "$$@"; status=$$?; \ + if [ $$status -ne 3 ]; then exit $$status; fi; \ + if [ $$attempt -ge $$max_attempts ]; then \ + printf "${RED}[ERROR] pytest reported an internal (teardown) error after %s attempts; failing.${RESET}\n" "$$attempt"; \ + exit $$status; \ + fi; \ + printf "${YELLOW}[WARN] pytest exited 3 (xdist/teardown internal error, all tests may have passed); retrying suite (attempt %s/%s)...${RESET}\n" "$$((attempt + 1))" "$$max_attempts"; \ + attempt=$$((attempt + 1)); \ + done + +# The 'typecheck' target runs static type analysis using ty and/or mypy. +# 1. Builds a list of existing Python source folders to check. +# 2. Depending on TYPECHECKER (ty|mypy|both, default: both), runs ty, +# mypy in strict mode, or both in sequence as a cross-check. +typecheck: install ## run ty and/or mypy type checking (TYPECHECKER=ty|mypy|both, default: both) + @typecheck_paths=""; \ + if [ -d "${SOURCE_FOLDER}" ]; then \ + typecheck_paths="${SOURCE_FOLDER}"; \ + fi; \ + if [ -z "$${typecheck_paths}" ]; then \ + printf "${YELLOW}[WARN] No typecheck folders found (SOURCE_FOLDER='${SOURCE_FOLDER}'), skipping typecheck${RESET}\n"; \ + exit 0; \ + fi; \ + case "${TYPECHECKER}" in \ + ty) \ + printf "${BLUE}[INFO] Running ty type checking in:$${typecheck_paths}${RESET}\n"; \ + ${UV_BIN} run --with ty ty check $${typecheck_paths} \ + ;; \ + mypy) \ + printf "${BLUE}[INFO] Running mypy strict type checking in:$${typecheck_paths}${RESET}\n"; \ + ${UV_BIN} run --with mypy mypy --strict $${typecheck_paths} \ + ;; \ + both) \ + printf "${BLUE}[INFO] Running ty type checking in:$${typecheck_paths}${RESET}\n"; \ + ${UV_BIN} run --with ty ty check $${typecheck_paths} && \ + printf "${BLUE}[INFO] Running mypy strict type checking in:$${typecheck_paths}${RESET}\n"; \ + ${UV_BIN} run --with mypy mypy --strict $${typecheck_paths} \ + ;; \ + *) \ + printf "${RED}[ERROR] Invalid TYPECHECKER='${TYPECHECKER}' (expected: ty, mypy, or both)${RESET}\n"; \ + exit 1 \ + ;; \ + esac + +# The 'security' target runs bandit to find common security issues in the +# Python source folders that exist. +security: install ## run security scans (bandit) + @bandit_paths=""; \ + if [ -d "${SOURCE_FOLDER}" ]; then \ + bandit_paths="${SOURCE_FOLDER}"; \ + fi; \ + if [ -n "$${bandit_paths}" ]; then \ + printf "${BLUE}[INFO] Running bandit security scan in:$${bandit_paths}${RESET}\n"; \ + ${UVX_BIN} bandit -r $${bandit_paths} -ll -q --ini .bandit; \ + else \ + printf "${YELLOW}[WARN] No bandit scan folders found (SOURCE_FOLDER='${SOURCE_FOLDER}'), skipping bandit${RESET}\n"; \ + fi + +# The 'docs-coverage' target checks documentation coverage using interrogate. +# 1. Builds a list of existing Python source folders to check. +# 2. Runs interrogate with verbose output against those folders. +docs-coverage: install ## check documentation coverage with interrogate + @docstring_paths=""; \ + if [ -d "${SOURCE_FOLDER}" ]; then \ + docstring_paths="${SOURCE_FOLDER}"; \ + fi; \ + if [ -d "tests" ]; then \ + docstring_paths="$${docstring_paths} tests"; \ + fi; \ + if [ -d ".rhiza/tests" ]; then \ + docstring_paths="$${docstring_paths} .rhiza/tests"; \ + fi; \ + if [ -n "$${docstring_paths}" ]; then \ + printf "${BLUE}[INFO] Checking documentation coverage in:$${docstring_paths}${RESET}\n"; \ + ${UV_BIN} run --with interrogate interrogate -vv --fail-under 100 --ignore-init-method --ignore-magic $${docstring_paths}; \ + else \ + printf "${YELLOW}[WARN] No docs-coverage folders found (SOURCE_FOLDER='${SOURCE_FOLDER}'), skipping docs-coverage${RESET}\n"; \ + fi + +test-pyproject: install ## run pyproject.toml structure tests + @${UV_BIN} run --with pytest pytest .rhiza/tests/test_pyproject.py \ + -v \ + --tb=long \ + --showlocals \ + -rA \ + --durations=0 \ + --no-header diff --git a/.rhiza/make.d/quality.mk b/.rhiza/make.d/quality.mk index 7504828..a3047bd 100644 --- a/.rhiza/make.d/quality.mk +++ b/.rhiza/make.d/quality.mk @@ -1,30 +1,36 @@ ## .rhiza/make.d/quality.mk - Quality and Formatting -# This file provides targets for code quality checks, linting, and formatting. - -# Configurable list of licenses that fail the compliance scan (semicolon-separated) -LICENSE_FAIL_ON ?= GPL;LGPL;AGPL +# The language-neutral gates: pre-commit, the TODO sweep, semgrep, and the runner for +# the template's own test suite. Everything that needs to know how the project declares +# its dependencies — `deptry`, the licence-compliance scan — and the `all` aggregate that +# names the per-language gates live in the language layer (python.mk, from the +# python-core bundle). # Declare phony targets (they don't produce files) -.PHONY: all deptry fmt license todos suppression-audit semgrep +.PHONY: fmt todos semgrep rhiza-test ##@ Quality and Formatting -all: fmt deptry test docs-coverage security license typecheck rhiza-test ## run all CI targets locally - -deptry: install-uv ## Run deptry - @if [ -d ${SOURCE_FOLDER} ]; then \ - $(UVX_BIN) -p ${PYTHON_VERSION} deptry ${SOURCE_FOLDER}; \ - fi - - @if [ -d ${MARIMO_FOLDER} ]; then \ - if [ -d ${SOURCE_FOLDER} ]; then \ - $(UVX_BIN) -p ${PYTHON_VERSION} deptry ${MARIMO_FOLDER} ${SOURCE_FOLDER} --ignore DEP004; \ - else \ - $(UVX_BIN) -p ${PYTHON_VERSION} deptry ${MARIMO_FOLDER} --ignore DEP004; \ - fi \ - fi - +# prek rather than pre-commit: a Rust reimplementation that reads the same +# `.pre-commit-config.yaml` and needs no Python of its own. Two consequences, one +# gained and one that has to be asked for. +# +# Gained: the `-p ${PYTHON_VERSION}` this recipe used to carry is gone. That flag +# existed because `uvx pre-commit` had to choose an interpreter to run pre-commit +# *itself* on, and a Rust or Go project ships no `.python-version` — so the whole +# language-neutral half of the template rested on rhiza.mk's fallback resolving to +# something real. prek is a binary and provisions each hook's toolchain itself, so the +# coupling is removed rather than merely satisfied. +# +# Asked for: `--config`. By default prek treats every directory below the root that +# holds a `.pre-commit-config.yaml` as a separate *project* and runs each one's hooks — +# useful in a monorepo, surprising anywhere else, and wrong in rhiza's own repo, where +# `bundles/{python,rust,go}-core` each ship one as template content. (go-core's hooks +# then run `go vet ./...` in a directory with no `go.mod` and fail.) Naming the config +# explicitly disables that discovery, so `make fmt` means exactly what it meant under +# pre-commit: this repo's config, once. A consumer who wants the monorepo behaviour +# drops the flag. `.prekignore` is documented for the same job but is not honoured by +# prek 0.4.12, so it is not what this relies on. fmt: install-uv ## check the pre-commit hooks and the linting - @${UVX_BIN} -p ${PYTHON_VERSION} pre-commit run --all-files + @${UVX_BIN} prek run --all-files --config .pre-commit-config.yaml todos: ## search and report all TODO/FIXME/HACK comments in the codebase @printf "${BLUE}[INFO] Searching for TODO, FIXME, and HACK comments...${RESET}\n" @@ -42,10 +48,6 @@ todos: ## search and report all TODO/FIXME/HACK comments in the codebase printf "${GREEN}[SUCCESS] No TODO/FIXME/HACK comments found!${RESET}\n" @printf "\n${BLUE}[INFO] Search complete.${RESET}\n" -suppression-audit: ## scan codebase for inline suppressions and report (grade, detail, histogram) - @printf "${BLUE}[INFO] Running suppression audit...${RESET}\n" - @${UV_BIN} run python .rhiza/utils/suppression_audit.py - semgrep: install ## run Semgrep static analysis @printf "${BLUE}[INFO] Running Semgrep...${RESET}\n" @if [ -d ${SOURCE_FOLDER} ]; then \ @@ -54,6 +56,24 @@ semgrep: install ## run Semgrep static analysis printf "${YELLOW}[WARN] SOURCE_FOLDER '${SOURCE_FOLDER}' not found, skipping semgrep.${RESET}\n"; \ fi -license: install ## run license compliance scan (fail on GPL, LGPL, AGPL) - @printf "${BLUE}[INFO] Running license compliance scan...${RESET}\n" - @${UV_BIN} run --with pip-licenses pip-licenses --fail-on="${LICENSE_FAIL_ON}" +# Owned by core, not by a language layer, because nothing about it is language-specific: +# `.rhiza/tests` validates the *template* — READMEs, release config, YAML — and runs +# under pytest whatever the project is written in. `uv` lives in core for the same +# reason. Each layer contributes its own modules to the suite (test_pyproject.py, +# test_cargo_toml.py, test_go_module.py) while the runner stays here, so the recipe +# exists once rather than identically in all three (#1471). +# +# `install` is a deliberate exception to core's rule of never naming a layer-owned +# target. It is a *prerequisite* here, not a definition, and the suite needs it: the +# shipped test_docstrings.py imports the project's own packages to run their doctests, +# which requires the dependencies installed. Make resolves it because every fragment is +# included into one namespace, and every profile selects exactly one language layer — +# `test_a_profile_never_selects_two_bundles_from_one_layer` and +# `test_every_profile_selects_a_language_layer` bracket that from both sides. A +# core-only tree is not a shipped configuration; there, this fails naming `install`. +rhiza-test: install ## run rhiza's own tests (if any) + @if [ -d ".rhiza/tests" ]; then \ + ${UV_BIN} run --with pytest --with pytest-timeout --with python-dotenv --with packaging pytest .rhiza/tests; \ + else \ + printf "${YELLOW}[WARN] No .rhiza/tests directory found, skipping rhiza-tests${RESET}\n"; \ + fi diff --git a/.rhiza/make.d/releasing.mk b/.rhiza/make.d/releasing.mk deleted file mode 100644 index 5dee330..0000000 --- a/.rhiza/make.d/releasing.mk +++ /dev/null @@ -1,50 +0,0 @@ -## .rhiza/make.d/releasing.mk - Releasing and Versioning -# This file provides targets for version bumping and release management. - -# Declare phony targets (they don't produce files) -.PHONY: bump release publish release-status pre-bump post-bump pre-release post-release - -# Hook targets (double-colon rules allow multiple definitions) -pre-bump:: ; @: -post-bump:: ; @: -pre-release:: ; @: -post-release:: ; @: - -# DRY_RUN support: pass DRY_RUN=1 to preview changes without applying them -_DRY_RUN_FLAG := $(if $(DRY_RUN),--dry-run,) -_VERSION=0.5.1 - -##@ Releasing and Versioning -bump: pre-bump ## bump version of the project (supports DRY_RUN=1) - @if [ -f "pyproject.toml" ]; then \ - $(MAKE) install; \ - PATH="$(abspath ${VENV})/bin:$$PATH" ${UVX_BIN} "rhiza-tools>=$(_VERSION)" bump $(_DRY_RUN_FLAG); \ - if [ -z "$(DRY_RUN)" ]; then \ - printf "${BLUE}[INFO] Checking uv.lock file...${RESET}\n"; \ - ${UV_BIN} lock; \ - fi; \ - else \ - printf "${YELLOW}[WARN] No pyproject.toml found, skipping bump${RESET}\n"; \ - fi - @$(MAKE) post-bump - -release: pre-release install-uv ## create tag and push to remote repository triggering release workflow (supports DRY_RUN=1) - ${UVX_BIN} "rhiza-tools>=$(_VERSION)" release $(_DRY_RUN_FLAG); - @$(MAKE) post-release - -publish: pre-release install-uv ## bump version, create tag and push in one step (supports DRY_RUN=1) - ${UVX_BIN} "rhiza-tools>=$(_VERSION)" release --with-bump $(_DRY_RUN_FLAG); - @$(MAKE) post-release - -release-status: ## show release workflow status and latest release information -ifeq ($(FORGE_TYPE),github) - @{ $(MAKE) --no-print-directory workflow-status; printf "\n"; $(MAKE) --no-print-directory latest-release; } 2>&1 | $${PAGER:-less -R} -else ifeq ($(FORGE_TYPE),gitlab) - @printf "${YELLOW}[WARN] GitLab detected — release-status is not yet supported for GitLab repositories.${RESET}\n" - @printf "${BLUE}[INFO] Please check your pipeline status in the GitLab UI.${RESET}\n" -else - @printf "${RED}[ERROR] Could not detect forge type (.github/workflows/ or .gitlab-ci.yml not found)${RESET}\n" -endif - - - diff --git a/.rhiza/make.d/test.mk b/.rhiza/make.d/test.mk index 3af2847..8792aab 100644 --- a/.rhiza/make.d/test.mk +++ b/.rhiza/make.d/test.mk @@ -1,107 +1,43 @@ -## Makefile.tests - Testing and benchmarking targets +## .rhiza/make.d/test.mk - optional Python testing extras (bundle: tests) # This file is included by the main Makefile. -# It provides targets for running the test suite with coverage and -# executing performance benchmarks. +# +# The gates the language contract names — `test`, `typecheck`, `security`, +# `docs-coverage` — moved into `python.mk` in #1475, because `python.mk`'s own `all` +# named them while this bundle owned them: a project syncing `core + python-core` +# without `tests` got an `all` that could not run. The Rust and Go layers never had +# that split, and now neither does Python. +# +# What is left here is what genuinely *is* optional — four gates no layer's `all` +# depends on, each of which needs its own tool and its own folder convention: +# benchmarks, Hypothesis property tests, stress/load tests and mutation testing. +# Keeping them out of the layer is what lets a project take the Python gate set +# without also declaring an opinion on mutation testing. +# +# `TESTS_FOLDER`, and the shared pytest config in `pytest.ini`, come from +# `python.mk` / `python-core`. This bundle requires `python-core`, so both are always +# present alongside it. # Declare phony targets (they don't produce files) -.PHONY: test benchmark typecheck security docs-coverage hypothesis-test coverage-badge stress test-pyproject mutation +.PHONY: benchmark hypothesis-test stress mutation -# Default directory for tests -TESTS_FOLDER := tests - -# Minimum coverage percent for tests to pass -# (Can be overridden in local.mk or via environment variable) -COVERAGE_FAIL_UNDER ?= 90 - -##@ Development and Testing - -# The 'test' target runs the complete test suite. -# 1. Cleans up any previous test results in _tests/. -# 2. Creates directories for HTML coverage and test reports. -# 3. Invokes pytest via the local virtual environment. -# 4. Generates terminal output, HTML coverage, JSON coverage, and HTML test reports. -test:: install ## run all tests - @rm -rf _tests; - - if [ -z "$$(find ${TESTS_FOLDER} -name 'test_*.py' -o -name '*_test.py' 2>/dev/null)" ]; then \ - printf "${YELLOW}[WARN] No test files found in ${TESTS_FOLDER}, skipping tests.${RESET}\n"; \ - exit 0; \ - fi; \ - mkdir -p _tests/html-coverage _tests/html-report; \ - if [ -d ${SOURCE_FOLDER} ]; then \ - ${UV_BIN} run pytest \ - -n auto \ - --ignore=${TESTS_FOLDER}/benchmarks \ - --ignore=${TESTS_FOLDER}/stress \ - --cov=${SOURCE_FOLDER} \ - --cov-report=term \ - --cov-report=html:_tests/html-coverage \ - --cov-fail-under=$(COVERAGE_FAIL_UNDER) \ - --cov-report=json:_tests/coverage.json \ - --cov-report=xml:_tests/coverage.xml \ - --html=_tests/html-report/report.html; \ - else \ - printf "${YELLOW}[WARN] Source folder ${SOURCE_FOLDER} not found, running tests without coverage${RESET}\n"; \ - ${UV_BIN} run pytest \ - -n auto \ - --ignore=${TESTS_FOLDER}/benchmarks \ - --ignore=${TESTS_FOLDER}/stress \ - --html=_tests/html-report/report.html; \ - fi - -# The 'typecheck' target runs static type analysis using ty. -# 1. Checks if the source directory exists. -# 2. Runs ty on the source folder. -typecheck: install ## run ty type checking - @if [ -d ${SOURCE_FOLDER} ]; then \ - printf "${BLUE}[INFO] Running ty type checking...${RESET}\n"; \ - ${UV_BIN} run ty check ${SOURCE_FOLDER}; \ - else \ - printf "${YELLOW}[WARN] Source folder ${SOURCE_FOLDER} not found, skipping typecheck${RESET}\n"; \ - fi - -# Extra flags forwarded to pip-audit (e.g. --ignore-vuln CVE-XXXX-YYYY) -PIP_AUDIT_ARGS ?= - -# The 'security' target performs security vulnerability scans. -# 1. Runs pip-audit via pip_audit_policy.py: fails on runtime dep CVEs, warns on tooling (pip/setuptools/wheel). -# 2. Runs bandit to find common security issues in the source code. -security: install ## run security scans (pip-audit and bandit) - @printf "${BLUE}[INFO] Running pip-audit for dependency vulnerabilities...${RESET}\n" - @${UV_BIN} run python .rhiza/utils/pip_audit_policy.py ${PIP_AUDIT_ARGS} - @printf "${BLUE}[INFO] Running bandit security scan...${RESET}\n" - @${UVX_BIN} bandit -r ${SOURCE_FOLDER} -ll -q --ini .bandit +##@ Development and Testing (extras) # The 'benchmark' target runs performance benchmarks using pytest-benchmark. # 1. Installs benchmarking dependencies (pytest-benchmark, pygal). # 2. Executes benchmarks found in the benchmarks/ subfolder. # 3. Generates histograms and JSON results. -# 4. Runs a post-analysis script to process the results. benchmark:: install ## run performance benchmarks @if [ -d "${TESTS_FOLDER}/benchmarks" ]; then \ printf "${BLUE}[INFO] Running performance benchmarks...${RESET}\n"; \ - ${UV_BIN} pip install pytest-benchmark==5.2.3 pygal==3.1.0; \ mkdir -p _tests/benchmarks; \ - ${UV_BIN} run pytest "${TESTS_FOLDER}/benchmarks/" \ + ${UV_BIN} run --with pytest --with pytest-benchmark==5.2.3 --with pygal==3.1.0 pytest "${TESTS_FOLDER}/benchmarks/" \ --benchmark-only \ --benchmark-histogram=_tests/benchmarks/histogram \ --benchmark-json=_tests/benchmarks/results.json; \ - ${UVX_BIN} "rhiza-tools>=0.2.3" analyze-benchmarks --benchmarks-json _tests/benchmarks/results.json --output-html _tests/benchmarks/report.html; \ else \ printf "${YELLOW}[WARN] Benchmarks folder not found, skipping benchmarks${RESET}\n"; \ fi -# The 'docs-coverage' target checks documentation coverage using interrogate. -# 1. Checks if SOURCE_FOLDER exists. -# 2. Runs interrogate on the source folder with verbose output. -docs-coverage: install ## check documentation coverage with interrogate - @if [ -d "${SOURCE_FOLDER}" ]; then \ - printf "${BLUE}[INFO] Checking documentation coverage in ${SOURCE_FOLDER}...${RESET}\n"; \ - ${UV_BIN} run interrogate -vv --fail-under 100 --ignore-init-method --ignore-magic ${SOURCE_FOLDER}; \ - else \ - printf "${YELLOW}[WARN] Source folder ${SOURCE_FOLDER} not found, skipping docs-coverage${RESET}\n"; \ - fi - # The 'hypothesis-test' target runs property-based tests using Hypothesis. # 1. Checks if hypothesis tests exist in the tests directory. # 2. Runs pytest with hypothesis-specific settings and statistics. @@ -113,7 +49,7 @@ hypothesis-test:: install ## run property-based tests with Hypothesis fi; \ printf "${BLUE}[INFO] Running Hypothesis property-based tests...${RESET}\n"; \ mkdir -p _tests/hypothesis; \ - PYTEST_HTML_TITLE="Hypothesis tests" ${UV_BIN} run pytest \ + PYTEST_HTML_TITLE="Hypothesis tests" ${UV_BIN} run --with pytest --with hypothesis --with pytest-html pytest \ --ignore=${TESTS_FOLDER}/benchmarks \ -v \ --hypothesis-show-statistics \ @@ -139,7 +75,7 @@ stress:: install ## run stress/load tests fi; \ printf "${BLUE}[INFO] Running stress/load tests...${RESET}\n"; \ mkdir -p _tests/stress; \ - ${UV_BIN} run pytest \ + ${UV_BIN} run --with pytest --with pytest-html pytest \ -v \ -m stress \ --tb=short \ @@ -153,20 +89,11 @@ mutation: install ## run mutation tests with mutmut printf "${BLUE}[INFO] Running mutation tests on ${SOURCE_FOLDER}...${RESET}\n"; \ mkdir -p _tests/mutation; \ run_status=0; \ - ${UV_BIN} run mutmut run \ + ${UV_BIN} run --with mutmut mutmut run \ --paths-to-mutate="${SOURCE_FOLDER}" \ --tests-dir="${TESTS_FOLDER}" || run_status=$$?; \ - ${UV_BIN} run mutmut html || exit $$?; \ + ${UV_BIN} run --with mutmut mutmut html || exit $$?; \ rm -rf _tests/mutation/html; \ mv html _tests/mutation/html || exit $$?; \ - ${UV_BIN} run mutmut results || exit $$?; \ + ${UV_BIN} run --with mutmut mutmut results || exit $$?; \ exit $$run_status - -test-pyproject: install ## run pyproject.toml structure tests - @${UV_BIN} run pytest .rhiza/tests/structure/test_pyproject.py \ - -v \ - --tb=long \ - --showlocals \ - -rA \ - --durations=0 \ - --no-header diff --git a/.rhiza/requirements/README.md b/.rhiza/requirements/README.md deleted file mode 100644 index b98278a..0000000 --- a/.rhiza/requirements/README.md +++ /dev/null @@ -1,27 +0,0 @@ -# Requirements Folder - -This folder contains the development dependencies for the Rhiza project, organized by purpose. - -## Files - -- **tests.txt** - Testing dependencies (pytest, pytest-cov, pytest-html, pytest-mock, PyYAML, defusedxml, hypothesis, pytest-benchmark, pygal) -- **marimo.txt** - Marimo notebook dependencies -- **docs.txt** - Documentation generation dependencies (interrogate, mkdocs, mkdocs-material, mkdocstrings) -- **tools.txt** - Development tools (pre-commit, python-dotenv, typer, ty) - -## Usage - -These requirements files are automatically installed by the `make install` command. - -To install specific requirement files manually: - -```bash -uv pip install -r .rhiza/requirements/tests.txt -uv pip install -r .rhiza/requirements/marimo.txt -uv pip install -r .rhiza/requirements/docs.txt -uv pip install -r .rhiza/requirements/tools.txt -``` - -## CI/CD - -GitHub Actions workflows automatically install these requirements as needed. diff --git a/.rhiza/requirements/docs.txt b/.rhiza/requirements/docs.txt deleted file mode 100644 index bbded50..0000000 --- a/.rhiza/requirements/docs.txt +++ /dev/null @@ -1,4 +0,0 @@ -# Documentation dependencies for rhiza -interrogate>=1.7.0 -mike>=2.2.0 -zensical>=0.0.33 diff --git a/.rhiza/requirements/marimo.txt b/.rhiza/requirements/marimo.txt deleted file mode 100644 index 032c872..0000000 --- a/.rhiza/requirements/marimo.txt +++ /dev/null @@ -1,2 +0,0 @@ -# Marimo dependencies for rhiza -marimo>=0.18.0 diff --git a/.rhiza/requirements/tests.txt b/.rhiza/requirements/tests.txt deleted file mode 100644 index 96cc631..0000000 --- a/.rhiza/requirements/tests.txt +++ /dev/null @@ -1,18 +0,0 @@ -# Test dependencies for rhiza -pytest>=8.0 -python-dotenv>=1.0 -pytest-cov>=6.0 -pytest-html>=4.0 -pytest-mock>=3.0 -pytest-xdist>=3.0 -pytest-timeout>=2.0 -PyYAML>=6.0 -defusedxml>=0.7.0 -mutmut>=2.0,<3.0 - -# For property-based testing -hypothesis>=6.150.0 - -# For benchmarks -pytest-benchmark>=5.2.3 -pygal>=3.1.0 diff --git a/.rhiza/requirements/tools.txt b/.rhiza/requirements/tools.txt deleted file mode 100644 index 92bbda1..0000000 --- a/.rhiza/requirements/tools.txt +++ /dev/null @@ -1,7 +0,0 @@ -# Development tool dependencies for rhiza -pre-commit==4.5.1 -python-dotenv==1.2.2 - -# for now needed until rhiza-tools is finished -typer==0.21.1 -ty>=0.0.30 diff --git a/.rhiza/rhiza.mk b/.rhiza/rhiza.mk index 19c57dd..6d27a3f 100644 --- a/.rhiza/rhiza.mk +++ b/.rhiza/rhiza.mk @@ -10,6 +10,37 @@ ifndef MAKE_VERSION $(error GNU Make is required. macOS ships BSD make — install GNU Make with: brew install make) endif +# Require a working POSIX shell. +# Recipes use POSIX shell syntax (mkdir -p, printf, [ ... ], curl | sh). GNU Make +# runs recipes with sh when it finds one on PATH -- even when make itself was +# launched from PowerShell or cmd -- so e.g. CI on windows-latest (which ships +# Git's sh.exe) works fine. Only when no POSIX shell is found does make fall back +# to cmd.exe, where the recipes fail on the first non-cmd line with errors like: +# process_begin: CreateProcess(NULL, # Ensure ... folder exists, ...) failed. +# So probe the shell make actually uses rather than guessing from the OS: run a +# POSIX command and require its output. Guarded by $(OS) so the probe only runs +# on Windows (no subprocess cost on Linux/macOS/WSL, where the shell is POSIX). +ifeq ($(OS),Windows_NT) +ifneq ($(shell printf POSIX),POSIX) +define RHIZA_WINDOWS_SHELL_ERROR + + This project's Makefile requires a POSIX shell, but make could not find one + and fell back to cmd.exe, which is not supported. + + Run 'make' from an environment that provides a POSIX shell, e.g.: + - WSL (recommended): https://learn.microsoft.com/windows/wsl/install + - Git Bash: bundled with Git for Windows (https://git-scm.com/download/win) + + Tip: if 'sh.exe' (from Git for Windows) is on your PATH, make will use it + automatically even from PowerShell or cmd. + + See README.md > Prerequisites for details. + +endef +$(error $(RHIZA_WINDOWS_SHELL_ERROR)) +endif +endif + # Colours for pretty output in help messages BLUE := \033[36m BOLD := \033[1m @@ -24,53 +55,60 @@ RESET := \033[0m # Declare phony targets (they don't produce files) .PHONY: \ help \ - post-bump \ post-install \ - post-release \ - post-sync \ - post-validate \ - pre-bump \ pre-install \ - pre-release \ - pre-sync \ - pre-validate \ print-logo \ readme \ - summarise-sync \ - sync \ - validate \ version-matrix \ ci-os-matrix +# --------------------------------------------------------------------------- +# uv as a *tool runner*, not as a language choice. +# +# Rhiza reaches for uvx to run pre-commit, mkdocs, semgrep and the rest whatever +# the project is written in, so provisioning it belongs to core. What is Python- +# specific — the project virtualenv, the `install` target that syncs it, and the +# project's own interpreter version — lives in the language layer +# (`bundles/python-core`), which ships `.rhiza/make.d/python.mk`. +# --------------------------------------------------------------------------- + # we need absolute paths! INSTALL_DIR ?= $(abspath ./bin) UV_BIN ?= $(shell command -v uv 2>/dev/null || echo ${INSTALL_DIR}/uv) UVX_BIN ?= $(shell command -v uvx 2>/dev/null || echo ${INSTALL_DIR}/uvx) -VENV ?= .venv -# Read Python version from .python-version (single source of truth) +# The interpreter rhiza's own tooling runs on. A Python project overrides this +# from `.python-version` (see python.mk, which makes it the project's version +# too); anywhere else the fallback is simply the Python uvx provisions. PYTHON_VERSION ?= $(strip $(shell cat .python-version 2>/dev/null || echo "3.13")) export PYTHON_VERSION -# Read Rhiza version from .rhiza/.rhiza-version (single source of truth for rhiza-tools) -RHIZA_VERSION ?= $(shell cat .rhiza/.rhiza-version 2>/dev/null || echo "0.10.2") -export RHIZA_VERSION - -# Default sync schedule (cron expression for GitHub Actions sync workflow) -# Override in your root Makefile to customise when sync runs. -# Example: RHIZA_SYNC_SCHEDULE = 0 9 * * 1-5 (weekdays at 9 AM UTC) -RHIZA_SYNC_SCHEDULE ?= 0 0 * * 1 - export UV_NO_MODIFY_PATH := 1 -export UV_VENV_CLEAR := 1 # Unset VIRTUAL_ENV to prevent uv from warning about path mismatches # when a virtual environment is already activated in the shell unexport VIRTUAL_ENV # Load .rhiza/.env (if present) and export its variables so recipes see them. +# This file is optional — sensible defaults are defined below. -include .rhiza/.env +# --------------------------------------------------------------------------- +# Default values for variables that may be set in .rhiza/.env. +# These ?= assignments are skipped when the variable is already defined by +# the included file, by an environment variable, or by the root Makefile. +# --------------------------------------------------------------------------- + +# Directory that holds the project's Python source package(s). +SOURCE_FOLDER ?= src + +# Directory that holds Marimo notebooks (used by marimo.mk and book.mk). +MARIMO_FOLDER ?= docs/notebooks + +# JSON array of GitHub Actions runner OS labels used by the CI matrix. +# Override in .rhiza/.env or your root Makefile to add more platforms. +RHIZA_CI_OS_MATRIX ?= ["ubuntu-latest"] + # ============================================================================== # Rhiza Core # ============================================================================== @@ -87,15 +125,7 @@ endef export RHIZA_LOGO # Declare phony targets for Rhiza Core -.PHONY: print-logo sync sync-experimental materialize validate readme pre-sync post-sync pre-validate post-validate _apply-sync-schedule - -# Hook targets (double-colon rules allow multiple definitions) -# Note: pre-install/post-install are defined in bootstrap.mk -# Note: pre-bump/post-bump/pre-release/post-release are defined in releasing.mk -pre-sync:: ; @: -post-sync:: ; @: -pre-validate:: ; @: -post-validate:: ; @: +.PHONY: print-logo ##@ Rhiza Workflows @@ -103,68 +133,17 @@ print-logo: @printf "${BLUE}$$RHIZA_LOGO${RESET}\n" -sync: pre-sync ## sync with template repository as defined in .rhiza/template.yml - @if git remote get-url origin 2>/dev/null | grep -iqE 'jebel-quant/rhiza(\.git)?$$'; then \ - printf "${BLUE}[INFO] Skipping sync in rhiza repository (no template.yml by design)${RESET}\n"; \ - else \ - $(MAKE) install-uv && \ - ${UVX_BIN} "rhiza==$(RHIZA_VERSION)" sync . && \ - $(MAKE) _apply-sync-schedule; \ - fi - @$(MAKE) post-sync - -_apply-sync-schedule: ## (internal) apply RHIZA_SYNC_SCHEDULE override to GitHub Actions sync workflow - @if [ "$(RHIZA_SYNC_SCHEDULE)" != "0 0 * * 1" ] && [ -f .github/workflows/rhiza_sync.yml ]; then \ - sed -i.bak "s|cron: '[^']*'|cron: '$(RHIZA_SYNC_SCHEDULE)'|" .github/workflows/rhiza_sync.yml && rm -f .github/workflows/rhiza_sync.yml.bak; \ - printf "${BLUE}[INFO] Applied custom sync schedule: $(RHIZA_SYNC_SCHEDULE)${RESET}\n"; \ - fi - -materialize: ## [DEPRECATED] use 'make sync' instead — materialize --force is now sync - @printf "${YELLOW}[WARN] 'make materialize' is deprecated and will be removed in a future release.${RESET}\n" - @printf "${YELLOW}[WARN] Please use 'make sync' instead (e.g. 'materialize --force' is now 'make sync').${RESET}\n" - @$(MAKE) sync - -summarise-sync: install-uv ## summarise differences created by sync with template repository - @if git remote get-url origin 2>/dev/null | grep -iqE 'jebel-quant/rhiza(\.git)?$$'; then \ - printf "${BLUE}[INFO] Skipping summarise-sync in rhiza repository (no template.yml by design)${RESET}\n"; \ - else \ - $(MAKE) install-uv; \ - ${UVX_BIN} "rhiza==$(RHIZA_VERSION)" summarise .; \ - fi - -rhiza-test: install ## run rhiza's own tests (if any) - @if [ -d ".rhiza/tests" ]; then \ - ${UV_BIN} run pytest .rhiza/tests; \ - else \ - printf "${YELLOW}[WARN] No .rhiza/tests directory found, skipping rhiza-tests${RESET}\n"; \ - fi - -validate: pre-validate rhiza-test ## validate project structure against template repository as defined in .rhiza/template.yml - @if git remote get-url origin 2>/dev/null | grep -iqE 'jebel-quant/rhiza(\.git)?$$'; then \ - printf "${BLUE}[INFO] Skipping validate in rhiza repository (no template.yml by design)${RESET}\n"; \ - else \ - $(MAKE) install-uv; \ - ${UVX_BIN} "rhiza==$(RHIZA_VERSION)" validate .; \ - fi - @$(MAKE) post-validate - -readme: install-uv ## update README.md with current Makefile help output - @${UVX_BIN} "rhiza-tools>=0.2.0" update-readme - ##@ Meta help: print-logo ## Display this help message +@printf "$(BOLD)Usage:$(RESET)\n" +@printf " make $(BLUE)$(RESET)\n\n" +@printf "$(BOLD)Targets:$(RESET)\n" - +@awk 'BEGIN {FS = ":.*##"; printf ""} /^[a-zA-Z_-]+:.*?##/ { printf " $(BLUE)%-20s$(RESET) %s\n", $$1, $$2 } /^##@/ { printf "\n$(BOLD)%s$(RESET)\n", substr($$0, 5) }' $(MAKEFILE_LIST) + +@awk 'BEGIN {FS = ":.*##"; printf ""} /^[a-zA-Z0-9_-]+:.*?##/ { printf " $(BLUE)%-20s$(RESET) %s\n", $$1, $$2 } /^##@/ { printf "\n$(BOLD)%s$(RESET)\n", substr($$0, 5) }' $(MAKEFILE_LIST) +@printf "\n" -version-matrix: install-uv ## Emit the list of supported Python versions from pyproject.toml - @${UVX_BIN} "rhiza-tools>=0.2.2" version-matrix - ci-os-matrix: ## Emit GitHub CI OSes (RHIZA_CI_OS_MATRIX as JSON array, default ["ubuntu-latest"]) - @printf '%s\n' '$(or $(RHIZA_CI_OS_MATRIX),["ubuntu-latest"])' + @$(info $(or $(RHIZA_CI_OS_MATRIX),["ubuntu-latest"])) print-% : ## print the value of a variable (usage: make print-VARIABLE) @printf "${BLUE}[INFO] Printing value of variable '$*':${RESET}\n" diff --git a/Makefile b/Makefile index e0c6738..c328337 100644 --- a/Makefile +++ b/Makefile @@ -1,12 +1,10 @@ ## Makefile (repo-owned) # Keep this file small. It can be edited without breaking template sync. -DEFAULT_AI_MODEL=claude-sonnet-4.6 LOGO_FILE=.rhiza/assets/rhiza-logo.svg -GH_AW_ENGINE ?= copilot # Default AI engine for gh-aw workflows (copilot, claude, or codex) -# Override template default: fix quoting bug and typo (mkdocstring -> mkdocstrings) -MKDOCS_EXTRA_PACKAGES = --with-editable . --with 'mkdocstrings[python]' +# Override template default: include mkdocstrings plugin for API docs +MKDOCS_EXTRA_PACKAGES = --with 'mkdocstrings[python]' # Always include the Rhiza API (template-managed) include .rhiza/rhiza.mk