* chore(build): migrate justfile tooling to uv All recipes now run through 'uv run' against .venv, with a shared DEV_EXTRAS variable (dev + vad) because uv syncs the environment exactly and would otherwise uninstall the dev tools between recipes. 'deps' installs dev+vad; new 'deps-all' covers every extra. 'build' switches to 'uv build'; 'version' uses --no-sync. Bump [tool.uv] required-version to >=0.12,<0.13 for uv 0.12.x, and raise the mypy python_version target to 3.12: mypy 1.19 in the lock cannot target 3.9 and numpy 2.x stubs need a 3.12 target to parse (mypy is not run in CI or pre-commit; this only affects 'just typecheck'). * fix(tests): isolate test runs from real XDG state; fix Arch model-path crash Local test runs failed en masse (185 failures) while CI stayed green: tests picked up the developer's real ~/.local/share/vocalinux models via the default XDG paths and switched IBus/keyboard code paths through the desktop session env (XDG_CURRENT_DESKTOP/ XDG_SESSION_TYPE on GNOME/Wayland, issue #478 branches). conftest now points XDG base dirs at a throwaway session-wide directory and clears session variables at import time, mirroring a fresh CI runner (paths like MODELS_DIR freeze at first import during collection, so fixture-time isolation is too late). Also guard the Arch branch of _get_system_model_paths(): it removed /usr/local/share/vocalinux/models unconditionally and crashed with ValueError on Arch hosts when a custom XDG_DATA_DIRS never added that path. Add regression tests, and make the lock-file test assert the actual $XDG_DATA_HOME/vocalinux contract instead of the hardcoded ~/.local/share default. * fix(tests): keep the IBus engine double a plain class Python 3.14's unittest.mock re-instantiates a user subclass when it builds child mocks (klass(name=...)). VocalinuxEngine inherits from whatever IBus.Engine resolves to, and the test module bound that to the MagicMock class itself, so every engine instantiation raised TypeError: VocalinuxEngine.__init__() got an unexpected keyword argument 'name' on 3.14 while passing on 3.13. Replace it with a plain stand-in class that still hands out per-instance MagicMocks for the IBus API surface the engine touches. The same pattern remains in test_ibus_engine.py, test_ibus_engine_utils.py, test_ibus_active_engine_guard.py and test_ibus_xkb_wayland.py; those never instantiate the engine, so they are unaffected for now. * fix(install): build the venv from the system Python install.sh looked up a bare `python3` from PATH. Started from an activated virtualenv — a shell left in uv's .venv after `just deps` — it built venv/ from that interpreter, where distro PyGObject is invisible even with --system-site-packages, and the run aborted with Distro PyGObject (python3-gi / python3-gobject) is not importable in the venv. A mismatched venv/ then survived every retry, because the installer reuses whatever venv it finds. - drop an inherited VIRTUAL_ENV (and its bin from PATH) before any interpreter lookup - select the interpreter explicitly: $SYSTEM_PYTHON (default /usr/bin/python3) or PATH python3, preferring one that can import gi, and create the venv with it instead of `python3` - recreate venv/ when it was built by a different interpreter - name the interpreter in the PyGObject guard's error Documents the two environments (uv's .venv for tooling, venv/ for the installed app) in CONTRIBUTING, the justfile header and AGENTS.md, and adds the mismatch symptom to the INSTALL troubleshooting section. * chore(python): raise the supported floor to 3.11 requires-python promised 3.9 while the CI matrix ran 3.9/3.10/3.11/3.13, the classifiers advertised up to 3.14, mypy targeted 3.12 and install.sh enforced 3.9 — five different answers to "which Python does this support". Settle on 3.11 everywhere: pyproject (requires-python, classifiers, black targets), install.sh's MIN_VERSION, check-system-deps.sh, the distro package map, the CI matrix (now 3.11-3.14, matching the classifiers), the release and nightly workflows, and the docs. mypy now targets the floor instead of 3.12, so type checking covers what we promise; numpy 2.x ships stubs in 3.12 syntax that a 3.11 target cannot parse, so its imports are skipped. (mypy has 71 pre-existing errors either way; no CI job gates on it.) uv.lock and the requirements/* exports are regenerated — dropping 3.9/3.10 removes most of the version-conditional resolution branches. tests/test_python_version_policy.py pins the invariant: install.sh's floor, requires-python, the classifiers and the CI matrix must agree. * ci: run lint and tests from uv.lock The lock file and the hash-pinned exports were not enforced anywhere: CI still built a venv by hand and installed unpinned `PyGObject` plus `-e ".[dev,vad]"`, so the tool versions it checked against had nothing to do with the ones `just lint` uses locally. Both Python jobs now use astral-sh/setup-uv and run through `uv sync --locked` / `uv run --locked`, which also fails the build when uv.lock drifts from pyproject.toml. Partially covers phase 5 of #701 (tests install from the lock, lock freshness gate); trusted publishing, artifact checksums and action SHA pinning are still open there. * ci: lint from a dedicated dependency group The lint job failed on this branch: `uv run --extra dev` syncs the whole project, so it tried to build pyaudio from source on a runner that installs no system dependencies. src/pyaudio/device_api.c:9:10: fatal error: portaudio.h: No such file or directory Move black/isort/flake8 out of the `dev` extra into a `lint` dependency group. CI runs `uv run --locked --only-group lint`, which installs the three pure-Python tools and skips the project entirely — no portaudio or girepository headers needed. `just deps` and DEV_EXTRAS request the group alongside the dev extras, so the local environment still carries test and lint tooling at once and `just lint` matches CI version for version. pre-commit is unaffected: its hooks pin their own tool versions upstream. * fix(install): never configure an engine the venv cannot import vosk became an optional extra in #705, but the installer still writes `"engine": "vosk"` into config.json on the whisper.cpp fallback path even when installing the extra only produced a warning. config.json also survives reinstalls — every writer guards on "file does not exist" — so an engine picked by an earlier attempt outlives the venv that supported it. Either way Vocalinux dies at startup: ModuleNotFoundError: No module named 'vosk' - the fallback now writes the vosk config only when `import vosk` actually succeeds, and otherwise leaves the engine configuration alone - a new verify_configured_engine() runs near the end of the install: if config.json selects vosk and the venv cannot import it, it says so and prints both fixes instead of leaving a broken install behind * feat(models): verify every model download against a pinned digest Pins a sha256/md5 per downloadable speech model in src/vocalinux/utils/model_checksums.txt and verifies against it in both install.sh and the runtime downloaders, before the file is moved into place. Verification fails closed. Covers phase 2.5 of #701; see the PR for details. * docs(agents): rebase AGENTS.md onto the #727 rewrite * fix(install): hash models that are already on disk instead of trusting them * refactor(models): one download path per engine, and fix Whisper's * docs(install): drop distros that cannot meet the 3.11 floor * docs: retire the distros and Python versions the 3.11 floor rules out * fix(install): abort instead of warning when Python is below the floor * fix(install): fall back to the system Python, not the one on PATH * fix(install): match an existing venv by installation, not by X.Y * fix(install): repair a config naming an engine the venv cannot import * fix(models): pin VOSK by the sha256 of bytes we fetched, not upstream's md5 * fix(justfile): request the dev set in every recipe that syncs * test(version): scan the docs and website trees for a stale Python floor * docs: correct the stale model-checksum and distro claims * fix(models): stamp the VOSK tree the app extracts, as the installer does * fix(install): replace an unverified VOSK model only once its successor is ready * docs(install): align the Fedora floor with the rest of the tree * test(install): drop an f-string with no placeholders * test(models): stub the downloader probe the VOSK installer test relies on * docs: drop the long dashes from two model-checksum comments * fix(install): gate on the interpreter, not on the distro release label * fix(justfile): export the lint group into requirements/dev.txt * fix(models): unpack the VOSK tree into staging and stamp it before the swap * fix(whisper): adopt a checkpoint an earlier release stored under the catalog name * docs(settings): state what a stray Whisper copy really means for deletion * fix(models): hash a whisper.cpp model found on disk before it reaches ctypes * docs(contributing): stop telling readers to uninstall their linters * fix(install): stop warning off distros the project documents as supported * docs(distros): file the supported derivatives under supported
243 lines
9.5 KiB
Python
243 lines
9.5 KiB
Python
#!/usr/bin/env python3
|
|
"""Regenerate src/vocalinux/utils/model_checksums.txt from upstream metadata.
|
|
|
|
Vocalinux downloads 40MB-2GB speech models at install time and on first run,
|
|
then hands them to native code (whisper.cpp loads its ggml files through
|
|
``ctypes.cdll``). Nothing about that is safe unless we know what we received,
|
|
so every downloader checks the file against a hash pinned *in this repository*
|
|
rather than one fetched alongside the download.
|
|
|
|
Where that hash comes from differs by registry, and so does what generating it
|
|
costs:
|
|
|
|
* whisper.cpp ships on Hugging Face, whose API exposes the sha256 of every LFS
|
|
blob (``lfs.oid``) plus the commit the listing was taken at, so nothing is
|
|
downloaded for it. That commit is pinned too -- the download URLs use it
|
|
instead of ``main``, so upstream retagging a file cannot turn every user's
|
|
install into a checksum failure.
|
|
* VOSK is different: ``model-list.json`` carries an md5 per model, but that is a
|
|
metadata field from the same host we are pinning against, so copying it pins
|
|
nothing an attacker could not change alongside the file. Those models are
|
|
downloaded here and pinned by the sha256 of the bytes we actually received;
|
|
the published md5 is checked against them on the way through, so a listing
|
|
that disagrees with its own file is caught at generation time. Downloading is
|
|
incremental -- a model already pinned by sha256 is not fetched again, so only
|
|
new models cost bandwidth. A first run, or any run with --refresh, therefore
|
|
pulls every VOSK zip: ~21.9GB, roughly half an hour on a fast connection.
|
|
|
|
The model names come from the package itself, so a model added to
|
|
vosk_model_info.py or whispercpp_model_info.py is picked up on the next run and
|
|
a model that upstream never published fails the generation loudly.
|
|
|
|
Usage: just model-checksums (or: python scripts/generate-model-checksums.py)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import re
|
|
import sys
|
|
import urllib.request
|
|
from pathlib import Path
|
|
from typing import Iterable, NamedTuple
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
SRC = REPO_ROOT / "src"
|
|
MANIFEST = SRC / "vocalinux" / "utils" / "model_checksums.txt"
|
|
|
|
HF_REPO = "ggerganov/whisper.cpp"
|
|
HF_API = f"https://huggingface.co/api/models/{HF_REPO}?blobs=true"
|
|
VOSK_MODEL_LIST = "https://alphacephei.com/vosk/models/model-list.json"
|
|
|
|
TIMEOUT = 60
|
|
|
|
|
|
class Entry(NamedTuple):
|
|
"""One verifiable artifact: the file name is the manifest's lookup key."""
|
|
|
|
filename: str
|
|
algo: str
|
|
digest: str
|
|
size: int
|
|
|
|
|
|
def _fetch_json(url: str):
|
|
request = urllib.request.Request(url, headers={"User-Agent": "vocalinux-checksum-generator"})
|
|
with urllib.request.urlopen(request, timeout=TIMEOUT) as response: # noqa: S310
|
|
return json.load(response)
|
|
|
|
|
|
def _existing_sha256_entries() -> dict:
|
|
"""sha256 entries already in the manifest, so a rerun re-downloads nothing."""
|
|
if not MANIFEST.exists():
|
|
return {}
|
|
|
|
entries = {}
|
|
for line in MANIFEST.read_text(encoding="utf-8").splitlines():
|
|
if line.startswith("#") or not line.strip():
|
|
continue
|
|
fields = line.split()
|
|
if len(fields) == 4 and fields[1] == "sha256":
|
|
entries[fields[0]] = Entry(fields[0], "sha256", fields[2], int(fields[3]))
|
|
return entries
|
|
|
|
|
|
def _download_and_hash(url: str, expected_md5: str) -> tuple[str, int]:
|
|
"""Stream ``url``, returning the sha256 of the bytes and their length.
|
|
|
|
The published md5 is verified on the way through: it is not what we pin, but
|
|
a mismatch means the listing and the file disagree, which is worth stopping
|
|
for rather than pinning either one.
|
|
"""
|
|
sha256, md5, size = hashlib.sha256(), hashlib.md5(), 0
|
|
request = urllib.request.Request(url, headers={"User-Agent": "vocalinux-checksum-generator"})
|
|
with urllib.request.urlopen(request, timeout=TIMEOUT) as response: # noqa: S310
|
|
for chunk in iter(lambda: response.read(1024 * 1024), b""):
|
|
sha256.update(chunk)
|
|
md5.update(chunk)
|
|
size += len(chunk)
|
|
|
|
if expected_md5 and md5.hexdigest() != expected_md5:
|
|
raise SystemExit(
|
|
f"{url}\n"
|
|
f" published md5: {expected_md5}\n"
|
|
f" actual md5: {md5.hexdigest()}\n"
|
|
"The listing does not describe the file it points at. Refusing to pin either."
|
|
)
|
|
return sha256.hexdigest(), size
|
|
|
|
|
|
def _model_names() -> tuple[list[str], list[str]]:
|
|
"""Model names the application can actually download, straight from the package."""
|
|
sys.path.insert(0, str(SRC))
|
|
from vocalinux.utils.vosk_model_info import VOSK_MODEL_INFO
|
|
from vocalinux.utils.whispercpp_model_info import WHISPERCPP_MODEL_INFO, whispercpp_model_file
|
|
|
|
vosk = {
|
|
name for info in VOSK_MODEL_INFO.values() for name in info["languages"].values() if name
|
|
}
|
|
whispercpp = {whispercpp_model_file(name) for name in WHISPERCPP_MODEL_INFO}
|
|
return sorted(whispercpp), sorted(vosk)
|
|
|
|
|
|
def _whispercpp_entries(wanted: Iterable[str]) -> tuple[list[Entry], str]:
|
|
payload = _fetch_json(HF_API)
|
|
revision = payload.get("sha")
|
|
if not revision:
|
|
raise SystemExit(f"{HF_API} returned no commit sha; refusing to pin an unknown revision")
|
|
|
|
by_name = {}
|
|
for sibling in payload.get("siblings", []):
|
|
lfs = sibling.get("lfs") or {}
|
|
digest = lfs.get("oid") or lfs.get("sha256")
|
|
if digest:
|
|
by_name[sibling["rfilename"]] = (digest, int(lfs.get("size", 0)))
|
|
|
|
entries, missing = [], []
|
|
for filename in wanted:
|
|
if filename not in by_name:
|
|
missing.append(filename)
|
|
continue
|
|
digest, size = by_name[filename]
|
|
entries.append(Entry(filename, "sha256", digest, size))
|
|
|
|
if missing:
|
|
raise SystemExit(
|
|
f"Hugging Face {HF_REPO}@{revision} publishes no sha256 for: {', '.join(missing)}.\n"
|
|
"Either the model was renamed upstream or whispercpp_model_info.py lists "
|
|
"a file that does not exist."
|
|
)
|
|
return entries, revision
|
|
|
|
|
|
def _vosk_entries(wanted: Iterable[str], refresh: bool) -> list[Entry]:
|
|
payload = _fetch_json(VOSK_MODEL_LIST)
|
|
by_name = {item["name"]: item for item in payload}
|
|
existing = {} if refresh else _existing_sha256_entries()
|
|
|
|
missing = [name for name in wanted if name not in by_name]
|
|
if missing:
|
|
raise SystemExit(
|
|
f"{VOSK_MODEL_LIST} does not list: {', '.join(missing)}.\n"
|
|
"Either the model was withdrawn upstream or vosk_model_info.py names "
|
|
"a model Alphacephei never shipped."
|
|
)
|
|
|
|
todo = [name for name in wanted if f"{name}.zip" not in existing]
|
|
if todo:
|
|
total = sum(int(by_name[name].get("size", 0)) for name in todo)
|
|
print(f" downloading {len(todo)} VOSK model(s), {total / 1e9:.1f} GB, to hash the bytes")
|
|
|
|
entries = []
|
|
for name in wanted:
|
|
filename = f"{name}.zip"
|
|
if filename in existing:
|
|
entries.append(existing[filename])
|
|
continue
|
|
|
|
item = by_name[name]
|
|
print(f" {name} ({int(item.get('size', 0)) / 1e6:.0f} MB)...", flush=True)
|
|
digest, size = _download_and_hash(item["url"], item.get("md5", ""))
|
|
entries.append(Entry(filename, "sha256", digest, size))
|
|
|
|
return entries
|
|
|
|
|
|
def _render(entries: list[Entry], revision: str) -> str:
|
|
width = max(len(entry.filename) for entry in entries)
|
|
lines = [
|
|
"# Checksums for every speech model Vocalinux downloads.",
|
|
"#",
|
|
"# Generated by scripts/generate-model-checksums.py -- do not edit by hand.",
|
|
"# Regenerate with `just model-checksums` after adding a model to",
|
|
"# vosk_model_info.py or whispercpp_model_info.py.",
|
|
"#",
|
|
"# Read by src/vocalinux/utils/model_checksums.py and by install.sh; keep the",
|
|
"# format (filename, algorithm, digest, size in bytes) parseable by awk.",
|
|
"#",
|
|
"# whisper.cpp models are pinned to this Hugging Face commit so upstream",
|
|
"# replacing a file cannot invalidate the digests below.",
|
|
f"# whispercpp-revision: {revision}",
|
|
"#",
|
|
"# OpenAI Whisper (.pt) models are absent on purpose: their download URLs embed",
|
|
"# the sha256 as a path segment, so the expected digest travels with the URL.",
|
|
"#",
|
|
"# VOSK digests are the sha256 of bytes this script downloaded, not the md5",
|
|
"# Alphacephei publishes; that md5 is only checked against them at that point.",
|
|
"",
|
|
]
|
|
for entry in entries:
|
|
lines.append(
|
|
f"{entry.filename.ljust(width)} {entry.algo:<6} {entry.digest} {entry.size}"
|
|
)
|
|
return "\n".join(lines) + "\n"
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument(
|
|
"--refresh",
|
|
action="store_true",
|
|
help="re-download every VOSK model instead of reusing pinned sha256 entries",
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
whispercpp_files, vosk_names = _model_names()
|
|
print(f"Resolving {len(whispercpp_files)} whisper.cpp and {len(vosk_names)} VOSK models...")
|
|
|
|
whispercpp_entries, revision = _whispercpp_entries(whispercpp_files)
|
|
print(f" whisper.cpp: {len(whispercpp_entries)} sha256 digests at {HF_REPO}@{revision[:12]}")
|
|
|
|
vosk_entries = _vosk_entries(vosk_names, args.refresh)
|
|
print(f" VOSK: {len(vosk_entries)} sha256 digests")
|
|
|
|
MANIFEST.write_text(_render(whispercpp_entries + vosk_entries, revision), encoding="utf-8")
|
|
print(f"Wrote {MANIFEST.relative_to(REPO_ROOT)}")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|