diff options
| author | historia <historiavg@proton.me> | 2026-08-26 22:27:51 -0400 |
|---|---|---|
| committer | historia <historiavg@proton.me> | 2026-08-26 22:27:51 -0400 |
| commit | f18f421d9180ae0e3bff9496b1fdaf53d3624a75 (patch) | |
| tree | 6f89ccbda3844bb74ebe239dbf15ed3441b13204 /app | |
| parent | 477ac3e827e3bdc9f14583fc3aa8db1fa2d27c52 (diff) | |
| download | tts-audiobook-generator-f18f421d9180ae0e3bff9496b1fdaf53d3624a75.tar.gz | |
feat: manage qwen-tts model installs manually, delete model(s) when uninstalled
Diffstat (limited to 'app')
| -rw-r--r-- | app/backends/__init__.py | 15 | ||||
| -rw-r--r-- | app/backends/qwen.py | 270 | ||||
| -rw-r--r-- | app/docs/backend-qwen.md | 2 | ||||
| -rw-r--r-- | app/tests/test_backends.py | 405 | ||||
| -rw-r--r-- | app/tests/test_hub.py | 77 | ||||
| -rw-r--r-- | app/ui/hub.py | 43 |
6 files changed, 779 insertions, 33 deletions
diff --git a/app/backends/__init__.py b/app/backends/__init__.py index 192067c..e59c464 100644 --- a/app/backends/__init__.py +++ b/app/backends/__init__.py @@ -18,8 +18,10 @@ importing this package must stay cheap and dependency-free. Adding a backend: create ``backends/<name>.py`` exposing ``detect() -> BackendStatus``, ``setup_screen(stdscr) -> int`` (the setup wizard run on the hub's own screen) and ``uninstall() -> int``, then append -a ``BackendInfo`` in ``_build_registry`` below. ``audiobook.py`` and the hub -pick it up automatically. A backend's standalone CLI keeps its own +a ``BackendInfo`` in ``_build_registry`` below. Optionally expose a +``configure_screen(stdscr) -> int`` to give the hub's "Configure <label>" +entry somewhere to go besides re-running the wizard. ``audiobook.py`` and +the hub pick it up automatically. A backend's standalone CLI keeps its own ``run_tui()`` entry (its own curses session), which is not part of the registry. """ @@ -158,12 +160,20 @@ 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). + + CONFIGURE_SCREEN, when given, is what the hub's "Configure <label>" + menu entry runs instead of SETUP_SCREEN once the backend exists — a + place for install-time-independent management (qwen uses it for its + per-model weight installs). Without one, the hub falls back to + SETUP_SCREEN; a backend whose wizard asks nothing (bare qwen) offers + no Configure entry at all. """ key: str label: str detect: Callable[[], BackendStatus] setup_screen: Callable[[object], int] uninstall: Callable[..., int] = lambda *args, **kwargs: 0 + configure_screen: Optional[Callable[[object], int]] = None REGISTRY: List[BackendInfo] = [] @@ -189,6 +199,7 @@ def _build_registry() -> None: detect=qwen.detect, setup_screen=qwen.setup_screen, uninstall=qwen.uninstall, + configure_screen=qwen.models_screen, )) REGISTRY.append(BackendInfo( key="faster", diff --git a/app/backends/qwen.py b/app/backends/qwen.py index c0c2cd1..3309c69 100644 --- a/app/backends/qwen.py +++ b/app/backends/qwen.py @@ -7,14 +7,24 @@ ONE Qwen3-TTS model per process — CustomVoice (built-in speakers), Base end-to-end: pip-install the package into the managed venv. There are no questions to ask — the port and which model to run live in ``app/converter/config.py`` (the model is chosen per run on the hub's -Generate-audiobooks screen), and only one server runs at a time. It is driven -by ``audiobook.py``'s hub but can also be run directly: +Generate-audiobooks screen), and only one server runs at a time. + +Model weights are not part of the install: each demo lazily fetches its +~4GB snapshot from HuggingFace into the standard hub cache the first time a +server for it starts. This module tracks those three cache directories so +weights can be pre-fetched ("Install") or deleted ("Uninstall") per model — +via the hub's Configure-qwen-tts screen — and so the backend uninstaller can +remove every downloaded weight alongside the package. + +It is driven by ``audiobook.py``'s hub but can also be run directly: Usage: python app/backends/qwen.py [--skip-install] """ import argparse +import os +import shutil import sys from pathlib import Path from typing import List, Optional @@ -33,7 +43,7 @@ from backends import ( ) from converter import config from converter.clients import QWEN3_TTS_SPEAKERS -from ui import taskview +from ui import taskview, tui QWEN_PIP_PKG = "qwen-tts" DEFAULT_PORT = 7860 @@ -60,6 +70,96 @@ DEFAULT_MODEL = "CustomVoice" QWEN_SPEAKERS = QWEN3_TTS_SPEAKERS +# -- HuggingFace weight cache ------------------------------------------------ +# +# The demo servers pull each model's snapshot into huggingface_hub's default +# hub cache on first start (nothing in this project redirects it). These +# helpers read and delete the same directories ``from_pretrained`` writes, +# honoring the same environment overrides, so per-model (un)installs land +# exactly where a server start would look. + +def _hf_cache_dir() -> Path: + """The HF hub cache dir servers fetch model weights into. + + Resolution mirrors huggingface_hub.constants: HF_HUB_CACHE beats + HUGGINGFACE_HUB_CACHE beats HF_HOME/hub beats ~/.cache/huggingface/hub. + """ + override = os.environ.get("HF_HUB_CACHE") or os.environ.get( + "HUGGINGFACE_HUB_CACHE") + if override: + return Path(override) + home = os.environ.get("HF_HOME") + if home: + return Path(home) / "hub" + return Path.home() / ".cache" / "huggingface" / "hub" + + +def repo_dir(repo_id: str) -> Path: + """The cache directory HF keeps REPO_ID's weights in (models--Qwen--…).""" + return _hf_cache_dir() / ("models--" + repo_id.replace("/", "--")) + + +def model_repo_dir(model: str) -> Path: + """The cached-weights directory for a MODEL_REPOS key.""" + return repo_dir(MODEL_REPOS[model]) + + +def _tree_has_file(path: Path) -> bool: + """True when any file or symlink exists under PATH (recursively).""" + try: + for item in path.iterdir(): + # Snapshot files are symlinks into blobs/; count them even when + # temporarily broken (presence is what the loader checks). + if item.is_symlink() or item.is_file(): + return True + if item.is_dir() and _tree_has_file(item): + return True + except OSError: + return False + return False + + +def model_installed(model: str) -> bool: + """True when MODEL's weights look complete in the local HF cache. + + A fetched repo has refs/main plus at least one file under snapshots/; + anything less counts as not installed. An interrupted download simply + resumes — via Install, or the next server start for that model. + """ + directory = model_repo_dir(model) + if not (directory / "refs" / "main").is_file(): + return False + return _tree_has_file(directory / "snapshots") + + +def installed_models() -> List[str]: + """The MODEL_REPOS keys whose weights are already on disk.""" + return [name for name in MODEL_REPOS if model_installed(name)] + + +def delete_model_weights(models: Optional[List[str]] = None) -> int: + """Delete the cached HF weight dirs of MODELS (every model by default). + + Best-effort rmtree of each ``models--Qwen--…`` directory; returns how + many were present and removed. Only those directories are ever touched — + the rest of the HF cache may be shared with unrelated tools. Prints its + progress, which streams into the task view under curses too. + """ + names = sorted(MODEL_REPOS) if models is None else list(models) + removed = 0 + for name in names: + directory = model_repo_dir(name) + if not directory.is_dir(): + continue + print(f"[INFO] Removing cached {MODEL_REPOS[name]} weights...") + shutil.rmtree(directory, ignore_errors=True) + removed += 1 + if removed: + print(f"[OK] Deleted cached weights for {removed} " + f"{'model' if removed == 1 else 'models'}.") + return removed + + def _is_installed() -> bool: if envs.env_script("qwen-tts-demo").is_file(): return True @@ -174,6 +274,30 @@ def build_parser() -> argparse.ArgumentParser: return parser +def _build_spec(model: str) -> ServerSpec: + """The single managed ServerSpec hosting MODEL on the configured port.""" + url = config.QWEN_API_URL + return ServerSpec( + "qwen", url, + [str(envs.env_script("qwen-tts-demo")), MODEL_REPOS[model], + "--ip", "127.0.0.1", + "--port", str(_config_port(url, DEFAULT_PORT))], + identity=desired_identity(model)) + + +def _managed_running_model() -> Optional[str]: + """The model a locally-managed, up-and-running demo answers as. + + None when no pid file exists (this tool never started that server), the + process is gone, or the probe cannot identify which model it hosts. + """ + if servers.pid_for("qwen") is None: + return None + if not servers.alive("qwen"): + return None + return model_for_identity(probe.identify_server(config.QWEN_API_URL)) + + def detect() -> BackendStatus: """Detect whether qwen-tts is installed, plus the launch command. @@ -192,13 +316,7 @@ def detect() -> BackendStatus: details.append(f"port: {_config_port(url, DEFAULT_PORT)}") details.append(f"model: {model}") details.append(f"speaker: {config.SPEAKER}") - demo = str(envs.env_script("qwen-tts-demo")) - specs = [ - ServerSpec("qwen", url, - [demo, MODEL_REPOS[model], "--ip", "127.0.0.1", - "--port", str(_config_port(url, DEFAULT_PORT))], - identity=desired_identity(model)), - ] + specs = [_build_spec(model)] managed = servers.manages(specs) # A locally-managed server names its running model via the probe of the # managed URL; a remotely-run demo names it via the remote-URL probe. @@ -244,20 +362,72 @@ def _detect_remote(managed: bool = False): return remote_models, remote_urls +def _hf_download_prefix() -> Optional[List[str]]: + """The venv's hf CLI argv prefix (None when neither script is present).""" + for name in ("hf", "huggingface-cli"): + candidate = envs.env_script(name) + if candidate.is_file(): + return [str(candidate)] + return None + + +def install_model(model: str, *, emit=None, cancel=None) -> int: + """Pre-download MODEL's weights into the HF cache via the venv's hf CLI. + + Exactly what the first server start does implicitly, made explicit: + streamed progress through EMIT (percent bars feed the task view), + CANCEL kills the download process group mid-flight, and re-running + resumes where a previous attempt left off. Returns the exit code. + """ + prefix = _hf_download_prefix() + if prefix is None: + print("[ERROR] No hf CLI found in the managed venv; pip install " + f"{QWEN_PIP_PKG} first") + return 1 + repo = MODEL_REPOS[model] + print(f"[INFO] Downloading {repo} into {_hf_cache_dir()}...") + rc = common.run_console_subprocess(prefix + ["download", repo], + emit=emit, cancel=cancel) + if rc == 0: + print(f"[OK] {repo} downloaded.") + return rc + + +def uninstall_model(model: str, *, emit=None, cancel=None) -> int: + """Remove MODEL's cached weights (the inverse of install_model). + + A locally-managed server that currently answers as MODEL is stopped + first (best-effort) so its weights are not deleted under a live + process; no server, a down one, or one serving another model leaves + everything else untouched. CANCEL is honored after that stop phase + only. Returns the exit code. + """ + if _managed_running_model() == model: + servers.stop("qwen") + if common.cancel_requested(cancel): + return 130 + delete_model_weights([model]) + return 0 + + def uninstall(*, emit=None, cancel=None) -> int: - """Remove the qwen-tts backend entirely: stop its server, pip uninstall. + """Remove the qwen-tts backend entirely: stop its server, pip uninstall, + then delete every downloaded model. qwen-tts is a pip package (``qwen_tts`` + the ``qwen-tts-demo`` script) installed into the managed venv, so uninstalling it removes the backend. - Any server this tool started is stopped first (best-effort). Model - weights already fetched into the HuggingFace cache stay on disk. + Any server this tool started is stopped first (best-effort). The three + models' weight snapshots — multi-GB directories lazily fetched into the + HuggingFace cache (~/.cache/huggingface/hub) — are deleted too, matching + the hub's confirmation dialog; only those three directories are removed, + never the shared cache itself. With EMIT given (the in-TUI task view) pip runs piped, streaming into EMIT, so its output never touches the terminal behind curses. CANCEL is - a ``threading.Event`` honored between phases only (after the server has - been stopped, before pip starts) — a started phase always completes, - so pip is never killed mid-run. Returns the exit code (130 when - cancelled before pip ran). + a ``threading.Event`` honored between phases only (stop servers / pip / + delete models) — a started phase always completes, so pip is never + killed mid-run. Returns the exit code (130 when cancelled before a + remaining phase). """ if servers.pid_for("qwen") is not None: # Only stop when a pid file exists: without one this tool never @@ -272,9 +442,75 @@ def uninstall(*, emit=None, cancel=None) -> int: f"{QWEN_PIP_PKG} from the managed venv manually") else: print(f"[OK] {QWEN_PIP_PKG} removed.") + # Weights go even when the pip step failed: the package can be + # re-installed any time, multi-GB snapshots are what actually cost disk. + if common.cancel_requested(cancel): + return 130 + delete_model_weights() return rc +def models_screen(stdscr) -> int: + """Per-model (un)install screen: the hub's Configure-qwen-tts leaf. + + Each of the three models gets exactly one action reflecting disk state: + Install pre-downloads its weights via the hf CLI (a streamed, resumable, + cancelable task-view run) and Uninstall deletes them again (stopping a + managed server that answers as that model first). Install requires the + pip package; without one a guidance flash replaces the download, since + pre-fetched weights without a backend to serve them buy nothing. The + status table and options re-render after every action, so Esc pops back + to Configure backends. Always returns 0. + """ + while True: + present = installed_models() + rows = [(name, "installed", "ok") if name in present + else ("not installed", "warn") for name in MODEL_REPOS] + options = [] + for name in MODEL_REPOS: + if name in present: + options.append((f"Uninstall {name}", ("uninstall", name))) + elif _is_installed(): + options.append((f"Install {name}", ("install", name))) + else: + options.append((f"{name} (backend not installed)", + ("noop", name))) + + def make_work(action: str, target: str): + def work(emit, cancel) -> int: + if action == "install": + return install_model(target, emit=emit, cancel=cancel) + return uninstall_model(target, emit=emit, cancel=cancel) + return work + + choice = tui.menu( + stdscr, "Configure qwen-tts", options, + back_value=tui.Wizard.BACK, + help_lines=["Models are normally pulled when their server first", + "starts; Install pre-downloads one right now."], + table_title="Model state", table_rows=rows) + if choice is tui.Wizard.BACK: + return 0 + action, name = choice + if action == "noop": + continue + if action == "install" and not _is_installed(): + tui.flash(stdscr, "Install the qwen-tts backend first " + "(Configure backends > Install Backend).", "warn") + continue + title = (f"Download {MODEL_REPOS[name]}" if action == "install" + else f"Delete {name} weights") + step = taskview.TaskStep(title, make_work(action, name)) + rc = taskview.run_steps(stdscr, title, [step], wait_on_finish=False) + if rc == 0: + tui.flash(stdscr, + f"{name} downloaded." if action == "install" + else f"{name} weights removed.", "ok") + else: + verb = "download" if action == "install" else "remove" + tui.flash(stdscr, f"Could not {verb} {name}.", "err") + + def main() -> int: parser = build_parser() args = parser.parse_args() diff --git a/app/docs/backend-qwen.md b/app/docs/backend-qwen.md index 5cfb0b3..3fd55ca 100644 --- a/app/docs/backend-qwen.md +++ b/app/docs/backend-qwen.md @@ -4,7 +4,7 @@ The easiest way is to run `python audiobook.py` → **Configure backends… → If you prefer to install the backend yourself (in your own environment, not the managed venv), the manual steps are below. Either way the hub detects a running server by its port (its `GET /info` names which of the three demos answers), so a manually-installed backend works once its server is up. To use a demo server on another machine, set `QWEN_REMOTE_URL` in `app/converter/config.py` to its `host:port` (default `127.0.0.1:7860`) — the hub probes it and offers the matching `qwen-tts [remote]` mode limited to the model that server hosts — or pass `--api-url` on the CLI. -Model weights download automatically from HuggingFace into the standard cache (`~/.cache/huggingface/hub`) the first time a server for each model starts — there is nothing else to install per model. +Model weights download automatically from HuggingFace into the standard cache (`~/.cache/huggingface/hub`) the first time a server for each model starts — there is nothing else to install per model. To pre-fetch or remove a single model's weights without starting its server, open **Configure backends… → Configure qwen-tts**: each of Base / CustomVoice / VoiceDesign gets an Install (a streamed, resumable download — canceling one just means it resumes later) or Uninstall action, with a server hosting that model stopped first. Uninstalling the whole backend deletes all three of those directories along with the pip package; only they are ever touched — anything else in your HuggingFace cache is left alone. Install qwen-tts with pip into your environment: diff --git a/app/tests/test_backends.py b/app/tests/test_backends.py index 5cf5633..1e1a3f9 100644 --- a/app/tests/test_backends.py +++ b/app/tests/test_backends.py @@ -15,6 +15,7 @@ from backends import ( get, invalidate_detect_cache, ) +from ui import tui class FormatLaunchHintTests(unittest.TestCase): @@ -50,6 +51,10 @@ class RegistryTests(unittest.TestCase): self.assertIs(get("audiocpp").key, "audiocpp") self.assertIsNone(get("nonexistent")) + def test_qwen_carries_the_per_model_configure_screen(self): + from backends import qwen + self.assertIs(get("qwen").configure_screen, qwen.models_screen) + class DetectAllTests(unittest.TestCase): def test_detect_all_returns_one_status_per_backend(self): @@ -313,6 +318,406 @@ class RemoteSuppressionTests(unittest.TestCase): self.assertEqual(status.remote_urls, {}) +class QwenModelCacheTests(unittest.TestCase): + """HF-cache awareness for the three demo repos (see backends.qwen).""" + + def test_repo_dirs_map_to_hf_cache_names(self): + from backends import qwen + with tempfile.TemporaryDirectory() as td, \ + patch.dict("os.environ", {"HF_HUB_CACHE": td}): + self.assertEqual( + qwen.model_repo_dir("CustomVoice"), + Path(td) / "models--Qwen--Qwen3-TTS-12Hz-1.7B-CustomVoice") + self.assertEqual( + qwen.model_repo_dir("Base"), + Path(td) / "models--Qwen--Qwen3-TTS-12Hz-1.7B-Base") + self.assertEqual( + qwen.model_repo_dir("VoiceDesign"), + Path(td) / "models--Qwen--Qwen3-TTS-12Hz-1.7B-VoiceDesign") + + def test_cache_dir_resolution_matches_huggingface_hub_precedence(self): + # HF_HUB_CACHE > HUGGINGFACE_HUB_CACHE > HF_HOME/hub > default. + # Listed vars are blanked so ambient env can't leak in. + from backends import qwen + with tempfile.TemporaryDirectory() as td: + base = Path(td) + with patch.dict("os.environ", {"HF_HUB_CACHE": str(base / "a"), + "HUGGINGFACE_HUB_CACHE": "", + "HF_HOME": ""}): + self.assertEqual(qwen._hf_cache_dir(), base / "a") + with patch.dict("os.environ", {"HF_HUB_CACHE": "", + "HUGGINGFACE_HUB_CACHE": + str(base / "b"), + "HF_HOME": ""}): + self.assertEqual(qwen._hf_cache_dir(), base / "b") + with patch.dict("os.environ", {"HF_HUB_CACHE": "", + "HUGGINGFACE_HUB_CACHE": "", + "HF_HOME": str(base / "c")}): + self.assertEqual(qwen._hf_cache_dir(), base / "c" / "hub") + with patch.dict("os.environ", {"HF_HUB_CACHE": "", + "HUGGINGFACE_HUB_CACHE": "", + "HF_HOME": ""}): + self.assertEqual(qwen._hf_cache_dir(), + Path.home() / ".cache" / "huggingface" + / "hub") + + def _seed_model(self, cache: Path, repo_id: str) -> Path: + """A fully-fetched-looking repo dir: refs/main + a snapshot file.""" + d = cache / ("models--" + repo_id.replace("/", "--")) + (d / "snapshots" / "abc123").mkdir(parents=True) + (d / "refs").mkdir() + (d / "refs" / "main").write_text("abc123\n", encoding="utf-8") + (d / "snapshots" / "abc123" / "config.json").write_bytes(b"x") + return d + + def test_installed_requires_refs_and_a_snapshot_file(self): + from backends import qwen + repo = qwen.MODEL_REPOS["CustomVoice"] + with tempfile.TemporaryDirectory() as td: + with patch.dict("os.environ", {"HF_HUB_CACHE": td}): + self.assertFalse(qwen.model_installed("CustomVoice")) + self.assertEqual(qwen.installed_models(), []) + self._seed_model(Path(td), repo) + self.assertTrue(qwen.model_installed("CustomVoice")) + self.assertEqual(qwen.installed_models(), ["CustomVoice"]) + + def test_partial_download_counts_as_not_installed(self): + # An interrupted fetch leaves blobs/ behind but no refs/main yet; + # resuming (Install or the next server start) takes over cleanly. + from backends import qwen + d = None + with tempfile.TemporaryDirectory() as td: + d = Path(td) + with patch.dict("os.environ", {"HF_HUB_CACHE": td}): + blob = (d / "models--Qwen--Qwen3-TTS-12Hz-1.7B-Base" + / "blobs") + blob.mkdir(parents=True) + (blob / "half.bin").write_bytes(b"x") + self.assertFalse(qwen.model_installed("Base")) + + +class QwenUninstallWeightsTests(unittest.TestCase): + """qwen.uninstall now also deletes every downloaded HF weight dir.""" + + def _seed_all(self, cache: Path): + from backends import qwen + for repo_id in qwen.MODEL_REPOS.values(): + d = cache / ("models--" + repo_id.replace("/", "--")) + (d / "snapshots" / "abc123").mkdir(parents=True) + (d / "snapshots" / "abc123" + / "model.safetensors").write_bytes(b"x") + (d / "refs").mkdir() + (d / "refs" / "main").write_text("abc123", encoding="utf-8") + + def test_uninstall_deletes_every_cached_model(self): + from backends import qwen + with tempfile.TemporaryDirectory() as td: + self._seed_all(Path(td)) + with patch.dict("os.environ", {"HF_HUB_CACHE": td}), \ + patch.object(qwen.servers, "pid_for", + return_value=None), \ + patch.object(qwen.common, "pip_uninstall", + return_value=0) as mk_pip: + rc = qwen.uninstall(emit="EMIT") + self.assertEqual(rc, 0) + mk_pip.assert_called_once_with([qwen.QWEN_PIP_PKG], emit="EMIT") + self.assertEqual(list(Path(td).iterdir()), []) + + def test_weights_deleted_even_when_pip_failed(self): + # The package is trivially re-installable; multi-GB snapshots are + # what actually cost disk. Deleting them is not conditional on pip. + from backends import qwen + with tempfile.TemporaryDirectory() as td: + self._seed_all(Path(td)) + with patch.dict("os.environ", {"HF_HUB_CACHE": td}), \ + patch.object(qwen.servers, "pid_for", + return_value=None), \ + patch.object(qwen.common, "pip_uninstall", + return_value=1): + rc = qwen.uninstall() + self.assertEqual(rc, 1) + self.assertEqual(list(Path(td).iterdir()), []) + + def test_cancel_after_pip_skips_weight_deletion(self): + import threading + + from backends import qwen + # Cancel fires mid-pip (the only moment the user can): everything + # through pip completes, but the weight-deletion phase never starts. + cancel = threading.Event() + + def pip_flips_cancel(*args, **kwargs): + cancel.set() + return 0 + + with tempfile.TemporaryDirectory() as td: + self._seed_all(Path(td)) + with patch.dict("os.environ", {"HF_HUB_CACHE": td}), \ + patch.object(qwen.servers, "pid_for", + return_value=None), \ + patch.object(qwen.common, "pip_uninstall", + side_effect=pip_flips_cancel) as mk_pip: + rc = qwen.uninstall(cancel=cancel) + self.assertEqual(rc, 130) + mk_pip.assert_called_once_with([qwen.QWEN_PIP_PKG], emit=None) + # Cancelled between phases: the weights stay untouched... + self.assertEqual(len(list(Path(td).iterdir())), 3) + + def test_only_qwen_repos_are_touched_in_the_shared_cache(self): + from backends import qwen + with tempfile.TemporaryDirectory() as td: + self._seed_all(Path(td)) + other = Path(td) / "models--Other--Repo" + other.mkdir() + (other / "weights.bin").write_bytes(b"x") + with patch.dict("os.environ", {"HF_HUB_CACHE": td}), \ + patch.object(qwen.servers, "pid_for", + return_value=None), \ + patch.object(qwen.common, "pip_uninstall", + return_value=0): + qwen.uninstall() + self.assertTrue(other.is_dir()) + + +class QwenUninstallModelTests(unittest.TestCase): + """Per-model uninstall: stop only a server serving THAT model.""" + + def test_stops_managed_server_only_when_it_serves_that_model(self): + from backends import qwen + cases = [("Base", True), ("CustomVoice", False), ("VoiceDesign", + False)] + for model, should_stop in cases: + with self.subTest(model=model): + with patch.object(qwen, "_managed_running_model", + return_value="Base"), \ + patch.object(qwen.servers, "stop") as mk_stop, \ + patch.object(qwen, "delete_model_weights") as mk_del: + rc = qwen.uninstall_model(model) + self.assertEqual((rc, mk_stop.called), + (0, should_stop)) + mk_del.assert_called_once_with([model]) + + # No managed server at all: nothing to stop either. + with patch.object(qwen, "_managed_running_model", + return_value=None), \ + patch.object(qwen.servers, "stop") as mk_stop: + qwen.uninstall_model("Base") + mk_stop.assert_not_called() + + def test_removes_only_that_models_cache_dir(self): + from backends import qwen + with tempfile.TemporaryDirectory() as td, \ + patch.dict("os.environ", {"HF_HUB_CACHE": td}), \ + patch.object(qwen, "_managed_running_model", + return_value=None): + kept = qwen.repo_dir(qwen.MODEL_REPOS["CustomVoice"]) + kept.mkdir(parents=True) + gone = qwen.repo_dir(qwen.MODEL_REPOS["VoiceDesign"]) + gone.mkdir(parents=True) + rc = qwen.uninstall_model("VoiceDesign") + self.assertEqual(rc, 0) + # Only the named model's directory is gone; every other cache + # entry (unrelated repos included) survives. + self.assertFalse(gone.exists()) + self.assertTrue(kept.is_dir()) + + def test_missing_weights_still_succeed(self): + # Idempotent removal, like apt purge on an already-clean system. + from backends import qwen + with patch.object(qwen, "_managed_running_model", + return_value=None): + self.assertEqual(qwen.uninstall_model("Base"), 0) + + def test_cancel_after_stop_skips_deletion(self): + import threading + + from backends import qwen + cancel = threading.Event() + cancel.set() + with patch.object(qwen, "_managed_running_model", + return_value="Base"), \ + patch.object(qwen.servers, "stop") as mk_stop, \ + patch.object(qwen, "delete_model_weights"): + rc = qwen.uninstall_model("Base", cancel=cancel) + self.assertEqual(rc, 130) + mk_stop.assert_called_once_with("qwen") + + +class QwenInstallModelTests(unittest.TestCase): + """install_model: venv hf CLI download of exactly one repo's weights.""" + + def test_hf_cli_prefers_hf_then_falls_back(self): + from backends import qwen + with tempfile.TemporaryDirectory() as td: + with patch.object(qwen.envs, "ENV_DIR", Path(td)): + self.assertIsNone(qwen._hf_download_prefix()) + cli = qwen.envs.env_script("huggingface-cli") + cli.parent.mkdir(parents=True) + cli.write_bytes(b"x") + self.assertEqual(qwen._hf_download_prefix(), [str(cli)]) + hf = qwen.envs.env_script("hf") + hf.write_bytes(b"x") + self.assertEqual(qwen._hf_download_prefix(), [str(hf)]) + + def test_download_runs_through_console_streaming(self): + from backends import qwen + with patch.object(qwen, "_hf_download_prefix", + return_value=["/venv/bin/hf"]), \ + patch.object(qwen.common, "run_console_subprocess", + return_value=0) as mk_run: + rc = qwen.install_model("VoiceDesign", emit="EMIT", + cancel="CANCEL") + self.assertEqual(rc, 0) + mk_run.assert_called_once_with( + ["/venv/bin/hf", "download", + "Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign"], + emit="EMIT", cancel="CANCEL") + + def test_no_cli_is_a_failure_not_an_exception(self): + from backends import qwen + with patch.object(qwen, "_hf_download_prefix", return_value=None), \ + patch.object(qwen.common, "run_console_subprocess"): + rc = qwen.install_model("Base") + self.assertEqual(rc, 1) + + +class QwenModelsScreenTests(unittest.TestCase): + """models_screen: per-model menu driving task-view steps.""" + + def _screen(self, answers, *, installed=("CustomVoice",), + package=True, extra=()): + """Run models_screen with scripted menu answers; record calls. + + Returns ``(rc, menus, flashes, runs)``: menus holds one + (title, options, kwargs) per render, flashes every (text, kind), + and runs each task-view run's title while executing its first + step's work inline (so delegation to install/uninstall model + functions is observable). EXTRA holds additional patch context + managers entered around the whole run. + """ + import contextlib + + from backends import qwen + choices = list(answers) + menus = [] + flashes = [] + runs = [] + + def fake_menu(stdscr, title, options, **kwargs): + menus.append((title, options, kwargs)) + return choices.pop(0) + + def fake_flash(scr, text, kind="warn"): + flashes.append((text, kind)) + + def fake_run(scr, title, steps, **kwargs): + runs.append(title) + steps[0].work(None, None) + return 0 + + patches = [ + patch.object(qwen, "_is_installed", return_value=package), + patch.object(qwen, "installed_models", + return_value=list(installed)), + patch.object(qwen.tui, "menu", fake_menu), + patch.object(qwen.tui, "flash", fake_flash), + patch.object(qwen.taskview, "run_steps", fake_run), + *extra, + ] + with contextlib.ExitStack() as stack: + for ctx in patches: + stack.enter_context(ctx) + rc = qwen.models_screen(None) + return rc, menus, flashes, runs + + def test_options_reflect_disk_state_per_model(self): + rc, menus, _, _ = self._screen([tui.Wizard.BACK], + installed=("CustomVoice",)) + self.assertEqual(rc, 0) + title, options, kwargs = menus[0] + self.assertEqual(title, "Configure qwen-tts") + # One action per model, mirroring disk state; order follows + # MODEL_REPOS. The table repeats the state in color. + self.assertEqual(options, [ + ("Uninstall CustomVoice", ("uninstall", "CustomVoice")), + ("Install Base", ("install", "Base")), + ("Install VoiceDesign", ("install", "VoiceDesign")), + ]) + self.assertEqual(kwargs["table_title"], "Model state") + self.assertEqual(kwargs["table_rows"][0], + ("CustomVoice", "installed", "ok")) + + def test_install_action_runs_a_download_step_in_the_task_view(self): + from backends import qwen + requested = [] + + def capture(model, *, emit=None, cancel=None): + requested.append(model) + return 0 + + rc, _, flashes, runs = self._screen( + [("install", "Base"), tui.Wizard.BACK], installed=(), + extra=[patch.object(qwen, "install_model", + side_effect=capture)]) + self.assertEqual(rc, 0) + self.assertEqual(requested, ["Base"]) + self.assertEqual(len(runs), 1) + self.assertEqual(runs[0], "Download Qwen/Qwen3-TTS-12Hz-1.7B-Base") + self.assertEqual(flashes[-1], ("Base downloaded.", "ok")) + + def test_uninstall_action_stops_the_server_then_deletes_weights(self): + from backends import qwen + stopped = [] + with tempfile.TemporaryDirectory() as td: + gone = Path(td) / "models--Qwen--Qwen3-TTS-12Hz-1.7B-CustomVoice" + gone.mkdir(parents=True) + other = Path(td) / "models--Other--Repo" + other.mkdir(parents=True) + rc, _, flashes, _ = self._screen( + [("uninstall", "CustomVoice"), tui.Wizard.BACK], + extra=[ + patch.dict("os.environ", {"HF_HUB_CACHE": td}), + patch.object(qwen, "_managed_running_model", + return_value="CustomVoice"), + patch.object(qwen.servers, "stop", + side_effect=lambda name: + stopped.append(name)), + # delete_model_weights stays real: it must rm the exact + # directory below (inside a redirected HF_HUB_CACHE). + ]) + self.assertEqual(rc, 0) + self.assertEqual(stopped, ["qwen"]) + self.assertFalse(gone.exists()) + self.assertTrue(other.is_dir()) + self.assertEqual(flashes[-1], + ("CustomVoice weights removed.", "ok")) + + def test_install_without_package_flashes_guidance_instead(self): + rc, menus, flashes, runs = self._screen( + [("install", "Base"), tui.Wizard.BACK], installed=(), + package=False) + self.assertEqual(rc, 0) + # No task-view run, one guidance flash instead of a download. + self.assertEqual(runs, []) + guidance = ("Install the qwen-tts backend first " + "(Configure backends > Install Backend).") + self.assertEqual(flashes, [(guidance, "warn")]) + # While the backend is missing, Install is replaced by dimmed-out + # "(backend not installed)" placeholders — actions stay inert. + self.assertEqual([label for label, _ in menus[0][1]], + ["CustomVoice (backend not installed)", + "Base (backend not installed)", + "VoiceDesign (backend not installed)"]) + + def test_noop_placeholder_actions_change_nothing(self): + rc, _, flashes, runs = self._screen( + [("noop", "Base"), tui.Wizard.BACK], installed=(), + package=False) + self.assertEqual(rc, 0) + self.assertEqual(runs, []) + self.assertEqual(flashes, []) + + class DetectCacheTests(unittest.TestCase): """detect_all's short-TTL cache (menu renders re-probe only after it).""" diff --git a/app/tests/test_hub.py b/app/tests/test_hub.py index 44c63fd..439be26 100644 --- a/app/tests/test_hub.py +++ b/app/tests/test_hub.py @@ -520,6 +520,83 @@ class SubmenuStatusTableTests(unittest.TestCase): self.assertEqual(self._labels(captured["options"]), ["Configure audio.cpp", "Uninstall Backend"]) + def test_configure_menu_offers_qwens_per_model_manager(self): + # qwen ships a dedicated configure screen (per-model weight + # installs): once installed, "Configure qwen-tts" appears in the + # flat action list. + captured = {} + infos = [BackendInfo("qwen", "qwen-tts", lambda: None, lambda: 0, + uninstall=lambda **kwargs: 0, + configure_screen=lambda scr: 0)] + statuses = [BackendStatus("qwen", "qwen-tts", installed=True, + configured=True)] + 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="/usr/bin/ffmpeg"): + result = hub._Hub(None).screen_configure() + self.assertIs(result, tui.Wizard.BACK) + self.assertEqual(self._labels(captured["options"]), + ["Configure qwen-tts", "Uninstall Backend"]) + self.assertEqual(captured["table_rows"], + [("qwen-tts", "installed", "warn", "body")]) + + def test_selecting_qwen_runs_its_configure_screen_not_setup(self): + ran = [] + invalidated = [] + + def configure(scr): + ran.append("configure") + return 0 + + def setup(scr): + ran.append("setup") + return 0 + + info = BackendInfo("qwen", "qwen-tts", lambda: None, setup, + uninstall=lambda **kwargs: 0, + configure_screen=configure) + + def first_pick(stdscr, title, options, **kwargs): + return ("configure", "qwen") + + statuses = [BackendStatus("qwen", "qwen-tts", installed=True, + configured=True)] + with patch.object(hub, "REGISTRY", [info]), \ + patch.object(hub, "get", return_value=info), \ + patch.object(hub, "detect_all", return_value=statuses), \ + patch.object(hub.tui, "menu", first_pick), \ + patch.object(hub, "invalidate_detect_cache", + side_effect=lambda: invalidated.append(True)), \ + patch.object(hub.shutil, "which", return_value="/x"): + leaf = hub._Hub(None).screen_configure() + # The selection pushes the backend's configure screen as one + # leaf of the wizard stack (which invalidates the status + # cache when it finishes). + self.assertIs(leaf(), tui.Wizard.BACK) + self.assertEqual(ran, ["configure"]) + self.assertEqual(invalidated, [True]) + + def test_bare_qwen_without_configure_screen_still_has_no_entry(self): + # Without a dedicated configure screen, plain qwen stays excluded + # from Configure backends (its wizard asks nothing to configure). + captured = {} + infos = [BackendInfo("qwen", "qwen-tts", lambda: None, lambda: 0, + uninstall=lambda **kwargs: 0)] + statuses = [BackendStatus("qwen", "qwen-tts", installed=True, + configured=True)] + 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) + self.assertEqual(self._labels(captured["options"]), + ["Uninstall Backend"]) + def test_convert_menu_builds_one_form_with_backend_field(self): captured = {} st = BackendStatus("qwen", "qwen-tts", installed=True, diff --git a/app/ui/hub.py b/app/ui/hub.py index 10484d2..230def3 100644 --- a/app/ui/hub.py +++ b/app/ui/hub.py @@ -150,10 +150,10 @@ class _Hub: no binary) or download its missing models (only once built, so build > configure > download — Build and Download never appear together) — heads the menu with a yellow ``[recommended]`` tag, - separated from the rest by a blank line. remaining actions are populated from the detected statuses: - configure each configurable backend (qwen asks nothing to - configure, so it has no entry), install (backends with nothing on - disk), and uninstall. + 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. 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 @@ -232,18 +232,33 @@ class _Hub: return self.screen_setup(info) def screen_setup(self, info): - """Run one backend's setup wizard as a leaf screen of the stack. + """Run one backend's setup/configure wizard as a leaf screen. The wizard drives its own internal ``tui.Wizard`` on this screen; Esc on its first screen (or Ctrl-C) returns here and the hub pops back to the menu that launched it. A crash flashes and does the same. """ def screen(): - self._run_setup(info) + # A dedicated configure screen (qwen's per-model manager) takes + # precedence over the plain setup wizard here; Install Backend + # keeps running the setup wizard either way. + if info.configure_screen is not None: + self._run_configure(info) + else: + self._run_setup(info) invalidate_detect_cache() return tui.Wizard.BACK return screen + def _run_configure(self, info) -> None: + """Run one backend's dedicated configure screen on this session.""" + try: + info.configure_screen(self.stdscr) + except tui.WizardCancelled: + pass + except Exception as exc: # noqa: BLE001 - keep the hub alive + tui.flash(self.stdscr, str(exc), "err") + def _run_setup(self, info) -> None: """Run one backend's setup wizard on this session (no stack frame).""" try: @@ -586,14 +601,16 @@ def _server_action_step(spec, action: str): def _configurable(info) -> bool: - """True when INFO has a setup wizard worth running to reconfigure. - - qwen-tts is excluded: its wizard asks no questions (ports live in the - Settings screen, the speaker is chosen per run on Generate audiobooks), - so a "Configure" entry could only ever flash "already installed". - Installing it stays possible via Install Backend. + """True when INFO has a configure screen worth running from the hub. + + A backend with a dedicated ``configure_screen`` (qwen manages its three + model weight installs there) is always configurable; other backends + count via their non-trivial setup wizard. Bare qwen — whose wizard asks + no questions: ports live in Settings, the speaker is chosen per run on + Generate audiobooks — would only ever flash "already installed", so it + stays excluded until it ships a dedicated screen. """ - return info.key != "qwen" + return info.configure_screen is not None or info.key != "qwen" def _installable(info, by_key: dict) -> bool: |
