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

1"""Command to emit supported Python versions from pyproject.toml. 

2 

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. 

6 

7Example: 

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 

19import json 

20import re 

21import sys 

22import tomllib 

23from pathlib import Path 

24 

25from rhiza_tools import console 

26 

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

31 

32 

33class RhizaError(Exception): 

34 """Base exception for Rhiza-related errors.""" 

35 

36 

37class VersionSpecifierError(RhizaError): 

38 """Raised when a version string or specifier is invalid.""" 

39 

40 

41class PyProjectError(RhizaError): 

42 """Raised when there are issues with pyproject.toml configuration.""" 

43 

44 

45def parse_version(v: str) -> tuple[int, ...]: 

46 """Parse a version string into a tuple of integers. 

47 

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. 

52 

53 Args: 

54 v: Version string to parse (e.g., "3.11", "3.11.0rc1"). 

55 

56 Returns: 

57 Tuple of integers representing the version. 

58 

59 Raises: 

60 VersionSpecifierError: If a version component has no numeric prefix. 

61 

62 Example: 

63 >>> parse_version("3.11") 

64 (3, 11) 

65 

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) 

77 

78 

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. 

81 

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. 

86 

87 Returns: 

88 True if the version satisfies the operator constraint, False otherwise. 

89 

90 Example: 

91 >>> _check_operator((3, 11), ">=", (3, 10)) 

92 True 

93 

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) 

112 

113 

114def satisfies(version: str, specifier: str) -> bool: 

115 """Check if a version satisfies a comma-separated list of specifiers. 

116 

117 This is a simplified version of packaging.specifiers.SpecifierSet. 

118 Supported operators: >=, <=, >, <, ==, != 

119 

120 Args: 

121 version: Version string to check (e.g., "3.11"). 

122 specifier: Comma-separated specifier string (e.g., ">=3.11,<3.14"). 

123 

124 Returns: 

125 True if the version satisfies all specifiers, False otherwise. 

126 

127 Raises: 

128 VersionSpecifierError: If the specifier format is invalid. 

129 

130 Example: 

131 >>> satisfies("3.11", ">=3.11") 

132 True 

133 

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

135 False 

136 

137 >>> satisfies("3.12", ">=3.11,<3.14") 

138 True 

139 """ 

140 version_tuple = parse_version(version) 

141 

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) 

155 

156 op, spec_v = match.groups() 

157 spec_v_tuple = parse_version(spec_v) 

158 

159 if not _check_operator(version_tuple, op, spec_v_tuple): 

160 return False 

161 

162 return True 

163 

164 

165def get_supported_versions(pyproject_path: Path, candidates: list[str]) -> list[str]: 

166 """Return all supported Python versions declared in pyproject.toml. 

167 

168 Reads project.requires-python, evaluates candidate versions against the 

169 specifier, and returns the subset that satisfy the constraint, in ascending order. 

170 

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

174 

175 Returns: 

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

177 

178 Raises: 

179 PyProjectError: If pyproject.toml doesn't exist, requires-python is missing, 

180 or no candidates match. 

181 

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) 

193 

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) 

197 

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) 

204 

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) 

210 

211 if not versions: 

212 msg = f"pyproject.toml: no supported Python versions match '{spec_str}'. Evaluated candidates: {candidates}" 

213 raise PyProjectError(msg) 

214 

215 return versions 

216 

217 

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. 

223 

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. 

227 

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

232 

233 Raises: 

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

235 

236 Example: 

237 Get supported versions (output to stdout):: 

238 

239 version_matrix_command() 

240 # Output: ["3.11", "3.12"] 

241 

242 Use custom pyproject.toml path:: 

243 

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

245 

246 Use custom candidates:: 

247 

248 version_matrix_command(candidates=["3.10", "3.11", "3.12"]) 

249 """ 

250 if pyproject_path is None: 

251 pyproject_path = Path("pyproject.toml") 

252 

253 if candidates is None: 

254 candidates = list(DEFAULT_PYTHON_CANDIDATES) 

255 

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)