Files
voculinux/scripts/check_exports.py
T
b90bdb075d 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>
2026-09-15 22:41:26 -07:00

132 lines
5.0 KiB
Python
Executable File

#!/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())