Skip to content

Commit f948405

Browse files
tschmclaude
andauthored
Add version-matrix command for Python version detection (#75)
* Add version-matrix command to expose Python version detection via CLI Provides a unified interface for determining supported Python versions from pyproject.toml, making it easier to use in scripts and CI workflows alongside other rhiza-tools commands. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * Add comprehensive tests for version_matrix command (100% coverage) Implements 44 tests covering all functions and edge cases: - parse_version: handles valid versions, suffixes, and invalid inputs - _check_operator: tests all comparison operators (>=, <=, >, <, ==, !=) - satisfies: validates single/multiple specifiers and complex combinations - get_supported_versions: covers success cases and error conditions - version_matrix_command: tests defaults, custom options, and error handling Coverage: 78 statements, 0 missed (100%) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * Fix RuntimeWarning in test_main_if_name_main_block Removes the module from sys.modules before using runpy.run_module() to avoid the warning: "module found in sys.modules after import of package but prior to execution; this may result in unpredictable behaviour" The fix ensures clean module execution by temporarily removing the module from sys.modules and restoring it afterwards, eliminating the warning while maintaining test coverage. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * Rename test file to avoid pytest collection conflict Renamed test_version_matrix.py to test_version_matrix_command.py to avoid naming conflict with .rhiza/tests/utils/test_version_matrix.py which tests the original utility script. This resolves the pytest error: "imported module 'test_version_matrix' has this __file__ attribute which is not the same as the test file we want to collect" Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * Add CLI tests for version-matrix command to achieve 100% coverage Adds two test cases to test_cli_commands.py that cover the CLI wrapper function for version-matrix, testing both default and custom candidate parsing. This brings test coverage from 99% to 100%. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * Remove unused Path import from test_version_matrix_command Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent fd5ac3b commit f948405

File tree

6 files changed

+760
-14
lines changed

6 files changed

+760
-14
lines changed

src/rhiza_tools/cli.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
from .commands.bump import bump_command
3737
from .commands.generate_badge import generate_coverage_badge_command
3838
from .commands.update_readme import update_readme_command
39+
from .commands.version_matrix import version_matrix_command
3940

4041

4142
def version_callback(value: bool) -> None:
@@ -199,3 +200,52 @@ def update_readme(
199200
$ rhiza-tools update-readme --dry-run
200201
"""
201202
update_readme_command(dry_run)
203+
204+
205+
@app.command(name="version-matrix")
206+
def version_matrix(
207+
pyproject: Annotated[
208+
Path,
209+
typer.Option(
210+
"--pyproject",
211+
help="Path to pyproject.toml file",
212+
),
213+
] = Path("pyproject.toml"),
214+
candidates: Annotated[
215+
str | None,
216+
typer.Option(
217+
"--candidates",
218+
help="Comma-separated list of candidate Python versions (e.g., '3.11,3.12,3.13')",
219+
),
220+
] = None,
221+
) -> None:
222+
"""Emit supported Python versions from pyproject.toml as JSON.
223+
224+
This command reads the requires-python field from pyproject.toml and outputs
225+
a JSON array of Python versions that satisfy the constraint. This is primarily
226+
used in GitHub Actions to compute the test matrix.
227+
228+
Args:
229+
pyproject: Path to the pyproject.toml file.
230+
candidates: Comma-separated list of candidate Python versions to evaluate.
231+
Defaults to "3.11,3.12,3.13,3.14".
232+
233+
Example:
234+
Get supported versions with defaults::
235+
236+
$ rhiza-tools version-matrix
237+
["3.11", "3.12"]
238+
239+
Use custom pyproject.toml path::
240+
241+
$ rhiza-tools version-matrix --pyproject /path/to/pyproject.toml
242+
243+
Use custom candidates::
244+
245+
$ rhiza-tools version-matrix --candidates "3.10,3.11,3.12"
246+
"""
247+
candidates_list = None
248+
if candidates:
249+
candidates_list = [v.strip() for v in candidates.split(",")]
250+
251+
version_matrix_command(pyproject_path=pyproject, candidates=candidates_list)

src/rhiza_tools/commands/__init__.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,20 @@
77
- bump_command: Version bumping with semantic versioning support
88
- update_readme_command: README synchronization with make help output
99
- generate_coverage_badge_command: Coverage badge generation
10+
- version_matrix_command: Python version matrix generation from pyproject.toml
1011
1112
Example:
1213
Import and use commands::
1314
14-
from rhiza_tools.commands import bump_command, update_readme_command
15+
from rhiza_tools.commands import bump_command, update_readme_command, version_matrix_command
1516
1617
bump_command("patch")
1718
update_readme_command()
19+
version_matrix_command()
1820
"""
1921

2022
from .bump import bump_command
2123
from .update_readme import update_readme_command
24+
from .version_matrix import version_matrix_command
2225

23-
__all__ = ["bump_command", "update_readme_command"]
26+
__all__ = ["bump_command", "update_readme_command", "version_matrix_command"]
Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
"""Command to emit supported Python versions from pyproject.toml.
2+
3+
This module implements functionality to parse pyproject.toml and determine which
4+
Python versions are supported based on the requires-python specifier. It's
5+
primarily used in GitHub Actions to compute the test matrix.
6+
7+
Example:
8+
Get supported versions as JSON::
9+
10+
from rhiza_tools.commands.version_matrix import version_matrix_command
11+
version_matrix_command()
12+
# Output: ["3.11", "3.12"]
13+
14+
Use with custom candidates::
15+
16+
version_matrix_command(candidates=["3.11", "3.12", "3.13", "3.14"])
17+
"""
18+
19+
import json
20+
import re
21+
import sys
22+
import tomllib
23+
from pathlib import Path
24+
25+
26+
class RhizaError(Exception):
27+
"""Base exception for Rhiza-related errors."""
28+
29+
30+
class VersionSpecifierError(RhizaError):
31+
"""Raised when a version string or specifier is invalid."""
32+
33+
34+
class PyProjectError(RhizaError):
35+
"""Raised when there are issues with pyproject.toml configuration."""
36+
37+
38+
def parse_version(v: str) -> tuple[int, ...]:
39+
"""Parse a version string into a tuple of integers.
40+
41+
This is intentionally simple and only supports numeric components.
42+
If a component contains non-numeric suffixes (e.g. '3.11.0rc1'),
43+
the leading numeric portion will be used (e.g. '0rc1' -> 0). If a
44+
component has no leading digits at all, a VersionSpecifierError is raised.
45+
46+
Args:
47+
v: Version string to parse (e.g., "3.11", "3.11.0rc1").
48+
49+
Returns:
50+
Tuple of integers representing the version.
51+
52+
Raises:
53+
VersionSpecifierError: If a version component has no numeric prefix.
54+
55+
Example:
56+
>>> parse_version("3.11")
57+
(3, 11)
58+
59+
>>> parse_version("3.11.0rc1")
60+
(3, 11, 0)
61+
"""
62+
parts: list[int] = []
63+
for part in v.split("."):
64+
match = re.match(r"\d+", part)
65+
if not match:
66+
msg = f"Invalid version component {part!r} in version {v!r}; expected a numeric prefix."
67+
raise VersionSpecifierError(msg)
68+
parts.append(int(match.group(0)))
69+
return tuple(parts)
70+
71+
72+
def _check_operator(version_tuple: tuple[int, ...], op: str, spec_v_tuple: tuple[int, ...]) -> bool:
73+
"""Check if a version tuple satisfies an operator constraint.
74+
75+
Args:
76+
version_tuple: The version to check as a tuple of integers.
77+
op: The comparison operator (>=, <=, >, <, ==, !=).
78+
spec_v_tuple: The specification version as a tuple of integers.
79+
80+
Returns:
81+
True if the version satisfies the operator constraint, False otherwise.
82+
83+
Example:
84+
>>> _check_operator((3, 11), ">=", (3, 10))
85+
True
86+
87+
>>> _check_operator((3, 9), ">=", (3, 10))
88+
False
89+
"""
90+
if op == ">=":
91+
return version_tuple >= spec_v_tuple
92+
elif op == "<=":
93+
return version_tuple <= spec_v_tuple
94+
elif op == ">":
95+
return version_tuple > spec_v_tuple
96+
elif op == "<":
97+
return version_tuple < spec_v_tuple
98+
elif op == "==":
99+
return version_tuple == spec_v_tuple
100+
elif op == "!=":
101+
return version_tuple != spec_v_tuple
102+
else:
103+
msg = f"Unknown operator: {op}"
104+
raise VersionSpecifierError(msg)
105+
106+
107+
def satisfies(version: str, specifier: str) -> bool:
108+
"""Check if a version satisfies a comma-separated list of specifiers.
109+
110+
This is a simplified version of packaging.specifiers.SpecifierSet.
111+
Supported operators: >=, <=, >, <, ==, !=
112+
113+
Args:
114+
version: Version string to check (e.g., "3.11").
115+
specifier: Comma-separated specifier string (e.g., ">=3.11,<3.14").
116+
117+
Returns:
118+
True if the version satisfies all specifiers, False otherwise.
119+
120+
Raises:
121+
VersionSpecifierError: If the specifier format is invalid.
122+
123+
Example:
124+
>>> satisfies("3.11", ">=3.11")
125+
True
126+
127+
>>> satisfies("3.10", ">=3.11")
128+
False
129+
130+
>>> satisfies("3.12", ">=3.11,<3.14")
131+
True
132+
"""
133+
version_tuple = parse_version(version)
134+
135+
# Split by comma for multiple constraints
136+
for spec in specifier.split(","):
137+
spec = spec.strip()
138+
# Match operator and version part
139+
match = re.match(r"(>=|<=|>|<|==|!=)\s*([\d.]+)", spec)
140+
if not match:
141+
# If no operator, assume ==
142+
if re.match(r"[\d.]+", spec):
143+
if version_tuple != parse_version(spec):
144+
return False
145+
continue
146+
msg = f"Invalid specifier {spec!r}; expected format like '>=3.11' or '3.11'"
147+
raise VersionSpecifierError(msg)
148+
149+
op, spec_v = match.groups()
150+
spec_v_tuple = parse_version(spec_v)
151+
152+
if not _check_operator(version_tuple, op, spec_v_tuple):
153+
return False
154+
155+
return True
156+
157+
158+
def get_supported_versions(pyproject_path: Path, candidates: list[str]) -> list[str]:
159+
"""Return all supported Python versions declared in pyproject.toml.
160+
161+
Reads project.requires-python, evaluates candidate versions against the
162+
specifier, and returns the subset that satisfy the constraint, in ascending order.
163+
164+
Args:
165+
pyproject_path: Path to the pyproject.toml file.
166+
candidates: List of candidate Python versions to check (e.g., ["3.11", "3.12"]).
167+
168+
Returns:
169+
List of supported versions (e.g., ["3.11", "3.12"]).
170+
171+
Raises:
172+
PyProjectError: If pyproject.toml doesn't exist, requires-python is missing,
173+
or no candidates match.
174+
175+
Example:
176+
>>> from pathlib import Path
177+
>>> path = Path("pyproject.toml")
178+
>>> candidates = ["3.11", "3.12", "3.13"]
179+
>>> versions = get_supported_versions(path, candidates) # doctest: +SKIP
180+
>>> print(versions) # doctest: +SKIP
181+
['3.11', '3.12']
182+
"""
183+
if not pyproject_path.exists():
184+
msg = f"pyproject.toml not found at {pyproject_path}"
185+
raise PyProjectError(msg)
186+
187+
# Load pyproject.toml using the tomllib standard library (Python 3.11+)
188+
with pyproject_path.open("rb") as f:
189+
data = tomllib.load(f)
190+
191+
# Extract the requires-python field from project metadata
192+
# This specifies the Python version constraint (e.g., ">=3.11")
193+
spec_str = data.get("project", {}).get("requires-python")
194+
if not spec_str:
195+
msg = "pyproject.toml: missing 'project.requires-python'"
196+
raise PyProjectError(msg)
197+
198+
# Filter candidate versions to find which ones satisfy the constraint
199+
versions: list[str] = []
200+
for v in candidates:
201+
if satisfies(v, spec_str):
202+
versions.append(v)
203+
204+
if not versions:
205+
msg = f"pyproject.toml: no supported Python versions match '{spec_str}'. Evaluated candidates: {candidates}"
206+
raise PyProjectError(msg)
207+
208+
return versions
209+
210+
211+
def version_matrix_command(
212+
pyproject_path: Path | None = None,
213+
candidates: list[str] | None = None,
214+
) -> None:
215+
"""Emit the list of supported Python versions from pyproject.toml as JSON.
216+
217+
This command reads pyproject.toml, parses the requires-python field, and outputs
218+
a JSON array of Python versions that satisfy the constraint. This is used in
219+
GitHub Actions to compute the test matrix.
220+
221+
Args:
222+
pyproject_path: Path to pyproject.toml. Defaults to ./pyproject.toml.
223+
candidates: List of candidate Python versions to evaluate. Defaults to
224+
["3.11", "3.12", "3.13", "3.14"].
225+
226+
Raises:
227+
SystemExit: If pyproject.toml is missing, invalid, or no versions match.
228+
229+
Example:
230+
Get supported versions (output to stdout)::
231+
232+
version_matrix_command()
233+
# Output: ["3.11", "3.12"]
234+
235+
Use custom pyproject.toml path::
236+
237+
version_matrix_command(pyproject_path=Path("/path/to/pyproject.toml"))
238+
239+
Use custom candidates::
240+
241+
version_matrix_command(candidates=["3.10", "3.11", "3.12"])
242+
"""
243+
if pyproject_path is None:
244+
pyproject_path = Path("pyproject.toml")
245+
246+
if candidates is None:
247+
candidates = ["3.11", "3.12", "3.13", "3.14"]
248+
249+
try:
250+
versions = get_supported_versions(pyproject_path, candidates)
251+
# Output as JSON array (matches the behavior of the original script)
252+
print(json.dumps(versions))
253+
except (PyProjectError, VersionSpecifierError) as e:
254+
print(f"[ERROR] {e}", file=sys.stderr)
255+
sys.exit(1)

tests/test_cli_commands.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,3 +61,41 @@ def test_update_readme(monkeypatch):
6161
result = runner.invoke(app, ["update-readme"])
6262
assert result.exit_code == 0
6363
mock_update_readme.assert_called_once_with(False)
64+
65+
66+
def test_version_matrix_command_no_candidates(monkeypatch, tmp_path):
67+
"""Test the version-matrix command with default candidates."""
68+
# Create a temporary pyproject.toml
69+
pyproject = tmp_path / "pyproject.toml"
70+
pyproject.write_text("""
71+
[project]
72+
name = "test-project"
73+
requires-python = ">=3.11"
74+
""")
75+
76+
# Mock the command function
77+
mock_version_matrix = MagicMock()
78+
monkeypatch.setattr("rhiza_tools.cli.version_matrix_command", mock_version_matrix)
79+
80+
result = runner.invoke(app, ["version-matrix", "--pyproject", str(pyproject)])
81+
assert result.exit_code == 0
82+
mock_version_matrix.assert_called_once_with(pyproject_path=pyproject, candidates=None)
83+
84+
85+
def test_version_matrix_command_with_candidates(monkeypatch, tmp_path):
86+
"""Test the version-matrix command with custom candidates."""
87+
# Create a temporary pyproject.toml
88+
pyproject = tmp_path / "pyproject.toml"
89+
pyproject.write_text("""
90+
[project]
91+
name = "test-project"
92+
requires-python = ">=3.11"
93+
""")
94+
95+
# Mock the command function
96+
mock_version_matrix = MagicMock()
97+
monkeypatch.setattr("rhiza_tools.cli.version_matrix_command", mock_version_matrix)
98+
99+
result = runner.invoke(app, ["version-matrix", "--pyproject", str(pyproject), "--candidates", "3.10,3.11,3.12"])
100+
assert result.exit_code == 0
101+
mock_version_matrix.assert_called_once_with(pyproject_path=pyproject, candidates=["3.10", "3.11", "3.12"])

0 commit comments

Comments
 (0)