diff --git a/CLI.md b/CLI.md index 2a866e3..4c2f282 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,71 @@ 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==0.9.0 # Pin to specific version for security + 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 }} +``` + +**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 ### .rhiza.history diff --git a/src/rhiza/cli.py b/src/rhiza/cli.py index 1062ef0..e7eeba9 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,31 @@ 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( + exists=True, + file_okay=True, + dir_okay=True, + 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/__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 new file mode 100644 index 0000000..d01e7e0 --- /dev/null +++ b/src/rhiza/commands/versions.py @@ -0,0 +1,175 @@ +"""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 collections.abc import Callable +from pathlib import Path + +from loguru import logger + +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 ValueError is raised. + + Args: + v: Version string to parse (e.g., "3.11", "3.11.0rc1"). + + Returns: + Tuple of integers representing the version. + + Raises: + 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 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: dict[str, Callable[[tuple[int, ...], tuple[int, ...]], bool]] = { + ">=": 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: + ValueError: 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; 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 bare version equality like '3.11' + if re.fullmatch(r"\d+(?:\.\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 ValueError(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: + 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+) + 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 RuntimeError(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 RuntimeError(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}") # noqa: TRY003 + + 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 RuntimeError as e: + logger.error(str(e)) + raise + 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 new file mode 100644 index 0000000..f219787 --- /dev/null +++ b/tests/test_commands/test_versions.py @@ -0,0 +1,264 @@ +"""Tests for the versions command and CLI wiring. + +This module verifies that the versions command correctly extracts supported +Python versions from pyproject.toml files. +""" + +import tomllib + +import pytest +from typer.testing import CliRunner + +from rhiza import cli +from rhiza.commands.versions import ( + 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 ValueError for malformed version.""" + with pytest.raises(ValueError, match="Invalid version component"): + 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_less_than_or_equal(self): + """Test <= operator.""" + 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): + """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 + 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): + """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 ValueError for invalid specifier.""" + 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.""" + + 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(RuntimeError) 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(RuntimeError) 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) + + def test_supported_versions_malformed_toml(self, tmp_path): + """Test supported_versions raises error for malformed TOML syntax.""" + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text('[project\nname = "test"') # Missing closing bracket + + with pytest.raises(tomllib.TOMLDecodeError): + 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, 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, match="Invalid specifier"): + versions(tmp_path) + + +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()