Quote-style only (double -> single for the line containing \$PWD); battery re-verified after format (15 checker tests pass).
192 lines
7.8 KiB
Python
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())
|