diff --git a/install.sh b/install.sh index c80231e..e5b5006 100755 --- a/install.sh +++ b/install.sh @@ -1882,6 +1882,22 @@ venv_can_import() { "$VENV_DIR/bin/python" -c "import $1" >/dev/null 2>&1 } +engine_import_error() { + local ENGINE="$1" + local MODULE + MODULE=$(engine_import_module "$ENGINE") + [ -n "$MODULE" ] || return 1 + + local IMPORT_ERROR + if IMPORT_ERROR=$("$VENV_DIR/bin/python" -c "import $MODULE" 2>&1); then + return 0 + fi + + print_error "$ENGINE installed but \`import $MODULE\` failed:" + print_error "$(printf '%s\n' "$IMPORT_ERROR" | tail -n 1)" + return 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 @@ -2384,6 +2400,7 @@ FALLBACK_CONFIG print_error "Failed to install the vosk engine" return 1 } + engine_import_error vosk || return 1 # Create config with vosk as default local VOSK_CONFIG_FILE="$CONFIG_DIR/config.json" @@ -2431,6 +2448,7 @@ VOSK_CONFIG print_error "Failed to install the parakeet engine" return 1 } + engine_import_error parakeet || return 1 # config_manager merges the remaining defaults, but a file with no # shortcuts section reads as a pre-push-to-talk config and gets @@ -2523,7 +2541,8 @@ REMOTE_CONFIG print_info "Installing Faster-Whisper engine..." print_info "This engine uses CTranslate2 for fast CPU inference." - if pip_install_extras_skip_pygobject "$PIP_LOG_FILE" faster_whisper; then + if pip_install_extras_skip_pygobject "$PIP_LOG_FILE" faster_whisper && + engine_import_error faster_whisper; then mkdir -p "$CONFIG_DIR" if [ ! -f "$CONFIG_DIR/config.json" ]; then cat > "$CONFIG_DIR/config.json" << 'FASTER_WHISPER_CONFIG' @@ -2770,6 +2789,7 @@ PY [ -n "$MODULE_FOR_CANDIDATE" ] || continue venv_can_import "$MODULE_FOR_CANDIDATE" || continue if set_configured_engine "$CONFIG_FILE" "$CANDIDATE"; then + SELECTED_ENGINE="$CANDIDATE" 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 diff --git a/pyproject.toml b/pyproject.toml index 1f07a52..5514bf6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,6 +59,9 @@ whisper = [ ] parakeet = [ "sherpa-onnx>=1.12.0,<2", + # Match the locked sherpa-onnx version; bump both together. armv7l wheels omit this + # dependency metadata, and the export test enforces the matching core pin. + "sherpa-onnx-core==1.13.7; platform_machine != 'armv7l'", ] faster_whisper = [ "faster-whisper>=1.0.0,<2", diff --git a/requirements/parakeet.txt b/requirements/parakeet.txt index f601de4..b878b30 100644 --- a/requirements/parakeet.txt +++ b/requirements/parakeet.txt @@ -508,6 +508,21 @@ sherpa-onnx==1.13.7 \ --hash=sha256:fce7cfab223d6bee0a58fa940b5057e2f3cf63613928b88e09ac99b477e40be0 \ --hash=sha256:fed61cc23dba8f212eeb2723d2e90108d6de95734c5ae427aeafdb10f09ec184 # via vocalinux +sherpa-onnx-core==1.13.7 ; platform_machine != 'armv7l' \ + --hash=sha256:07de22f590299a68d9b5c985264f380579600a831ee95510ab12f4068800d08c \ + --hash=sha256:861c1e18fbf9bd2c725992c5a1797efe1b934b7df7bb18986d1fdc60e91290c9 \ + --hash=sha256:9ee6d4f05c14eb961370ba794f363088407465bf2006b73ab67508abd80be033 \ + --hash=sha256:b191e79d4952199bd11ce05c5b29c5efb08ed43a5d96839202e8ab603ab96278 \ + --hash=sha256:befe8193f02ed7935875acf540b8a554da0a89060965b32c1364f37c4ca7f3b3 \ + --hash=sha256:c53a930543f3a0f1063956fd3d01081acd6cb66f46c9b1f544b4393d6cbc1893 \ + --hash=sha256:cbcb78ef8a3bb74acf0b9d0a8715e9b857491be8e2b1e8f589f09ecf3d2f2a0b \ + --hash=sha256:cf17e20cada234df952f71a0e9412422c557e08246a6759b8aa6ea4bbd215a5a \ + --hash=sha256:d0131f93e8fabca773701e28301d81dff4f7445ba59e280fca319c585bac850a \ + --hash=sha256:e11c2dca5526ff473bd5cf94f40da7ff7075ec4c44f3464cc1bceffdc6f5e487 \ + --hash=sha256:e1e6333ecde50c69b8a43feff6eedf1033ec2a3fe58009506572bd717fc78e42 \ + --hash=sha256:e3066e94c24c898c5794e08507f955e14bc904299bac80f357d0bcb7d93e3825 \ + --hash=sha256:f48029e0c79b4cc654076806cbf62094a51dcd26c6bd1ce17db3bad03aff02b1 + # via vocalinux six==1.17.0 \ --hash=sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274 \ --hash=sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81 diff --git a/tests/test_aur_packaging.py b/tests/test_aur_packaging.py index c8a1188..6ab38cf 100644 --- a/tests/test_aur_packaging.py +++ b/tests/test_aur_packaging.py @@ -314,6 +314,8 @@ def test_every_selectable_engine_reaches_optdepends() -> None: dist = re.split(r"[<>=!~\[; ]", spec, maxsplit=1)[0].strip().lower() if dist in {"torch", "torchaudio"}: continue # pulled in by python-openai-whisper, not named alone + if dist == "sherpa-onnx-core": + continue # Arch's python-sherpa-onnx links the system onnxruntime instead arch_name = f"python-{dist.replace('_', '-')}" if arch_name not in optdepends: missing.append(f"{extra} -> {arch_name}") diff --git a/tests/test_dependency_exports.py b/tests/test_dependency_exports.py index 4efb042..4ee4cd8 100644 --- a/tests/test_dependency_exports.py +++ b/tests/test_dependency_exports.py @@ -53,6 +53,15 @@ def _exported_names(path: Path) -> set[str]: return names +def _exported_versions(path: Path, names: set[str]) -> dict[str, str]: + versions = {} + for line in path.read_text(encoding="utf-8").splitlines(): + match = re.match(r"^([A-Za-z0-9._-]+)==([^ ;\\]+)", line) + if match and match.group(1).lower() in names: + versions[match.group(1).lower()] = match.group(2) + return versions + + def _extras() -> dict[str, list[str]]: return _pyproject()["project"]["optional-dependencies"] @@ -321,6 +330,27 @@ def test_every_extra_reaches_its_own_export() -> None: assert not missing, "re-run `just lock`:\n" + "\n".join(missing) +def test_parakeet_extra_declares_sherpa_onnx_core() -> None: + """The lock needs an explicit dependency when armv7l wheel metadata omits it.""" + assert "sherpa-onnx-core" in _requirement_names(_extras()["parakeet"]) + + +def test_parakeet_export_pins_sherpa_onnx_core_to_sherpa_version() -> None: + """The core library version must match the locked sherpa-onnx version.""" + versions = _exported_versions( + REQUIREMENTS / "parakeet.txt", {"sherpa-onnx", "sherpa-onnx-core"} + ) + assert versions["sherpa-onnx-core"] == versions["sherpa-onnx"] + + +def test_parakeet_lock_pins_sherpa_onnx_core_to_sherpa_version() -> None: + """The lock keeps sherpa-onnx-core at the engine's version.""" + with (REPO_ROOT / "uv.lock").open("rb") as handle: + packages = tomllib.load(handle)["package"] + versions = {package["name"]: package["version"] for package in packages if "version" in package} + assert versions["sherpa-onnx-core"] == versions["sherpa-onnx"] + + def test_every_extra_the_installer_installs_is_pinnable() -> None: """install.sh is the path that hands users unpinned ranges today (2.3). diff --git a/tests/test_installer_engine_config.py b/tests/test_installer_engine_config.py index 5c9fe0c..7dee9f0 100644 --- a/tests/test_installer_engine_config.py +++ b/tests/test_installer_engine_config.py @@ -5,6 +5,8 @@ cannot import — vosk is an optional extra since #705. Vocalinux then dies at startup with "No module named 'vosk'" instead of the installer saying so. """ +from __future__ import annotations + import json import subprocess from pathlib import Path @@ -21,13 +23,13 @@ print_success() { echo "SUCCESS: $*"; } """ -def _run(script: str) -> subprocess.CompletedProcess: +def _run(script: str) -> subprocess.CompletedProcess[str]: return subprocess.run( ["bash", "-c", PRELUDE + script], capture_output=True, text=True, timeout=30 ) -def _venv_python(venv_dir: Path, importable: "set[str] | None" = None) -> Path: +def _venv_python(venv_dir: Path, importable: set[str] | None = None) -> Path: """A stub venv interpreter: real python3, with chosen imports forced. ``importable`` names the engine modules that succeed; every other engine @@ -40,7 +42,9 @@ def _venv_python(venv_dir: Path, importable: "set[str] | None" = None) -> Path: branches = "".join( 'if [[ "$*" == *"import {module}"* ]]; then\n' - " exit {code}\n" + " if [ {code} -eq 0 ]; then exit 0; fi\n" + " echo \"ModuleNotFoundError: No module named '{module}'\" >&2\n" + " exit 1\n" "fi\n".format(module=module, code=0 if module in ok else 1) for module in sorted(modules, key=len, reverse=True) ) @@ -62,21 +66,51 @@ def _config(config_dir: Path, engine: str) -> Path: def _check( config_dir: Path, venv_dir: Path, selected_engine: str = "" -) -> subprocess.CompletedProcess: +) -> subprocess.CompletedProcess[str]: 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 +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 verify_configured_engine echo "EXIT=$?" +echo "SELECTED=$SELECTED_ENGINE" +""" + return _run(script) + + +def _check_engine_import(venv_dir: Path, engine: str) -> subprocess.CompletedProcess[str]: + script = f""" +VENV_DIR="{venv_dir}" +for fn in engine_import_module engine_import_error; do + source <(sed -n "/^$fn() {{$/,/^}}$/p" "{INSTALL_SH}") +done +engine_import_error "{engine}" +echo "EXIT=$?" """ return _run(script) -def test_repairs_a_config_naming_an_uninstallable_engine(tmp_path): +def test_engine_import_error_reports_success_and_failure(tmp_path: Path) -> None: + venv = tmp_path / "venv" + _venv_python(venv, importable={"sherpa_onnx"}) + + imported = _check_engine_import(venv, "parakeet") + + assert "EXIT=0" in imported.stdout + _venv_python(venv, importable=set()) + + failed = _check_engine_import(venv, "parakeet") + + assert "EXIT=1" in failed.stdout + assert "ERROR: parakeet installed but `import sherpa_onnx` failed:" in failed.stdout + assert "ERROR: ModuleNotFoundError: No module named 'sherpa_onnx'" in failed.stdout + + +def test_repairs_a_config_naming_an_uninstallable_engine(tmp_path: Path) -> None: """Don't leave a config the app cannot start with.""" venv = tmp_path / "venv" _venv_python(venv, importable={"pywhispercpp.model"}) @@ -89,7 +123,7 @@ def test_repairs_a_config_naming_an_uninstallable_engine(tmp_path): assert "Switched" in result.stdout -def test_repairs_a_leftover_whisper_engine_too(tmp_path): +def test_repairs_a_leftover_whisper_engine_too(tmp_path: Path) -> None: """The check was vosk-only; whisper is an optional extra as well.""" venv = tmp_path / "venv" _venv_python(venv, importable={"pywhispercpp.model"}) @@ -101,7 +135,7 @@ def test_repairs_a_leftover_whisper_engine_too(tmp_path): assert _engine_of(config) == "whisper_cpp" -def test_fails_when_no_engine_works(tmp_path): +def test_fails_when_no_engine_works(tmp_path: Path) -> None: """Nothing to fall back to: the install must not report success.""" venv = tmp_path / "venv" _venv_python(venv, importable=set()) @@ -115,7 +149,7 @@ def test_fails_when_no_engine_works(tmp_path): assert _engine_of(config) == "vosk" -def test_leaves_a_working_engine_alone(tmp_path): +def test_leaves_a_working_engine_alone(tmp_path: Path) -> None: venv = tmp_path / "venv" _venv_python(venv, importable={"vosk"}) config = _config(tmp_path / "config", "vosk") @@ -127,7 +161,7 @@ def test_leaves_a_working_engine_alone(tmp_path): assert "WARNING" not in result.stdout -def test_ignores_engines_that_need_no_extra(tmp_path): +def test_ignores_engines_that_need_no_extra(tmp_path: Path) -> None: venv = tmp_path / "venv" _venv_python(venv, importable=set()) config = _config(tmp_path / "config", "remote_api") @@ -138,7 +172,7 @@ def test_ignores_engines_that_need_no_extra(tmp_path): assert _engine_of(config) == "remote_api" -def test_silent_without_a_config_file(tmp_path): +def test_silent_without_a_config_file(tmp_path: Path) -> None: venv = tmp_path / "venv" _venv_python(venv, importable=set()) @@ -147,7 +181,7 @@ 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): +def test_repairs_whisper_cpp_to_vosk_when_that_is_what_works(tmp_path: Path) -> None: """Reinstall: config still says whisper_cpp, pywhispercpp is gone, vosk is in.""" venv = tmp_path / "venv" _venv_python(venv, importable={"vosk"}) @@ -160,7 +194,7 @@ def test_repairs_whisper_cpp_to_vosk_when_that_is_what_works(tmp_path): assert "Switched" in result.stdout -def test_honors_selected_engine_ahead_of_the_default_fallback(tmp_path): +def test_honors_selected_engine_ahead_of_the_default_fallback(tmp_path: Path) -> None: """SELECTED_ENGINE is tried before the default vosk/whisper extras.""" venv = tmp_path / "venv" _venv_python(venv, importable={"vosk", "whisper"}) @@ -172,14 +206,26 @@ def test_honors_selected_engine_ahead_of_the_default_fallback(tmp_path): assert _engine_of(config) == "whisper" -def test_vosk_fallback_rewrites_an_existing_config_file(): +def test_selected_engine_tracks_a_parakeet_config_repair(tmp_path: Path) -> None: + venv = tmp_path / "venv" + _venv_python(venv, importable={"pywhispercpp.model"}) + config = _config(tmp_path / "config", "parakeet") + + result = _check(tmp_path / "config", venv, selected_engine="parakeet") + + assert "EXIT=0" in result.stdout + assert "SELECTED=whisper_cpp" in result.stdout + assert _engine_of(config) == "whisper_cpp" + + +def test_vosk_fallback_rewrites_an_existing_config_file() -> None: """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): +def test_unreadable_config_is_not_treated_as_an_engine(tmp_path: Path) -> None: venv = tmp_path / "venv" _venv_python(venv, importable=set()) config_dir = tmp_path / "config" diff --git a/uv.lock b/uv.lock index 27d1c7f..2379d70 100644 --- a/uv.lock +++ b/uv.lock @@ -2501,6 +2501,26 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8c/07/58fb06acf48e3040ee9aba95a7330f0804bf5ec8868ae49e90872b4bf979/sherpa_onnx-1.13.7-cp314-cp314-win_arm64.whl", hash = "sha256:52ca4392620bb4a0cb30373f245861107516c8831bface1c82c17a9e34f3f5c9", size = 2305339, upload-time = "2026-09-01T06:05:03.446Z" }, ] +[[package]] +name = "sherpa-onnx-core" +version = "1.13.7" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/ce/16f5cebe925b2e7b08821ed966969382b2bc37a918de2ad402e8ab8a6eae/sherpa_onnx_core-1.13.7-py3-none-android_24_arm64_v8a.whl", hash = "sha256:e11c2dca5526ff473bd5cf94f40da7ff7075ec4c44f3464cc1bceffdc6f5e487", size = 9710257, upload-time = "2026-09-04T14:54:00.278Z" }, + { url = "https://files.pythonhosted.org/packages/e1/32/a90abb898d4cc311932c904e6088d1c762eacabdc9356f08e8cdf6b62e6f/sherpa_onnx_core-1.13.7-py3-none-android_24_armeabi_v7a.whl", hash = "sha256:9ee6d4f05c14eb961370ba794f363088407465bf2006b73ab67508abd80be033", size = 9124693, upload-time = "2026-09-05T22:26:30.231Z" }, + { url = "https://files.pythonhosted.org/packages/47/32/4b43de282aa643baec523c833e183a4dad833d0aa442770f01d9fc907307/sherpa_onnx_core-1.13.7-py3-none-android_24_x86.whl", hash = "sha256:f48029e0c79b4cc654076806cbf62094a51dcd26c6bd1ce17db3bad03aff02b1", size = 11105887, upload-time = "2026-09-05T22:32:49.997Z" }, + { url = "https://files.pythonhosted.org/packages/8a/6f/e1aef05ee2010dffb389fcbdc96fe849c8dab1996f7b6ec7abfafde40b95/sherpa_onnx_core-1.13.7-py3-none-android_24_x86_64.whl", hash = "sha256:e1e6333ecde50c69b8a43feff6eedf1033ec2a3fe58009506572bd717fc78e42", size = 10863620, upload-time = "2026-09-05T16:43:44.314Z" }, + { url = "https://files.pythonhosted.org/packages/d0/04/0348c408b99ab5bd490f70cfb5e40eb90c1ad269a17380400f9f345bd530/sherpa_onnx_core-1.13.7-py3-none-macosx_10_15_universal2.whl", hash = "sha256:d0131f93e8fabca773701e28301d81dff4f7445ba59e280fca319c585bac850a", size = 20329436, upload-time = "2026-09-01T05:33:47.588Z" }, + { url = "https://files.pythonhosted.org/packages/ca/08/49bcf63aa98d8d94cc234398d7670447b3562796662f88dc3f28e43a2f80/sherpa_onnx_core-1.13.7-py3-none-macosx_10_15_x86_64.whl", hash = "sha256:c53a930543f3a0f1063956fd3d01081acd6cb66f46c9b1f544b4393d6cbc1893", size = 10835919, upload-time = "2026-09-01T05:04:07.52Z" }, + { url = "https://files.pythonhosted.org/packages/26/0b/e03cdda14781c32f1f84b86c8307884a1a0afe89c563def821a9a2b466e1/sherpa_onnx_core-1.13.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b191e79d4952199bd11ce05c5b29c5efb08ed43a5d96839202e8ab603ab96278", size = 9523627, upload-time = "2026-09-01T05:13:35.625Z" }, + { url = "https://files.pythonhosted.org/packages/b0/f3/edb7082cd721ce801bf8bb537a08c5c3771ad9d0b27ae4c943d23a1becdb/sherpa_onnx_core-1.13.7-py3-none-manylinux2014_aarch64.whl", hash = "sha256:861c1e18fbf9bd2c725992c5a1797efe1b934b7df7bb18986d1fdc60e91290c9", size = 13058938, upload-time = "2026-09-01T05:04:11.848Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b8/7176b23789f9f1814368b2faee2bf9ed4c16cb9e55498517a733114bd24d/sherpa_onnx_core-1.13.7-py3-none-manylinux2014_x86_64.whl", hash = "sha256:e3066e94c24c898c5794e08507f955e14bc904299bac80f357d0bcb7d93e3825", size = 10373118, upload-time = "2026-09-01T05:12:24.901Z" }, + { url = "https://files.pythonhosted.org/packages/cd/54/8b34f96b48e790c69f1b6c0a42d12b82e97e401bd1259b861f97f4b40752/sherpa_onnx_core-1.13.7-py3-none-manylinux_2_35_armv7l.whl", hash = "sha256:cf17e20cada234df952f71a0e9412422c557e08246a6759b8aa6ea4bbd215a5a", size = 9922252, upload-time = "2026-09-01T06:44:45.369Z" }, + { url = "https://files.pythonhosted.org/packages/ca/6c/ff34618f69bb1671f86056b9d1856b13be8376823a382353498f96c423ce/sherpa_onnx_core-1.13.7-py3-none-win32.whl", hash = "sha256:07de22f590299a68d9b5c985264f380579600a831ee95510ab12f4068800d08c", size = 14530298, upload-time = "2026-09-01T05:13:37.064Z" }, + { url = "https://files.pythonhosted.org/packages/20/87/2ac78c15d4b6bc8ba663b4fef8a9d01ef32a09c336b828d27af3e4962116/sherpa_onnx_core-1.13.7-py3-none-win_amd64.whl", hash = "sha256:cbcb78ef8a3bb74acf0b9d0a8715e9b857491be8e2b1e8f589f09ecf3d2f2a0b", size = 16526006, upload-time = "2026-09-01T05:30:51.329Z" }, + { url = "https://files.pythonhosted.org/packages/44/0e/77f9f9243245a865854b174d65eae52e7805cad885f8328f0eb1774fc6a7/sherpa_onnx_core-1.13.7-py3-none-win_arm64.whl", hash = "sha256:befe8193f02ed7935875acf540b8a554da0a89060965b32c1364f37c4ca7f3b3", size = 15689427, upload-time = "2026-09-01T05:25:56.119Z" }, +] + [[package]] name = "six" version = "1.17.0" @@ -2980,6 +3000,7 @@ faster-whisper = [ ] parakeet = [ { name = "sherpa-onnx" }, + { name = "sherpa-onnx-core", marker = "platform_machine != 'armv7l'" }, ] vad = [ { name = "onnxruntime" }, @@ -3032,6 +3053,7 @@ requires-dist = [ { name = "pyyaml", marker = "extra == 'dev'", specifier = ">=6.0,<7" }, { name = "requests", specifier = ">=2.28.0" }, { name = "sherpa-onnx", marker = "extra == 'parakeet'", specifier = ">=1.12.0,<2" }, + { name = "sherpa-onnx-core", marker = "platform_machine != 'armv7l' and extra == 'parakeet'", specifier = "==1.13.7" }, { name = "sphinx", marker = "extra == 'docs'", specifier = ">=6.0.0" }, { name = "sphinx-rtd-theme", marker = "extra == 'docs'", specifier = ">=1.2.0" }, { name = "torch", marker = "extra == 'whisper'", specifier = ">=2.13.0" },