aboutsummaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/backends/__init__.py8
-rw-r--r--app/backends/audiocpp/__init__.py6
-rw-r--r--app/backends/audiocpp/build.py114
-rw-r--r--app/backends/common.py114
-rw-r--r--app/backends/envs.py16
-rwxr-xr-xapp/backends/faster.py38
-rw-r--r--app/backends/qwen.py27
-rw-r--r--app/tests/test_backends.py55
-rw-r--r--app/tests/test_backends_audiocpp.py230
-rw-r--r--app/tests/test_backends_common.py120
-rw-r--r--app/tests/test_backends_envs.py28
-rw-r--r--app/tests/test_backends_faster.py97
-rw-r--r--app/tests/test_hub.py158
-rw-r--r--app/ui/hub.py69
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")