Coverage for src/rhiza_hooks/check_python_version.py: 100%
76 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-07-30 04:36 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-07-30 04:36 +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().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
148 """
149 comparator = _COMPARATORS.get(operator)
150 if comparator is None:
151 # Unknown operator: be permissive.
152 return True
153 return comparator(parse_version(version), parse_version(constraint_version))
156def _format_specifier(clauses: list[tuple[str, str]]) -> str:
157 """Render specifier clauses back into a compact string (e.g. ``>=3.11,<3.14``)."""
158 return ",".join(f"{operator}{version}" for operator, version in clauses)
161def check_version_consistency(repo_root: Path) -> list[str]:
162 """Check Python version consistency across project files.
164 Args:
165 repo_root: Root directory of the repository
167 Returns:
168 List of error messages (empty if consistent)
169 """
170 python_version = get_python_version_file(repo_root)
171 requires_python = get_pyproject_requires_python(repo_root)
173 if python_version is None or requires_python is None:
174 # One or both files don't specify a version, that's okay
175 return []
177 # Every clause of a (possibly compound) specifier must be satisfied.
178 unsatisfied = any(
179 not version_satisfies_constraint(python_version, operator, constraint_version)
180 for operator, constraint_version in requires_python
181 )
182 if not unsatisfied:
183 return []
185 return [
186 f"Python version mismatch: .python-version has {python_version}, "
187 f"but pyproject.toml requires-python is {_format_specifier(requires_python)}"
188 ]
191def main(argv: list[str] | None = None) -> int:
192 """Main entry point for the hook."""
193 parser = argparse.ArgumentParser(description="Check Python version consistency")
194 parser.add_argument(
195 "filenames",
196 nargs="*",
197 help="Filenames (ignored, checks repo root)",
198 )
199 parser.parse_args(argv) # validate/consume pre-commit's filename args; result unused
201 repo_root = find_repo_root()
202 errors = check_version_consistency(repo_root)
204 if errors:
205 for error in errors:
206 print(f"ERROR: {error}")
207 return 1
209 return 0
212if __name__ == "__main__": # pragma: no mutate
213 sys.exit(main())