Coverage for src/rhiza_hooks/check_python_version.py: 100%

74 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-10-27 05:51 +0000

1#!/usr/bin/env python3 

2"""Check that Python version is consistent across project files.""" 

3 

4from __future__ import annotations 

5 

6import argparse 

7import operator 

8import re 

9import sys 

10from collections.abc import Callable 

11from pathlib import Path 

12 

13from rhiza_hooks._repo import find_repo_root 

14from rhiza_hooks._toml import load_toml 

15 

16# Version comparison is a table lookup keyed by the specifier operator. A bare 

17# specifier ("") is treated as equality, matching `_parse_specifier`, which 

18# normalizes a missing operator to "==". `~=` (compatible release) means "same 

19# major, at least this minor", i.e. `>=` plus an equal major component. 

20_COMPARATORS: dict[str, Callable[[tuple[int, int], tuple[int, int]], bool]] = { 

21 ">=": operator.ge, 

22 ">": operator.gt, 

23 "<=": operator.le, 

24 "<": operator.lt, 

25 "==": operator.eq, 

26 "": operator.eq, 

27 "!=": operator.ne, 

28 "~=": lambda v, cv: v >= cv and v[0] == cv[0], 

29} 

30 

31 

32def get_python_version_file(repo_root: Path) -> str | None: 

33 """Read Python version from .python-version file. 

34 

35 Args: 

36 repo_root: Root directory of the repository 

37 

38 The first ``major.minor`` anywhere in the file is used, so interpreter-prefixed 

39 forms that uv and pyenv accept are checked too. Content naming no version at 

40 all — or that is not UTF-8 — is treated as unspecified, like a missing file. 

41 

42 Args: 

43 repo_root: Root directory of the repository 

44 

45 Returns: 

46 ``major.minor`` string, or None if the file is missing or names no version 

47 

48 >>> import tempfile 

49 >>> with tempfile.TemporaryDirectory() as d: 

50 ... _ = (Path(d) / ".python-version").write_text("pypy3.10") 

51 ... get_python_version_file(Path(d)) 

52 '3.10' 

53 >>> with tempfile.TemporaryDirectory() as d: 

54 ... _ = (Path(d) / ".python-version").write_text("system") 

55 ... get_python_version_file(Path(d)) is None 

56 True 

57 """ 

58 version_file = repo_root / ".python-version" 

59 if not version_file.exists(): 

60 return None 

61 

62 try: 

63 content = version_file.read_text(encoding="utf-8") 

64 except UnicodeDecodeError: 

65 return None 

66 match = re.search(r"\d+\.\d+", content) 

67 return match.group(0) if match else None 

68 

69 

70def parse_version(version_str: str) -> tuple[int, int]: 

71 """Parse a version string into a tuple of (major, minor). 

72 

73 This is deliberately not :func:`rhiza_hooks._version.parse_version`, which the 

74 Rust and Go hooks share. That one is lenient: it accepts any number of 

75 components, reads the leading digits of ``"1.21rc1"``, and returns None for 

76 text with no version in it. This one has a narrower contract: the comparators 

77 in ``_COMPARATORS`` are typed on ``tuple[int, int]`` and compare exactly 

78 ``major.minor``, the granularity ``requires-python`` is checked at, so it 

79 returns a pair and raises on anything else rather than returning a sentinel 

80 that every comparison would need to handle. 

81 

82 Args: 

83 version_str: Version string like "3.11" or "3.12" 

84 

85 Returns: 

86 Tuple of (major, minor) integers 

87 

88 >>> parse_version("3.11") 

89 (3, 11) 

90 >>> parse_version("3.12") 

91 (3, 12) 

92 """ 

93 normalized = version_str.strip() 

94 if re.fullmatch(r"\d+\.\d+", normalized) is None: 

95 msg = f"Invalid version string: {version_str!r}. Expected 'major.minor'." 

96 raise ValueError(msg) 

97 

98 parts = normalized.split(".") 

99 return (int(parts[0]), int(parts[1])) 

100 

101 

102def _parse_specifier(requires_python: str) -> list[tuple[str, str]]: 

103 """Parse a (possibly compound) requires-python specifier into clauses. 

104 

105 The specifier is split on commas and the leading ``operator`` + 

106 ``major.minor`` is extracted from each clause, so ``">=3.11,<3.14"`` yields 

107 ``[(">=", "3.11"), ("<", "3.14")]``. A clause with no operator defaults to 

108 ``"=="``. Clauses that contain no recognizable ``major.minor`` version are 

109 skipped. 

110 

111 Note: only the ``major.minor`` of each clause is considered; patch-level and 

112 wildcard parts (e.g. the ``.*`` in ``!=3.10.*``) are ignored, matching the 

113 granularity of :func:`version_satisfies_constraint`. 

114 

115 Args: 

116 requires_python: The raw ``requires-python`` string from pyproject.toml. 

117 

118 Returns: 

119 List of (operator, version) clauses (empty if none are parseable). 

120 """ 

121 clauses: list[tuple[str, str]] = [] 

122 for part in requires_python.split(","): 

123 match = re.match(r"\s*([><=!~]+)?\s*(\d+\.\d+)", part) 

124 if match is None: 

125 continue 

126 operator = match.group(1) or "==" # Default to exact match if no operator 

127 clauses.append((operator, match.group(2))) 

128 return clauses 

129 

130 

131def get_pyproject_requires_python(repo_root: Path) -> list[tuple[str, str]] | None: 

132 """Read requires-python constraint(s) from pyproject.toml. 

133 

134 Args: 

135 repo_root: Root directory of the repository 

136 

137 Returns: 

138 List of (operator, version) clauses, or None if not specified or 

139 unparseable. A compound specifier yields one entry per comma-separated 

140 clause, e.g. ">=3.11,<3.14" -> [(">=", "3.11"), ("<", "3.14")]. 

141 """ 

142 # A missing, malformed, unreadable or non-UTF-8 pyproject.toml is treated as 

143 # "unspecified" rather than crashing the hook; see :mod:`rhiza_hooks._toml`. 

144 data = load_toml(repo_root / "pyproject.toml") 

145 if data is None: 

146 return None 

147 

148 requires_python = data.get("project", {}).get("requires-python") 

149 if not requires_python: 

150 return None 

151 

152 clauses = _parse_specifier(requires_python) 

153 # No clause parsed (e.g. "invalid-version"): treat as unspecified. 

154 return clauses or None 

155 

156 

157def version_satisfies_constraint(version: str, operator: str, constraint_version: str) -> bool: 

158 """Check if a version satisfies a constraint. 

159 

160 Args: 

161 version: The version to check (e.g., "3.12") 

162 operator: The comparison operator (e.g., ">=", "==") 

163 constraint_version: The version in the constraint (e.g., "3.11") 

164 

165 Returns: 

166 True if version satisfies the constraint 

167 

168 >>> version_satisfies_constraint("3.12", ">=", "3.11") 

169 True 

170 >>> version_satisfies_constraint("3.10", ">=", "3.11") 

171 False 

172 

173 ``~=`` additionally pins the major component: 

174 

175 >>> version_satisfies_constraint("3.12", "~=", "3.11") 

176 True 

177 >>> version_satisfies_constraint("4.0", "~=", "3.11") 

178 False 

179 

180 An operator this hook does not model is treated permissively rather than as a 

181 violation — the hook reports disagreements it is sure about, not everything it 

182 cannot parse: 

183 

184 >>> version_satisfies_constraint("3.10", "<>", "3.11") 

185 True 

186 """ 

187 comparator = _COMPARATORS.get(operator) 

188 if comparator is None: 

189 # Unknown operator: be permissive. 

190 return True 

191 return comparator(parse_version(version), parse_version(constraint_version)) 

192 

193 

194def _format_specifier(clauses: list[tuple[str, str]]) -> str: 

195 """Render specifier clauses back into a compact string (e.g. ``>=3.11,<3.14``).""" 

196 return ",".join(f"{operator}{version}" for operator, version in clauses) 

197 

198 

199def check_version_consistency(repo_root: Path) -> list[str]: 

200 """Check Python version consistency across project files. 

201 

202 Args: 

203 repo_root: Root directory of the repository 

204 

205 Returns: 

206 List of error messages (empty if consistent) 

207 """ 

208 python_version = get_python_version_file(repo_root) 

209 requires_python = get_pyproject_requires_python(repo_root) 

210 

211 if python_version is None or requires_python is None: 

212 # One or both files don't specify a version, that's okay 

213 return [] 

214 

215 # Every clause of a (possibly compound) specifier must be satisfied. 

216 unsatisfied = any( 

217 not version_satisfies_constraint(python_version, operator, constraint_version) 

218 for operator, constraint_version in requires_python 

219 ) 

220 if not unsatisfied: 

221 return [] 

222 

223 return [ 

224 f"Python version mismatch: .python-version has {python_version}, " 

225 f"but pyproject.toml requires-python is {_format_specifier(requires_python)}" 

226 ] 

227 

228 

229def main(argv: list[str] | None = None) -> int: 

230 """Main entry point for the hook.""" 

231 parser = argparse.ArgumentParser(description="Check Python version consistency") 

232 parser.add_argument( 

233 "filenames", 

234 nargs="*", 

235 help="Filenames (ignored, checks repo root)", 

236 ) 

237 parser.parse_args(argv) # validate/consume pre-commit's filename args; result unused 

238 

239 repo_root = find_repo_root() 

240 errors = check_version_consistency(repo_root) 

241 

242 if errors: 

243 for error in errors: 

244 print(f"ERROR: {error}", file=sys.stderr) 

245 return 1 

246 

247 return 0 

248 

249 

250if __name__ == "__main__": 

251 sys.exit(main())