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
« 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."""
4from __future__ import annotations
6import argparse
7import re
8import sys
9import tomllib
10from pathlib import Path
13def get_python_version_file(repo_root: Path) -> str | None:
14 """Read Python version from .python-version file.
16 Args:
17 repo_root: Root directory of the repository
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
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
32def parse_version(version_str: str) -> tuple[int, int]:
33 """Parse a version string into a tuple of (major, minor).
35 Args:
36 version_str: Version string like "3.11" or "3.12"
38 Returns:
39 Tuple of (major, minor) integers
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]))
50def get_pyproject_requires_python(repo_root: Path) -> tuple[str, str] | None:
51 """Read requires-python constraint from pyproject.toml.
53 Args:
54 repo_root: Root directory of the repository
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
64 try:
65 with pyproject_file.open("rb") as f:
66 data = tomllib.load(f)
67 except Exception:
68 return None
70 requires_python = data.get("project", {}).get("requires-python")
71 if not requires_python:
72 return None
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
79 operator = match.group(1) or "==" # Default to exact match if no operator
80 version = match.group(2)
81 return (operator, version)
84def version_satisfies_constraint(version: str, operator: str, constraint_version: str) -> bool:
85 """Check if a version satisfies a constraint.
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")
92 Returns:
93 True if version satisfies the constraint
94 """
95 v = parse_version(version)
96 cv = parse_version(constraint_version)
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
118def check_version_consistency(repo_root: Path) -> list[str]:
119 """Check Python version consistency across project files.
121 Args:
122 repo_root: Root directory of the repository
124 Returns:
125 List of error messages (empty if consistent)
126 """
127 errors: list[str] = []
129 python_version = get_python_version_file(repo_root)
130 requires_python = get_pyproject_requires_python(repo_root)
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 []
136 operator, constraint_version = requires_python
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 )
144 return errors
147def find_repo_root() -> Path:
148 """Find the repository root directory.
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()
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
171 repo_root = find_repo_root()
172 errors = check_version_consistency(repo_root)
174 if errors:
175 for error in errors:
176 print(f"ERROR: {error}")
177 return 1
179 return 0
182if __name__ == "__main__":
183 sys.exit(main())