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) 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