/
/
/
1"""Verify pyproject.toml Python-version fields match .python-version."""
2
3from __future__ import annotations
4
5import sys
6import tomllib
7from pathlib import Path
8
9# ruff: noqa: T201
10
11
12def _emit_warnings(lines: list[str]) -> None:
13 """Print warnings; in an interactive terminal, overwrite pre-commit's verbose metadata."""
14 block = "\n".join(lines)
15 try:
16 with Path("/dev/tty").open("r"):
17 # Move up past pre-commit's "- hook id", "- duration", blank line, clear,
18 # then colorize the block in 256-color orange (xterm palette index 208).
19 block = f"\033[3A\033[J\033[38;5;208m{block}\033[0m"
20 except OSError:
21 pass
22 print(block)
23
24
25def main() -> int:
26 """Entry point; returns 0 on success, 1 on any drift."""
27 root = Path(__file__).resolve().parent.parent
28 pin = (root / ".python-version").read_text().strip()
29 parts = pin.split(".")
30 if len(parts) < 2 or not all(p.isdigit() for p in parts):
31 print(f"ERROR: .python-version has unexpected content: {pin!r}")
32 return 1
33 major_minor = f"{parts[0]}.{parts[1]}"
34 py_target = f"py{parts[0]}{parts[1]}"
35 expected_requires = f">={pin}"
36 expected_classifier = f"Programming Language :: Python :: {major_minor}"
37
38 with (root / "pyproject.toml").open("rb") as fp:
39 data = tomllib.load(fp)
40
41 # Hard errors: runtime/distribution contract â must match .python-version exactly.
42 errors: list[str] = []
43 # Soft warnings: linter/type-checker compat targets â may intentionally lag the
44 # runtime (e.g. pinned to py313 while runtime is 3.14 to keep dev backport-friendly
45 # with stable). Surfaced for awareness but not blocking.
46 warnings: list[str] = []
47
48 project = data.get("project", {})
49 requires = project.get("requires-python", "")
50 if requires != expected_requires:
51 errors.append(f"project.requires-python is {requires!r}, expected {expected_requires!r}")
52
53 classifiers = project.get("classifiers", [])
54 python_classifiers = [
55 c for c in classifiers if c.startswith("Programming Language :: Python ::")
56 ]
57 if python_classifiers != [expected_classifier]:
58 errors.append(
59 f"project.classifiers Python entries are {python_classifiers!r}, "
60 f"expected exactly [{expected_classifier!r}]"
61 )
62
63 ruff_target = data.get("tool", {}).get("ruff", {}).get("target-version", "")
64 if ruff_target != py_target:
65 warnings.append(f"tool.ruff.target-version is {ruff_target!r}, expected {py_target!r}")
66
67 mypy_python = data.get("tool", {}).get("mypy", {}).get("python_version", "")
68 if mypy_python != major_minor:
69 warnings.append(f"tool.mypy.python_version is {mypy_python!r}, expected {major_minor!r}")
70
71 if warnings:
72 _emit_warnings(
73 [f"pyproject.toml soft drift against .python-version ({pin}):"]
74 + [f" - WARN: {warn}" for warn in warnings]
75 )
76
77 if errors:
78 print(f"pyproject.toml drift detected against .python-version ({pin}):")
79 for err in errors:
80 print(f" - ERROR: {err}")
81 return 1
82
83 return 0
84
85
86if __name__ == "__main__":
87 sys.exit(main())
88