Skip to content
Open
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
66 changes: 66 additions & 0 deletions CLI.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
29 changes: 29 additions & 0 deletions src/rhiza/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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)
13 changes: 12 additions & 1 deletion src/rhiza/commands/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand All @@ -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"]
175 changes: 175 additions & 0 deletions src/rhiza/commands/versions.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading