Files
rune/scripts/check_dep_lock.py
Nick 48d2cd8491 style(ci): ruff format check_dep_lock.py (#150)
Quote-style only (double -> single for the line containing \$PWD);
battery re-verified after format (15 checker tests pass).
2026-09-07 15:10:37 +01:00

192 lines
7.8 KiB
Python

#!/usr/bin/env python3
"""CI dependency-lock drift ratchet (#150).
`requirements-ci.txt` is the committed, fully-pinned CI dependency set,
generated from pyproject.toml. This script re-runs the compile into a temp
file and fails if the committed lock no longer matches pyproject.toml —
the lock is a ratchet, reviewed and re-committed, never auto-mutated.
Regenerate after ANY dependency edit (then commit the result):
uv pip compile pyproject.toml -o requirements-ci.txt --extra dev --extra memory --python-version 3.11
--python-version 3.11 matches the CI runners (not the dev machine's
interpreter) so environment markers resolve to runner reality. CI installs
this file with plain pip and then `pip install -e . --no-deps`: the project's
own dependencies come ONLY from the pinned file, so `requirements-ci.txt` is
plain requirements.txt format and runners need no uv.
Comparison is on requirement pins only (comments and uv's per-line "via"
annotations are stripped) because the hand-maintained header — and uv's
temp-file `-o` header path — cannot regenerate byte-identically.
Known limitations, disclosed (#150 gate review — cheap, honest, accepted):
1. Marker-only drift is invisible. Environment markers (`; python_version
< "3.12"`) are stripped before comparison, so a lock change that alters
ONLY a marker leaves the ratchet green. Regenerate + eyeball the diff
after any marker-affecting edit; the pin diff cannot catch it.
2. Direct-URL requirement lines (`name @ https://...`) do not fit the
`==`-pin contract: they trip the loud non-pinned-line failure above
rather than being compared. This repo's lock is index-generated and has
none; if a direct-URL dependency is ever locked, the checker must be
taught the form first (it cannot silently pass one — that much holds).
"""
import re
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
LOCK_FILE = ROOT / "requirements-ci.txt"
COMPILE_ARGS = [
"pip",
"compile",
"pyproject.toml",
"-o",
"{out}",
"--extra",
"dev",
"--extra",
"memory",
"--python-version",
"3.11",
]
REGEN_HINT = "run: uv pip compile pyproject.toml -o requirements-ci.txt --extra dev --extra memory --python-version 3.11 && commit"
def uv_binary() -> str:
"""Locate the uv binary to compile with, most-trusted first.
1. Interpreter-adjacent (sys.executable's directory): CI installs the
PINNED uv into the same venv that runs this script, so that copy is
the one whose resolver generated the lock. A uv found on PATH could
be any version and silently compile with a different resolver.
2. shutil.which (PATH).
3. ~/.local/bin/uv (the documented local install location).
"""
adjacent = Path(sys.executable).resolve().parent / "uv"
if adjacent.is_file():
return str(adjacent)
found = shutil.which("uv")
if found:
return found
fallback = Path.home() / ".local" / "bin" / "uv"
if fallback.is_file():
return str(fallback)
return "uv"
def run_uv_compile(out_path: Path, root: Path = ROOT) -> int:
"""Compile pyproject into out_path; return the subprocess exit code.
uv's full resolution listing (stdout) is captured and only printed when
the compile fails — on success it is noise on CI logs (#150 gate review).
"""
args = [uv_binary()] + [part.format(out=str(out_path)) for part in COMPILE_ARGS]
try:
proc = subprocess.run(args, cwd=str(root), check=False, capture_output=True, text=True)
except FileNotFoundError:
return -1
if proc.returncode != 0:
if proc.stdout:
print(proc.stdout, file=sys.stderr)
if proc.stderr:
print(proc.stderr, file=sys.stderr)
return proc.returncode
def lock_requirements(path: Path) -> dict[str, str]:
"""Extract normalized name -> pinned version from a requirements file.
Strips comments (whole-line and uv's indented 'via' annotations) and
skips options (-r/--index-url...). Options are the ONLY tolerated
non-pin lines: any requirement line that is not exactly pinned with
'==' (floor specs like 'rune>=0.49.0', ranges, wildcards, bare names)
exits 1 with an actionable message — a degraded floor-style lock must
fail loudly, never silently pass the pin diff (#150 gate review).
"""
pins: dict[str, str] = {}
for raw in path.read_text().splitlines():
line = raw.strip()
if not line or line.startswith("#"):
continue
# uv puts 'via' annotations on their own indented '# ...' lines; be
# defensive about inline comments too.
line = re.sub(r"\s+#.*$", "", line).strip()
if line.startswith("-"):
continue # options (-r, -c, --index-url, ...)
line = line.split(";", 1)[0].strip() # drop environment markers
version = line.partition("==")[2].strip()
if "==" not in line or not version or "*" in version:
print(
f"FAIL: {path} contains a requirement that is not exactly pinned: '{line}'\n"
"Every lock line must pin with '==' (no floors like '>=', ranges,"
" wildcards, or bare names) — otherwise the same commit resolves\n"
"differently by date, which is exactly what this lock exists to"
" prevent.\n"
f"Fix the line or regenerate the lock: {REGEN_HINT}",
file=sys.stderr,
)
raise SystemExit(1)
name, _, _ = line.partition("==")
name = re.sub(r"[-_.]+", "-", name.strip()).lower()
pins[name] = version
return pins
def compare(committed: Path, regenerated: Path) -> str | None:
"""Return None when pins match, else a human-readable drift report."""
old = lock_requirements(committed)
new = lock_requirements(regenerated)
problems = []
for name in sorted(set(old) - set(new)):
problems.append(f" {name}=={old[name]} pinned in lock but no longer resolved")
for name in sorted(set(new) - set(old)):
problems.append(f" {name}=={new[name]} resolved but missing from lock")
for name in sorted(set(old) & set(new)):
if old[name] != new[name]:
problems.append(f" {name}: {old[name]} (lock) -> {new[name]} (regenerated)")
if not problems:
return None
return "requirements-ci.txt is out of sync with pyproject.toml:\n" + "\n".join(problems) + f"\n{REGEN_HINT}"
def main() -> int:
with tempfile.TemporaryDirectory() as tmp:
regenerated = Path(tmp) / "requirements-ci.regenerated.txt"
code = run_uv_compile(regenerated)
if code == -1:
# FileNotFoundError from subprocess: the uv binary itself is
# missing. House actionable pattern — what to install, what to
# run — instead of a raw traceback (#150 gate review).
print(
"FAIL: 'uv' executable not found (checked interpreter-adjacent dir,"
" PATH, and ~/.local/bin/uv).\n"
"Install the pinned uv into the CI venv and put it on PATH:\n"
" .ci-venv/bin/pip install --disable-pip-version-check uv==0.10.11\n"
' export PATH="$PWD/.ci-venv/bin:$PATH"\n'
"Locally: see https://docs.astral.sh/uv/getting-started/installation/",
file=sys.stderr,
)
return 1
if code != 0:
print(
f"FAIL: dependency-lock regeneration failed (uv pip compile exited non-zero). {REGEN_HINT}",
file=sys.stderr,
)
return 1
report = compare(LOCK_FILE, regenerated)
if report is None:
print("Dependency lock in sync with pyproject.toml (requirements-ci.txt)")
return 0
print(f"FAIL: {report}", file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(main())