Coverage for src/rhiza_tools/commands/version_matrix.py: 100%
80 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-30 13:37 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-30 13:37 +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
27# Candidate Python versions evaluated against ``requires-python`` when the caller
28# does not pass an explicit list. Single source of truth so the runtime default
29# and the docstrings that mention it cannot drift apart.
30DEFAULT_PYTHON_CANDIDATES = ["3.11", "3.12", "3.13", "3.14"]
33class RhizaError(Exception):
34 """Base exception for Rhiza-related errors."""
37class VersionSpecifierError(RhizaError):
38 """Raised when a version string or specifier is invalid."""
41class PyProjectError(RhizaError):
42 """Raised when there are issues with pyproject.toml configuration."""
45def parse_version(v: str) -> tuple[int, ...]:
46 """Parse a version string into a tuple of integers.
48 This is intentionally simple and only supports numeric components.
49 If a component contains non-numeric suffixes (e.g. '3.11.0rc1'),
50 the leading numeric portion will be used (e.g. '0rc1' -> 0). If a
51 component has no leading digits at all, a VersionSpecifierError is raised.
53 Args:
54 v: Version string to parse (e.g., "3.11", "3.11.0rc1").
56 Returns:
57 Tuple of integers representing the version.
59 Raises:
60 VersionSpecifierError: If a version component has no numeric prefix.
62 Example:
63 >>> parse_version("3.11")
64 (3, 11)
66 >>> parse_version("3.11.0rc1")
67 (3, 11, 0)
68 """
69 parts: list[int] = []
70 for part in v.split("."):
71 match = re.match(r"\d+", part)
72 if not match:
73 msg = f"Invalid version component {part!r} in version {v!r}; expected a numeric prefix."
74 raise VersionSpecifierError(msg)
75 parts.append(int(match.group(0)))
76 return tuple(parts)
79def _check_operator(version_tuple: tuple[int, ...], op: str, spec_v_tuple: tuple[int, ...]) -> bool:
80 """Check if a version tuple satisfies an operator constraint.
82 Args:
83 version_tuple: The version to check as a tuple of integers.
84 op: The comparison operator (>=, <=, >, <, ==, !=).
85 spec_v_tuple: The specification version as a tuple of integers.
87 Returns:
88 True if the version satisfies the operator constraint, False otherwise.
90 Example:
91 >>> _check_operator((3, 11), ">=", (3, 10))
92 True
94 >>> _check_operator((3, 9), ">=", (3, 10))
95 False
96 """
97 if op == ">=":
98 return version_tuple >= spec_v_tuple
99 elif op == "<=":
100 return version_tuple <= spec_v_tuple
101 elif op == ">":
102 return version_tuple > spec_v_tuple
103 elif op == "<":
104 return version_tuple < spec_v_tuple
105 elif op == "==":
106 return version_tuple == spec_v_tuple
107 elif op == "!=":
108 return version_tuple != spec_v_tuple
109 else:
110 msg = f"Unknown operator: {op}"
111 raise VersionSpecifierError(msg)
114def satisfies(version: str, specifier: str) -> bool:
115 """Check if a version satisfies a comma-separated list of specifiers.
117 This is a simplified version of packaging.specifiers.SpecifierSet.
118 Supported operators: >=, <=, >, <, ==, !=
120 Args:
121 version: Version string to check (e.g., "3.11").
122 specifier: Comma-separated specifier string (e.g., ">=3.11,<3.14").
124 Returns:
125 True if the version satisfies all specifiers, False otherwise.
127 Raises:
128 VersionSpecifierError: If the specifier format is invalid.
130 Example:
131 >>> satisfies("3.11", ">=3.11")
132 True
134 >>> satisfies("3.10", ">=3.11")
135 False
137 >>> satisfies("3.12", ">=3.11,<3.14")
138 True
139 """
140 version_tuple = parse_version(version)
142 # Split by comma for multiple constraints
143 for raw_spec in specifier.split(","):
144 spec = raw_spec.strip()
145 # Match operator and version part
146 match = re.match(r"(>=|<=|>|<|==|!=)\s*([\d.]+)", spec)
147 if not match:
148 # If no operator, assume ==
149 if re.match(r"[\d.]+", spec):
150 if version_tuple != parse_version(spec):
151 return False
152 continue
153 msg = f"Invalid specifier {spec!r}; expected format like '>=3.11' or '3.11'"
154 raise VersionSpecifierError(msg)
156 op, spec_v = match.groups()
157 spec_v_tuple = parse_version(spec_v)
159 if not _check_operator(version_tuple, op, spec_v_tuple):
160 return False
162 return True
165def get_supported_versions(pyproject_path: Path, candidates: list[str]) -> list[str]:
166 """Return all supported Python versions declared in pyproject.toml.
168 Reads project.requires-python, evaluates candidate versions against the
169 specifier, and returns the subset that satisfy the constraint, in ascending order.
171 Args:
172 pyproject_path: Path to the pyproject.toml file.
173 candidates: List of candidate Python versions to check (e.g., ["3.11", "3.12"]).
175 Returns:
176 List of supported versions (e.g., ["3.11", "3.12"]).
178 Raises:
179 PyProjectError: If pyproject.toml doesn't exist, requires-python is missing,
180 or no candidates match.
182 Example:
183 >>> from pathlib import Path
184 >>> path = Path("pyproject.toml")
185 >>> candidates = ["3.11", "3.12", "3.13"]
186 >>> versions = get_supported_versions(path, candidates) # doctest: +SKIP
187 >>> print(versions) # doctest: +SKIP
188 ['3.11', '3.12']
189 """
190 if not pyproject_path.exists():
191 msg = f"pyproject.toml not found at {pyproject_path}"
192 raise PyProjectError(msg)
194 # Load pyproject.toml using the tomllib standard library (Python 3.11+)
195 with pyproject_path.open("rb") as f:
196 data = tomllib.load(f)
198 # Extract the requires-python field from project metadata
199 # This specifies the Python version constraint (e.g., ">=3.11")
200 spec_str = data.get("project", {}).get("requires-python")
201 if not spec_str:
202 msg = "pyproject.toml: missing 'project.requires-python'"
203 raise PyProjectError(msg)
205 # Filter candidate versions to find which ones satisfy the constraint
206 versions: list[str] = []
207 for v in candidates:
208 if satisfies(v, spec_str):
209 versions.append(v)
211 if not versions:
212 msg = f"pyproject.toml: no supported Python versions match '{spec_str}'. Evaluated candidates: {candidates}"
213 raise PyProjectError(msg)
215 return versions
218def version_matrix_command(
219 pyproject_path: Path | None = None,
220 candidates: list[str] | None = None,
221) -> None:
222 """Emit the list of supported Python versions from pyproject.toml as JSON.
224 This command reads pyproject.toml, parses the requires-python field, and outputs
225 a JSON array of Python versions that satisfy the constraint. This is used in
226 GitHub Actions to compute the test matrix.
228 Args:
229 pyproject_path: Path to pyproject.toml. Defaults to ./pyproject.toml.
230 candidates: List of candidate Python versions to evaluate. Defaults to
231 :data:`DEFAULT_PYTHON_CANDIDATES`.
233 Raises:
234 SystemExit: If pyproject.toml is missing, invalid, or no versions match.
236 Example:
237 Get supported versions (output to stdout)::
239 version_matrix_command()
240 # Output: ["3.11", "3.12"]
242 Use custom pyproject.toml path::
244 version_matrix_command(pyproject_path=Path("/path/to/pyproject.toml"))
246 Use custom candidates::
248 version_matrix_command(candidates=["3.10", "3.11", "3.12"])
249 """
250 if pyproject_path is None:
251 pyproject_path = Path("pyproject.toml")
253 if candidates is None:
254 candidates = list(DEFAULT_PYTHON_CANDIDATES)
256 try:
257 versions = get_supported_versions(pyproject_path, candidates)
258 # Output as JSON array (matches the behavior of the original script)
259 print(json.dumps(versions))
260 except (PyProjectError, VersionSpecifierError) as e:
261 console.error(str(e))
262 sys.exit(1)