diff options
Diffstat (limited to 'app')
| -rw-r--r-- | app/backends/__init__.py | 8 | ||||
| -rw-r--r-- | app/backends/audiocpp/__init__.py | 6 | ||||
| -rw-r--r-- | app/backends/audiocpp/build.py | 114 | ||||
| -rw-r--r-- | app/backends/common.py | 114 | ||||
| -rw-r--r-- | app/backends/envs.py | 16 | ||||
| -rwxr-xr-x | app/backends/faster.py | 38 | ||||
| -rw-r--r-- | app/backends/qwen.py | 27 | ||||
| -rw-r--r-- | app/tests/test_backends.py | 55 | ||||
| -rw-r--r-- | app/tests/test_backends_audiocpp.py | 230 | ||||
| -rw-r--r-- | app/tests/test_backends_common.py | 120 | ||||
| -rw-r--r-- | app/tests/test_backends_envs.py | 28 | ||||
| -rw-r--r-- | app/tests/test_backends_faster.py | 97 | ||||
| -rw-r--r-- | app/tests/test_hub.py | 158 | ||||
| -rw-r--r-- | app/ui/hub.py | 69 |
14 files changed, 1055 insertions, 25 deletions
diff --git a/app/backends/__init__.py b/app/backends/__init__.py index e59c464..8c9f045 100644 --- a/app/backends/__init__.py +++ b/app/backends/__init__.py @@ -160,6 +160,10 @@ class BackendInfo: removes the backend (stops its servers, pip-uninstalls, deletes its files); the hub runs it inside the task view, calling it with optional ``emit``/``cancel`` keywords (cancel honored between phases only). + UPDATE refreshes the installed backend to the latest upstream version + (pip -U / git fetch+reset, rebuilding where a binary must match the + sources); same calling convention as UNINSTALL. Without UPDATE a + backend is skipped by the hub's "Update backends" action. CONFIGURE_SCREEN, when given, is what the hub's "Configure <label>" menu entry runs instead of SETUP_SCREEN once the backend exists — a @@ -173,6 +177,7 @@ class BackendInfo: detect: Callable[[], BackendStatus] setup_screen: Callable[[object], int] uninstall: Callable[..., int] = lambda *args, **kwargs: 0 + update: Optional[Callable[..., int]] = None configure_screen: Optional[Callable[[object], int]] = None @@ -192,6 +197,7 @@ def _build_registry() -> None: detect=audiocpp.detect, setup_screen=audiocpp.setup_screen, uninstall=audiocpp.uninstall, + update=audiocpp.update, )) REGISTRY.append(BackendInfo( key="qwen", @@ -199,6 +205,7 @@ def _build_registry() -> None: detect=qwen.detect, setup_screen=qwen.setup_screen, uninstall=qwen.uninstall, + update=qwen.update, configure_screen=qwen.models_screen, )) REGISTRY.append(BackendInfo( @@ -207,6 +214,7 @@ def _build_registry() -> None: detect=faster.detect, setup_screen=faster.setup_screen, uninstall=faster.uninstall, + update=faster.update, )) for info in REGISTRY: _BY_KEY[info.key] = info diff --git a/app/backends/audiocpp/__init__.py b/app/backends/audiocpp/__init__.py index b97032b..f55cf35 100644 --- a/app/backends/audiocpp/__init__.py +++ b/app/backends/audiocpp/__init__.py @@ -10,7 +10,8 @@ Modules: models install state on disk, missing-model guidance, downloads voices reference-.wav transcription planning and execution configsync app/converter/config.py + server.json port/id/backend sync - build checkout lifecycle: ggml patches, binary build, uninstall + build checkout lifecycle: ggml patches, binary build, update, + uninstall remote querying a running server for its models/voices status detect() for the hub's backend menu wizard the TUI wizard and the CLI entry points @@ -66,6 +67,7 @@ from .build import ( find_local_checkout, find_audiocpp_server_bin, uninstall, + update, ) from .remote import fetch_server_models, fetch_server_voices from .wizard import ( @@ -99,7 +101,7 @@ __all__ = [ "update_server_backend", # build "find_local_checkout", "find_audiocpp_server_bin", "find_build_script", - "apply_ggml_patches", "build_audiocpp", "uninstall", + "apply_ggml_patches", "build_audiocpp", "uninstall", "update", # remote "fetch_server_models", "fetch_server_voices", # wizard / status diff --git a/app/backends/audiocpp/build.py b/app/backends/audiocpp/build.py index e63a799..b850859 100644 --- a/app/backends/audiocpp/build.py +++ b/app/backends/audiocpp/build.py @@ -11,8 +11,8 @@ from typing import List, Optional from backends import common, servers from backends.common import APP_DIR -from .catalog import _BACKEND_TOKEN_RE -from .constants import AUDIOCPP_DIR_NAME, PATCH_DIR +from .catalog import _BACKEND_TOKEN_RE, detect_backend, load_server_config +from .constants import AUDIOCPP_DIR_NAME, BACKENDS, PATCH_DIR def uninstall(*, emit=None, cancel=None) -> int: """Remove the audio.cpp backend entirely: stop its server, delete the checkout. @@ -47,6 +47,116 @@ def uninstall(*, emit=None, cancel=None) -> int: return 0 +def update(*, emit=None, cancel=None) -> int: + """Update the audio.cpp backend: refresh the checkout, rebuild if stale. + + A managed server that is running is stopped first (best-effort): it + serves the binary whose sources are being replaced. Phases: stop + server / git update / rebuild — CANCEL is honored between phases only, + so a started phase always completes. The git update is a fetch plus + hard reset to origin's HEAD (see ``common.git_update``): everything + that matters lives untracked in the checkout (models, build trees, + server.json) and survives, while the vendored-ggml patch edit is + intentionally wiped — the rebuild re-applies it (the patch step is + idempotent and fails loudly when upstream re-shaped the file). + + The rebuild target is the backend recorded in server.json, else the + one detected from existing build directories; when neither names one + (nothing was ever built) the update stops after the checkout refresh + — 'Build audio.cpp server' handles a first build. The rebuild itself + runs when the sources changed (HEAD moved) or the on-disk binary is + missing or older than HEAD's commit time — the latter heals an + interrupted (cancelled or failed) earlier rebuild, which leaves the + previous binary in place against already-updated sources. An + up-to-date checkout with a fresh binary costs one fetch. Returns the + exit code (130 when cancelled before a remaining phase). + """ + # Only stop when a pid file exists: without one this tool never + # started the server, so the "not started by this tool" notice would + # be uninstall-time noise. + if servers.pid_for("audiocpp") is not None: + servers.stop("audiocpp") + if common.cancel_requested(cancel): + return 130 + checkout = find_local_checkout() + if checkout is None: + print("[INFO] No audio.cpp checkout to update.") + return 0 + head_before = common.git_head(checkout) + rc = common.git_update(checkout, emit=emit, cancel=cancel) + if rc != 0: + print(f"[WARNING] checkout update failed (exit {rc}); update " + f"manually: git -C {checkout} pull") + return rc + head_after = common.git_head(checkout) + if common.cancel_requested(cancel): + return 130 + backend = _rebuild_backend(checkout) + if backend is None: + print("[INFO] audiocpp_server was never built for a known " + "backend; skipping the rebuild. 'Build audio.cpp server' " + "builds one.") + return 0 + binary = built_server_binary(checkout, backend) + if not _rebuild_needed(checkout, binary, + moved=head_after not in (None, head_before)): + print(f"[OK] {checkout} is already at origin's HEAD with an " + "up-to-date audiocpp_server.") + return 0 + if head_after in (None, head_before): + print(f"[INFO] audiocpp_server on disk is older than the " + f"checked-out sources (earlier build interrupted?); " + f"rebuilding for {backend}.") + else: + print(f"[OK] Updated {checkout} to {head_after[:12]}; rebuilding " + f"audiocpp_server for {backend}.") + build_rc = build_audiocpp(checkout, backend, emit=emit, cancel=cancel) + if build_rc != 0: + print(f"[WARNING] rebuild exited with code {build_rc}; see the " + "messages above (the build log under app/logs/ has the " + "full output). The binary on disk is now older than the " + "checked-out sources; re-running 'Update backends' will " + "retry the rebuild.") + else: + print("[OK] rebuild complete.") + return build_rc + + +def _rebuild_needed(checkout: Path, binary: Optional[Path], + *, moved: bool) -> bool: + """True when audiocpp_server must be (re)built after an update. + + True when the checkout moved, the binary is missing, its age cannot + be compared (no commit time), or it predates HEAD's commit — the + last case is what a cancelled or failed earlier rebuild leaves + behind (old binary, already-updated sources). + """ + if moved or binary is None: + return True + commit_time = common.git_commit_time(checkout) + if commit_time is None: + return True + try: + return binary.stat().st_mtime <= commit_time + except OSError: + return True + + +def _rebuild_backend(checkout: Path) -> Optional[str]: + """The inference backend to rebuild for after an update, or None. + + server.json's recorded backend wins (it is what the managed server + launches); an existing build directory's token is the fallback for a + checkout that was built but never configured. None means neither + names a valid backend — there is no binary to keep fresh. + """ + server_config = load_server_config(checkout / "server.json") or {} + recorded = server_config.get("backend") + if recorded in BACKENDS: + return recorded + return detect_backend(checkout) + + def find_local_checkout() -> Optional[Path]: """Return the managed audio.cpp checkout at ``app/audio.cpp``. diff --git a/app/backends/common.py b/app/backends/common.py index 098dfb2..4edfd61 100644 --- a/app/backends/common.py +++ b/app/backends/common.py @@ -496,22 +496,124 @@ def git_clone(url: str, target: Path, *, emit=None, cancel=None) -> int: emit=emit, cancel=cancel) +def git_head(checkout: Path) -> Optional[str]: + """CHECKOUT's current HEAD commit sha, or None when it is not a repo.""" + proc = run_console_subprocess_quiet(["git", "-C", str(checkout), + "rev-parse", "HEAD"]) + if proc is None or proc.returncode != 0: + return None + return proc.stdout.decode("utf-8", errors="replace").strip() or None + + +def git_commit_time(checkout: Path) -> Optional[int]: + """CHECKOUT's HEAD commit time as a unix timestamp, or None. + + Uses the *committer* time (``%ct``): a rebase or cherry-pick rewrites + it to when the rewrite happened, so a force-pushed or rebased branch + always looks newer than binaries built from the pre-rewrite sources. + None (not a repo, probe failed) leaves the decision to the caller. + """ + proc = run_console_subprocess_quiet(["git", "-C", str(checkout), + "show", "-s", "--format=%ct", + "HEAD"]) + if proc is None or proc.returncode != 0: + return None + try: + return int(proc.stdout.decode("ascii", errors="replace").strip()) + except ValueError: + return None + + +def git_update(checkout: Path, *, emit=None, cancel=None) -> int: + """Update CHECKOUT to its remote's HEAD: fetch, then hard reset. + + The backend checkouts are read-only working copies of upstream repos — + all state that matters (models, build trees, server.json, voices.json) + is untracked and survives the reset, while local edits the installers + made (the vendored-ggml patch in the audio.cpp checkout) are meant to + be re-applied by the caller afterwards. ``git reset --hard`` is used + instead of ``git pull`` because a pull merges against the working tree + and would conflict on exactly those re-applied-by-design edits. + + The branch reset to is the remote's default (``refs/remotes/origin/ + HEAD``), falling back to ``main`` when the symbolic ref is missing (a + bare-ish mirror or a restrictive server). EMIT/CANCEL behave like + git_clone's (fetch runs with --progress so the task view sees updates). + Returns the exit code of the first failing step (0 when the checkout + now matches origin's HEAD). + """ + if emit is None: + print(f"[INFO] Updating git checkout {checkout}...") + else: + emit(f"[INFO] Updating git checkout {checkout}...") + fetch_argv = ["git", "-C", str(checkout), "fetch"] + reset_argv = ["git", "-C", str(checkout), "reset", "--hard"] + if emit is not None: + # --progress makes git report percentage updates even though stderr + # is piped (it normally only does so on a terminal), feeding the + # task view. + fetch_argv.append("--progress") + fetch_argv.append("origin") + fetch_rc = run_console_subprocess(fetch_argv, emit=emit, cancel=cancel) + if fetch_rc != 0: + return fetch_rc + branch = _origin_default_branch(checkout) + return run_console_subprocess(reset_argv + [f"origin/{branch}"], + emit=emit, cancel=cancel) + + +def _origin_default_branch(checkout: Path) -> str: + """The remote's default branch name for CHECKOUT ("main" as fallback).""" + proc = run_console_subprocess_quiet( + ["git", "-C", str(checkout), "symbolic-ref", + "refs/remotes/origin/HEAD"]) + if proc is not None and proc.returncode == 0: + ref = proc.stdout.decode("utf-8", errors="replace").strip() + # refs/remotes/origin/HEAD -> refs/remotes/origin/main + name = ref.rpartition("/")[2] + if name: + return name + return "main" + + +def run_console_subprocess_quiet(argv: List[str], + cwd: Optional[Path] = None): + """Run ARGV silently and return the completed result. + + Unlike run_console_subprocess (which streams or returns only an exit + code) this captures stdout and needs the process object itself, for the + small git probes (rev-parse, symbolic-ref) whose *output* matters and + whose failure is a normal, non-fatal outcome. Returns None when the + process could not be started. + """ + import subprocess + try: + return subprocess.run( + argv, capture_output=True, + cwd=str(cwd) if cwd is not None else None, check=False) + except OSError: + return None + + def pip_install(packages: List[str], *, emit=None, cancel=None, - env_dir: Optional[Path] = None) -> int: + env_dir: Optional[Path] = None, + upgrade: bool = False) -> int: """pip install PACKAGES into a managed venv. Returns exit code. Delegates to ``backends.envs.pip_install`` so backend TTS packages are installed into their dedicated tool-managed environments (``envs/tts`` default; ``envs/qwen`` / ``envs/faster`` via ENV_DIR) rather than into whatever interpreter happens to be running the wizard — and never two - conflicting stacks into the same env. With EMIT given (the in-TUI task - view) pip runs with its output streamed into EMIT; CANCEL aborts it. - The import is local to avoid a circular import (envs imports this - module). + conflicting stacks into the same env. With UPGRADE pip runs with + ``-U`` (the backend update action's freshness check: pip only installs + when a newer version resolves, else reports "already satisfied"). + With EMIT given (the in-TUI task view) pip runs with its output streamed + into EMIT; CANCEL aborts it. The import is local to avoid a circular + import (envs imports this module). """ from backends import envs return envs.pip_install(packages, emit=emit, cancel=cancel, - env_dir=env_dir) + env_dir=env_dir, upgrade=upgrade) def pip_uninstall(packages: List[str], *, emit=None, diff --git a/app/backends/envs.py b/app/backends/envs.py index 50154c2..ff6d1bf 100644 --- a/app/backends/envs.py +++ b/app/backends/envs.py @@ -190,22 +190,28 @@ def install_requirements(skip_optional: bool = False) -> int: def pip_install(packages: List[str], *, emit=None, cancel=None, - env_dir: Optional[Path] = None) -> int: + env_dir: Optional[Path] = None, + upgrade: bool = False) -> int: """pip install PACKAGES into ENV (an env dir, default the app env), creating it first if needed. Used by the qwen/faster setup wizards to install their TTS packages into their dedicated backend venvs (QWEN_ENV_DIR / FASTER_ENV_DIR), never - alongside each other or the app requirements. Returns pip's exit code. - With EMIT given (the in-TUI task view) pip runs with ``--progress-bar - off`` so its output is clean status lines rather than carriage-return - progress spam. + alongside each other or the app requirements. With UPGRADE the install + runs with ``-U``: pip then resolves the latest version itself and + reports "Requirement already satisfied" when the env already holds it — + the backend update action's cheap freshness check. Returns pip's exit + code. With EMIT given (the in-TUI task view) pip runs with + ``--progress-bar off`` so its output is clean status lines rather than + carriage-return progress spam. """ if not env_exists(env_dir) and create_env(env_dir) != 0: return 1 target = env_dir if env_dir is not None else ENV_DIR print(f"[INFO] pip install {' '.join(packages)} into {target}...") argv = [str(env_python(env_dir)), "-m", "pip", "install"] + if upgrade: + argv.append("-U") if emit is not None: argv.append("--progress-bar") argv.append("off") diff --git a/app/backends/faster.py b/app/backends/faster.py index 3631153..e52a023 100755 --- a/app/backends/faster.py +++ b/app/backends/faster.py @@ -511,6 +511,44 @@ def _detect_remote(managed: bool = False): return False, {} +def update(*, emit=None, cancel=None) -> int: + """Update the faster-qwen3-tts backend: pip upgrade + checkout refresh. + + A managed server that is running is stopped first (best-effort): the + server runs ``examples/openai_server.py`` from the checkout being + reset and imports the package being upgraded. Phases: stop server / + pip install -U / git update — CANCEL is honored between phases only, + so a started phase always completes. The pip package (into + FASTER_ENV) and the cloned checkout are refreshed independently: the + checkout only holds ``examples/openai_server.py`` (and the untracked + voices.json, which a hard reset leaves alone), so a failed phase is + warned about and reflected in the exit code without undoing the + other. Returns the exit code (130 when cancelled before a remaining + phase). + """ + if servers.pid_for("faster") is not None: + servers.stop("faster") + if common.cancel_requested(cancel): + return 130 + rc = common.pip_install([FASTER_PIP_PKG], emit=emit, cancel=cancel, + env_dir=FASTER_ENV, upgrade=True) + if rc != 0: + print(f"[WARNING] pip install -U failed (exit {rc}); update " + f"{FASTER_PIP_PKG} manually") + else: + print(f"[OK] {FASTER_PIP_PKG} is up to date (or just upgraded).") + if common.cancel_requested(cancel): + return 130 + if _is_cloned(): + clone_rc = common.git_update(_checkout(), emit=emit, cancel=cancel) + if clone_rc != 0: + print(f"[WARNING] checkout update failed (exit {clone_rc}); " + f"run 'git -C {_checkout()} pull' manually") + return clone_rc + print(f"[OK] {_checkout()} is at origin's HEAD.") + return rc + + def uninstall(*, emit=None, cancel=None) -> int: """Remove the faster-qwen3-tts backend entirely. diff --git a/app/backends/qwen.py b/app/backends/qwen.py index b2a1df4..4f3afd9 100644 --- a/app/backends/qwen.py +++ b/app/backends/qwen.py @@ -457,6 +457,33 @@ def uninstall(*, emit=None, cancel=None) -> int: return rc +def update(*, emit=None, cancel=None) -> int: + """Update the qwen-tts backend: pip install -U qwen-tts in its venv. + + A managed server that is running is stopped first (best-effort): it + imports ``qwen_tts`` from the very venv being upgraded, so an in-place + upgrade under a live process would leave it serving stale code. + CANCEL is a ``threading.Event`` honored between phases only (stop + server / pip) — a started phase always completes, so pip is never + killed mid-run. pip itself is the freshness check: it resolves the + latest version, upgrades when there is one, and reports "Requirement + already satisfied" otherwise. Returns the exit code (130 when + cancelled before a remaining phase). + """ + if servers.pid_for("qwen") is not None: + servers.stop("qwen") + if common.cancel_requested(cancel): + return 130 + rc = common.pip_install([QWEN_PIP_PKG], emit=emit, cancel=cancel, + env_dir=QWEN_ENV, upgrade=True) + if rc != 0: + print(f"[WARNING] pip install -U failed (exit {rc}); update " + f"{QWEN_PIP_PKG} manually") + else: + print(f"[OK] {QWEN_PIP_PKG} is up to date (or just upgraded).") + return rc + + def models_screen(stdscr) -> int: """Per-model (un)install screen: the hub's Configure-qwen-tts leaf. diff --git a/app/tests/test_backends.py b/app/tests/test_backends.py index 7fdcce6..d4534fb 100644 --- a/app/tests/test_backends.py +++ b/app/tests/test_backends.py @@ -47,6 +47,10 @@ class RegistryTests(unittest.TestCase): self.assertTrue(callable(info.setup_screen), info.key) self.assertTrue(callable(info.uninstall), info.key) + def test_every_entry_has_an_update_action(self): + for info in REGISTRY: + self.assertTrue(callable(info.update), info.key) + def test_get_returns_entry_by_key(self): self.assertIs(get("audiocpp").key, "audiocpp") self.assertIsNone(get("nonexistent")) @@ -868,3 +872,54 @@ class QwenUninstallTests(unittest.TestCase): return_value=1): rc = qwen.uninstall() self.assertEqual(rc, 1) + + +class QwenUpdateTests(unittest.TestCase): + """qwen.update: stop the single server, then pip install -U the package.""" + + def test_stops_server_and_pip_upgrades_into_the_qwen_env(self): + from backends import qwen + with patch.object(qwen.servers, "pid_for", return_value=1234), \ + patch.object(qwen.servers, "stop") as mk_stop, \ + patch.object(qwen.common, "pip_install", + return_value=0) as mk_pip: + rc = qwen.update(emit="EMIT") + self.assertEqual(rc, 0) + self.assertEqual([c.args[0] for c in mk_stop.call_args_list], + ["qwen"]) + # The task view's emit is forwarded, the install is an upgrade, + # and the package lands in the qwen backend's own venv. + mk_pip.assert_called_once_with([qwen.QWEN_PIP_PKG], emit="EMIT", + cancel=None, env_dir=qwen.QWEN_ENV, + upgrade=True) + + def test_skips_stop_when_no_server_was_started(self): + from backends import qwen + with patch.object(qwen.servers, "pid_for", return_value=None), \ + patch.object(qwen.servers, "stop") as mk_stop, \ + patch.object(qwen.common, "pip_install", return_value=0): + rc = qwen.update() + self.assertEqual(rc, 0) + mk_stop.assert_not_called() + + def test_cancel_before_pip_skips_the_upgrade(self): + import threading + + from backends import qwen + cancel = threading.Event() + cancel.set() + with patch.object(qwen.servers, "pid_for", return_value=1234), \ + patch.object(qwen.servers, "stop") as mk_stop, \ + patch.object(qwen.common, "pip_install") as mk_pip: + rc = qwen.update(cancel=cancel) + self.assertEqual(rc, 130) + self.assertEqual(mk_stop.call_count, 1) + mk_pip.assert_not_called() + + def test_pip_failure_propagates_the_exit_code(self): + from backends import qwen + with patch.object(qwen.servers, "pid_for", return_value=None), \ + patch.object(qwen.servers, "stop"), \ + patch.object(qwen.common, "pip_install", return_value=1): + rc = qwen.update() + self.assertEqual(rc, 1) diff --git a/app/tests/test_backends_audiocpp.py b/app/tests/test_backends_audiocpp.py index ab405aa..3e97b82 100644 --- a/app/tests/test_backends_audiocpp.py +++ b/app/tests/test_backends_audiocpp.py @@ -1277,6 +1277,236 @@ class BuildAudiocppTests(unittest.TestCase): self.assertIn("No build script found", notices[0]) +class AudiocppUpdateTests(unittest.TestCase): + """update: stop the server, refresh the checkout, rebuild when stale. + + The rebuild fires when the checkout moved OR the on-disk binary is + missing/older than HEAD's commit time (an interrupted earlier build). + """ + + COMMIT_TIME = 1_000_000 + + def setUp(self): + self._td = tempfile.TemporaryDirectory() + self.checkout = Path(self._td.name) / "audio.cpp" + self.checkout.mkdir() + self.addCleanup(common.drain_post_tui_notices) + + def tearDown(self): + self._td.cleanup() + + def _make_binary(self, backend="cuda"): + bin_dir = self.checkout / "build" / f"linux-{backend}-release" / "bin" + bin_dir.mkdir(parents=True, exist_ok=True) + binary = bin_dir / "audiocpp_server" + binary.write_bytes(b"x") + return binary + + def _patch_decision(self, heads, binary, *, commit_time=COMMIT_TIME): + """Patch git state + a built binary for BACKEND ("cuda" default). + + Returns the (mocks) (build, git_update) pair for assertions. + BINARY None means no binary on disk (a present binary is stamped + newer than COMMIT_TIME — stamp it differently after calling this + to simulate staleness); COMMIT_TIME None means the commit-time + probe cannot be answered. + """ + if binary is not None and commit_time is not None: + os.utime(binary, (commit_time + 100,) * 2) + return patch.object(common, "git_head", side_effect=heads), \ + patch.object(common, "git_commit_time", + return_value=commit_time), \ + patch.object(make_server.build, "load_server_config", + return_value={"backend": "cuda"}) + + def test_no_checkout_is_a_reported_noop(self): + with patch.object(make_server.build, "find_local_checkout", + return_value=None), \ + patch.object(make_server.build.servers, "pid_for", + return_value=None), \ + patch.object(common, "git_update") as mk_git: + rc = make_server.build.update() + self.assertEqual(rc, 0) + mk_git.assert_not_called() + + def test_stops_server_then_skips_rebuild_for_a_fresh_binary(self): + binary = self._make_binary() + patches = self._patch_decision(["a", "a"], binary) + with patch.object(make_server.build, "find_local_checkout", + return_value=self.checkout), \ + patch.object(make_server.build.servers, "pid_for", + return_value=1234), \ + patch.object(make_server.build.servers, "stop") as mk_stop, \ + patches[0], patches[1], patches[2], \ + patch.object(common, "git_update", + return_value=0) as mk_git, \ + patch.object(make_server.build, "build_audiocpp") as mk_build: + rc = make_server.build.update(emit="EMIT") + self.assertEqual(rc, 0) + mk_stop.assert_called_once_with("audiocpp") + mk_git.assert_called_once_with(self.checkout, emit="EMIT", + cancel=None) + # HEAD did not move and the binary is newer than HEAD's commit: + # the binary still matches the sources. + mk_build.assert_not_called() + + def test_moved_head_rebuilds_even_with_a_fresh_binary(self): + binary = self._make_binary() + patches = self._patch_decision(["a", "b"], binary) + with patch.object(make_server.build, "find_local_checkout", + return_value=self.checkout), \ + patch.object(make_server.build.servers, "pid_for", + return_value=None), \ + patches[0], patches[1], patches[2], \ + patch.object(common, "git_update", return_value=0), \ + patch.object(make_server.build, "build_audiocpp", + return_value=0) as mk_build: + rc = make_server.build.update(emit="EMIT") + self.assertEqual(rc, 0) + mk_build.assert_called_once_with(self.checkout, "cuda", + emit="EMIT", cancel=None) + + def test_moved_head_falls_back_to_the_detected_backend(self): + patches = self._patch_decision(["a", "b"], None) + with patch.object(make_server.build, "find_local_checkout", + return_value=self.checkout), \ + patch.object(make_server.build.servers, "pid_for", + return_value=None), \ + patches[0], patches[1], \ + patch.object(make_server.build, "load_server_config", + return_value={}), \ + patch.object(make_server.build, "detect_backend", + return_value="vulkan"), \ + patch.object(common, "git_update", return_value=0), \ + patch.object(make_server.build, "build_audiocpp", + return_value=0) as mk_build: + rc = make_server.build.update() + self.assertEqual(rc, 0) + mk_build.assert_called_once_with(self.checkout, "vulkan", + emit=None, cancel=None) + + def test_no_known_backend_skips_the_rebuild(self): + with patch.object(make_server.build, "find_local_checkout", + return_value=self.checkout), \ + patch.object(make_server.build.servers, "pid_for", + return_value=None), \ + patch.object(common, "git_head", side_effect=["a", "b"]), \ + patch.object(common, "git_update", return_value=0), \ + patch.object(make_server.build, "load_server_config", + return_value={}), \ + patch.object(make_server.build, "detect_backend", + return_value=None), \ + patch.object(make_server.build, "build_audiocpp") as mk_build: + rc = make_server.build.update() + self.assertEqual(rc, 0) + mk_build.assert_not_called() + + def test_stale_binary_rebuilds_without_head_movement(self): + # The cancelled-rebuild scenario: sources already at HEAD, the old + # binary predates the new commit → the next update rebuilds. + binary = self._make_binary() + patches = self._patch_decision(["a", "a"], binary) + os.utime(binary, (self.COMMIT_TIME - 100,) * 2) + with patch.object(make_server.build, "find_local_checkout", + return_value=self.checkout), \ + patch.object(make_server.build.servers, "pid_for", + return_value=None), \ + patches[0], patches[1], patches[2], \ + patch.object(common, "git_update", return_value=0), \ + patch.object(make_server.build, "build_audiocpp", + return_value=0) as mk_build: + rc = make_server.build.update() + self.assertEqual(rc, 0) + mk_build.assert_called_once_with(self.checkout, "cuda", + emit=None, cancel=None) + + def test_missing_binary_rebuilds_without_head_movement(self): + patches = self._patch_decision(["a", "a"], None) + with patch.object(make_server.build, "find_local_checkout", + return_value=self.checkout), \ + patch.object(make_server.build.servers, "pid_for", + return_value=None), \ + patches[0], patches[1], patches[2], \ + patch.object(common, "git_update", return_value=0), \ + patch.object(make_server.build, "build_audiocpp", + return_value=0) as mk_build: + rc = make_server.build.update() + self.assertEqual(rc, 0) + mk_build.assert_called_once_with(self.checkout, "cuda", + emit=None, cancel=None) + + def test_unknown_commit_time_rebuilds_without_head_movement(self): + binary = self._make_binary() + patches = self._patch_decision(["a", "a"], binary, commit_time=None) + with patch.object(make_server.build, "find_local_checkout", + return_value=self.checkout), \ + patch.object(make_server.build.servers, "pid_for", + return_value=None), \ + patches[0], patches[1], patches[2], \ + patch.object(common, "git_update", return_value=0), \ + patch.object(make_server.build, "build_audiocpp", + return_value=0) as mk_build: + rc = make_server.build.update() + self.assertEqual(rc, 0) + mk_build.assert_called_once_with(self.checkout, "cuda", + emit=None, cancel=None) + + def test_checkout_failure_skips_the_rebuild(self): + with patch.object(make_server.build, "find_local_checkout", + return_value=self.checkout), \ + patch.object(make_server.build.servers, "pid_for", + return_value=None), \ + patch.object(common, "git_update", + return_value=128) as mk_git, \ + patch.object(make_server.build, "build_audiocpp") as mk_build: + rc = make_server.build.update() + self.assertEqual(rc, 128) + mk_git.assert_called_once() + mk_build.assert_not_called() + + def test_rebuild_failure_propagates_the_exit_code(self): + with patch.object(make_server.build, "find_local_checkout", + return_value=self.checkout), \ + patch.object(make_server.build.servers, "pid_for", + return_value=None), \ + patch.object(common, "git_head", side_effect=["a", "b"]), \ + patch.object(common, "git_update", return_value=0), \ + patch.object(make_server.build, "load_server_config", + return_value={"backend": "cuda"}), \ + patch.object(make_server.build, "build_audiocpp", + return_value=2): + rc = make_server.build.update() + self.assertEqual(rc, 2) + + def test_cancel_before_the_update_skips_everything_after_stopping(self): + cancel = threading.Event() + cancel.set() + with patch.object(make_server.build, "find_local_checkout", + return_value=self.checkout), \ + patch.object(make_server.build.servers, "pid_for", + return_value=1234), \ + patch.object(make_server.build.servers, "stop") as mk_stop, \ + patch.object(common, "git_update") as mk_git: + rc = make_server.build.update(cancel=cancel) + self.assertEqual(rc, 130) + mk_stop.assert_called_once_with("audiocpp") + mk_git.assert_not_called() + + def test_cancel_after_the_checkout_skips_the_rebuild(self): + cancel = threading.Event() + cancel.set() + with patch.object(make_server.build, "find_local_checkout", + return_value=self.checkout), \ + patch.object(make_server.build.servers, "pid_for", + return_value=None), \ + patch.object(common, "git_head", side_effect=["a", "b"]), \ + patch.object(common, "git_update", return_value=0), \ + patch.object(make_server.build, "build_audiocpp") as mk_build: + rc = make_server.build.update(cancel=cancel) + self.assertEqual(rc, 130) + mk_build.assert_not_called() + + class AudiocppDetectTests(unittest.TestCase): """backends.audiocpp.detect() status reporting.""" diff --git a/app/tests/test_backends_common.py b/app/tests/test_backends_common.py index 5bc967a..a94994b 100644 --- a/app/tests/test_backends_common.py +++ b/app/tests/test_backends_common.py @@ -73,6 +73,126 @@ class GitCloneTests(unittest.TestCase): self.assertEqual(run.call_args[1]["emit"], emit) +class GitUpdateTests(unittest.TestCase): + """git_update: fetch, then hard reset to origin's default branch.""" + + def _patched(self, fetch_rc=0, branch="main"): + """Patch run_console_subprocess (fetch/reset) and the branch probe.""" + run = mock.patch.object(common, "run_console_subprocess", + return_value=fetch_rc).start() + mock.patch.object(common, "_origin_default_branch", + return_value=branch).start() + return run + + def tearDown(self): + mock.patch.stopall() + + def test_fetch_then_hard_reset_to_origin_head(self): + run = self._patched() + self.assertEqual(common.git_update(common.Path("/co")), 0) + self.assertEqual( + run.call_args_list[0][0][0], + ["git", "-C", "/co", "fetch", "origin"]) + self.assertEqual( + run.call_args_list[1][0][0], + ["git", "-C", "/co", "reset", "--hard", "origin/main"]) + + def test_streaming_adds_progress_and_passes_emit(self): + emit = lambda line: None # noqa: E731 + run = self._patched() + self.assertEqual(common.git_update(common.Path("/co"), emit=emit), 0) + self.assertEqual( + run.call_args_list[0][0][0], + ["git", "-C", "/co", "fetch", "--progress", "origin"]) + self.assertEqual(run.call_args_list[0][1]["emit"], emit) + self.assertEqual(run.call_args_list[1][1]["emit"], emit) + + def test_fetch_failure_short_circuits_the_reset(self): + run = self._patched(fetch_rc=128) + self.assertEqual(common.git_update(common.Path("/co")), 128) + self.assertEqual(run.call_count, 1) + + def test_reset_uses_the_remote_default_branch(self): + run = self._patched(branch="trunk") + self.assertEqual(common.git_update(common.Path("/co")), 0) + self.assertEqual( + run.call_args_list[1][0][0], + ["git", "-C", "/co", "reset", "--hard", "origin/trunk"]) + + +class OriginDefaultBranchTests(unittest.TestCase): + def test_symbolic_ref_name_is_returned(self): + proc = mock.Mock(returncode=0, + stdout=b"refs/remotes/origin/master\n") + with mock.patch.object(common, "run_console_subprocess_quiet", + return_value=proc): + self.assertEqual(common._origin_default_branch( + common.Path("/co")), "master") + + def test_missing_ref_falls_back_to_main(self): + proc = mock.Mock(returncode=128, stdout=b"") + with mock.patch.object(common, "run_console_subprocess_quiet", + return_value=proc): + self.assertEqual(common._origin_default_branch( + common.Path("/co")), "main") + + def test_unstartable_probe_falls_back_to_main(self): + with mock.patch.object(common, "run_console_subprocess_quiet", + return_value=None): + self.assertEqual(common._origin_default_branch( + common.Path("/co")), "main") + + +class GitHeadTests(unittest.TestCase): + def test_head_sha_is_returned(self): + proc = mock.Mock(returncode=0, stdout=b"abc123\n") + with mock.patch.object(common, "run_console_subprocess_quiet", + return_value=proc) as run: + self.assertEqual(common.git_head(common.Path("/co")), "abc123") + self.assertEqual(run.call_args[0][0], + ["git", "-C", "/co", "rev-parse", "HEAD"]) + + def test_not_a_repo_yields_none(self): + proc = mock.Mock(returncode=128, stdout=b"") + with mock.patch.object(common, "run_console_subprocess_quiet", + return_value=proc): + self.assertIsNone(common.git_head(common.Path("/co"))) + + def test_unstartable_probe_yields_none(self): + with mock.patch.object(common, "run_console_subprocess_quiet", + return_value=None): + self.assertIsNone(common.git_head(common.Path("/co"))) + + +class GitCommitTimeTests(unittest.TestCase): + def test_committer_time_is_parsed(self): + proc = mock.Mock(returncode=0, stdout=b"1756300000\n") + with mock.patch.object(common, "run_console_subprocess_quiet", + return_value=proc) as run: + self.assertEqual(common.git_commit_time(common.Path("/co")), + 1756300000) + self.assertEqual(run.call_args[0][0], + ["git", "-C", "/co", "show", "-s", + "--format=%ct", "HEAD"]) + + def test_not_a_repo_yields_none(self): + proc = mock.Mock(returncode=128, stdout=b"") + with mock.patch.object(common, "run_console_subprocess_quiet", + return_value=proc): + self.assertIsNone(common.git_commit_time(common.Path("/co"))) + + def test_unparsable_output_yields_none(self): + proc = mock.Mock(returncode=0, stdout=b"not-a-number\n") + with mock.patch.object(common, "run_console_subprocess_quiet", + return_value=proc): + self.assertIsNone(common.git_commit_time(common.Path("/co"))) + + def test_unstartable_probe_yields_none(self): + with mock.patch.object(common, "run_console_subprocess_quiet", + return_value=None): + self.assertIsNone(common.git_commit_time(common.Path("/co"))) + + class ParseRequestOptionsTests(unittest.TestCase): """parse_request_options: the shared --option / TUI-field parser.""" diff --git a/app/tests/test_backends_envs.py b/app/tests/test_backends_envs.py index 8b17458..2f0f910 100644 --- a/app/tests/test_backends_envs.py +++ b/app/tests/test_backends_envs.py @@ -154,6 +154,34 @@ class PipInstallTests(unittest.TestCase): self.assertEqual(rc, 1) run.assert_not_called() + def test_upgrade_adds_the_u_flag(self): + calls = [] + + def fake_run(argv, **kwargs): + calls.append(list(argv)) + return 0 + + with patch.object(envs, "env_exists", return_value=True), \ + patch.object(envs.common, "run_console_subprocess", + side_effect=fake_run): + self.assertEqual(envs.pip_install(["qwen-tts"], upgrade=True), 0) + self.assertIn("-U", calls[0]) + # -U sits before the packages; nothing else about the argv changes. + self.assertLess(calls[0].index("-U"), calls[0].index("qwen-tts")) + + def test_no_upgrade_flag_by_default(self): + calls = [] + + def fake_run(argv, **kwargs): + calls.append(list(argv)) + return 0 + + with patch.object(envs, "env_exists", return_value=True), \ + patch.object(envs.common, "run_console_subprocess", + side_effect=fake_run): + self.assertEqual(envs.pip_install(["qwen-tts"]), 0) + self.assertNotIn("-U", calls[0]) + class PipUninstallTests(unittest.TestCase): def test_missing_env_is_success_without_running_pip(self): diff --git a/app/tests/test_backends_faster.py b/app/tests/test_backends_faster.py index 8f024bf..44f4907 100644 --- a/app/tests/test_backends_faster.py +++ b/app/tests/test_backends_faster.py @@ -470,3 +470,100 @@ class UninstallTests(unittest.TestCase): rc = make_voices.uninstall(cancel=cancel) self.assertEqual(rc, 130) self.assertTrue(checkout.exists()) + + +class UpdateTests(unittest.TestCase): + """update: stop the server, pip install -U, refresh the checkout.""" + + def test_pip_upgrade_and_checkout_update(self): + with patch.object(make_voices.servers, "pid_for", + return_value=1234), \ + patch.object(make_voices.servers, "stop") as mk_stop, \ + patch.object(make_voices.common, "pip_install", + return_value=0) as mk_pip, \ + patch.object(make_voices, "_is_cloned", + return_value=True), \ + patch.object(make_voices, "_checkout", + return_value=Path("/co")), \ + patch.object(make_voices.common, "git_update", + return_value=0) as mk_git: + rc = make_voices.update(emit="EMIT") + self.assertEqual(rc, 0) + mk_stop.assert_called_once_with("faster") + # The task view's emit is forwarded, the install is an upgrade, + # and the package lands in the faster backend's own venv. + mk_pip.assert_called_once_with([make_voices.FASTER_PIP_PKG], + emit="EMIT", cancel=None, + env_dir=make_voices.FASTER_ENV, + upgrade=True) + mk_git.assert_called_once_with(Path("/co"), emit="EMIT", cancel=None) + + def test_no_checkout_updates_the_package_only(self): + with patch.object(make_voices.servers, "pid_for", + return_value=None), \ + patch.object(make_voices.servers, "stop") as mk_stop, \ + patch.object(make_voices.common, "pip_install", + return_value=0) as mk_pip, \ + patch.object(make_voices, "_is_cloned", + return_value=False), \ + patch.object(make_voices.common, "git_update") as mk_git: + rc = make_voices.update() + self.assertEqual(rc, 0) + mk_stop.assert_not_called() + mk_git.assert_not_called() + mk_pip.assert_called_once() + + def test_cancel_before_pip_skips_everything_after_stopping(self): + cancel = threading.Event() + cancel.set() + with patch.object(make_voices.servers, "pid_for", + return_value=1234), \ + patch.object(make_voices.servers, "stop") as mk_stop, \ + patch.object(make_voices.common, "pip_install") as mk_pip: + rc = make_voices.update(cancel=cancel) + self.assertEqual(rc, 130) + mk_stop.assert_called_once_with("faster") + mk_pip.assert_not_called() + + def test_cancel_after_pip_skips_the_checkout(self): + cancel = threading.Event() + cancel.set() + with patch.object(make_voices.servers, "pid_for", + return_value=None), \ + patch.object(make_voices.common, "pip_install", + return_value=0), \ + patch.object(make_voices, "_is_cloned", + return_value=True), \ + patch.object(make_voices.common, "git_update") as mk_git: + rc = make_voices.update(cancel=cancel) + self.assertEqual(rc, 130) + mk_git.assert_not_called() + + def test_checkout_failure_propagates_after_a_successful_pip(self): + with patch.object(make_voices.servers, "pid_for", + return_value=None), \ + patch.object(make_voices.common, "pip_install", + return_value=0), \ + patch.object(make_voices, "_is_cloned", + return_value=True), \ + patch.object(make_voices, "_checkout", + return_value=Path("/co")), \ + patch.object(make_voices.common, "git_update", + return_value=3) as mk_git: + rc = make_voices.update() + self.assertEqual(rc, 3) + mk_git.assert_called_once() + + def test_pip_failure_still_updates_the_checkout(self): + with patch.object(make_voices.servers, "pid_for", + return_value=None), \ + patch.object(make_voices.common, "pip_install", + return_value=1), \ + patch.object(make_voices, "_is_cloned", + return_value=True), \ + patch.object(make_voices, "_checkout", + return_value=Path("/co")), \ + patch.object(make_voices.common, "git_update", + return_value=0): + rc = make_voices.update() + self.assertEqual(rc, 1) diff --git a/app/tests/test_hub.py b/app/tests/test_hub.py index a9d4a4b..4186c92 100644 --- a/app/tests/test_hub.py +++ b/app/tests/test_hub.py @@ -113,7 +113,7 @@ class HubHelperTests(unittest.TestCase): self.assertEqual(hub._status_mark(remote_models), ("running [remote] (Base)", "ok", "body")) self.assertEqual(hub._status_mark(installed), - ("installed", "warn", "body")) + ("installed", "ok", "body")) self.assertEqual(hub._status_mark(none), ("unavailable", "err", "dim")) self.assertEqual(hub._status_mark(None), @@ -213,7 +213,7 @@ class HubMenuTests(unittest.TestCase): "Start/Stop Backend Servers", "Settings", "Quit"]) # The status table is passed through, one row per backend. self.assertEqual(captured["rows"], - [("qwen-tts", "installed", "warn", "body")]) + [("qwen-tts", "installed", "ok", "body")]) def test_table_dims_name_when_not_installed_and_not_running(self): captured = {} @@ -378,7 +378,7 @@ class SubmenuStatusTableTests(unittest.TestCase): self.assertEqual(captured["table_title"], "Backend status") self.assertEqual( captured["table_rows"], - [("qwen-tts", "installed", "warn", "body"), + [("qwen-tts", "installed", "ok", "body"), ("faster-qwen3-tts", "running [remote]", "ok", "body")]) self.assertIsNone(captured["notice_lines"]) @@ -398,6 +398,32 @@ class SubmenuStatusTableTests(unittest.TestCase): self.assertEqual([label for label, _ in captured["options"]], ["Install Backend"]) + def test_configure_backends_menu_lists_update_between_install_uninstall(self): + captured = {} + infos = [BackendInfo("qwen", "qwen-tts", lambda: None, lambda: 0, + update=lambda **kw: 0), + BackendInfo("faster", "faster-qwen3-tts", lambda: None, + lambda: 0, update=lambda **kw: 0)] + statuses = [ + BackendStatus("qwen", "qwen-tts", installed=True, + configured=True), + BackendStatus("faster", "faster-qwen3-tts", installed=False, + configured=False), + ] + with patch.object(hub, "REGISTRY", infos), \ + patch.object(hub, "detect_all", return_value=statuses), \ + patch.object(hub.tui, "menu", + self._capture_menu(captured)), \ + patch.object(hub.shutil, "which", return_value="/x"): + result = hub._Hub(None).screen_configure() + self.assertIs(result, tui.Wizard.BACK) + # Update sits between Install and Uninstall; it is offered once for + # the whole set of installed backends (faster has nothing on disk + # and so contributes nothing). + self.assertEqual([label for label, _ in captured["options"]], + ["Install Backend", "Update backends", + "Uninstall Backend"]) + def test_configure_backends_menu_audiocpp_model_actions(self): captured = {} infos = [BackendInfo("audiocpp", "audio.cpp", lambda: None, lambda: 0)] @@ -541,7 +567,7 @@ class SubmenuStatusTableTests(unittest.TestCase): self.assertEqual(self._labels(captured["options"]), ["Configure qwen-tts", "Uninstall Backend"]) self.assertEqual(captured["table_rows"], - [("qwen-tts", "installed", "warn", "body")]) + [("qwen-tts", "installed", "ok", "body")]) def test_selecting_qwen_runs_its_configure_screen_not_setup(self): ran = [] @@ -653,7 +679,7 @@ class SubmenuStatusTableTests(unittest.TestCase): self.assertIs(result, tui.Wizard.BACK) self.assertEqual(captured["table_title"], "Backend status") self.assertEqual( - captured["table_rows"], [("qwen-tts", "installed", "warn", + captured["table_rows"], [("qwen-tts", "installed", "ok", "body")]) def test_server_menu_lists_only_installed_backends(self): @@ -2762,6 +2788,128 @@ class ConfigureBackendsDispatchTests(unittest.TestCase): self.assertEqual(len(flashes), 1) self.assertEqual(flashes[0][1], "err") + def test_update_backends_action_runs_one_step_per_installed_backend(self): + calls = [] + + def make_update(name): + def update(*, emit=None, cancel=None): + calls.append((name, emit, cancel)) + return 0 + return update + + # faster is installed in statuses but has no update action → one + # step fewer; audiocpp's on-disk check routes through the checkout. + infos = [BackendInfo("audiocpp", "audio.cpp", lambda: None, + lambda: 0, update=make_update("audiocpp")), + BackendInfo("qwen", "qwen-tts", lambda: None, lambda: 0, + update=make_update("qwen")), + BackendInfo("faster", "faster-qwen3-tts", lambda: None, + lambda: 0)] + statuses = [BackendStatus("audiocpp", "audio.cpp", installed=True, + configured=True), + BackendStatus("qwen", "qwen-tts", installed=True, + configured=True), + BackendStatus("faster", "faster-qwen3-tts", + installed=True, configured=True)] + patch_flash, flashes = self._capture_flashes() + with patch.object(hub, "REGISTRY", infos), \ + patch.object(hub, "detect_all", return_value=statuses), \ + patch.object(hub.audiocpp_backend, "find_local_checkout", + return_value=Path("/co")), \ + patch.object(hub.taskview, "run_steps", + return_value=0) as mk_run, \ + patch_flash: + hub._update_backends_action(None) + # One task-view run titled "Update backends", one step per + # updatable backend in registry order; executing a step + # forwards emit/cancel to that backend's update. + mk_run.assert_called_once() + self.assertEqual(mk_run.call_args[0][0], None) + self.assertEqual(mk_run.call_args[0][1], "Update backends") + steps = mk_run.call_args[0][2] + self.assertEqual([step.title for step in steps], + ["Update audio.cpp", "Update qwen-tts"]) + def emit(line): + pass + steps[1].work(emit, "CANCEL") + self.assertEqual(calls, [("qwen", emit, "CANCEL")]) + self.assertEqual(flashes[-1][1], "ok") + + def test_update_backends_action_flashes_error_when_something_failed(self): + infos = [BackendInfo("qwen", "qwen-tts", lambda: None, lambda: 0, + update=lambda **kw: 0)] + statuses = [BackendStatus("qwen", "qwen-tts", installed=True, + configured=True)] + patch_flash, flashes = self._capture_flashes() + with patch.object(hub, "REGISTRY", infos), \ + patch.object(hub, "detect_all", return_value=statuses), \ + patch.object(hub.taskview, "run_steps", return_value=1), \ + patch_flash: + hub._update_backends_action(None) + self.assertEqual(flashes[-1][1], "err") + self.assertIn("did not complete", flashes[-1][0]) + + def test_update_backends_action_flashes_warn_when_cancelled(self): + infos = [BackendInfo("qwen", "qwen-tts", lambda: None, lambda: 0, + update=lambda **kw: 0)] + statuses = [BackendStatus("qwen", "qwen-tts", installed=True, + configured=True)] + patch_flash, flashes = self._capture_flashes() + with patch.object(hub, "REGISTRY", infos), \ + patch.object(hub, "detect_all", return_value=statuses), \ + patch.object(hub.taskview, "run_steps", return_value=130), \ + patch_flash: + hub._update_backends_action(None) + self.assertEqual(flashes[-1][1], "warn") + self.assertIn("cancelled", flashes[-1][0]) + + def test_update_backends_action_without_targets_flashes_a_hint(self): + # An installed backend without an update action (and nothing else + # installed): the entry never shows, but a direct call still + # explains itself instead of running an empty task view. + infos = [BackendInfo("qwen", "qwen-tts", lambda: None, lambda: 0)] + statuses = [BackendStatus("qwen", "qwen-tts", installed=True, + configured=True)] + patch_flash, flashes = self._capture_flashes() + with patch.object(hub, "REGISTRY", infos), \ + patch.object(hub, "detect_all", return_value=statuses), \ + patch.object(hub.taskview, "run_steps") as mk_run, \ + patch_flash: + hub._update_backends_action(None) + mk_run.assert_not_called() + self.assertEqual(flashes, + [("No installed backend supports updating.", + "warn")]) + + def test_selecting_update_runs_the_action_and_reshows_the_menu(self): + titles = [] + + def fake_menu(stdscr, title, options, **kwargs): + titles.append(title) + return "update" if len(titles) == 1 else tui.Wizard.BACK + + ran = [] + invalidated = [] + info = BackendInfo("qwen", "qwen-tts", lambda: None, lambda: 0, + update=lambda **kw: 0) + statuses = [BackendStatus("qwen", "qwen-tts", installed=True, + configured=True)] + with patch.object(hub.tui, "menu", fake_menu), \ + patch.object(hub, "detect_all", return_value=statuses), \ + patch.object(hub, "REGISTRY", [info]), \ + patch.object(hub, "_update_backends_action", + side_effect=lambda scr: ran.append("update")), \ + patch.object(hub, "invalidate_detect_cache", + side_effect=lambda: invalidated.append(True)), \ + patch.object(hub.shutil, "which", return_value="/x"): + result = hub._Hub(None).screen_configure() + self.assertIs(result, tui.Wizard.BACK) + self.assertEqual(ran, ["update"]) + self.assertEqual(invalidated, [True]) + # An inline action: the same menu re-shows (second title) with a + # freshly detected status table. + self.assertEqual(titles, ["Configure backends", "Configure backends"]) + def test_pick_backend_install_lists_uninstalled_only(self): captured = {} diff --git a/app/ui/hub.py b/app/ui/hub.py index 9c1bd97..a57ab6c 100644 --- a/app/ui/hub.py +++ b/app/ui/hub.py @@ -154,7 +154,9 @@ class _Hub: separated from the rest by a blank line. The remaining actions are populated from the detected statuses: configure each configurable backend (qwen offers its per-model weight (un)installer there), - install (backends with nothing on disk), and uninstall. + install (backends with nothing on disk), update (every installed + backend refreshed to the latest upstream version in one task-view + run), and uninstall. Selecting one pushes the next screen; Esc pops back to the main menu. The Build action downloads any missing models alongside the build (a split view), so it heals a configured-but-unbuilt backend @@ -202,13 +204,16 @@ class _Hub: for info in installed if _configurable(info)] if any(_installable(info, by_key) for info in REGISTRY): options.append(("Install Backend", "install")) + if any(_updatable(info, by_key) for info in REGISTRY): + options.append(("Update backends", "update")) if any(_uninstallable(info, by_key) for info in REGISTRY): options.append(("Uninstall Backend", "uninstall")) choice = tui.menu( self.stdscr, "Configure backends", options, back_value=tui.Wizard.BACK, - help_lines=["Install, configure, or remove a TTS backend."], + help_lines=["Install, update, configure, or remove a TTS " + "backend."], table_title="Backend status", table_rows=_status_rows(statuses), notice_lines=_notice_lines()) @@ -216,6 +221,10 @@ class _Hub: return tui.Wizard.BACK if choice == "install": return self.screen_install + if choice == "update": + _update_backends_action(self.stdscr) + invalidate_detect_cache() + continue # an inline action: re-show this same menu if choice == "uninstall": return self.screen_uninstall if choice == "download_models": @@ -644,6 +653,16 @@ def _uninstallable(info, by_key: dict) -> bool: return status is not None and status.installed +def _updatable(info, by_key: dict) -> bool: + """True when the "Update backends" action has something to do for INFO. + + The same on-disk predicate as _uninstallable — update acts on exactly + what uninstall removes (the pip package / the checkout) — plus the + backend must implement an update action at all. + """ + return info.update is not None and _uninstallable(info, by_key) + + def _download_models_action(stdscr) -> None: """Run the "Download Missing Models (audio.cpp)" action inside the TUI. @@ -695,6 +714,45 @@ def _download_models_action(stdscr) -> None: "err") +def _update_backends_action(stdscr) -> None: + """Run the "Update backends" action inside the TUI. + + One task-view step per installed backend that implements update, in + registry order; each update stops its managed server first (best- + effort) and then refreshes — pip install -U for the pip backends, + git fetch + hard reset for the checkouts, with audio.cpp's binary + rebuilt when its checkout moved. A failing backend's step is marked + [FAIL] and the remaining backends still update (the run's exit code + is the first failure). A flash summarizes the result; the status + table re-detects when the menu re-shows. + """ + statuses = detect_all() + by_key = {st.key: st for st in statuses} + targets = [info for info in REGISTRY if _updatable(info, by_key)] + if not targets: + tui.flash(stdscr, "No installed backend supports updating.") + return + + def make_work(info): + def work(emit, cancel): + return info.update(emit=emit, cancel=cancel) + return work + + steps = [taskview.TaskStep(f"Update {info.label}", make_work(info)) + for info in targets] + rc = taskview.run_steps(stdscr, "Update backends", steps) + if rc == 0: + tui.flash(stdscr, "Every backend is up to date (or just " + "updated).", "ok") + elif rc == 130: + tui.flash(stdscr, "Update cancelled — re-run 'Update backends' " + "any time.", "warn") + else: + tui.flash(stdscr, "Some updates did not complete (failed or " + "cancelled) — see the log above. Re-run 'Update " + "backends' to retry.", "err") + + def _status_mark(status: Optional[BackendStatus]) -> Tuple[str, str, str]: """Map a backend's state to (status_text, status_kind, name_kind). @@ -705,8 +763,9 @@ def _status_mark(status: Optional[BackendStatus]) -> Tuple[str, str, str]: "running [local, remote]". Otherwise a backend set up only part-way (``status.partial``) shows that label verbatim (amber), e.g. audio.cpp's "downloaded (not built)" or "built (not configured)"; 'installed' - (orange/warn) when the backend is present on disk; or 'unavailable' - (red/err). A backend that is neither installed nor running is unusable, + (green/ok) when the backend is present on disk — amber (warn) instead + when its models are missing — or 'unavailable' (red/err). A backend + that is neither installed nor running is unusable, so its name is dimmed (NAME_KIND). A multi-model backend (qwen) also names which models answered in parentheses, e.g. "running [local, remote] (Base, @@ -734,7 +793,7 @@ def _status_mark(status: Optional[BackendStatus]) -> Tuple[str, str, str]: if status is not None and status.installed: if status.models_missing and not status.running: return ("installed (models missing)", "warn", "body") - return ("installed", "warn", "body") + return ("installed", "ok", "body") return ("unavailable", "err", "dim") |
