Coverage for src/rhiza_hooks/check_python_version.py: 100%
74 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-27 05:51 +0000
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-27 05:51 +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
10from collections.abc import Callable
11from pathlib import Path
13from rhiza_hooks._repo import find_repo_root
14from rhiza_hooks._toml import load_toml
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 The first ``major.minor`` anywhere in the file is used, so interpreter-prefixed
39 forms that uv and pyenv accept are checked too. Content naming no version at
40 all — or that is not UTF-8 — is treated as unspecified, like a missing file.
42 Args:
43 repo_root: Root directory of the repository
45 Returns:
46 ``major.minor`` string, or None if the file is missing or names no version
48 >>> import tempfile
49 >>> with tempfile.TemporaryDirectory() as d:
50 ... _ = (Path(d) / ".python-version").write_text("pypy3.10")
51 ... get_python_version_file(Path(d))
52 '3.10'
53 >>> with tempfile.TemporaryDirectory() as d:
54 ... _ = (Path(d) / ".python-version").write_text("system")
55 ... get_python_version_file(Path(d)) is None
56 True
57 """
58 version_file = repo_root / ".python-version"
59 if not version_file.exists():
60 return None
62 try:
63 content = version_file.read_text(encoding="utf-8")
64 except UnicodeDecodeError:
65 return None
66 match = re.search(r"\d+\.\d+", content)
67 return match.group(0) if match else None
70def parse_version(version_str: str) -> tuple[int, int]:
71 """Parse a version string into a tuple of (major, minor).
73 This is deliberately not :func:`rhiza_hooks._version.parse_version`, which the
74 Rust and Go hooks share. That one is lenient: it accepts any number of
75 components, reads the leading digits of ``"1.21rc1"``, and returns None for
76 text with no version in it. This one has a narrower contract: the comparators
77 in ``_COMPARATORS`` are typed on ``tuple[int, int]`` and compare exactly
78 ``major.minor``, the granularity ``requires-python`` is checked at, so it
79 returns a pair and raises on anything else rather than returning a sentinel
80 that every comparison would need to handle.
82 Args:
83 version_str: Version string like "3.11" or "3.12"
85 Returns:
86 Tuple of (major, minor) integers
88 >>> parse_version("3.11")
89 (3, 11)
90 >>> parse_version("3.12")
91 (3, 12)
92 """
93 normalized = version_str.strip()
94 if re.fullmatch(r"\d+\.\d+", normalized) is None:
95 msg = f"Invalid version string: {version_str!r}. Expected 'major.minor'."
96 raise ValueError(msg)
98 parts = normalized.split(".")
99 return (int(parts[0]), int(parts[1]))
102def _parse_specifier(requires_python: str) -> list[tuple[str, str]]:
103 """Parse a (possibly compound) requires-python specifier into clauses.
105 The specifier is split on commas and the leading ``operator`` +
106 ``major.minor`` is extracted from each clause, so ``">=3.11,<3.14"`` yields
107 ``[(">=", "3.11"), ("<", "3.14")]``. A clause with no operator defaults to
108 ``"=="``. Clauses that contain no recognizable ``major.minor`` version are
109 skipped.
111 Note: only the ``major.minor`` of each clause is considered; patch-level and
112 wildcard parts (e.g. the ``.*`` in ``!=3.10.*``) are ignored, matching the
113 granularity of :func:`version_satisfies_constraint`.
115 Args:
116 requires_python: The raw ``requires-python`` string from pyproject.toml.
118 Returns:
119 List of (operator, version) clauses (empty if none are parseable).
120 """
121 clauses: list[tuple[str, str]] = []
122 for part in requires_python.split(","):
123 match = re.match(r"\s*([><=!~]+)?\s*(\d+\.\d+)", part)
124 if match is None:
125 continue
126 operator = match.group(1) or "==" # Default to exact match if no operator
127 clauses.append((operator, match.group(2)))
128 return clauses
131def get_pyproject_requires_python(repo_root: Path) -> list[tuple[str, str]] | None:
132 """Read requires-python constraint(s) from pyproject.toml.
134 Args:
135 repo_root: Root directory of the repository
137 Returns:
138 List of (operator, version) clauses, or None if not specified or
139 unparseable. A compound specifier yields one entry per comma-separated
140 clause, e.g. ">=3.11,<3.14" -> [(">=", "3.11"), ("<", "3.14")].
141 """
142 # A missing, malformed, unreadable or non-UTF-8 pyproject.toml is treated as
143 # "unspecified" rather than crashing the hook; see :mod:`rhiza_hooks._toml`.
144 data = load_toml(repo_root / "pyproject.toml")
145 if data is None:
146 return None
148 requires_python = data.get("project", {}).get("requires-python")
149 if not requires_python:
150 return None
152 clauses = _parse_specifier(requires_python)
153 # No clause parsed (e.g. "invalid-version"): treat as unspecified.
154 return clauses or None
157def version_satisfies_constraint(version: str, operator: str, constraint_version: str) -> bool:
158 """Check if a version satisfies a constraint.
160 Args:
161 version: The version to check (e.g., "3.12")
162 operator: The comparison operator (e.g., ">=", "==")
163 constraint_version: The version in the constraint (e.g., "3.11")
165 Returns:
166 True if version satisfies the constraint
168 >>> version_satisfies_constraint("3.12", ">=", "3.11")
169 True
170 >>> version_satisfies_constraint("3.10", ">=", "3.11")
171 False
173 ``~=`` additionally pins the major component:
175 >>> version_satisfies_constraint("3.12", "~=", "3.11")
176 True
177 >>> version_satisfies_constraint("4.0", "~=", "3.11")
178 False
180 An operator this hook does not model is treated permissively rather than as a
181 violation — the hook reports disagreements it is sure about, not everything it
182 cannot parse:
184 >>> version_satisfies_constraint("3.10", "<>", "3.11")
185 True
186 """
187 comparator = _COMPARATORS.get(operator)
188 if comparator is None:
189 # Unknown operator: be permissive.
190 return True
191 return comparator(parse_version(version), parse_version(constraint_version))
194def _format_specifier(clauses: list[tuple[str, str]]) -> str:
195 """Render specifier clauses back into a compact string (e.g. ``>=3.11,<3.14``)."""
196 return ",".join(f"{operator}{version}" for operator, version in clauses)
199def check_version_consistency(repo_root: Path) -> list[str]:
200 """Check Python version consistency across project files.
202 Args:
203 repo_root: Root directory of the repository
205 Returns:
206 List of error messages (empty if consistent)
207 """
208 python_version = get_python_version_file(repo_root)
209 requires_python = get_pyproject_requires_python(repo_root)
211 if python_version is None or requires_python is None:
212 # One or both files don't specify a version, that's okay
213 return []
215 # Every clause of a (possibly compound) specifier must be satisfied.
216 unsatisfied = any(
217 not version_satisfies_constraint(python_version, operator, constraint_version)
218 for operator, constraint_version in requires_python
219 )
220 if not unsatisfied:
221 return []
223 return [
224 f"Python version mismatch: .python-version has {python_version}, "
225 f"but pyproject.toml requires-python is {_format_specifier(requires_python)}"
226 ]
229def main(argv: list[str] | None = None) -> int:
230 """Main entry point for the hook."""
231 parser = argparse.ArgumentParser(description="Check Python version consistency")
232 parser.add_argument(
233 "filenames",
234 nargs="*",
235 help="Filenames (ignored, checks repo root)",
236 )
237 parser.parse_args(argv) # validate/consume pre-commit's filename args; result unused
239 repo_root = find_repo_root()
240 errors = check_version_consistency(repo_root)
242 if errors:
243 for error in errors:
244 print(f"ERROR: {error}", file=sys.stderr)
245 return 1
247 return 0
250if __name__ == "__main__":
251 sys.exit(main())