Coverage for src / rhiza_tools / commands / version_matrix.py: 100%
79 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-02-23 01:10 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-02-23 01:10 +0000
1"""Command to emit supported Python versions from pyproject.toml.
3This module implements functionality to parse pyproject.toml and determine which
4Python versions are supported based on the requires-python specifier. It's
5primarily used in GitHub Actions to compute the test matrix.
7Example:
8 Get supported versions as JSON::
10 from rhiza_tools.commands.version_matrix import version_matrix_command
11 version_matrix_command()
12 # Output: ["3.11", "3.12"]
14 Use with custom candidates::
16 version_matrix_command(candidates=["3.11", "3.12", "3.13", "3.14"])
17"""
19import json
20import re
21import sys
22import tomllib
23from pathlib import Path
25from rhiza_tools import console
28class RhizaError(Exception):
29 """Base exception for Rhiza-related errors."""
32class VersionSpecifierError(RhizaError):
33 """Raised when a version string or specifier is invalid."""
36class PyProjectError(RhizaError):
37 """Raised when there are issues with pyproject.toml configuration."""
40def parse_version(v: str) -> tuple[int, ...]:
41 """Parse a version string into a tuple of integers.
43 This is intentionally simple and only supports numeric components.
44 If a component contains non-numeric suffixes (e.g. '3.11.0rc1'),
45 the leading numeric portion will be used (e.g. '0rc1' -> 0). If a
46 component has no leading digits at all, a VersionSpecifierError is raised.
48 Args:
49 v: Version string to parse (e.g., "3.11", "3.11.0rc1").
51 Returns:
52 Tuple of integers representing the version.
54 Raises:
55 VersionSpecifierError: If a version component has no numeric prefix.
57 Example:
58 >>> parse_version("3.11")
59 (3, 11)
61 >>> parse_version("3.11.0rc1")
62 (3, 11, 0)
63 """
64 parts: list[int] = []
65 for part in v.split("."):
66 match = re.match(r"\d+", part)
67 if not match:
68 msg = f"Invalid version component {part!r} in version {v!r}; expected a numeric prefix."
69 raise VersionSpecifierError(msg)
70 parts.append(int(match.group(0)))
71 return tuple(parts)
74def _check_operator(version_tuple: tuple[int, ...], op: str, spec_v_tuple: tuple[int, ...]) -> bool:
75 """Check if a version tuple satisfies an operator constraint.
77 Args:
78 version_tuple: The version to check as a tuple of integers.
79 op: The comparison operator (>=, <=, >, <, ==, !=).
80 spec_v_tuple: The specification version as a tuple of integers.
82 Returns:
83 True if the version satisfies the operator constraint, False otherwise.
85 Example:
86 >>> _check_operator((3, 11), ">=", (3, 10))
87 True
89 >>> _check_operator((3, 9), ">=", (3, 10))
90 False
91 """
92 if 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 elif op == "!=":
103 return version_tuple != spec_v_tuple
104 else:
105 msg = f"Unknown operator: {op}"
106 raise VersionSpecifierError(msg)
109def satisfies(version: str, specifier: str) -> bool:
110 """Check if a version satisfies a comma-separated list of specifiers.
112 This is a simplified version of packaging.specifiers.SpecifierSet.
113 Supported operators: >=, <=, >, <, ==, !=
115 Args:
116 version: Version string to check (e.g., "3.11").
117 specifier: Comma-separated specifier string (e.g., ">=3.11,<3.14").
119 Returns:
120 True if the version satisfies all specifiers, False otherwise.
122 Raises:
123 VersionSpecifierError: If the specifier format is invalid.
125 Example:
126 >>> satisfies("3.11", ">=3.11")
127 True
129 >>> satisfies("3.10", ">=3.11")
130 False
132 >>> satisfies("3.12", ">=3.11,<3.14")
133 True
134 """
135 version_tuple = parse_version(version)
137 # Split by comma for multiple constraints
138 for spec in specifier.split(","):
139 spec = spec.strip()
140 # Match operator and version part
141 match = re.match(r"(>=|<=|>|<|==|!=)\s*([\d.]+)", spec)
142 if not match:
143 # If no operator, assume ==
144 if re.match(r"[\d.]+", spec):
145 if version_tuple != parse_version(spec):
146 return False
147 continue
148 msg = f"Invalid specifier {spec!r}; expected format like '>=3.11' or '3.11'"
149 raise VersionSpecifierError(msg)
151 op, spec_v = match.groups()
152 spec_v_tuple = parse_version(spec_v)
154 if not _check_operator(version_tuple, op, spec_v_tuple):
155 return False
157 return True
160def get_supported_versions(pyproject_path: Path, candidates: list[str]) -> list[str]:
161 """Return all supported Python versions declared in pyproject.toml.
163 Reads project.requires-python, evaluates candidate versions against the
164 specifier, and returns the subset that satisfy the constraint, in ascending order.
166 Args:
167 pyproject_path: Path to the pyproject.toml file.
168 candidates: List of candidate Python versions to check (e.g., ["3.11", "3.12"]).
170 Returns:
171 List of supported versions (e.g., ["3.11", "3.12"]).
173 Raises:
174 PyProjectError: If pyproject.toml doesn't exist, requires-python is missing,
175 or no candidates match.
177 Example:
178 >>> from pathlib import Path
179 >>> path = Path("pyproject.toml")
180 >>> candidates = ["3.11", "3.12", "3.13"]
181 >>> versions = get_supported_versions(path, candidates) # doctest: +SKIP
182 >>> print(versions) # doctest: +SKIP
183 ['3.11', '3.12']
184 """
185 if not pyproject_path.exists():
186 msg = f"pyproject.toml not found at {pyproject_path}"
187 raise PyProjectError(msg)
189 # Load pyproject.toml using the tomllib standard library (Python 3.11+)
190 with pyproject_path.open("rb") as f:
191 data = tomllib.load(f)
193 # Extract the requires-python field from project metadata
194 # This specifies the Python version constraint (e.g., ">=3.11")
195 spec_str = data.get("project", {}).get("requires-python")
196 if not spec_str:
197 msg = "pyproject.toml: missing 'project.requires-python'"
198 raise PyProjectError(msg)
200 # Filter candidate versions to find which ones satisfy the constraint
201 versions: list[str] = []
202 for v in candidates:
203 if satisfies(v, spec_str):
204 versions.append(v)
206 if not versions:
207 msg = f"pyproject.toml: no supported Python versions match '{spec_str}'. Evaluated candidates: {candidates}"
208 raise PyProjectError(msg)
210 return versions
213def version_matrix_command(
214 pyproject_path: Path | None = None,
215 candidates: list[str] | None = None,
216) -> None:
217 """Emit the list of supported Python versions from pyproject.toml as JSON.
219 This command reads pyproject.toml, parses the requires-python field, and outputs
220 a JSON array of Python versions that satisfy the constraint. This is used in
221 GitHub Actions to compute the test matrix.
223 Args:
224 pyproject_path: Path to pyproject.toml. Defaults to ./pyproject.toml.
225 candidates: List of candidate Python versions to evaluate. Defaults to
226 ["3.11", "3.12", "3.13", "3.14"].
228 Raises:
229 SystemExit: If pyproject.toml is missing, invalid, or no versions match.
231 Example:
232 Get supported versions (output to stdout)::
234 version_matrix_command()
235 # Output: ["3.11", "3.12"]
237 Use custom pyproject.toml path::
239 version_matrix_command(pyproject_path=Path("/path/to/pyproject.toml"))
241 Use custom candidates::
243 version_matrix_command(candidates=["3.10", "3.11", "3.12"])
244 """
245 if pyproject_path is None:
246 pyproject_path = Path("pyproject.toml")
248 if candidates is None:
249 candidates = ["3.11", "3.12", "3.13", "3.14"]
251 try:
252 versions = get_supported_versions(pyproject_path, candidates)
253 # Output as JSON array (matches the behavior of the original script)
254 print(json.dumps(versions))
255 except (PyProjectError, VersionSpecifierError) as e:
256 console.error(str(e))
257 sys.exit(1)