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

76 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-07-30 04:36 +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().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 comparator = _COMPARATORS.get(operator) 

150 if comparator is None: 

151 # Unknown operator: be permissive. 

152 return True 

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

154 

155 

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

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

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

159 

160 

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

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

163 

164 Args: 

165 repo_root: Root directory of the repository 

166 

167 Returns: 

168 List of error messages (empty if consistent) 

169 """ 

170 python_version = get_python_version_file(repo_root) 

171 requires_python = get_pyproject_requires_python(repo_root) 

172 

173 if python_version is None or requires_python is None: 

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

175 return [] 

176 

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

178 unsatisfied = any( 

179 not version_satisfies_constraint(python_version, operator, constraint_version) 

180 for operator, constraint_version in requires_python 

181 ) 

182 if not unsatisfied: 

183 return [] 

184 

185 return [ 

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

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

188 ] 

189 

190 

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

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

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

194 parser.add_argument( 

195 "filenames", 

196 nargs="*", 

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

198 ) 

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

200 

201 repo_root = find_repo_root() 

202 errors = check_version_consistency(repo_root) 

203 

204 if errors: 

205 for error in errors: 

206 print(f"ERROR: {error}") 

207 return 1 

208 

209 return 0 

210 

211 

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

213 sys.exit(main())