From f43f9dcd72c9d111ff8ca810578b1e0eed39d373 Mon Sep 17 00:00:00 2001 From: Jatin K Malik Date: Mon, 24 Aug 2026 10:42:52 -0700 Subject: [PATCH] fix: leftover engine repair, just --no-sync, and ggml verify from #713 (#732) * fix: leftover engine repair, just --no-sync, and ggml verify from #713 #713 only rewrote a broken config.json toward whisper_cpp, so a reinstall that kept the old engine still died when vosk was the extra that installed. verify_configured_engine now prefers SELECTED_ENGINE, then any working extra, and the vosk fallback rewrites an existing file. just recipes use uv run --no-sync so they do not prune extras from just deps-all. CONTRIBUTING lists libgirepository-2.0-dev for Ubuntu 24.04. An unverified ggml file is not loaded if unlink fails, and an unpinned name is refused rather than deleted. * fix(appimage): retry tooling downloads on curl reset * fix(build): bootstrap .venv for --no-sync just recipes and cover the contributor path in CI --------- Co-authored-by: Sergey Silaev --- .github/workflows/unified-pipeline.yml | 42 ++++++ AGENTS.md | 3 +- CONTRIBUTING.md | 29 +++-- install.sh | 121 ++++++++++-------- justfile | 74 ++++++----- packaging/appimage/build.sh | 10 +- .../speech_recognition/recognition_manager.py | 22 ++-- tests/test_dependency_exports.py | 65 ++++++++++ tests/test_installer_engine_config.py | 37 +++++- tests/test_recognition_manager_advanced.py | 13 +- tests/test_recognition_manager_downloads.py | 50 ++++++++ tests/test_whisper_support.py | 5 + 12 files changed, 359 insertions(+), 112 deletions(-) diff --git a/.github/workflows/unified-pipeline.yml b/.github/workflows/unified-pipeline.yml index 3400d7f..199c1e4 100644 --- a/.github/workflows/unified-pipeline.yml +++ b/.github/workflows/unified-pipeline.yml @@ -164,6 +164,48 @@ jobs: files: coverage.xml fail_ci_if_error: false + just-contributor-path: + name: 🧰 Contributor Path (just) + runs-on: ubuntu-latest + timeout-minutes: 20 + needs: [changes, python-lint] + if: needs.changes.outputs.python == 'true' + steps: + - uses: actions/checkout@v5 + + - name: Set up uv + uses: astral-sh/setup-uv@v6 + with: + # Caches uv's download cache, not .venv/, so the checkout below stays + # the fresh clone this job exists to exercise. + enable-cache: true + + - name: Install just + run: | + uv tool install rust-just + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + libgirepository-2.0-dev \ + libcairo2-dev \ + pkg-config \ + python3-dev \ + portaudio19-dev \ + gir1.2-gtk-3.0 \ + gir1.2-appindicator3-0.1 \ + xdotool + + # The rest of the pipeline drives uv directly, so nothing here checks that + # the workflow CONTRIBUTING documents still works. `just deps` is skipped on + # purpose: running it first would populate .venv/ and hide exactly the + # breakage this job is for, where `uv run --no-sync` finds no interpreter to + # run the tooling with. + - name: just test, from a clean checkout + run: just test + appimage-smoke-test: name: 📦 Build AppImage (smoke test, ${{ matrix.arch }}) runs-on: ${{ matrix.runner }} diff --git a/AGENTS.md b/AGENTS.md index a1c5ad5..ff08ee6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,7 +37,7 @@ Rules: Two virtualenvs, on purpose: -- **`.venv/`** — uv's, for dev tooling. Every `just` recipe runs through `uv run`, so no activation is needed. +- **`.venv/`** — uv's, for dev tooling. `just deps` syncs it; every other Python `just` recipe uses `uv run --no-sync` so it does not prune extras that `just deps-all` installed. No activation needed. - **`venv/`** — what `install.sh` builds for the app itself, from the *system* Python so distro `gi` is importable. Never run `install.sh` from an activated `.venv`: it drops an inherited `VIRTUAL_ENV` from `PATH` and picks `$SYSTEM_PYTHON` (default `/usr/bin/python3`) precisely because a venv built from uv's interpreter cannot see distro PyGObject. @@ -74,6 +74,7 @@ just typecheck # mypy src/ just test # pytest -v just test-cov # pytest --cov=src --cov-report=html just deps # sync .venv with dev+vad extras and the lint group +just deps-all # also whisper/vosk/docs; later recipes use --no-sync so they keep it just lock # regenerate uv.lock + requirements/*.txt just lock-check # fail if uv.lock is stale vs pyproject.toml just model-checksums # refresh pinned model digests after adding a model diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9a159ed..8732ae6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -70,13 +70,15 @@ This will: 2. **Install system dependencies:** ```bash - # Ubuntu + # Ubuntu 24.04+ (`just deps` pip-builds PyGObject 3.56 from the lock) sudo apt update sudo apt install -y python3-pip python3-gi python3-gi-cairo \ - gir1.2-gtk-3.0 libgirepository1.0-dev \ + gir1.2-gtk-3.0 libgirepository-2.0-dev libgirepository1.0-dev \ python3-dev portaudio19-dev python3-venv xdotool - # Debian 12+ + # Debian 12 cannot pip-build PyGObject 3.56 (glib 2.74). Use Option 1 + # (`./install.sh --dev`) for tests and running from source, then + # `venv/bin/pytest` / `venv/bin/python -m vocalinux.main --debug`. sudo apt install -y python3-pip python3-gi python3-gi-cairo \ gir1.2-gtk-3.0 libgirepository1.0-dev libcairo2-dev \ python3-dev portaudio19-dev python3-venv xdotool @@ -98,6 +100,9 @@ This will: # Requires uv (https://docs.astral.sh/uv/); creates .venv/ with dev + vad extras just deps ``` + `just deps` compiles PyGObject from the lock into `.venv`. That needs + `libgirepository-2.0-dev` (Ubuntu 24.04+). Debian 12 cannot build it; use + Option 1 and the installer `venv/` instead. 4. **Run the application:** ```bash @@ -106,7 +111,7 @@ This will: 5. **(Optional) Install pre-commit hooks:** ```bash - uv run --extra dev --extra vad --group lint pre-commit install + uv run --no-sync pre-commit install ``` > **Note:** Pre-commit hooks are optional. The CI pipeline runs the same checks, so you can skip this if you prefer faster local commits. @@ -119,14 +124,14 @@ The repository uses two virtual environments on purpose — don't merge them: | `.venv/` | `just deps` (uv) | pinned in `.python-version` | dev tooling: pytest, black, mypy, `just` recipes | | `venv/` | `./install.sh` | the system Python | running the installed app, which needs distro PyGObject | -`gi` (PyGObject) is the reason. `uv sync` builds it from source into `.venv/`, -which works wherever glib 2.80+ and the `girepository` headers are present (Arch, -Fedora, recent CI runners) but fails on Ubuntu 24.04 and Debian — there the distro -package is the only option, and it is importable only from the system interpreter. -`install.sh` therefore never reuses `.venv/`: it builds `venv/` from the system -Python with `--system-site-packages`, and rebuilds it if another interpreter -created it. Set `SYSTEM_PYTHON=/usr/bin/python3.12 ./install.sh` on systems that -ship several system interpreters. +`gi` (PyGObject) is the reason. `just deps` / `uv sync` build it from source into +`.venv/`, which works wherever glib 2.80+ and `libgirepository-2.0-dev` are +present (Arch, Fedora, Ubuntu 24.04 with that package, CI). Debian 12 cannot +build PyGObject 3.56 at all; use `./install.sh --dev` and `venv/bin/pytest`. +`install.sh` never reuses `.venv/`: it builds `venv/` from the system Python with +`--system-site-packages`, and rebuilds it if another interpreter created it. Set +`SYSTEM_PYTHON=/usr/bin/python3.12 ./install.sh` on systems that ship several +system interpreters. ## Making Changes diff --git a/install.sh b/install.sh index 7b0b602..29ac231 100755 --- a/install.sh +++ b/install.sh @@ -2897,6 +2897,48 @@ should_rebuild_whispercpp() { return 1 } +# Module each engine needs, and the pip name that provides it. Defined here so +# the whisper.cpp -> vosk fallback can rewrite an existing config.json; the +# writers above used to skip a file that already existed. +engine_import_module() { + case "$1" in + vosk) echo "vosk" ;; + whisper) echo "whisper" ;; + whisper_cpp) echo "pywhispercpp.model" ;; + *) echo "" ;; + esac +} + +engine_pip_name() { + case "$1" in + vosk) echo "vosk" ;; + whisper) echo "openai-whisper" ;; + whisper_cpp) echo "pywhispercpp" ;; + *) echo "" ;; + esac +} + +venv_can_import() { + [ -x "$VENV_DIR/bin/python" ] || return 1 + "$VENV_DIR/bin/python" -c "import $1" >/dev/null 2>&1 +} + +# Point config.json at engine $2. Non-zero if it could not be rewritten. +set_configured_engine() { + "$VENV_DIR/bin/python" - "$1" "$2" <<'PY' 2>/dev/null +import json +import sys + +path, engine = sys.argv[1], sys.argv[2] +with open(path) as handle: + config = json.load(handle) +config.setdefault("speech_recognition", {})["engine"] = engine +with open(path, "w") as handle: + json.dump(config, handle, indent=2) + handle.write("\n") +PY +} + install_whispercpp_with_gpu_support() { local PIP_LOG_FILE="$1" @@ -3065,7 +3107,13 @@ install_whispercpp_with_gpu_support() { fi local FALLBACK_VOSK_CONFIG="$CONFIG_DIR/config.json" - if [ ! -f "$FALLBACK_VOSK_CONFIG" ]; then + if [ -f "$FALLBACK_VOSK_CONFIG" ]; then + if set_configured_engine "$FALLBACK_VOSK_CONFIG" "vosk"; then + print_success "Switched $FALLBACK_VOSK_CONFIG to the vosk engine." + else + print_warning "Could not rewrite $FALLBACK_VOSK_CONFIG to vosk." + fi + else mkdir -p "$CONFIG_DIR" cat > "$FALLBACK_VOSK_CONFIG" << 'FALLBACK_VOSK_CONFIG' { @@ -4286,51 +4334,10 @@ else print_info "Models will be downloaded automatically on first application run" fi -# config.json survives reinstalls — every writer above guards on "file does not -# exist" — so an engine picked by an earlier attempt can outlive the venv that -# supported it. Repair it here instead of letting the app die at startup with -# ModuleNotFoundError. -# Module each engine needs, and the pip name that provides it. whisper_cpp is -# the default engine and the fallback, so it is what a broken config is moved to. -engine_import_module() { - case "$1" in - vosk) echo "vosk" ;; - whisper) echo "whisper" ;; - whisper_cpp) echo "pywhispercpp.model" ;; - *) echo "" ;; - esac -} - -engine_pip_name() { - case "$1" in - vosk) echo "vosk" ;; - whisper) echo "openai-whisper" ;; - whisper_cpp) echo "pywhispercpp" ;; - *) echo "" ;; - esac -} - -venv_can_import() { - [ -x "$VENV_DIR/bin/python" ] || return 1 - "$VENV_DIR/bin/python" -c "import $1" >/dev/null 2>&1 -} - -# Point config.json at engine $2. Non-zero if it could not be rewritten. -set_configured_engine() { - "$VENV_DIR/bin/python" - "$1" "$2" <<'PY' 2>/dev/null -import json -import sys - -path, engine = sys.argv[1], sys.argv[2] -with open(path) as handle: - config = json.load(handle) -config.setdefault("speech_recognition", {})["engine"] = engine -with open(path, "w") as handle: - json.dump(config, handle, indent=2) - handle.write("\n") -PY -} - +# config.json survives reinstalls, so an engine picked by an earlier attempt +# can outlive the venv that supported it. Repair it here instead of letting the +# app die at startup with ModuleNotFoundError. Prefer SELECTED_ENGINE when that +# extra is importable (the whisper.cpp -> vosk fallback), then any working extra. verify_configured_engine() { local CONFIG_FILE="$CONFIG_DIR/config.json" [ -f "$CONFIG_FILE" ] || return 0 @@ -4356,15 +4363,25 @@ PY print_warning "$CONFIG_FILE selects the $CONFIGURED_ENGINE engine, but it is not importable in $VENV_DIR." - # Leaving it means the app dies at startup with ModuleNotFoundError, so move - # the config to the default engine when that one works. - if [ "$CONFIGURED_ENGINE" != "whisper_cpp" ] && venv_can_import "pywhispercpp.model"; then - if set_configured_engine "$CONFIG_FILE" "whisper_cpp"; then - print_success "Switched $CONFIG_FILE to the whisper_cpp engine." + # Leaving it means the app dies at startup with ModuleNotFoundError. Prefer + # SELECTED_ENGINE (set by the whisper.cpp -> vosk fallback), then any extra + # the venv can actually import. + local CANDIDATE MODULE_FOR_CANDIDATE TRIED="" + for CANDIDATE in ${SELECTED_ENGINE:+$SELECTED_ENGINE} whisper_cpp vosk whisper; do + [ "$CANDIDATE" != "$CONFIGURED_ENGINE" ] || continue + case " $TRIED " in + *" $CANDIDATE "*) continue ;; + esac + TRIED="$TRIED $CANDIDATE" + MODULE_FOR_CANDIDATE=$(engine_import_module "$CANDIDATE") + [ -n "$MODULE_FOR_CANDIDATE" ] || continue + venv_can_import "$MODULE_FOR_CANDIDATE" || continue + if set_configured_engine "$CONFIG_FILE" "$CANDIDATE"; then + print_success "Switched $CONFIG_FILE to the $CANDIDATE engine." print_info "Reinstall $(engine_pip_name "$CONFIGURED_ENGINE") and switch back in Settings if you want it." return 0 fi - fi + done print_error "Vocalinux cannot start with this configuration and no working engine is available." print_error "Install the engine: $VENV_DIR/bin/pip install $(engine_pip_name "$CONFIGURED_ENGINE")" diff --git a/justfile b/justfile index c5537c5..c7d84b4 100644 --- a/justfile +++ b/justfile @@ -4,26 +4,38 @@ # .venv/ — run `just deps` after cloning (requires uv). # # Two environments, deliberately separate: -# .venv/ dev tooling, built by uv from .python-version (3.13). -# venv/ what `just install` creates, always from the system Python — on -# distros where PyGObject cannot be pip-built (Ubuntu 24.04, Debian) -# the distro package is importable only from that interpreter. +# .venv/ dev tooling, built by uv from .python-version (3.13). `just deps` +# pip-builds PyGObject from the lock and needs libgirepository-2.0-dev +# (Ubuntu 24.04+). Debian 12 cannot build 3.56; use `just install-dev`. +# venv/ what `just install` creates, always from the system Python so the +# distro PyGObject package is importable. # install.sh ignores an activated .venv and rebuilds # venv/ if another interpreter created it, so `just install` is safe to # run from any shell. Override with SYSTEM_PYTHON=/usr/bin/python3.12. -# Extras and groups installed by `just deps` and requested by every recipe below. -# `uv sync` prunes whatever the flags do not name — omitting `--group lint` really -# does uninstall the linters — and `uv run` has pruned in past versions, so every -# recipe asks for the same set rather than depending on which uv is installed. -# CI lints with `--only-group lint` instead: that skips the project, whose -# pyaudio/PyGObject need system headers a lint runner has no reason to install. +# Extras and groups installed by `just deps`. `uv sync` prunes whatever the flags +# do not name — omitting `--group lint` really does uninstall the linters. +# Recipes that run tools use `uv run --no-sync` so they do not undo `just deps-all` +# (whisper/vosk/docs). They depend on `_tooling` for the same reason: with nothing +# left to create .venv/, `uv run --no-sync` on a fresh clone leaves an empty one +# behind and fails with `Failed to spawn: pytest`. CI lints with `--only-group lint` +# instead: that skips the project, whose pyaudio/PyGObject need system headers a +# lint runner has no reason to install. DEV_EXTRAS := "--extra dev --extra vad --group lint" # List available recipes default: @just --list +# Create .venv/ and keep it matching the lock. --inexact is what makes this safe +# to run ahead of every recipe: a plain `uv sync` removes extraneous packages, so +# it would uninstall whatever `just deps-all` installed — the very thing the +# --no-sync flags below exist to prevent. `version` skips this; it reads one +# string out of version.py and needs a bare interpreter, not a sync. +[private] +_tooling: + uv sync --inexact {{DEV_EXTRAS}} + # Install Vocalinux install: ./install.sh @@ -41,36 +53,36 @@ deps-all: uv sync --all-extras --group lint # Run test suite -test: +test: _tooling @echo "Running tests..." - uv run {{DEV_EXTRAS}} pytest -v + uv run --no-sync pytest -v # Run tests with coverage -test-cov: +test-cov: _tooling @echo "Running tests with coverage..." - uv run {{DEV_EXTRAS}} pytest --cov=src --cov-report=html --cov-report=term + uv run --no-sync pytest --cov=src --cov-report=html --cov-report=term @echo "Coverage report generated in htmlcov/" # Run linters (flake8, black, isort) -lint: +lint: _tooling @echo "Running flake8..." - uv run {{DEV_EXTRAS}} flake8 src/ tests/ --count --select=E9,F63,F7,F82 --show-source --statistics + uv run --no-sync flake8 src/ tests/ --count --select=E9,F63,F7,F82 --show-source --statistics @echo "Checking black formatting..." - uv run {{DEV_EXTRAS}} black --check --diff src/ tests/ + uv run --no-sync black --check --diff src/ tests/ @echo "Checking isort..." - uv run {{DEV_EXTRAS}} isort --check-only --diff --profile black src/ tests/ + uv run --no-sync isort --check-only --diff --profile black src/ tests/ # Auto-format code (black + isort) -format: +format: _tooling @echo "Formatting with black..." - uv run {{DEV_EXTRAS}} black src/ tests/ + uv run --no-sync black src/ tests/ @echo "Sorting imports with isort..." - uv run {{DEV_EXTRAS}} isort --profile black src/ tests/ + uv run --no-sync isort --profile black src/ tests/ # Run type checking (mypy) -typecheck: +typecheck: _tooling @echo "Running mypy..." - uv run {{DEV_EXTRAS}} mypy src/ + uv run --no-sync mypy src/ # Build distribution packages build: @@ -103,8 +115,8 @@ lock-check: # every zip not already in the manifest is downloaded. A first run, or any run # with --refresh, pulls ~21.9GB and takes ~30 minutes. # Run after adding a model to vosk_model_info.py or whispercpp_model_info.py. -model-checksums: - uv run {{DEV_EXTRAS}} python scripts/generate-model-checksums.py +model-checksums: _tooling + uv run --no-sync python scripts/generate-model-checksums.py # Remove build artifacts clean: @@ -131,16 +143,16 @@ run-debug: vocalinux --debug # Run from source -run-source: - uv run {{DEV_EXTRAS}} python -m vocalinux.main +run-source: _tooling + uv run --no-sync python -m vocalinux.main # Run from source with debug logging -run-source-debug: - uv run {{DEV_EXTRAS}} python -m vocalinux.main --debug +run-source-debug: _tooling + uv run --no-sync python -m vocalinux.main --debug # Run pre-commit hooks on all files -pre-commit: - uv run {{DEV_EXTRAS}} pre-commit run --all-files +pre-commit: _tooling + uv run --no-sync pre-commit run --all-files # Print the current version version: diff --git a/packaging/appimage/build.sh b/packaging/appimage/build.sh index fff4622..bef5235 100755 --- a/packaging/appimage/build.sh +++ b/packaging/appimage/build.sh @@ -83,11 +83,15 @@ mkdir -p "$APPDIR/usr/bin" "$APPDIR/usr/lib" "$TOOLDIR" "$OUTDIR" export APPIMAGE_EXTRACT_AND_RUN="${APPIMAGE_EXTRACT_AND_RUN:-1}" echo "== Fetching AppImage tooling ($ARCH) ==" -curl -fsSL -o "$TOOLDIR/linuxdeploy" \ +# Default --retry skips TLS RST (curl 35). GitHub continuous AppImages flake that way. +curl_retry() { + curl --retry 5 --retry-all-errors --retry-delay 2 --fail --silent --show-error --location "$@" +} +curl_retry -o "$TOOLDIR/linuxdeploy" \ "https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-${ARCH}.AppImage" -curl -fsSL -o "$TOOLDIR/linuxdeploy-plugin-gtk.sh" \ +curl_retry -o "$TOOLDIR/linuxdeploy-plugin-gtk.sh" \ https://raw.githubusercontent.com/linuxdeploy/linuxdeploy-plugin-gtk/master/linuxdeploy-plugin-gtk.sh -curl -fsSL -o "$TOOLDIR/appimagetool" \ +curl_retry -o "$TOOLDIR/appimagetool" \ "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-${ARCH}.AppImage" chmod +x "$TOOLDIR/linuxdeploy" "$TOOLDIR/linuxdeploy-plugin-gtk.sh" "$TOOLDIR/appimagetool" diff --git a/src/vocalinux/speech_recognition/recognition_manager.py b/src/vocalinux/speech_recognition/recognition_manager.py index 8c3db6b..19d1f3e 100644 --- a/src/vocalinux/speech_recognition/recognition_manager.py +++ b/src/vocalinux/speech_recognition/recognition_manager.py @@ -23,6 +23,7 @@ from ..common_types import RecognitionState from ..ui.audio_feedback import play_error_sound, play_start_sound, play_stop_sound from ..utils.model_checksums import ( ChecksumError, + expected_for, verify_model_file, write_verification_stamp, ) @@ -1276,9 +1277,15 @@ class SpeechRecognitionManager: # re-hashes only ggml-tiny.bin. whisper.cpp maps this straight through # ctypes, so hash it here; a failure demotes it to "not downloaded". if os.path.exists(model_path) and not self._whispercpp_model_is_verified(model_path): - if self._defer_download: + if os.path.exists(model_path): + logger.error( + "Refusing to load unverified whisper.cpp model at %s", + model_path, + ) self._model_initialized = False - return + if self._defer_download: + return + raise RuntimeError(f"whisper.cpp model at {model_path} failed verification") if not os.path.exists(model_path): if self._defer_download: @@ -1307,19 +1314,18 @@ class SpeechRecognitionManager: @staticmethod def _whispercpp_model_is_verified(model_path: str) -> bool: - """Hash the model against its pin, or delete it and report False. + """Hash the model against its pin. Delete only a digest/size mismatch. - Cheap enough to do every time: sha256 runs at GB/s and whisper.cpp is - about to read the whole file anyway, so this is CPU over pages that are - being fetched regardless. Deleting rather than raising keeps the caller - simple, since an unverifiable model is indistinguishable from a missing - one as far as what happens next. + An unpinned name is refused, not deleted. If remove fails, return False + so the caller does not hand the file to ctypes. """ try: verify_model_file(model_path) return True except ChecksumError as error: logger.error("whisper.cpp model at %s is not trustworthy: %s", model_path, error) + if expected_for(model_path) is None: + return False try: os.remove(model_path) except OSError as remove_error: diff --git a/tests/test_dependency_exports.py b/tests/test_dependency_exports.py index 4d4e49a..ffc00a3 100644 --- a/tests/test_dependency_exports.py +++ b/tests/test_dependency_exports.py @@ -72,7 +72,72 @@ def test_documented_uv_run_examples_do_not_prune_the_linters(): assert not offenders, "these examples uninstall the linters:\n" + "\n".join(offenders) +def test_justfile_uv_run_recipes_do_not_sync(): + """`uv run` without --no-sync prunes whisper/vosk after `just deps-all`.""" + offenders = [] + for number, line in enumerate(JUSTFILE.read_text(encoding="utf-8").splitlines(), 1): + stripped = line.lstrip() + if stripped.startswith("#") or not stripped.startswith("uv run"): + continue + if "--no-sync" not in stripped: + offenders.append(f"{number}: {stripped}") + assert not offenders, "uv run without --no-sync undoes just deps-all:\n" + "\n".join(offenders) + + def test_the_dev_extra_reaches_the_dev_export(): dev = _requirement_names(_pyproject()["project"]["optional-dependencies"]["dev"]) missing = sorted(set(dev) - _exported_names(DEV_EXPORT)) assert not missing, f"missing from requirements/dev.txt; re-run `just lock`: {missing}" + + +#: Recipes that may call `uv run --no-sync` without depending on `_tooling`. +#: `version` reads one string out of version.py with a bare interpreter, so +#: syncing the whole dev environment ahead of it would buy nothing. +NO_TOOLING_NEEDED = {"version"} + + +def _justfile_recipes() -> dict: + """Map every recipe name to its dependency list and its body lines.""" + recipes = {} + current = None + for line in JUSTFILE.read_text(encoding="utf-8").splitlines(): + header = re.match(r"^([a-z_][A-Za-z0-9_-]*):(.*)$", line) + if header: + current = header.group(1) + recipes[current] = (header.group(2).split(), []) + elif current and line.startswith((" ", "\t")): + recipes[current][1].append(line.strip()) + elif line.strip(): + current = None + return recipes + + +def test_no_sync_recipes_bootstrap_the_venv(): + """Something has to create .venv before `uv run --no-sync` can use it. + + Nothing does, once every recipe stops syncing: a fresh clone gets an empty + .venv and `error: Failed to spawn: pytest`. No CI job would catch it either, + because the pipeline drives uv directly and never runs `just`. + """ + offenders = [] + for name, (dependencies, body) in _justfile_recipes().items(): + if name in NO_TOOLING_NEEDED: + continue + if not any("uv run --no-sync" in body_line for body_line in body): + continue + if "_tooling" not in dependencies: + offenders.append(name) + assert not offenders, "these run tooling out of .venv but never create it:\n" + "\n".join( + offenders + ) + + +def test_default_is_the_first_recipe(): + """`just` with no arguments runs the first recipe, whatever it is named. + + A recipe added above `default` silently becomes what bare `just` does, and + `[private]` does not exempt it — which is how `_tooling` first landed here, + turning `just` into a sync instead of the recipe listing. + """ + first = next(iter(_justfile_recipes())) + assert first == "default", f"bare `just` would run `{first}` instead of listing recipes" diff --git a/tests/test_installer_engine_config.py b/tests/test_installer_engine_config.py index 602ed1f..4fda516 100644 --- a/tests/test_installer_engine_config.py +++ b/tests/test_installer_engine_config.py @@ -60,10 +60,13 @@ def _config(config_dir: Path, engine: str) -> Path: return path -def _check(config_dir: Path, venv_dir: Path) -> subprocess.CompletedProcess: +def _check( + config_dir: Path, venv_dir: Path, selected_engine: str = "" +) -> subprocess.CompletedProcess: script = f""" CONFIG_DIR="{config_dir}" VENV_DIR="{venv_dir}" +SELECTED_ENGINE="{selected_engine}" for fn in engine_import_module engine_pip_name venv_can_import set_configured_engine verify_configured_engine; do source <(sed -n "/^$fn() {{$/,/^}}$/p" "{INSTALL_SH}") done @@ -144,6 +147,38 @@ def test_silent_without_a_config_file(tmp_path): assert "EXIT=0" in result.stdout +def test_repairs_whisper_cpp_to_vosk_when_that_is_what_works(tmp_path): + """Reinstall: config still says whisper_cpp, pywhispercpp is gone, vosk is in.""" + venv = tmp_path / "venv" + _venv_python(venv, importable={"vosk"}) + config = _config(tmp_path / "config", "whisper_cpp") + + result = _check(tmp_path / "config", venv) + + assert "EXIT=0" in result.stdout + assert _engine_of(config) == "vosk" + assert "Switched" in result.stdout + + +def test_honors_selected_engine_ahead_of_the_default_fallback(tmp_path): + """SELECTED_ENGINE is tried before the default vosk/whisper extras.""" + venv = tmp_path / "venv" + _venv_python(venv, importable={"vosk", "whisper"}) + config = _config(tmp_path / "config", "whisper_cpp") + + result = _check(tmp_path / "config", venv, selected_engine="whisper") + + assert "EXIT=0" in result.stdout + assert _engine_of(config) == "whisper" + + +def test_vosk_fallback_rewrites_an_existing_config_file(): + """The fallback used to write vosk only when config.json was missing.""" + text = INSTALL_SH.read_text(encoding="utf-8") + assert 'set_configured_engine "$FALLBACK_VOSK_CONFIG" "vosk"' in text + assert '[ ! -f "$FALLBACK_VOSK_CONFIG" ]' not in text + + def test_unreadable_config_is_not_treated_as_an_engine(tmp_path): venv = tmp_path / "venv" _venv_python(venv, importable=set()) diff --git a/tests/test_recognition_manager_advanced.py b/tests/test_recognition_manager_advanced.py index d54b84e..4e4bfd3 100644 --- a/tests/test_recognition_manager_advanced.py +++ b/tests/test_recognition_manager_advanced.py @@ -480,10 +480,15 @@ class TestInitWhispercpp(unittest.TestCase): "pywhispercpp.model": mock_pywhispercpp, }, ): - try: - mgr._init_whispercpp() - except Exception: - pass + with patch.object( + SpeechRecognitionManager, + "_whispercpp_model_is_verified", + return_value=True, + ): + try: + mgr._init_whispercpp() + except Exception: + pass def test_cpu_fallback_filters_unsupported_whispercpp_params(self): mgr = _make_manager(engine="whisper_cpp") diff --git a/tests/test_recognition_manager_downloads.py b/tests/test_recognition_manager_downloads.py index b3e6d60..b824671 100644 --- a/tests/test_recognition_manager_downloads.py +++ b/tests/test_recognition_manager_downloads.py @@ -580,6 +580,56 @@ class TestWhispercppRejectsAnUnverifiedModelOnDisk: load.assert_not_called() assert manager._model_initialized is False + def test_a_model_that_cannot_be_deleted_is_still_not_loaded(self, tmp_path): + model = tmp_path / "ggml-tiny.bin" + model.write_bytes(b"not the model that is pinned") + + manager = _make_manager(engine="whisper_cpp") + manager._defer_download = False + + mock_pywhispercpp = MagicMock() + with patch.dict( + "sys.modules", + {"pywhispercpp": mock_pywhispercpp, "pywhispercpp.model": mock_pywhispercpp}, + ): + with patch( + "vocalinux.speech_recognition.recognition_manager.get_model_path", + return_value=str(model), + ): + with patch( + "vocalinux.speech_recognition.recognition_manager.os.remove", + side_effect=OSError("read-only"), + ): + with patch.object(manager, "_load_whispercpp_model") as load: + with pytest.raises(RuntimeError, match="failed verification"): + manager._init_whispercpp() + + assert model.exists() + load.assert_not_called() + + def test_an_unpinned_model_is_refused_not_deleted(self, tmp_path): + model = tmp_path / "ggml-not-in-manifest.bin" + model.write_bytes(b"bytes") + + manager = _make_manager(engine="whisper_cpp") + manager._defer_download = True + + mock_pywhispercpp = MagicMock() + with patch.dict( + "sys.modules", + {"pywhispercpp": mock_pywhispercpp, "pywhispercpp.model": mock_pywhispercpp}, + ): + with patch( + "vocalinux.speech_recognition.recognition_manager.get_model_path", + return_value=str(model), + ): + with patch.object(manager, "_load_whispercpp_model") as load: + manager._init_whispercpp() + + assert model.exists(), "a missing pin is not a reason to delete the file" + load.assert_not_called() + assert manager._model_initialized is False + class TestAudioReconnection: """Test audio reconnection logic.""" diff --git a/tests/test_whisper_support.py b/tests/test_whisper_support.py index 97d8596..65b022b 100644 --- a/tests/test_whisper_support.py +++ b/tests/test_whisper_support.py @@ -153,6 +153,11 @@ class TestWhisperSupport: patch( "vocalinux.speech_recognition.recognition_manager._show_notification" ) as notify_mock, + patch.object( + rm.SpeechRecognitionManager, + "_whispercpp_model_is_verified", + return_value=True, + ), patch.dict( os.environ, {"GGML_VULKAN": "1", "GGML_CUDA": "1"},