From 840d0a07730f53f7732007a4aeffcb2b9b747dd5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 27 Jan 2026 04:22:43 +0000 Subject: [PATCH 01/14] Initial plan From ec8894af13dadeb5a6cfaadad25d1bb13edf1ef0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 27 Jan 2026 04:26:27 +0000 Subject: [PATCH 02/14] Add versions command to extract supported Python versions from pyproject.toml Co-authored-by: tschm <2046079+tschm@users.noreply.github.com> --- src/rhiza/cli.py | 26 ++++ src/rhiza/commands/versions.py | 187 +++++++++++++++++++++++++ tests/test_commands/test_versions.py | 202 +++++++++++++++++++++++++++ 3 files changed, 415 insertions(+) create mode 100644 src/rhiza/commands/versions.py create mode 100644 tests/test_commands/test_versions.py diff --git a/src/rhiza/cli.py b/src/rhiza/cli.py index 1062ef0..ae4e763 100644 --- a/src/rhiza/cli.py +++ b/src/rhiza/cli.py @@ -16,6 +16,7 @@ from rhiza.commands.migrate import migrate as migrate_cmd from rhiza.commands.summarise import summarise as summarise_cmd from rhiza.commands.uninstall import uninstall as uninstall_cmd +from rhiza.commands.versions import versions as versions_cmd from rhiza.commands.welcome import welcome as welcome_cmd app = typer.Typer( @@ -353,3 +354,28 @@ def summarise( gh pr create --title "chore: Sync with rhiza" --body-file pr-body.md """ summarise_cmd(target, output) + + +@app.command() +def versions( + target: Annotated[ + Path, + typer.Argument( + help="Path to pyproject.toml or directory containing it (defaults to current directory)", + ), + ] = Path("."), +) -> None: + r"""Extract supported Python versions from pyproject.toml. + + Reads the 'requires-python' field from pyproject.toml and outputs a JSON + list of supported Python versions that satisfy the version constraint. + + This command evaluates candidate versions (3.11, 3.12, 3.13, 3.14) against + the version specifier in pyproject.toml and returns those that match. + + Examples: + rhiza versions + rhiza versions /path/to/project + rhiza versions /path/to/project/pyproject.toml + """ + versions_cmd(target) diff --git a/src/rhiza/commands/versions.py b/src/rhiza/commands/versions.py new file mode 100644 index 0000000..3ed56a2 --- /dev/null +++ b/src/rhiza/commands/versions.py @@ -0,0 +1,187 @@ +"""Command for extracting supported Python versions from pyproject.toml. + +This module provides functionality to read a pyproject.toml file and determine +which Python versions are supported based on the requires-python field. +""" + +import json +import re +import tomllib +from pathlib import Path + +from loguru import logger + + +class RhizaError(Exception): + """Base exception for Rhiza-related errors.""" + + +class VersionSpecifierError(RhizaError): + """Raised when a version string or specifier is invalid.""" + + +class PyProjectError(RhizaError): + """Raised when there are issues with pyproject.toml configuration.""" + + +CANDIDATES = ["3.11", "3.12", "3.13", "3.14"] # extend as needed + + +def parse_version(v: str) -> tuple[int, ...]: + """Parse a version string into a tuple of integers. + + This is intentionally simple and only supports numeric components. + If a component contains non-numeric suffixes (e.g. '3.11.0rc1'), + the leading numeric portion will be used (e.g. '0rc1' -> 0). If a + component has no leading digits at all, a VersionSpecifierError is raised. + + Args: + v: Version string to parse (e.g., "3.11", "3.11.0rc1"). + + Returns: + Tuple of integers representing the version. + + Raises: + VersionSpecifierError: If a version component has no numeric prefix. + """ + parts: list[int] = [] + for part in v.split("."): + match = re.match(r"\d+", part) + if not match: + msg = f"Invalid version component {part!r} in version {v!r}; expected a numeric prefix." + raise VersionSpecifierError(msg) + parts.append(int(match.group(0))) + return tuple(parts) + + +def _check_operator(version_tuple: tuple[int, ...], op: str, spec_v_tuple: tuple[int, ...]) -> bool: + """Check if a version tuple satisfies an operator constraint.""" + operators = { + ">=": lambda v, s: v >= s, + "<=": lambda v, s: v <= s, + ">": lambda v, s: v > s, + "<": lambda v, s: v < s, + "==": lambda v, s: v == s, + "!=": lambda v, s: v != s, + } + return operators[op](version_tuple, spec_v_tuple) + + +def satisfies(version: str, specifier: str) -> bool: + """Check if a version satisfies a comma-separated list of specifiers. + + This is a simplified version of packaging.specifiers.SpecifierSet. + Supported operators: >=, <=, >, <, ==, != + + Args: + version: Version string to check (e.g., "3.11"). + specifier: Comma-separated specifier string (e.g., ">=3.11,<3.14"). + + Returns: + True if the version satisfies all specifiers, False otherwise. + + Raises: + VersionSpecifierError: If the specifier format is invalid. + """ + version_tuple = parse_version(version) + + # Split by comma for multiple constraints + for spec in specifier.split(","): + spec = spec.strip() + # Match operator and version part + match = re.match(r"(>=|<=|>|<|==|!=)\s*([\d.]+)", spec) + if not match: + # If no operator, assume == + if re.match(r"[\d.]+", spec): + if version_tuple != parse_version(spec): + return False + continue + msg = f"Invalid specifier {spec!r}; expected format like '>=3.11' or '3.11'" + raise VersionSpecifierError(msg) + + op, spec_v = match.groups() + spec_v_tuple = parse_version(spec_v) + + if not _check_operator(version_tuple, op, spec_v_tuple): + return False + + return True + + +def supported_versions(pyproject_path: Path) -> list[str]: + """Return all supported Python versions declared in pyproject.toml. + + Reads project.requires-python, evaluates candidate versions against the + specifier, and returns the subset that satisfy the constraint, in ascending order. + + Args: + pyproject_path: Path to the pyproject.toml file. + + Returns: + list[str]: The supported versions (e.g., ["3.11", "3.12"]). + + Raises: + PyProjectError: If requires-python is missing or no candidates match. + FileNotFoundError: If pyproject.toml does not exist. + """ + if not pyproject_path.exists(): + raise FileNotFoundError(f"pyproject.toml not found at: {pyproject_path}") + + # Load pyproject.toml using the tomllib standard library (Python 3.11+) + with pyproject_path.open("rb") as f: + data = tomllib.load(f) + + # Extract the requires-python field from project metadata + # This specifies the Python version constraint (e.g., ">=3.11") + spec_str = data.get("project", {}).get("requires-python") + if not spec_str: + msg = "pyproject.toml: missing 'project.requires-python'" + raise PyProjectError(msg) + + # Filter candidate versions to find which ones satisfy the constraint + versions: list[str] = [] + for v in CANDIDATES: + if satisfies(v, spec_str): + versions.append(v) + + if not versions: + msg = f"pyproject.toml: no supported Python versions match '{spec_str}'. Evaluated candidates: {CANDIDATES}" + raise PyProjectError(msg) + + return versions + + +def versions(target: Path) -> None: + """Extract and print supported Python versions from pyproject.toml. + + Args: + target: Path to pyproject.toml file or directory containing it. + """ + target = target.resolve() + + # Determine the pyproject.toml path + if target.is_file() and target.name == "pyproject.toml": + pyproject_path = target + elif target.is_dir(): + pyproject_path = target / "pyproject.toml" + else: + logger.error(f"Invalid target: {target}") + logger.error("Target must be a directory or pyproject.toml file") + raise ValueError(f"Invalid target: {target}") + + logger.info(f"Reading Python version requirements from: {pyproject_path}") + + try: + versions_list = supported_versions(pyproject_path) + logger.success(f"Supported Python versions: {versions_list}") + print(json.dumps(versions_list)) + except FileNotFoundError as e: + logger.error(str(e)) + logger.error("Ensure pyproject.toml exists in the target location") + raise + except PyProjectError as e: + logger.error(str(e)) + raise + except VersionSpecifierError as e: + logger.error(f"Invalid version specifier: {e}") + raise diff --git a/tests/test_commands/test_versions.py b/tests/test_commands/test_versions.py new file mode 100644 index 0000000..c03903f --- /dev/null +++ b/tests/test_commands/test_versions.py @@ -0,0 +1,202 @@ +"""Tests for the versions command and CLI wiring. + +This module verifies that the versions command correctly extracts supported +Python versions from pyproject.toml files. +""" + +from pathlib import Path + +import pytest +from typer.testing import CliRunner + +from rhiza import cli +from rhiza.commands.versions import ( + PyProjectError, + VersionSpecifierError, + parse_version, + satisfies, + supported_versions, + versions, +) + +runner = CliRunner() + + +class TestParseVersion: + """Tests for parse_version function.""" + + def test_simple_version(self): + """Parse simple version strings.""" + assert parse_version("3.11") == (3, 11) + assert parse_version("3.12") == (3, 12) + assert parse_version("3.14") == (3, 14) + + def test_three_part_version(self): + """Parse three-part version strings.""" + assert parse_version("3.11.0") == (3, 11, 0) + assert parse_version("3.12.5") == (3, 12, 5) + + def test_version_with_rc_suffix(self): + """Parse version with release candidate suffix.""" + assert parse_version("3.11.0rc1") == (3, 11, 0) + assert parse_version("3.14.0a1") == (3, 14, 0) + assert parse_version("3.13.0b2") == (3, 13, 0) + + def test_malformed_version(self): + """Raise VersionSpecifierError for malformed version.""" + with pytest.raises(VersionSpecifierError): + parse_version("abc.11") + + +class TestSatisfies: + """Tests for satisfies function.""" + + def test_greater_than_or_equal(self): + """Test >= operator.""" + assert satisfies("3.11", ">=3.11") is True + assert satisfies("3.12", ">=3.11") is True + assert satisfies("3.10", ">=3.11") is False + + def test_less_than(self): + """Test < operator.""" + assert satisfies("3.10", "<3.11") is True + assert satisfies("3.11", "<3.11") is False + assert satisfies("3.12", "<3.11") is False + + def test_compound_specifier(self): + """Test comma-separated specifiers.""" + assert satisfies("3.11", ">=3.11,<3.14") is True + assert satisfies("3.12", ">=3.11,<3.14") is True + assert satisfies("3.14", ">=3.11,<3.14") is False + assert satisfies("3.10", ">=3.11,<3.14") is False + + def test_invalid_specifier(self): + """Raise VersionSpecifierError for invalid specifier.""" + with pytest.raises(VersionSpecifierError): + satisfies("3.11", "~=3.11") + + +class TestSupportedVersions: + """Tests for supported_versions function.""" + + def test_supported_versions_with_valid_pyproject(self, tmp_path): + """Test supported_versions with valid pyproject.toml.""" + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text('[project]\nname = "test"\nrequires-python = ">=3.11"\n') + + versions = supported_versions(pyproject) + assert isinstance(versions, list) + assert "3.11" in versions + assert "3.12" in versions + assert "3.13" in versions + assert "3.14" in versions + + def test_supported_versions_with_upper_bound(self, tmp_path): + """Test supported_versions with upper bound constraint.""" + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text('[project]\nname = "test"\nrequires-python = ">=3.12,<3.14"\n') + + versions = supported_versions(pyproject) + assert "3.11" not in versions + assert "3.12" in versions + assert "3.13" in versions + assert "3.14" not in versions + + def test_supported_versions_missing_requires_python(self, tmp_path): + """Test supported_versions raises error when requires-python is missing.""" + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text('[project]\nname = "test"\n') + + with pytest.raises(PyProjectError) as exc_info: + supported_versions(pyproject) + assert "missing 'project.requires-python'" in str(exc_info.value) + + def test_supported_versions_no_matching_versions(self, tmp_path): + """Test supported_versions raises error when no candidates match.""" + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text('[project]\nname = "test"\nrequires-python = ">=2.7,<3.0"\n') + + with pytest.raises(PyProjectError) as exc_info: + supported_versions(pyproject) + assert "no supported Python versions match" in str(exc_info.value) + + def test_supported_versions_file_not_found(self, tmp_path): + """Test supported_versions raises error when file doesn't exist.""" + pyproject = tmp_path / "nonexistent.toml" + + with pytest.raises(FileNotFoundError): + supported_versions(pyproject) + + +class TestVersionsCommand: + """Tests for the versions command.""" + + def test_versions_with_directory(self, tmp_path): + """Test versions command with directory argument.""" + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text('[project]\nname = "test"\nrequires-python = ">=3.11"\n') + + # Should not raise an error + versions(tmp_path) + + def test_versions_with_pyproject_file(self, tmp_path): + """Test versions command with pyproject.toml file argument.""" + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text('[project]\nname = "test"\nrequires-python = ">=3.11"\n') + + # Should not raise an error + versions(pyproject) + + def test_versions_missing_pyproject(self, tmp_path): + """Test versions command fails when pyproject.toml doesn't exist.""" + with pytest.raises(FileNotFoundError): + versions(tmp_path) + + def test_versions_invalid_target(self, tmp_path): + """Test versions command fails with invalid target.""" + invalid_file = tmp_path / "invalid.txt" + invalid_file.write_text("content") + + with pytest.raises(ValueError): + versions(invalid_file) + + +class TestVersionsCLI: + """Tests for the versions CLI command.""" + + def test_versions_cli_success(self, tmp_path): + """Test versions CLI command with valid pyproject.toml.""" + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text('[project]\nname = "test"\nrequires-python = ">=3.11"\n') + + result = runner.invoke(cli.app, ["versions", str(tmp_path)]) + assert result.exit_code == 0 + assert "3.11" in result.stdout or '"3.11"' in result.stdout + + def test_versions_cli_with_file_path(self, tmp_path): + """Test versions CLI command with direct file path.""" + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text('[project]\nname = "test"\nrequires-python = ">=3.12"\n') + + result = runner.invoke(cli.app, ["versions", str(pyproject)]) + assert result.exit_code == 0 + assert "3.12" in result.stdout or '"3.12"' in result.stdout + + def test_versions_cli_default_directory(self): + """Test versions CLI command with default directory (current).""" + # This test assumes the test is run from a directory with a valid pyproject.toml + result = runner.invoke(cli.app, ["versions"]) + # Should succeed if run from rhiza-cli root + assert result.exit_code == 0 + + def test_versions_cli_missing_pyproject(self, tmp_path): + """Test versions CLI command fails when pyproject.toml is missing.""" + result = runner.invoke(cli.app, ["versions", str(tmp_path)]) + assert result.exit_code != 0 + + def test_versions_cli_help(self): + """Test versions CLI command help.""" + result = runner.invoke(cli.app, ["versions", "--help"]) + assert result.exit_code == 0 + assert "pyproject.toml" in result.stdout.lower() + assert "versions" in result.stdout.lower() From 0027fac98aad384914f8a50beaf683862e769978 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 27 Jan 2026 04:27:38 +0000 Subject: [PATCH 03/14] Update CLI documentation with versions command Co-authored-by: tschm <2046079+tschm@users.noreply.github.com> --- CLI.md | 64 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/CLI.md b/CLI.md index 2a866e3..607f825 100644 --- a/CLI.md +++ b/CLI.md @@ -10,6 +10,7 @@ This document provides a quick reference for the Rhiza command-line interface. | `rhiza materialize` | Inject templates into a target repository | | `rhiza migrate` | Migrate to the new `.rhiza` folder structure | | `rhiza validate` | Validate template configuration | +| `rhiza versions` | Extract supported Python versions from pyproject.toml | ## Common Usage Patterns @@ -165,6 +166,69 @@ rhiza validate .. # Validate parent directory --- +### rhiza versions + +**Purpose:** Extract supported Python versions from pyproject.toml + +**Syntax:** +```bash +rhiza versions [TARGET] +``` + +**Parameters:** +- `TARGET` - Path to pyproject.toml file or directory containing it (default: current directory) + +**Examples:** +```bash +rhiza versions # Use pyproject.toml in current directory +rhiza versions /path/to/project # Use pyproject.toml in specified directory +rhiza versions /path/to/pyproject.toml # Use specific pyproject.toml file +``` + +**What It Does:** +- Reads the `requires-python` field from pyproject.toml +- Evaluates candidate versions (3.11, 3.12, 3.13, 3.14) against the version specifier +- Outputs a JSON list of supported Python versions + +**Output Format:** +```json +["3.11", "3.12", "3.13", "3.14"] +``` + +**Use Cases:** +- Generating test matrices for CI/CD pipelines +- Determining which Python versions to target +- Validating version compatibility + +**Example Integration with GitHub Actions:** +```yaml +jobs: + get-versions: + runs-on: ubuntu-latest + outputs: + python-versions: ${{ steps.versions.outputs.versions }} + steps: + - uses: actions/checkout@v4 + - name: Get supported Python versions + id: versions + run: | + pip install rhiza + echo "versions=$(rhiza versions)" >> $GITHUB_OUTPUT + + test: + needs: get-versions + strategy: + matrix: + python-version: ${{ fromJson(needs.get-versions.outputs.python-versions) }} + runs-on: ubuntu-latest + steps: + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} +``` + +--- + ## Generated Files ### .rhiza.history From 6e5a649712b066fa4025a489c6d84d7c2a070969 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 27 Jan 2026 04:28:44 +0000 Subject: [PATCH 04/14] Fix linting issues in versions command and tests Co-authored-by: tschm <2046079+tschm@users.noreply.github.com> --- src/rhiza/commands/versions.py | 4 ++-- tests/test_commands/test_versions.py | 4 +--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/rhiza/commands/versions.py b/src/rhiza/commands/versions.py index 3ed56a2..101e6be 100644 --- a/src/rhiza/commands/versions.py +++ b/src/rhiza/commands/versions.py @@ -125,7 +125,7 @@ def supported_versions(pyproject_path: Path) -> list[str]: FileNotFoundError: If pyproject.toml does not exist. """ if not pyproject_path.exists(): - raise FileNotFoundError(f"pyproject.toml not found at: {pyproject_path}") + raise FileNotFoundError(f"pyproject.toml not found at: {pyproject_path}") # noqa: TRY003 # Load pyproject.toml using the tomllib standard library (Python 3.11+) with pyproject_path.open("rb") as f: @@ -167,7 +167,7 @@ def versions(target: Path) -> None: else: logger.error(f"Invalid target: {target}") logger.error("Target must be a directory or pyproject.toml file") - raise ValueError(f"Invalid target: {target}") + raise ValueError(f"Invalid target: {target}") # noqa: TRY003 logger.info(f"Reading Python version requirements from: {pyproject_path}") diff --git a/tests/test_commands/test_versions.py b/tests/test_commands/test_versions.py index c03903f..cfd560d 100644 --- a/tests/test_commands/test_versions.py +++ b/tests/test_commands/test_versions.py @@ -4,8 +4,6 @@ Python versions from pyproject.toml files. """ -from pathlib import Path - import pytest from typer.testing import CliRunner @@ -157,7 +155,7 @@ def test_versions_invalid_target(self, tmp_path): invalid_file = tmp_path / "invalid.txt" invalid_file.write_text("content") - with pytest.raises(ValueError): + with pytest.raises(ValueError, match="Invalid target"): versions(invalid_file) From 1a91e034ca4e1ce41677c9c4d8f9e63676625c4c Mon Sep 17 00:00:00 2001 From: Thomas Schmelzer Date: Tue, 27 Jan 2026 09:04:03 +0400 Subject: [PATCH 05/14] Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/rhiza/commands/versions.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/rhiza/commands/versions.py b/src/rhiza/commands/versions.py index 101e6be..b7113c4 100644 --- a/src/rhiza/commands/versions.py +++ b/src/rhiza/commands/versions.py @@ -88,11 +88,11 @@ def satisfies(version: str, specifier: str) -> bool: # Split by comma for multiple constraints for spec in specifier.split(","): spec = spec.strip() - # Match operator and version part - match = re.match(r"(>=|<=|>|<|==|!=)\s*([\d.]+)", spec) + # Match operator and version part; require a fully-formed version like '3', '3.11', '3.11.1' + match = re.fullmatch(r"(>=|<=|>|<|==|!=)\s*(\d+(?:\.\d+)*)", spec) if not match: - # If no operator, assume == - if re.match(r"[\d.]+", spec): + # If no operator, assume bare version equality like '3.11' + if re.fullmatch(r"\d+(?:\.\d+)*", spec): if version_tuple != parse_version(spec): return False continue From a064f9855beee6a9753aede424d6497d80420b6c Mon Sep 17 00:00:00 2001 From: Thomas Schmelzer Date: Tue, 27 Jan 2026 09:04:42 +0400 Subject: [PATCH 06/14] Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/rhiza/commands/versions.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/rhiza/commands/versions.py b/src/rhiza/commands/versions.py index b7113c4..110ad4c 100644 --- a/src/rhiza/commands/versions.py +++ b/src/rhiza/commands/versions.py @@ -128,8 +128,12 @@ def supported_versions(pyproject_path: Path) -> list[str]: raise FileNotFoundError(f"pyproject.toml not found at: {pyproject_path}") # noqa: TRY003 # Load pyproject.toml using the tomllib standard library (Python 3.11+) - with pyproject_path.open("rb") as f: - data = tomllib.load(f) + try: + with pyproject_path.open("rb") as f: + data = tomllib.load(f) + except tomllib.TOMLDecodeError as e: + msg = f"pyproject.toml is not valid TOML: {e}" + raise PyProjectError(msg) from e # Extract the requires-python field from project metadata # This specifies the Python version constraint (e.g., ">=3.11") From 842d56bf4294059ab320dce8f746a3ed5d5e78df Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 27 Jan 2026 05:18:51 +0000 Subject: [PATCH 07/14] Address PR review feedback: Add missing tests, update exports, and clarify comments - Add tests for missing operators (<=, >, ==, !=) - Add test for malformed TOML handling - Export versions command in __init__.py - Update module docstring to document versions command - Add clarifying comments for custom exceptions and CANDIDATES list Co-authored-by: tschm <2046079+tschm@users.noreply.github.com> --- src/rhiza/commands/__init__.py | 13 ++++++++++- src/rhiza/commands/versions.py | 6 ++++++ tests/test_commands/test_versions.py | 32 ++++++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 1 deletion(-) diff --git a/src/rhiza/commands/__init__.py b/src/rhiza/commands/__init__.py index edbd379..a2c6303 100644 --- a/src/rhiza/commands/__init__.py +++ b/src/rhiza/commands/__init__.py @@ -34,6 +34,14 @@ YAML syntax checking, required field verification, field type validation, and repository format verification. +### versions + +Extract supported Python versions from pyproject.toml. + +Reads the `requires-python` field from pyproject.toml and outputs a JSON +list of supported Python versions that satisfy the version constraint. +This is useful for generating test matrices in CI/CD pipelines. + ## Usage Example These functions are typically invoked through the CLI: @@ -44,6 +52,8 @@ $ rhiza materialize # Apply templates to project $ rhiza validate # Validate template configuration + + $ rhiza versions # Extract supported Python versions ``` For more detailed usage examples and workflows, see the USAGE.md guide @@ -53,5 +63,6 @@ from .init import init from .materialize import materialize from .validate import validate +from .versions import versions -__all__ = ["init", "materialize", "validate"] +__all__ = ["init", "materialize", "validate", "versions"] diff --git a/src/rhiza/commands/versions.py b/src/rhiza/commands/versions.py index 110ad4c..aeab42a 100644 --- a/src/rhiza/commands/versions.py +++ b/src/rhiza/commands/versions.py @@ -12,6 +12,10 @@ from loguru import logger +# Custom exceptions for version parsing and validation errors. +# These provide a clear hierarchy for distinguishing between different +# error types (version parsing vs. pyproject.toml issues) and match +# the exception design from the original .rhiza/utils/version_matrix.py script. class RhizaError(Exception): """Base exception for Rhiza-related errors.""" @@ -24,6 +28,8 @@ class PyProjectError(RhizaError): """Raised when there are issues with pyproject.toml configuration.""" +# Candidate Python versions to evaluate against pyproject.toml requirements. +# Includes upcoming versions (e.g., 3.14) for forward compatibility and CI/CD planning. CANDIDATES = ["3.11", "3.12", "3.13", "3.14"] # extend as needed diff --git a/tests/test_commands/test_versions.py b/tests/test_commands/test_versions.py index cfd560d..f8a7edc 100644 --- a/tests/test_commands/test_versions.py +++ b/tests/test_commands/test_versions.py @@ -61,6 +61,28 @@ def test_less_than(self): assert satisfies("3.11", "<3.11") is False assert satisfies("3.12", "<3.11") is False + def test_less_than_or_equal(self): + """Test <= operator.""" + assert satisfies("3.11", "<=3.11") is True + assert satisfies("3.10", "<=3.11") is True + assert satisfies("3.12", "<=3.11") is False + + def test_greater_than(self): + """Test > operator.""" + assert satisfies("3.12", ">3.11") is True + assert satisfies("3.11", ">3.11") is False + assert satisfies("3.10", ">3.11") is False + + def test_equal(self): + """Test == operator.""" + assert satisfies("3.11", "==3.11") is True + assert satisfies("3.12", "==3.11") is False + + def test_not_equal(self): + """Test != operator.""" + assert satisfies("3.12", "!=3.11") is True + assert satisfies("3.11", "!=3.11") is False + def test_compound_specifier(self): """Test comma-separated specifiers.""" assert satisfies("3.11", ">=3.11,<3.14") is True @@ -125,6 +147,16 @@ def test_supported_versions_file_not_found(self, tmp_path): with pytest.raises(FileNotFoundError): supported_versions(pyproject) + def test_supported_versions_malformed_toml(self, tmp_path): + """Test supported_versions raises error for malformed TOML.""" + pyproject = tmp_path / "pyproject.toml" + # Write invalid TOML (missing closing quote) + pyproject.write_text('[project]\nname = "test\nrequires-python = ">=3.11"\n') + + with pytest.raises(PyProjectError) as exc_info: + supported_versions(pyproject) + assert "not valid TOML" in str(exc_info.value) + class TestVersionsCommand: """Tests for the versions command.""" From 4d75bc3df85ccd78867de0ea97aee69512d7a4b0 Mon Sep 17 00:00:00 2001 From: Thomas Schmelzer Date: Tue, 27 Jan 2026 09:36:57 +0400 Subject: [PATCH 08/14] Update src/rhiza/cli.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/rhiza/cli.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/rhiza/cli.py b/src/rhiza/cli.py index ae4e763..e7eeba9 100644 --- a/src/rhiza/cli.py +++ b/src/rhiza/cli.py @@ -361,6 +361,9 @@ def versions( target: Annotated[ Path, typer.Argument( + exists=True, + file_okay=True, + dir_okay=True, help="Path to pyproject.toml or directory containing it (defaults to current directory)", ), ] = Path("."), From b3ac4bc2c93d657882604e5de19fe153158199af Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 27 Jan 2026 05:39:55 +0000 Subject: [PATCH 09/14] Improve docstring clarity and add security note to CLI example - Clarify parse_version docstring example for better understanding - Add security note and version pinning to GitHub Actions example in CLI.md Co-authored-by: tschm <2046079+tschm@users.noreply.github.com> --- CLI.md | 4 +++- src/rhiza/commands/versions.py | 7 ++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/CLI.md b/CLI.md index 607f825..4c2f282 100644 --- a/CLI.md +++ b/CLI.md @@ -212,7 +212,7 @@ jobs: - name: Get supported Python versions id: versions run: | - pip install rhiza + pip install rhiza==0.9.0 # Pin to specific version for security echo "versions=$(rhiza versions)" >> $GITHUB_OUTPUT test: @@ -227,6 +227,8 @@ jobs: python-version: ${{ matrix.python-version }} ``` +**Security Note:** Always pin `rhiza` to a specific version (e.g., `pip install rhiza==0.9.0`) in CI/CD workflows to avoid supply chain risks from installing the latest mutable version. + --- ## Generated Files diff --git a/src/rhiza/commands/versions.py b/src/rhiza/commands/versions.py index aeab42a..371df65 100644 --- a/src/rhiza/commands/versions.py +++ b/src/rhiza/commands/versions.py @@ -37,9 +37,10 @@ def parse_version(v: str) -> tuple[int, ...]: """Parse a version string into a tuple of integers. This is intentionally simple and only supports numeric components. - If a component contains non-numeric suffixes (e.g. '3.11.0rc1'), - the leading numeric portion will be used (e.g. '0rc1' -> 0). If a - component has no leading digits at all, a VersionSpecifierError is raised. + For version components with non-numeric suffixes (e.g., the component + '0rc1' in version '3.11.0rc1'), only the leading numeric portion is + extracted (resulting in 0). If a component has no leading digits at + all, a VersionSpecifierError is raised. Args: v: Version string to parse (e.g., "3.11", "3.11.0rc1"). From cda436a85aada9cfc388b93bc619cf1007282e54 Mon Sep 17 00:00:00 2001 From: Thomas Schmelzer Date: Tue, 27 Jan 2026 09:09:50 +0400 Subject: [PATCH 10/14] Replace custom exceptions with built-in types for consistency Use ValueError and RuntimeError instead of custom VersionSpecifierError and PyProjectError classes to match exception handling patterns used in other commands (init.py, materialize.py). Also export versions command in __init__.py. Co-Authored-By: Claude Opus 4.5 --- src/rhiza/commands/versions.py | 52 ++++++++-------------------- tests/test_commands/test_versions.py | 14 ++++---- 2 files changed, 20 insertions(+), 46 deletions(-) diff --git a/src/rhiza/commands/versions.py b/src/rhiza/commands/versions.py index 371df65..5e2e335 100644 --- a/src/rhiza/commands/versions.py +++ b/src/rhiza/commands/versions.py @@ -11,25 +11,6 @@ from loguru import logger - -# Custom exceptions for version parsing and validation errors. -# These provide a clear hierarchy for distinguishing between different -# error types (version parsing vs. pyproject.toml issues) and match -# the exception design from the original .rhiza/utils/version_matrix.py script. -class RhizaError(Exception): - """Base exception for Rhiza-related errors.""" - - -class VersionSpecifierError(RhizaError): - """Raised when a version string or specifier is invalid.""" - - -class PyProjectError(RhizaError): - """Raised when there are issues with pyproject.toml configuration.""" - - -# Candidate Python versions to evaluate against pyproject.toml requirements. -# Includes upcoming versions (e.g., 3.14) for forward compatibility and CI/CD planning. CANDIDATES = ["3.11", "3.12", "3.13", "3.14"] # extend as needed @@ -37,10 +18,9 @@ def parse_version(v: str) -> tuple[int, ...]: """Parse a version string into a tuple of integers. This is intentionally simple and only supports numeric components. - For version components with non-numeric suffixes (e.g., the component - '0rc1' in version '3.11.0rc1'), only the leading numeric portion is - extracted (resulting in 0). If a component has no leading digits at - all, a VersionSpecifierError is raised. + If a component contains non-numeric suffixes (e.g. '3.11.0rc1'), + the leading numeric portion will be used (e.g. '0rc1' -> 0). If a + component has no leading digits at all, a ValueError is raised. Args: v: Version string to parse (e.g., "3.11", "3.11.0rc1"). @@ -49,14 +29,14 @@ def parse_version(v: str) -> tuple[int, ...]: Tuple of integers representing the version. Raises: - VersionSpecifierError: If a version component has no numeric prefix. + ValueError: If a version component has no numeric prefix. """ parts: list[int] = [] for part in v.split("."): match = re.match(r"\d+", part) if not match: msg = f"Invalid version component {part!r} in version {v!r}; expected a numeric prefix." - raise VersionSpecifierError(msg) + raise ValueError(msg) # noqa: TRY003 parts.append(int(match.group(0))) return tuple(parts) @@ -88,7 +68,7 @@ def satisfies(version: str, specifier: str) -> bool: True if the version satisfies all specifiers, False otherwise. Raises: - VersionSpecifierError: If the specifier format is invalid. + ValueError: If the specifier format is invalid. """ version_tuple = parse_version(version) @@ -104,7 +84,7 @@ def satisfies(version: str, specifier: str) -> bool: return False continue msg = f"Invalid specifier {spec!r}; expected format like '>=3.11' or '3.11'" - raise VersionSpecifierError(msg) + raise ValueError(msg) # noqa: TRY003 op, spec_v = match.groups() spec_v_tuple = parse_version(spec_v) @@ -128,26 +108,22 @@ def supported_versions(pyproject_path: Path) -> list[str]: list[str]: The supported versions (e.g., ["3.11", "3.12"]). Raises: - PyProjectError: If requires-python is missing or no candidates match. + RuntimeError: If requires-python is missing or no candidates match. FileNotFoundError: If pyproject.toml does not exist. """ if not pyproject_path.exists(): raise FileNotFoundError(f"pyproject.toml not found at: {pyproject_path}") # noqa: TRY003 # Load pyproject.toml using the tomllib standard library (Python 3.11+) - try: - with pyproject_path.open("rb") as f: - data = tomllib.load(f) - except tomllib.TOMLDecodeError as e: - msg = f"pyproject.toml is not valid TOML: {e}" - raise PyProjectError(msg) from e + with pyproject_path.open("rb") as f: + data = tomllib.load(f) # Extract the requires-python field from project metadata # This specifies the Python version constraint (e.g., ">=3.11") spec_str = data.get("project", {}).get("requires-python") if not spec_str: msg = "pyproject.toml: missing 'project.requires-python'" - raise PyProjectError(msg) + raise RuntimeError(msg) # noqa: TRY003 # Filter candidate versions to find which ones satisfy the constraint versions: list[str] = [] @@ -157,7 +133,7 @@ def supported_versions(pyproject_path: Path) -> list[str]: if not versions: msg = f"pyproject.toml: no supported Python versions match '{spec_str}'. Evaluated candidates: {CANDIDATES}" - raise PyProjectError(msg) + raise RuntimeError(msg) # noqa: TRY003 return versions @@ -190,9 +166,9 @@ def versions(target: Path) -> None: logger.error(str(e)) logger.error("Ensure pyproject.toml exists in the target location") raise - except PyProjectError as e: + except RuntimeError as e: logger.error(str(e)) raise - except VersionSpecifierError as e: + except ValueError as e: logger.error(f"Invalid version specifier: {e}") raise diff --git a/tests/test_commands/test_versions.py b/tests/test_commands/test_versions.py index f8a7edc..c93b555 100644 --- a/tests/test_commands/test_versions.py +++ b/tests/test_commands/test_versions.py @@ -9,8 +9,6 @@ from rhiza import cli from rhiza.commands.versions import ( - PyProjectError, - VersionSpecifierError, parse_version, satisfies, supported_versions, @@ -41,8 +39,8 @@ def test_version_with_rc_suffix(self): assert parse_version("3.13.0b2") == (3, 13, 0) def test_malformed_version(self): - """Raise VersionSpecifierError for malformed version.""" - with pytest.raises(VersionSpecifierError): + """Raise ValueError for malformed version.""" + with pytest.raises(ValueError): parse_version("abc.11") @@ -91,8 +89,8 @@ def test_compound_specifier(self): assert satisfies("3.10", ">=3.11,<3.14") is False def test_invalid_specifier(self): - """Raise VersionSpecifierError for invalid specifier.""" - with pytest.raises(VersionSpecifierError): + """Raise ValueError for invalid specifier.""" + with pytest.raises(ValueError): satisfies("3.11", "~=3.11") @@ -127,7 +125,7 @@ def test_supported_versions_missing_requires_python(self, tmp_path): pyproject = tmp_path / "pyproject.toml" pyproject.write_text('[project]\nname = "test"\n') - with pytest.raises(PyProjectError) as exc_info: + with pytest.raises(RuntimeError) as exc_info: supported_versions(pyproject) assert "missing 'project.requires-python'" in str(exc_info.value) @@ -136,7 +134,7 @@ def test_supported_versions_no_matching_versions(self, tmp_path): pyproject = tmp_path / "pyproject.toml" pyproject.write_text('[project]\nname = "test"\nrequires-python = ">=2.7,<3.0"\n') - with pytest.raises(PyProjectError) as exc_info: + with pytest.raises(RuntimeError) as exc_info: supported_versions(pyproject) assert "no supported Python versions match" in str(exc_info.value) From aaaf5124276b151e04421cd49453b95edc6b4d5e Mon Sep 17 00:00:00 2001 From: Thomas Schmelzer Date: Tue, 27 Jan 2026 09:12:29 +0400 Subject: [PATCH 11/14] Add missing test coverage for satisfies operators and malformed TOML Add test cases for <=, >, ==, and != operators which were documented but not tested. Also add test for malformed TOML syntax to verify tomllib.TOMLDecodeError is raised appropriately. Co-Authored-By: Claude Opus 4.5 --- tests/test_commands/test_versions.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/tests/test_commands/test_versions.py b/tests/test_commands/test_versions.py index c93b555..0e64b28 100644 --- a/tests/test_commands/test_versions.py +++ b/tests/test_commands/test_versions.py @@ -4,6 +4,8 @@ Python versions from pyproject.toml files. """ +import tomllib + import pytest from typer.testing import CliRunner @@ -61,8 +63,8 @@ def test_less_than(self): def test_less_than_or_equal(self): """Test <= operator.""" - assert satisfies("3.11", "<=3.11") is True assert satisfies("3.10", "<=3.11") is True + assert satisfies("3.11", "<=3.11") is True assert satisfies("3.12", "<=3.11") is False def test_greater_than(self): @@ -75,10 +77,12 @@ def test_equal(self): """Test == operator.""" assert satisfies("3.11", "==3.11") is True assert satisfies("3.12", "==3.11") is False + assert satisfies("3.10", "==3.11") is False def test_not_equal(self): """Test != operator.""" assert satisfies("3.12", "!=3.11") is True + assert satisfies("3.10", "!=3.11") is True assert satisfies("3.11", "!=3.11") is False def test_compound_specifier(self): @@ -146,14 +150,12 @@ def test_supported_versions_file_not_found(self, tmp_path): supported_versions(pyproject) def test_supported_versions_malformed_toml(self, tmp_path): - """Test supported_versions raises error for malformed TOML.""" + """Test supported_versions raises error for malformed TOML syntax.""" pyproject = tmp_path / "pyproject.toml" - # Write invalid TOML (missing closing quote) - pyproject.write_text('[project]\nname = "test\nrequires-python = ">=3.11"\n') + pyproject.write_text('[project\nname = "test"') # Missing closing bracket - with pytest.raises(PyProjectError) as exc_info: + with pytest.raises(tomllib.TOMLDecodeError): supported_versions(pyproject) - assert "not valid TOML" in str(exc_info.value) class TestVersionsCommand: From 1efc2485521821c083b8d3ced6f6d6888f805dc3 Mon Sep 17 00:00:00 2001 From: Thomas Schmelzer Date: Tue, 27 Jan 2026 10:31:56 +0400 Subject: [PATCH 12/14] Fix linting and type checking issues - Add type annotation to operators dict to fix mypy error - Add match parameter to pytest.raises for more specific assertions - Remove unnecessary noqa comments (auto-cleaned by ruff) Co-Authored-By: Claude Opus 4.5 --- src/rhiza/commands/versions.py | 11 ++++++----- tests/test_commands/test_versions.py | 4 ++-- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/rhiza/commands/versions.py b/src/rhiza/commands/versions.py index 5e2e335..d01e7e0 100644 --- a/src/rhiza/commands/versions.py +++ b/src/rhiza/commands/versions.py @@ -7,6 +7,7 @@ import json import re import tomllib +from collections.abc import Callable from pathlib import Path from loguru import logger @@ -36,14 +37,14 @@ def parse_version(v: str) -> tuple[int, ...]: match = re.match(r"\d+", part) if not match: msg = f"Invalid version component {part!r} in version {v!r}; expected a numeric prefix." - raise ValueError(msg) # noqa: TRY003 + raise ValueError(msg) parts.append(int(match.group(0))) return tuple(parts) def _check_operator(version_tuple: tuple[int, ...], op: str, spec_v_tuple: tuple[int, ...]) -> bool: """Check if a version tuple satisfies an operator constraint.""" - operators = { + operators: dict[str, Callable[[tuple[int, ...], tuple[int, ...]], bool]] = { ">=": lambda v, s: v >= s, "<=": lambda v, s: v <= s, ">": lambda v, s: v > s, @@ -84,7 +85,7 @@ def satisfies(version: str, specifier: str) -> bool: return False continue msg = f"Invalid specifier {spec!r}; expected format like '>=3.11' or '3.11'" - raise ValueError(msg) # noqa: TRY003 + raise ValueError(msg) op, spec_v = match.groups() spec_v_tuple = parse_version(spec_v) @@ -123,7 +124,7 @@ def supported_versions(pyproject_path: Path) -> list[str]: spec_str = data.get("project", {}).get("requires-python") if not spec_str: msg = "pyproject.toml: missing 'project.requires-python'" - raise RuntimeError(msg) # noqa: TRY003 + raise RuntimeError(msg) # Filter candidate versions to find which ones satisfy the constraint versions: list[str] = [] @@ -133,7 +134,7 @@ def supported_versions(pyproject_path: Path) -> list[str]: if not versions: msg = f"pyproject.toml: no supported Python versions match '{spec_str}'. Evaluated candidates: {CANDIDATES}" - raise RuntimeError(msg) # noqa: TRY003 + raise RuntimeError(msg) return versions diff --git a/tests/test_commands/test_versions.py b/tests/test_commands/test_versions.py index 0e64b28..61578ca 100644 --- a/tests/test_commands/test_versions.py +++ b/tests/test_commands/test_versions.py @@ -42,7 +42,7 @@ def test_version_with_rc_suffix(self): def test_malformed_version(self): """Raise ValueError for malformed version.""" - with pytest.raises(ValueError): + with pytest.raises(ValueError, match="Invalid version component"): parse_version("abc.11") @@ -94,7 +94,7 @@ def test_compound_specifier(self): def test_invalid_specifier(self): """Raise ValueError for invalid specifier.""" - with pytest.raises(ValueError): + with pytest.raises(ValueError, match="Invalid specifier"): satisfies("3.11", "~=3.11") From db897c22532767d9c0def86b2c5f78013a2ffe93 Mon Sep 17 00:00:00 2001 From: Thomas Schmelzer Date: Tue, 27 Jan 2026 10:36:08 +0400 Subject: [PATCH 13/14] Add tests for 100% coverage on versions command Cover implicit equality specifiers and exception handlers in the versions function that were previously untested. Co-Authored-By: Claude Opus 4.5 --- tests/test_commands/test_versions.py | 33 ++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/tests/test_commands/test_versions.py b/tests/test_commands/test_versions.py index 61578ca..e984c1e 100644 --- a/tests/test_commands/test_versions.py +++ b/tests/test_commands/test_versions.py @@ -97,6 +97,21 @@ def test_invalid_specifier(self): with pytest.raises(ValueError, match="Invalid specifier"): satisfies("3.11", "~=3.11") + def test_implicit_equality_match(self): + """Test bare version specifier (implicit equality) that matches.""" + assert satisfies("3.11", "3.11") is True + + def test_implicit_equality_no_match(self): + """Test bare version specifier (implicit equality) that doesn't match.""" + assert satisfies("3.12", "3.11") is False + + def test_implicit_equality_in_compound(self): + """Test bare version in compound specifier continues to next constraint.""" + # First spec matches (3.11 == 3.11), second spec also checked + assert satisfies("3.11", "3.11,<3.12") is True + # First spec doesn't match, returns False early + assert satisfies("3.12", "3.11,<3.14") is False + class TestSupportedVersions: """Tests for supported_versions function.""" @@ -190,6 +205,24 @@ def test_versions_invalid_target(self, tmp_path): with pytest.raises(ValueError, match="Invalid target"): versions(invalid_file) + def test_versions_runtime_error_missing_requires_python(self, tmp_path): + """Test versions command raises RuntimeError when requires-python is missing.""" + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text('[project]\nname = "test"\n') + + with pytest.raises(RuntimeError) as exc_info: + versions(tmp_path) + assert "missing 'project.requires-python'" in str(exc_info.value) + + def test_versions_value_error_invalid_specifier(self, tmp_path): + """Test versions command raises ValueError for invalid version specifier.""" + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text('[project]\nname = "test"\nrequires-python = "~=3.11"\n') + + with pytest.raises(ValueError) as exc_info: + versions(tmp_path) + assert "Invalid specifier" in str(exc_info.value) + class TestVersionsCLI: """Tests for the versions CLI command.""" From 46befd0308e9a01a5d357c717f70d31c7a6a216f Mon Sep 17 00:00:00 2001 From: Thomas Schmelzer Date: Tue, 27 Jan 2026 10:38:14 +0400 Subject: [PATCH 14/14] Fix ruff PT011 linting error in test Use match parameter in pytest.raises() instead of separate assertion. Co-Authored-By: Claude Opus 4.5 --- tests/test_commands/test_versions.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/test_commands/test_versions.py b/tests/test_commands/test_versions.py index e984c1e..f219787 100644 --- a/tests/test_commands/test_versions.py +++ b/tests/test_commands/test_versions.py @@ -219,9 +219,8 @@ def test_versions_value_error_invalid_specifier(self, tmp_path): pyproject = tmp_path / "pyproject.toml" pyproject.write_text('[project]\nname = "test"\nrequires-python = "~=3.11"\n') - with pytest.raises(ValueError) as exc_info: + with pytest.raises(ValueError, match="Invalid specifier"): versions(tmp_path) - assert "Invalid specifier" in str(exc_info.value) class TestVersionsCLI: