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

76 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-15 06:14 +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 

10import tomllib 

11from collections.abc import Callable 

12from pathlib import Path 

13 

14from rhiza_hooks._repo import find_repo_root 

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 Returns: 

39 Python version string or None if file doesn't exist 

40 """ 

41 version_file = repo_root / ".python-version" 

42 if not version_file.exists(): 

43 return None 

44 

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

46 # Extract major.minor version 

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

48 return match.group(1) if match else content 

49 

50 

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

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

53 

54 Args: 

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

56 

57 Returns: 

58 Tuple of (major, minor) integers 

59 

60 >>> parse_version("3.11") 

61 (3, 11) 

62 >>> parse_version("3.12") 

63 (3, 12) 

64 """ 

65 normalized = version_str.strip() 

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

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

68 raise ValueError(msg) 

69 

70 parts = normalized.split(".") 

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

72 

73 

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

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

76 

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

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

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

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

81 skipped. 

82 

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

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

85 granularity of :func:`version_satisfies_constraint`. 

86 

87 Args: 

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

89 

90 Returns: 

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

92 """ 

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

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

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

96 if match is None: 

97 continue 

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

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

100 return clauses 

101 

102 

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

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

105 

106 Args: 

107 repo_root: Root directory of the repository 

108 

109 Returns: 

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

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

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

113 """ 

114 pyproject_file = repo_root / "pyproject.toml" 

115 if not pyproject_file.exists(): 

116 return None 

117 

118 try: 

119 with pyproject_file.open("rb") as f: 

120 data = tomllib.load(f) 

121 except (tomllib.TOMLDecodeError, OSError): 

122 # Malformed TOML, or filesystem-level access/open errors (for example: 

123 # path is a directory, permission denied, or the file disappears between 

124 # exists() and open()), are treated as "unspecified" rather than 

125 # crashing the hook. Anything else (e.g. a genuine bug) is left to 

126 # surface. 

127 return None 

128 

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

130 if not requires_python: 

131 return None 

132 

133 clauses = _parse_specifier(requires_python) 

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

135 return clauses or None 

136 

137 

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

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

140 

141 Args: 

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

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

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

145 

146 Returns: 

147 True if version satisfies the constraint 

148 

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

150 True 

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

152 False 

153 

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

155 

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

157 True 

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

159 False 

160 

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

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

163 cannot parse: 

164 

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

166 True 

167 """ 

168 comparator = _COMPARATORS.get(operator) 

169 if comparator is None: 

170 # Unknown operator: be permissive. 

171 return True 

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

173 

174 

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

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

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

178 

179 

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

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

182 

183 Args: 

184 repo_root: Root directory of the repository 

185 

186 Returns: 

187 List of error messages (empty if consistent) 

188 """ 

189 python_version = get_python_version_file(repo_root) 

190 requires_python = get_pyproject_requires_python(repo_root) 

191 

192 if python_version is None or requires_python is None: 

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

194 return [] 

195 

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

197 unsatisfied = any( 

198 not version_satisfies_constraint(python_version, operator, constraint_version) 

199 for operator, constraint_version in requires_python 

200 ) 

201 if not unsatisfied: 

202 return [] 

203 

204 return [ 

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

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

207 ] 

208 

209 

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

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

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

213 parser.add_argument( 

214 "filenames", 

215 nargs="*", 

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

217 ) 

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

219 

220 repo_root = find_repo_root() 

221 errors = check_version_consistency(repo_root) 

222 

223 if errors: 

224 for error in errors: 

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

226 return 1 

227 

228 return 0 

229 

230 

231if __name__ == "__main__": # pragma: no mutate 

232 sys.exit(main())