Import the vocalinux source tree (tray app, Next.js site in web/, packaging, installers, docs and tests) on top of the Gitea template commit. The project README replaces the template README, and the template CI files (.gitea/workflows/ci.yml, .jscpd.json, CI.md) are kept.
132 lines
5.0 KiB
Python
Executable File
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())
|