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

82 statements  

« prev     ^ index     » next       coverage.py v7.13.3, created at 2026-02-08 08:53 +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 parts = version_str.split(".") 

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

43 

44 

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

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

47 

48 Args: 

49 repo_root: Root directory of the repository 

50 

51 Returns: 

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

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

54 """ 

55 pyproject_file = repo_root / "pyproject.toml" 

56 if not pyproject_file.exists(): 

57 return None 

58 

59 try: 

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

61 data = tomllib.load(f) 

62 except Exception: 

63 return None 

64 

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

66 if not requires_python: 

67 return None 

68 

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

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

71 if not match: 

72 return None 

73 

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

75 version = match.group(2) 

76 return (operator, version) 

77 

78 

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

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

81 

82 Args: 

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

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

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

86 

87 Returns: 

88 True if version satisfies the constraint 

89 """ 

90 v = parse_version(version) 

91 cv = parse_version(constraint_version) 

92 

93 if operator == ">=": 

94 return v >= cv 

95 elif operator == ">": 

96 return v > cv 

97 elif operator == "<=": 

98 return v <= cv 

99 elif operator == "<": 

100 return v < cv 

101 elif operator == "==" or operator == "": 

102 return v == cv 

103 elif operator == "!=": 

104 return v != cv 

105 elif operator == "~=": 

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

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

108 else: 

109 # Unknown operator, be permissive 

110 return True 

111 

112 

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

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

115 

116 Args: 

117 repo_root: Root directory of the repository 

118 

119 Returns: 

120 List of error messages (empty if consistent) 

121 """ 

122 errors: list[str] = [] 

123 

124 python_version = get_python_version_file(repo_root) 

125 requires_python = get_pyproject_requires_python(repo_root) 

126 

127 if python_version is None or requires_python is None: 

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

129 return [] 

130 

131 operator, constraint_version = requires_python 

132 

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

134 errors.append( 

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

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

137 ) 

138 

139 return errors 

140 

141 

142def find_repo_root() -> Path: 

143 """Find the repository root directory. 

144 

145 Returns: 

146 Path to the repository root 

147 """ 

148 current = Path.cwd() 

149 while current != current.parent: 

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

151 return current 

152 current = current.parent 

153 return Path.cwd() 

154 

155 

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

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

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

159 parser.add_argument( 

160 "filenames", 

161 nargs="*", 

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

163 ) 

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

165 

166 repo_root = find_repo_root() 

167 errors = check_version_consistency(repo_root) 

168 

169 if errors: 

170 for error in errors: 

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

172 return 1 

173 

174 return 0 

175 

176 

177if __name__ == "__main__": 

178 sys.exit(main())