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
« 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."""
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
40 """
41 parts = version_str.split(".")
42 return (int(parts[0]), int(parts[1]))
45def get_pyproject_requires_python(repo_root: Path) -> tuple[str, str] | None:
46 """Read requires-python constraint from pyproject.toml.
48 Args:
49 repo_root: Root directory of the repository
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
59 try:
60 with pyproject_file.open("rb") as f:
61 data = tomllib.load(f)
62 except Exception:
63 return None
65 requires_python = data.get("project", {}).get("requires-python")
66 if not requires_python:
67 return None
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
74 operator = match.group(1) or "==" # Default to exact match if no operator
75 version = match.group(2)
76 return (operator, version)
79def version_satisfies_constraint(version: str, operator: str, constraint_version: str) -> bool:
80 """Check if a version satisfies a constraint.
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")
87 Returns:
88 True if version satisfies the constraint
89 """
90 v = parse_version(version)
91 cv = parse_version(constraint_version)
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
113def check_version_consistency(repo_root: Path) -> list[str]:
114 """Check Python version consistency across project files.
116 Args:
117 repo_root: Root directory of the repository
119 Returns:
120 List of error messages (empty if consistent)
121 """
122 errors: list[str] = []
124 python_version = get_python_version_file(repo_root)
125 requires_python = get_pyproject_requires_python(repo_root)
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 []
131 operator, constraint_version = requires_python
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 )
139 return errors
142def find_repo_root() -> Path:
143 """Find the repository root directory.
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()
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
166 repo_root = find_repo_root()
167 errors = check_version_consistency(repo_root)
169 if errors:
170 for error in errors:
171 print(f"ERROR: {error}")
172 return 1
174 return 0
177if __name__ == "__main__":
178 sys.exit(main())