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

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 

28class RhizaError(Exception): 

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

30 

31 

32class VersionSpecifierError(RhizaError): 

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

34 

35 

36class PyProjectError(RhizaError): 

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

38 

39 

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

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

42 

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. 

47 

48 Args: 

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

50 

51 Returns: 

52 Tuple of integers representing the version. 

53 

54 Raises: 

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

56 

57 Example: 

58 >>> parse_version("3.11") 

59 (3, 11) 

60 

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) 

72 

73 

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. 

76 

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. 

81 

82 Returns: 

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

84 

85 Example: 

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

87 True 

88 

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) 

107 

108 

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

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

111 

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

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

114 

115 Args: 

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

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

118 

119 Returns: 

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

121 

122 Raises: 

123 VersionSpecifierError: If the specifier format is invalid. 

124 

125 Example: 

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

127 True 

128 

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

130 False 

131 

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

133 True 

134 """ 

135 version_tuple = parse_version(version) 

136 

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) 

150 

151 op, spec_v = match.groups() 

152 spec_v_tuple = parse_version(spec_v) 

153 

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

155 return False 

156 

157 return True 

158 

159 

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

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

162 

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

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

165 

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

169 

170 Returns: 

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

172 

173 Raises: 

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

175 or no candidates match. 

176 

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) 

188 

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) 

192 

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) 

199 

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) 

205 

206 if not versions: 

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

208 raise PyProjectError(msg) 

209 

210 return versions 

211 

212 

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. 

218 

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. 

222 

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

227 

228 Raises: 

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

230 

231 Example: 

232 Get supported versions (output to stdout):: 

233 

234 version_matrix_command() 

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

236 

237 Use custom pyproject.toml path:: 

238 

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

240 

241 Use custom candidates:: 

242 

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

244 """ 

245 if pyproject_path is None: 

246 pyproject_path = Path("pyproject.toml") 

247 

248 if candidates is None: 

249 candidates = ["3.11", "3.12", "3.13", "3.14"] 

250 

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)