fix(deps): export and cap every engine extra (#828)
* fix(deps): export and cap every engine extra * fix(deps): docstring main and tighten export pairing tests Give check_exports.main the required public docstring, annotate the dependency export test helpers, and require --extra to sit with its -o on the same just lock line (whisper pip-compile stays special-cased). --------- Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
This commit is contained in:
co-authored by
Jatin K Malik
parent
092dde105e
commit
b90bdb075d
Executable
+131
@@ -0,0 +1,131 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fail if any `requirements/*.txt` export is behind `uv.lock`.
|
||||
|
||||
`just lock` writes the exports and nothing ever re-ran it, so a committed export
|
||||
stays whatever it was on the day it was written. `just lock-check` and
|
||||
`just flatpak-deps-check` are both in the justfile and neither is wired to a
|
||||
workflow, which is how three engine exports could be added with nothing
|
||||
comparing them to the lock they came from. The lock itself is covered:
|
||||
`uv sync --locked` and `uv run --locked` in the pipeline fail when it drifts
|
||||
from pyproject.toml. Nothing covered the step after that one.
|
||||
|
||||
The export lines are read out of the `lock` recipe rather than listed here, so
|
||||
an export added there is checked without this file changing.
|
||||
|
||||
`uv export` reads `uv.lock` and nothing else, so this runs under UV_OFFLINE=1
|
||||
and reproduces every file byte for byte on a cold cache. The env var rather than
|
||||
`--offline`: uv echoes its own argv into the header of the file it writes, and
|
||||
the flag would change the bytes this exists to compare.
|
||||
|
||||
`uv pip compile` lines stay out of scope. requirements/whisper.txt and the
|
||||
AppImage files resolve against live indexes on purpose (see
|
||||
requirements/whisper.in), so they are neither reproducible offline nor derived
|
||||
from the lock.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
JUSTFILE = REPO_ROOT / "justfile"
|
||||
|
||||
|
||||
def _lock_recipe() -> list[str]:
|
||||
"""The body of the `lock` recipe, one command per entry.
|
||||
|
||||
Continuations are joined. The recipe already wraps its `uv pip compile`
|
||||
lines, so a wrapped `uv export` is a matter of time, and without this it
|
||||
would reach `shlex.split` with a dangling backslash: that raises `ValueError:
|
||||
No escaped character` instead of naming a file, and the `-o` sitting on the
|
||||
next physical line would be invisible either way.
|
||||
"""
|
||||
body: list[str] = []
|
||||
inside = False
|
||||
for line in JUSTFILE.read_text(encoding="utf-8").splitlines():
|
||||
if re.match(r"^lock:\s*$", line):
|
||||
inside = True
|
||||
continue
|
||||
if not inside:
|
||||
continue
|
||||
if line.strip() and not line.startswith((" ", "\t")):
|
||||
break
|
||||
command = line.strip()
|
||||
if body and body[-1].endswith("\\"):
|
||||
body[-1] = body[-1][:-1].rstrip() + " " + command
|
||||
else:
|
||||
body.append(command)
|
||||
return body
|
||||
|
||||
|
||||
def export_commands() -> list[tuple[list[str], Path]]:
|
||||
"""Every `uv export` line in `just lock`, with the file it writes.
|
||||
|
||||
A line with no `-o` writes to stdout, which no export in the recipe does and
|
||||
which this could not compare: reported rather than skipped, because skipping
|
||||
it is the failure this whole script is for.
|
||||
"""
|
||||
commands = []
|
||||
for line in _lock_recipe():
|
||||
if not line.startswith("uv export"):
|
||||
continue
|
||||
argv = shlex.split(line)
|
||||
if "-o" not in argv:
|
||||
raise SystemExit(f"`just lock` exports to stdout, so nothing can check it:\n {line}")
|
||||
commands.append((argv, REPO_ROOT / argv[argv.index("-o") + 1]))
|
||||
return commands
|
||||
|
||||
|
||||
def regenerate(argv: list[str], target: Path, scratch: Path) -> str:
|
||||
"""Re-run one export into `scratch` and return what it would have written.
|
||||
|
||||
uv records the command it ran in the file's header, so the scratch path is
|
||||
put back to the declared one before comparing: the header is part of the
|
||||
export and worth checking, just not the part that says where it went.
|
||||
"""
|
||||
rerun = list(argv)
|
||||
rerun[rerun.index("-o") + 1] = str(scratch)
|
||||
result = subprocess.run(
|
||||
rerun,
|
||||
cwd=REPO_ROOT,
|
||||
env={**os.environ, "UV_OFFLINE": "1"},
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise SystemExit(f"{shlex.join(rerun)}\n{result.stderr.strip()}")
|
||||
written = scratch.read_text(encoding="utf-8")
|
||||
return written.replace(str(scratch), target.relative_to(REPO_ROOT).as_posix())
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Check every lock-generated export and report whether any are stale."""
|
||||
commands = export_commands()
|
||||
if not commands:
|
||||
print("no `uv export` lines in the lock recipe; nothing was checked", file=sys.stderr)
|
||||
return 1
|
||||
stale = []
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
for argv, target in commands:
|
||||
expected = regenerate(argv, target, Path(tmp) / target.name)
|
||||
name = target.relative_to(REPO_ROOT)
|
||||
if not target.is_file():
|
||||
stale.append(f"{name} is named in `just lock` but is not committed")
|
||||
elif target.read_text(encoding="utf-8") != expected:
|
||||
stale.append(f"{name} is behind uv.lock")
|
||||
if stale:
|
||||
print("\n".join(stale), file=sys.stderr)
|
||||
print("re-run `just lock` and commit the result", file=sys.stderr)
|
||||
return 1
|
||||
print(f"{len(commands)} exports match uv.lock")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user