Coverage for src/rhiza_hooks/check_python_version.py: 100%
76 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-15 06:14 +0000
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-15 06:14 +0000
1#!/usr/bin/env python3
2"""Check that Python version is consistent across project files."""
4from __future__ import annotations
6import argparse
7import operator
8import re
9import sys
10import tomllib
11from collections.abc import Callable
12from pathlib import Path
14from rhiza_hooks._repo import find_repo_root
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}
32def get_python_version_file(repo_root: Path) -> str | None:
33 """Read Python version from .python-version file.
35 Args:
36 repo_root: Root directory of the repository
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
45 content = version_file.read_text(encoding="utf-8").strip()
46 # Extract major.minor version
47 match = re.match(r"(\d+\.\d+)", content)
48 return match.group(1) if match else content
51def parse_version(version_str: str) -> tuple[int, int]:
52 """Parse a version string into a tuple of (major, minor).
54 Args:
55 version_str: Version string like "3.11" or "3.12"
57 Returns:
58 Tuple of (major, minor) integers
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)
70 parts = normalized.split(".")
71 return (int(parts[0]), int(parts[1]))
74def _parse_specifier(requires_python: str) -> list[tuple[str, str]]:
75 """Parse a (possibly compound) requires-python specifier into clauses.
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.
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`.
87 Args:
88 requires_python: The raw ``requires-python`` string from pyproject.toml.
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
103def get_pyproject_requires_python(repo_root: Path) -> list[tuple[str, str]] | None:
104 """Read requires-python constraint(s) from pyproject.toml.
106 Args:
107 repo_root: Root directory of the repository
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
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
129 requires_python = data.get("project", {}).get("requires-python")
130 if not requires_python:
131 return None
133 clauses = _parse_specifier(requires_python)
134 # No clause parsed (e.g. "invalid-version"): treat as unspecified.
135 return clauses or None
138def version_satisfies_constraint(version: str, operator: str, constraint_version: str) -> bool:
139 """Check if a version satisfies a constraint.
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")
146 Returns:
147 True if version satisfies the constraint
149 >>> version_satisfies_constraint("3.12", ">=", "3.11")
150 True
151 >>> version_satisfies_constraint("3.10", ">=", "3.11")
152 False
154 ``~=`` additionally pins the major component:
156 >>> version_satisfies_constraint("3.12", "~=", "3.11")
157 True
158 >>> version_satisfies_constraint("4.0", "~=", "3.11")
159 False
161 An operator this hook does not model is treated permissively rather than as a
162 violation — the hook reports disagreements it is sure about, not everything it
163 cannot parse:
165 >>> version_satisfies_constraint("3.10", "<>", "3.11")
166 True
167 """
168 comparator = _COMPARATORS.get(operator)
169 if comparator is None:
170 # Unknown operator: be permissive.
171 return True
172 return comparator(parse_version(version), parse_version(constraint_version))
175def _format_specifier(clauses: list[tuple[str, str]]) -> str:
176 """Render specifier clauses back into a compact string (e.g. ``>=3.11,<3.14``)."""
177 return ",".join(f"{operator}{version}" for operator, version in clauses)
180def check_version_consistency(repo_root: Path) -> list[str]:
181 """Check Python version consistency across project files.
183 Args:
184 repo_root: Root directory of the repository
186 Returns:
187 List of error messages (empty if consistent)
188 """
189 python_version = get_python_version_file(repo_root)
190 requires_python = get_pyproject_requires_python(repo_root)
192 if python_version is None or requires_python is None:
193 # One or both files don't specify a version, that's okay
194 return []
196 # Every clause of a (possibly compound) specifier must be satisfied.
197 unsatisfied = any(
198 not version_satisfies_constraint(python_version, operator, constraint_version)
199 for operator, constraint_version in requires_python
200 )
201 if not unsatisfied:
202 return []
204 return [
205 f"Python version mismatch: .python-version has {python_version}, "
206 f"but pyproject.toml requires-python is {_format_specifier(requires_python)}"
207 ]
210def main(argv: list[str] | None = None) -> int:
211 """Main entry point for the hook."""
212 parser = argparse.ArgumentParser(description="Check Python version consistency")
213 parser.add_argument(
214 "filenames",
215 nargs="*",
216 help="Filenames (ignored, checks repo root)",
217 )
218 parser.parse_args(argv) # validate/consume pre-commit's filename args; result unused
220 repo_root = find_repo_root()
221 errors = check_version_consistency(repo_root)
223 if errors:
224 for error in errors:
225 print(f"ERROR: {error}", file=sys.stderr)
226 return 1
228 return 0
231if __name__ == "__main__": # pragma: no mutate
232 sys.exit(main())