Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions src/rhiza_tools/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
from .commands.bump import bump_command
from .commands.generate_badge import generate_coverage_badge_command
from .commands.update_readme import update_readme_command
from .commands.version_matrix import version_matrix_command


def version_callback(value: bool) -> None:
Expand Down Expand Up @@ -199,3 +200,52 @@ def update_readme(
$ rhiza-tools update-readme --dry-run
"""
update_readme_command(dry_run)


@app.command(name="version-matrix")
def version_matrix(
pyproject: Annotated[
Path,
typer.Option(
"--pyproject",
help="Path to pyproject.toml file",
),
] = Path("pyproject.toml"),
candidates: Annotated[
str | None,
typer.Option(
"--candidates",
help="Comma-separated list of candidate Python versions (e.g., '3.11,3.12,3.13')",
),
] = None,
) -> None:
"""Emit supported Python versions from pyproject.toml as JSON.

This command reads the requires-python field from pyproject.toml and outputs
a JSON array of Python versions that satisfy the constraint. This is primarily
used in GitHub Actions to compute the test matrix.

Args:
pyproject: Path to the pyproject.toml file.
candidates: Comma-separated list of candidate Python versions to evaluate.
Defaults to "3.11,3.12,3.13,3.14".

Example:
Get supported versions with defaults::

$ rhiza-tools version-matrix
["3.11", "3.12"]

Use custom pyproject.toml path::

$ rhiza-tools version-matrix --pyproject /path/to/pyproject.toml

Use custom candidates::

$ rhiza-tools version-matrix --candidates "3.10,3.11,3.12"
"""
candidates_list = None
if candidates:
candidates_list = [v.strip() for v in candidates.split(",")]

version_matrix_command(pyproject_path=pyproject, candidates=candidates_list)
7 changes: 5 additions & 2 deletions src/rhiza_tools/commands/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,20 @@
- bump_command: Version bumping with semantic versioning support
- update_readme_command: README synchronization with make help output
- generate_coverage_badge_command: Coverage badge generation
- version_matrix_command: Python version matrix generation from pyproject.toml

Example:
Import and use commands::

from rhiza_tools.commands import bump_command, update_readme_command
from rhiza_tools.commands import bump_command, update_readme_command, version_matrix_command

bump_command("patch")
update_readme_command()
version_matrix_command()
"""

from .bump import bump_command
from .update_readme import update_readme_command
from .version_matrix import version_matrix_command

__all__ = ["bump_command", "update_readme_command"]
__all__ = ["bump_command", "update_readme_command", "version_matrix_command"]
255 changes: 255 additions & 0 deletions src/rhiza_tools/commands/version_matrix.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,255 @@
"""Command to emit supported Python versions from pyproject.toml.

This module implements functionality to parse pyproject.toml and determine which
Python versions are supported based on the requires-python specifier. It's
primarily used in GitHub Actions to compute the test matrix.

Example:
Get supported versions as JSON::

from rhiza_tools.commands.version_matrix import version_matrix_command
version_matrix_command()
# Output: ["3.11", "3.12"]

Use with custom candidates::

version_matrix_command(candidates=["3.11", "3.12", "3.13", "3.14"])
"""

import json
import re
import sys
import tomllib
from pathlib import Path


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."""


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.

Example:
>>> parse_version("3.11")
(3, 11)

>>> parse_version("3.11.0rc1")
(3, 11, 0)
"""
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.

Args:
version_tuple: The version to check as a tuple of integers.
op: The comparison operator (>=, <=, >, <, ==, !=).
spec_v_tuple: The specification version as a tuple of integers.

Returns:
True if the version satisfies the operator constraint, False otherwise.

Example:
>>> _check_operator((3, 11), ">=", (3, 10))
True

>>> _check_operator((3, 9), ">=", (3, 10))
False
"""
if op == ">=":
return version_tuple >= spec_v_tuple
elif op == "<=":
return version_tuple <= spec_v_tuple
elif op == ">":
return version_tuple > spec_v_tuple
elif op == "<":
return version_tuple < spec_v_tuple
elif op == "==":
return version_tuple == spec_v_tuple
elif op == "!=":
return version_tuple != spec_v_tuple
else:
msg = f"Unknown operator: {op}"
raise VersionSpecifierError(msg)


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.

Example:
>>> satisfies("3.11", ">=3.11")
True

>>> satisfies("3.10", ">=3.11")
False

>>> satisfies("3.12", ">=3.11,<3.14")
True
"""
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 get_supported_versions(pyproject_path: Path, candidates: list[str]) -> 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.
candidates: List of candidate Python versions to check (e.g., ["3.11", "3.12"]).

Returns:
List of supported versions (e.g., ["3.11", "3.12"]).

Raises:
PyProjectError: If pyproject.toml doesn't exist, requires-python is missing,
or no candidates match.

Example:
>>> from pathlib import Path
>>> path = Path("pyproject.toml")
>>> candidates = ["3.11", "3.12", "3.13"]
>>> versions = get_supported_versions(path, candidates) # doctest: +SKIP
>>> print(versions) # doctest: +SKIP
['3.11', '3.12']
"""
if not pyproject_path.exists():
msg = f"pyproject.toml not found at {pyproject_path}"
raise PyProjectError(msg)

# 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 version_matrix_command(
pyproject_path: Path | None = None,
candidates: list[str] | None = None,
) -> None:
"""Emit the list of supported Python versions from pyproject.toml as JSON.

This command reads pyproject.toml, parses the requires-python field, and outputs
a JSON array of Python versions that satisfy the constraint. This is used in
GitHub Actions to compute the test matrix.

Args:
pyproject_path: Path to pyproject.toml. Defaults to ./pyproject.toml.
candidates: List of candidate Python versions to evaluate. Defaults to
["3.11", "3.12", "3.13", "3.14"].

Raises:
SystemExit: If pyproject.toml is missing, invalid, or no versions match.

Example:
Get supported versions (output to stdout)::

version_matrix_command()
# Output: ["3.11", "3.12"]

Use custom pyproject.toml path::

version_matrix_command(pyproject_path=Path("/path/to/pyproject.toml"))

Use custom candidates::

version_matrix_command(candidates=["3.10", "3.11", "3.12"])
"""
if pyproject_path is None:
pyproject_path = Path("pyproject.toml")

if candidates is None:
candidates = ["3.11", "3.12", "3.13", "3.14"]

try:
versions = get_supported_versions(pyproject_path, candidates)
# Output as JSON array (matches the behavior of the original script)
print(json.dumps(versions))
except (PyProjectError, VersionSpecifierError) as e:
print(f"[ERROR] {e}", file=sys.stderr)
sys.exit(1)
38 changes: 38 additions & 0 deletions tests/test_cli_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,41 @@ def test_update_readme(monkeypatch):
result = runner.invoke(app, ["update-readme"])
assert result.exit_code == 0
mock_update_readme.assert_called_once_with(False)


def test_version_matrix_command_no_candidates(monkeypatch, tmp_path):
"""Test the version-matrix command with default candidates."""
# Create a temporary pyproject.toml
pyproject = tmp_path / "pyproject.toml"
pyproject.write_text("""
[project]
name = "test-project"
requires-python = ">=3.11"
""")

# Mock the command function
mock_version_matrix = MagicMock()
monkeypatch.setattr("rhiza_tools.cli.version_matrix_command", mock_version_matrix)

result = runner.invoke(app, ["version-matrix", "--pyproject", str(pyproject)])
assert result.exit_code == 0
mock_version_matrix.assert_called_once_with(pyproject_path=pyproject, candidates=None)


def test_version_matrix_command_with_candidates(monkeypatch, tmp_path):
"""Test the version-matrix command with custom candidates."""
# Create a temporary pyproject.toml
pyproject = tmp_path / "pyproject.toml"
pyproject.write_text("""
[project]
name = "test-project"
requires-python = ">=3.11"
""")

# Mock the command function
mock_version_matrix = MagicMock()
monkeypatch.setattr("rhiza_tools.cli.version_matrix_command", mock_version_matrix)

result = runner.invoke(app, ["version-matrix", "--pyproject", str(pyproject), "--candidates", "3.10,3.11,3.12"])
assert result.exit_code == 0
mock_version_matrix.assert_called_once_with(pyproject_path=pyproject, candidates=["3.10", "3.11", "3.12"])
Loading