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

82 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-06-08 07:00 +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 re 

8import sys 

9import tomllib 

10from pathlib import Path 

11 

12 

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

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

15 

16 Args: 

17 repo_root: Root directory of the repository 

18 

19 Returns: 

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

21 """ 

22 version_file = repo_root / ".python-version" 

23 if not version_file.exists(): 

24 return None 

25 

26 content = version_file.read_text().strip() 

27 # Extract major.minor version 

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

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

30 

31 

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

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

34 

35 Args: 

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

37 

38 Returns: 

39 Tuple of (major, minor) integers 

40 

41 >>> parse_version("3.11") 

42 (3, 11) 

43 >>> parse_version("3.12") 

44 (3, 12) 

45 """ 

46 parts = version_str.split(".") 

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

48 

49 

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

51 """Read requires-python constraint from pyproject.toml. 

52 

53 Args: 

54 repo_root: Root directory of the repository 

55 

56 Returns: 

57 Tuple of (operator, version) or None if not specified. 

58 For example: (">=", "3.11") or ("==", "3.12") 

59 """ 

60 pyproject_file = repo_root / "pyproject.toml" 

61 if not pyproject_file.exists(): 

62 return None 

63 

64 try: 

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

66 data = tomllib.load(f) 

67 except Exception: 

68 return None 

69 

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

71 if not requires_python: 

72 return None 

73 

74 # Parse the constraint (e.g., ">=3.11", "==3.12", "~=3.11") 

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

76 if not match: 

77 return None 

78 

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

80 version = match.group(2) 

81 return (operator, version) 

82 

83 

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

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

86 

87 Args: 

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

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

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

91 

92 Returns: 

93 True if version satisfies the constraint 

94 """ 

95 v = parse_version(version) 

96 cv = parse_version(constraint_version) 

97 

98 if operator == ">=": 

99 return v >= cv 

100 elif operator == ">": 

101 return v > cv 

102 elif operator == "<=": 

103 return v <= cv 

104 elif operator == "<": 

105 return v < cv 

106 elif operator == "==" or operator == "": 

107 return v == cv 

108 elif operator == "!=": 

109 return v != cv 

110 elif operator == "~=": 

111 # Compatible release: ~=3.11 means >=3.11, <4.0 

112 return v >= cv and v[0] == cv[0] 

113 else: 

114 # Unknown operator, be permissive 

115 return True 

116 

117 

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

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

120 

121 Args: 

122 repo_root: Root directory of the repository 

123 

124 Returns: 

125 List of error messages (empty if consistent) 

126 """ 

127 errors: list[str] = [] 

128 

129 python_version = get_python_version_file(repo_root) 

130 requires_python = get_pyproject_requires_python(repo_root) 

131 

132 if python_version is None or requires_python is None: 

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

134 return [] 

135 

136 operator, constraint_version = requires_python 

137 

138 if not version_satisfies_constraint(python_version, operator, constraint_version): 

139 errors.append( 

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

141 f"but pyproject.toml requires-python is {operator}{constraint_version}" 

142 ) 

143 

144 return errors 

145 

146 

147def find_repo_root() -> Path: 

148 """Find the repository root directory. 

149 

150 Returns: 

151 Path to the repository root 

152 """ 

153 current = Path.cwd() 

154 while current != current.parent: 

155 if (current / ".git").exists(): 

156 return current 

157 current = current.parent 

158 return Path.cwd() 

159 

160 

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

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

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

164 parser.add_argument( 

165 "filenames", 

166 nargs="*", 

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

168 ) 

169 args = parser.parse_args(argv) # noqa: F841 

170 

171 repo_root = find_repo_root() 

172 errors = check_version_consistency(repo_root) 

173 

174 if errors: 

175 for error in errors: 

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

177 return 1 

178 

179 return 0 

180 

181 

182if __name__ == "__main__": 

183 sys.exit(main())