diff options
Diffstat (limited to 'app')
| -rw-r--r-- | app/backends/__init__.py | 7 | ||||
| -rwxr-xr-x | app/backends/audiocpp.py | 15 | ||||
| -rw-r--r-- | app/backends/common.py | 23 | ||||
| -rw-r--r-- | app/backends/envs.py | 9 | ||||
| -rwxr-xr-x | app/backends/faster.py | 17 | ||||
| -rw-r--r-- | app/backends/qwen.py | 16 | ||||
| -rw-r--r-- | app/tests/test_backends.py | 37 | ||||
| -rw-r--r-- | app/tests/test_backends_audiocpp.py | 29 | ||||
| -rw-r--r-- | app/tests/test_backends_envs.py | 45 | ||||
| -rw-r--r-- | app/tests/test_backends_faster.py | 58 | ||||
| -rw-r--r-- | app/tests/test_hub.py | 79 | ||||
| -rw-r--r-- | app/ui/hub.py | 42 |
12 files changed, 347 insertions, 30 deletions
diff --git a/app/backends/__init__.py b/app/backends/__init__.py index 83f9866..63f0709 100644 --- a/app/backends/__init__.py +++ b/app/backends/__init__.py @@ -147,13 +147,16 @@ class BackendInfo: SETUP_SCREEN runs the setup wizard on an already-open curses screen (the hub's), returning 0 on completion and non-zero when aborted; the - hub calls it as one screen of its own ``tui.Wizard`` stack. + hub calls it as one screen of its own ``tui.Wizard`` stack. UNINSTALL + 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). """ key: str label: str detect: Callable[[], BackendStatus] setup_screen: Callable[[object], int] - uninstall: Callable[[], int] = lambda: 0 + uninstall: Callable[..., int] = lambda *args, **kwargs: 0 REGISTRY: List[BackendInfo] = [] diff --git a/app/backends/audiocpp.py b/app/backends/audiocpp.py index 0feb480..31aa01d 100755 --- a/app/backends/audiocpp.py +++ b/app/backends/audiocpp.py @@ -1650,16 +1650,25 @@ def delete_model_files(server_json: Path, entries: List[dict]) -> int: return removed -def uninstall() -> int: +def uninstall(*, emit=None, cancel=None) -> int: """Remove the audio.cpp backend entirely: stop its server, delete the checkout. The checkout (``app/audio.cpp``, or wherever ``find_local_checkout`` resolves it) holds the built binary, the downloaded models, and the server.json, so removing the directory uninstalls the backend. A running - server this tool started is stopped first (best-effort). Returns the exit - code. + server this tool started is stopped first (best-effort). + + EMIT is accepted for registry symmetry with the other backends but is + unused here — this uninstall has no subprocess phase, and its prints are + captured by the task view when run in the TUI. CANCEL is a + ``threading.Event`` honored between phases only (after the server has + been stopped, before the checkout is deleted), so a started phase always + completes and the uninstall never tears halfway. Returns the exit code + (130 when cancelled before a remaining phase). """ 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 remove.") diff --git a/app/backends/common.py b/app/backends/common.py index cb573c3..10b4ccf 100644 --- a/app/backends/common.py +++ b/app/backends/common.py @@ -71,6 +71,18 @@ def drain_post_tui_notices() -> List[str]: return notices +def cancel_requested(cancel) -> bool: + """True when CANCEL (a ``threading.Event``) is given and set. + + Shared guard for the multi-phase uninstall actions: cancellation is + honored only between phases (stop servers / pip / delete files), so a + phase that already started always runs to completion and an uninstall + never tears halfway. Callers return 130 when this fires before a + pending phase. + """ + return cancel is not None and cancel.is_set() + + def normalize_dir_arg(value: str) -> Path: """Normalize a user-supplied path argument. @@ -301,8 +313,7 @@ def run_console_subprocess(argv: List[str], cwd: Optional[Path] = None, """Run a subprocess, streaming output to the console or to EMIT. With EMIT None the child inherits the real terminal and its output - appears normally (used by the non-interactive CLI paths and the quick - ``tui.suspend`` actions like uninstall). With EMIT given (a + appears normally (the non-interactive CLI paths). With EMIT given (a ``callable(str)``) the child's stdout/stderr are merged, read line by line (splitting on both ``\\n`` and ``\\r`` so carriage-return progress updates like git's or tqdm's surface as lines), and each line is passed @@ -459,11 +470,13 @@ def pip_install(packages: List[str]) -> int: return envs.pip_install(packages) -def pip_uninstall(packages: List[str]) -> int: +def pip_uninstall(packages: List[str], *, emit=None) -> int: """pip uninstall PACKAGES from the managed venv. Returns exit code. Delegates to ``backends.envs.pip_uninstall`` (local import to avoid a - circular import). Used by the backends' ``uninstall`` action. + circular import). Used by the backends' ``uninstall`` action. With EMIT + given (the in-TUI task view) pip runs piped, streaming into EMIT, so + its output never touches the terminal behind curses. """ from backends import envs - return envs.pip_uninstall(packages) + return envs.pip_uninstall(packages, emit=emit) diff --git a/app/backends/envs.py b/app/backends/envs.py index 7b3c54b..cfeeec6 100644 --- a/app/backends/envs.py +++ b/app/backends/envs.py @@ -113,18 +113,21 @@ def pip_install(packages: List[str], *, emit=None, cancel=None) -> int: return common.run_console_subprocess(argv, emit=emit, cancel=cancel) -def pip_uninstall(packages: List[str]) -> int: +def pip_uninstall(packages: List[str], *, emit=None) -> int: """pip uninstall PACKAGES from the venv. Returns pip's exit code. Used by the backends' ``uninstall`` action to remove pip-installed TTS packages from the managed environment. A missing env is a no-op (there - is nothing to uninstall from), reported as success. + is nothing to uninstall from), reported as success. With EMIT given + (the in-TUI task view) pip runs with its output piped and streamed to + EMIT, so nothing writes to the terminal behind curses. """ if not env_exists(): return 0 print(f"[INFO] pip uninstall {' '.join(packages)} from {ENV_DIR}...") return common.run_console_subprocess( - [str(env_python()), "-m", "pip", "uninstall", "-y", *packages]) + [str(env_python()), "-m", "pip", "uninstall", "-y", *packages], + emit=emit) def module_available(module: str) -> bool: diff --git a/app/backends/faster.py b/app/backends/faster.py index 585e480..8c64183 100755 --- a/app/backends/faster.py +++ b/app/backends/faster.py @@ -591,21 +591,32 @@ def _detect_remote(managed: bool = False): return False, {} -def uninstall() -> int: +def uninstall(*, emit=None, cancel=None) -> int: """Remove the faster-qwen3-tts backend entirely. Uninstalls the pip package (``faster-qwen3-tts``) from the managed venv and deletes the cloned checkout (``app/faster-qwen3-tts``, which holds examples/openai_server.py and voices.json). A running server this tool - started is stopped first (best-effort). Returns the exit code. + started is stopped first (best-effort). + + 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 (stop server / pip / + delete checkout) — a started phase always completes, so pip is never + killed mid-run. Returns the exit code (130 when cancelled before a + remaining phase). """ servers.stop("faster") - rc = common.pip_uninstall(["faster-qwen3-tts"]) + if common.cancel_requested(cancel): + return 130 + rc = common.pip_uninstall(["faster-qwen3-tts"], emit=emit) if rc != 0: print("[WARNING] pip uninstall failed (exit " f"{rc}); remove faster-qwen3-tts from the managed venv manually") else: print("[OK] faster-qwen3-tts removed.") + if common.cancel_requested(cancel): + return 130 checkout = _checkout() if checkout.is_dir(): print(f"[INFO] Removing checkout {checkout}...") diff --git a/app/backends/qwen.py b/app/backends/qwen.py index be7ebfe..c6a11bf 100644 --- a/app/backends/qwen.py +++ b/app/backends/qwen.py @@ -354,17 +354,25 @@ def _detect_remote(managed: bool = False): return remote_models, remote_urls -def uninstall() -> int: +def uninstall(*, emit=None, cancel=None) -> int: """Remove the qwen-tts backend entirely: stop its servers, pip uninstall. 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). Returns the - exit code. + Any server this tool started is stopped first (best-effort). + + 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 servers + have 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). """ servers.stop("qwen-custom") servers.stop("qwen-clone") - rc = common.pip_uninstall([QWEN_PIP_PKG]) + if common.cancel_requested(cancel): + return 130 + rc = common.pip_uninstall([QWEN_PIP_PKG], emit=emit) if rc != 0: print(f"[WARNING] pip uninstall failed (exit {rc}); remove " f"{QWEN_PIP_PKG} from the managed venv manually") diff --git a/app/tests/test_backends.py b/app/tests/test_backends.py index 0d3be37..8ea6a3f 100644 --- a/app/tests/test_backends.py +++ b/app/tests/test_backends.py @@ -325,3 +325,40 @@ class QwenSetupScreenTests(unittest.TestCase): self.assertIs(mk_steps.call_args[0][0], settings) mk_run.assert_called_once() self.assertEqual(mk_run.call_args[0][2], steps) + + +class QwenUninstallTests(unittest.TestCase): + """qwen.uninstall: stop both servers, then pip-uninstall the package.""" + + def test_stops_servers_and_pips(self): + from backends import qwen + with patch.object(qwen.servers, "stop") as mk_stop, \ + patch.object(qwen.common, "pip_uninstall", + return_value=0) as mk_pip: + rc = qwen.uninstall(emit="EMIT") + self.assertEqual(rc, 0) + self.assertEqual([c.args[0] for c in mk_stop.call_args_list], + ["qwen-custom", "qwen-clone"]) + # The task view's emit is forwarded so pip never touches the terminal. + mk_pip.assert_called_once_with([qwen.QWEN_PIP_PKG], emit="EMIT") + + def test_cancel_before_pip_skips_uninstall(self): + import threading + + from backends import qwen + cancel = threading.Event() + cancel.set() + with patch.object(qwen.servers, "stop") as mk_stop, \ + patch.object(qwen.common, "pip_uninstall") as mk_pip: + rc = qwen.uninstall(cancel=cancel) + self.assertEqual(rc, 130) + self.assertEqual(mk_stop.call_count, 2) + mk_pip.assert_not_called() + + def test_pip_failure_warns_but_still_succeeds(self): + from backends import qwen + with patch.object(qwen.servers, "stop"), \ + patch.object(qwen.common, "pip_uninstall", + return_value=1): + rc = qwen.uninstall() + self.assertEqual(rc, 0) diff --git a/app/tests/test_backends_audiocpp.py b/app/tests/test_backends_audiocpp.py index e206ddd..665dbf0 100644 --- a/app/tests/test_backends_audiocpp.py +++ b/app/tests/test_backends_audiocpp.py @@ -1836,6 +1836,35 @@ class UninstallTests(unittest.TestCase): self.assertEqual(rc, 0) mk_stop.assert_called_once_with("audiocpp") + def test_accepts_task_view_kwargs_for_registry_symmetry(self): + # The hub calls uninstall(emit=..., cancel=...); emit is unused here + # (no subprocess phase) and cancel=None behaves like the plain call. + with tempfile.TemporaryDirectory() as td: + checkout = Path(td) / "audio.cpp" + checkout.mkdir() + with patch.object(make_server, "find_local_checkout", + return_value=checkout), \ + patch.object(make_server.servers, "stop"): + rc = make_server.uninstall(emit=lambda line: None, + cancel=None) + self.assertEqual(rc, 0) + self.assertFalse(checkout.exists()) + + def test_cancel_before_delete_keeps_checkout(self): + # Cancel is honored between phases only: once the server is stopped + # and cancellation is pending, the checkout deletion never starts. + with tempfile.TemporaryDirectory() as td: + checkout = Path(td) / "audio.cpp" + checkout.mkdir() + cancel = threading.Event() + cancel.set() + with patch.object(make_server, "find_local_checkout", + return_value=checkout), \ + patch.object(make_server.servers, "stop"): + rc = make_server.uninstall(cancel=cancel) + self.assertEqual(rc, 130) + self.assertTrue(checkout.exists()) + if __name__ == "__main__": unittest.main() diff --git a/app/tests/test_backends_envs.py b/app/tests/test_backends_envs.py index bf27f60..82d903a 100644 --- a/app/tests/test_backends_envs.py +++ b/app/tests/test_backends_envs.py @@ -106,6 +106,51 @@ class PipInstallTests(unittest.TestCase): run.assert_not_called() +class PipUninstallTests(unittest.TestCase): + def test_missing_env_is_success_without_running_pip(self): + with patch.object(envs, "env_exists", return_value=False), \ + patch.object(envs.common, "run_console_subprocess") as run: + rc = envs.pip_uninstall(["qwen-tts"]) + self.assertEqual(rc, 0) + run.assert_not_called() + + def test_runs_pip_uninstall_against_the_venv_python(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): + rc = envs.pip_uninstall(["qwen-tts"]) + self.assertEqual(rc, 0) + # The uninstall targets the venv's python. + self.assertEqual(calls[0][0], str(envs.env_python())) + self.assertIn("uninstall", calls[0]) + self.assertIn("-y", calls[0]) + self.assertIn("qwen-tts", calls[0]) + + def test_streams_to_emit_when_given(self): + with patch.object(envs, "env_exists", return_value=True), \ + patch.object(envs.common, "run_console_subprocess", + return_value=0) as run: + rc = envs.pip_uninstall(["qwen-tts"], emit="EMIT") + self.assertEqual(rc, 0) + # The task view's emit is forwarded so pip never touches the + # terminal behind curses. + self.assertEqual(run.call_args.kwargs.get("emit"), "EMIT") + + def test_console_path_passes_no_emit(self): + with patch.object(envs, "env_exists", return_value=True), \ + patch.object(envs.common, "run_console_subprocess", + return_value=0) as run: + rc = envs.pip_uninstall(["qwen-tts"]) + self.assertEqual(rc, 0) + self.assertIsNone(run.call_args.kwargs.get("emit")) + + class ModuleAvailableTests(unittest.TestCase): def test_false_when_env_missing(self): with patch.object(envs, "env_exists", return_value=False): diff --git a/app/tests/test_backends_faster.py b/app/tests/test_backends_faster.py index eb1fa28..7e27b47 100644 --- a/app/tests/test_backends_faster.py +++ b/app/tests/test_backends_faster.py @@ -3,6 +3,7 @@ import json import sys import tempfile +import threading import unittest import contextlib from pathlib import Path @@ -276,3 +277,60 @@ class SetupScreenTests(unittest.TestCase): self.assertIs(mk_steps.call_args[0][0], settings) mk_run.assert_called_once() self.assertEqual(mk_run.call_args[0][2], steps) + + +class UninstallTests(unittest.TestCase): + """uninstall: stop the server, pip-uninstall, delete the checkout.""" + + def test_pips_and_removes_checkout(self): + with tempfile.TemporaryDirectory() as td: + checkout = Path(td) / "faster-qwen3-tts" + checkout.mkdir() + with patch.object(make_voices, "_checkout", + return_value=checkout), \ + patch.object(make_voices.servers, "stop") as mk_stop, \ + patch.object(make_voices.common, "pip_uninstall", + return_value=0) as mk_pip: + rc = make_voices.uninstall(emit="EMIT") + self.assertEqual(rc, 0) + mk_stop.assert_called_once_with("faster") + # The task view's emit is forwarded so pip never touches the terminal. + mk_pip.assert_called_once_with(["faster-qwen3-tts"], emit="EMIT") + self.assertFalse(checkout.exists()) + + def test_no_checkout_still_uninstalls_the_package(self): + with patch.object(make_voices, "_checkout", + return_value=Path("/no/such/dir")), \ + patch.object(make_voices.servers, "stop"), \ + patch.object(make_voices.common, "pip_uninstall", + return_value=0) as mk_pip: + rc = make_voices.uninstall() + self.assertEqual(rc, 0) + mk_pip.assert_called_once_with(["faster-qwen3-tts"], emit=None) + + def test_cancel_before_pip_skips_everything_after_stopping(self): + cancel = threading.Event() + cancel.set() + with patch.object(make_voices.servers, "stop") as mk_stop, \ + patch.object(make_voices.common, "pip_uninstall") as mk_pip: + rc = make_voices.uninstall(cancel=cancel) + self.assertEqual(rc, 130) + mk_stop.assert_called_once_with("faster") + mk_pip.assert_not_called() + + def test_cancel_before_delete_keeps_checkout(self): + # Cancel between phases: pip runs to completion, but a pending + # cancellation stops the checkout deletion from starting. + with tempfile.TemporaryDirectory() as td: + checkout = Path(td) / "faster-qwen3-tts" + checkout.mkdir() + cancel = threading.Event() + cancel.set() + with patch.object(make_voices, "_checkout", + return_value=checkout), \ + patch.object(make_voices.servers, "stop"), \ + patch.object(make_voices.common, "pip_uninstall", + return_value=0): + rc = make_voices.uninstall(cancel=cancel) + self.assertEqual(rc, 130) + self.assertTrue(checkout.exists()) diff --git a/app/tests/test_hub.py b/app/tests/test_hub.py index 965cbaa..ec079f5 100644 --- a/app/tests/test_hub.py +++ b/app/tests/test_hub.py @@ -1730,14 +1730,85 @@ class ConfigureBackendsDispatchTests(unittest.TestCase): self.assertIs(result, tui.Wizard.BACK) self.assertEqual(flashes, ["kaboom"]) - def test_screen_uninstall_runs_uninstall_and_goes_back(self): - import contextlib + def test_screen_uninstall_runs_in_task_view_and_goes_back(self): info = BackendInfo("qwen", "qwen-tts", lambda: None, lambda: 0) with patch.object(hub._Hub, "_pick_backend", return_value=info), \ + patch.object(hub.tui, "confirm", return_value=True), \ + patch.object(hub.taskview, "run_steps", + return_value=0) as mk_run, \ patch.object(info, "uninstall") as mk_uninstall, \ - patch.object(hub.tui, "suspend", contextlib.nullcontext): + patch.object(hub.tui, "flash"): result = hub._Hub(None).screen_uninstall() - mk_uninstall.assert_called_once_with() + # The uninstall runs as one task-view step on the session (no + # suspend); executing the step forwards emit/cancel to uninstall. + mk_run.assert_called_once() + self.assertEqual(mk_run.call_args[0][0], None) + steps = mk_run.call_args[0][2] + self.assertEqual([step.title for step in steps], + ["Uninstall qwen-tts"]) + self.assertFalse(mk_run.call_args.kwargs["wait_on_finish"]) + emit = lambda line: None + steps[0].work(emit, None) + mk_uninstall.assert_called_once_with(emit=emit, cancel=None) + self.assertIs(result, tui.Wizard.BACK) + + def test_screen_uninstall_success_flashes_ok(self): + flashes = [] + + def fake_flash(scr, text, kind="warn"): + flashes.append((text, kind)) + + info = BackendInfo("qwen", "qwen-tts", lambda: None, lambda: 0) + with patch.object(hub._Hub, "_pick_backend", return_value=info), \ + patch.object(hub.tui, "confirm", return_value=True), \ + patch.object(hub.taskview, "run_steps", return_value=0), \ + patch.object(info, "uninstall"), \ + patch.object(hub.tui, "flash", fake_flash): + result = hub._Hub(None).screen_uninstall() + self.assertIs(result, tui.Wizard.BACK) + self.assertEqual(flashes[-1], ("qwen-tts uninstalled.", "ok")) + + def test_screen_uninstall_failure_flashes_error(self): + flashes = [] + + def fake_flash(scr, text, kind="warn"): + flashes.append((text, kind)) + + info = BackendInfo("qwen", "qwen-tts", lambda: None, lambda: 0) + with patch.object(hub._Hub, "_pick_backend", return_value=info), \ + patch.object(hub.tui, "confirm", return_value=True), \ + patch.object(hub.taskview, "run_steps", return_value=1), \ + patch.object(info, "uninstall"), \ + patch.object(hub.tui, "flash", fake_flash): + result = hub._Hub(None).screen_uninstall() + self.assertIs(result, tui.Wizard.BACK) + self.assertEqual(flashes[-1][1], "err") + self.assertIn("Could not fully uninstall", flashes[-1][0]) + + def test_screen_uninstall_confirm_declined_skips_uninstall(self): + info = BackendInfo("qwen", "qwen-tts", lambda: None, lambda: 0) + with patch.object(hub._Hub, "_pick_backend", return_value=info), \ + patch.object(hub.tui, "confirm", return_value=False) \ + as mk_confirm, \ + patch.object(hub.taskview, "run_steps") as mk_run, \ + patch.object(info, "uninstall") as mk_uninstall: + result = hub._Hub(None).screen_uninstall() + mk_run.assert_not_called() + mk_uninstall.assert_not_called() + self.assertIs(result, tui.Wizard.BACK) + # The confirm names the backend and is Esc-able (cancel_value set). + self.assertIn("Uninstall qwen-tts?", mk_confirm.call_args[0][1]) + + def test_screen_uninstall_esc_on_confirm_backs_out(self): + info = BackendInfo("qwen", "qwen-tts", lambda: None, lambda: 0) + with patch.object(hub._Hub, "_pick_backend", return_value=info), \ + patch.object(hub.tui, "confirm", + return_value=tui.Wizard.BACK), \ + patch.object(hub.taskview, "run_steps") as mk_run, \ + patch.object(info, "uninstall") as mk_uninstall: + result = hub._Hub(None).screen_uninstall() + mk_run.assert_not_called() + mk_uninstall.assert_not_called() self.assertIs(result, tui.Wizard.BACK) def _capture_flashes(self): diff --git a/app/ui/hub.py b/app/ui/hub.py index f937b84..d20b28e 100644 --- a/app/ui/hub.py +++ b/app/ui/hub.py @@ -10,10 +10,10 @@ The entire hub runs in one curses session, driven by a single ``tui.Wizard`` stack of screens (the ``_Hub`` class below). Every menu/action is a screen that returns the next screen, ``Wizard.BACK`` (Esc/q) to pop one screen, or None to quit. Backend setup wizards and the conversion run view run as -opaque leaf screens on this same session (the wizards' long setup tails and -model downloads run inside the ``ui.taskview`` task view, and only the quick -uninstall/start/stop actions use ``tui.suspend``); a leaf screen finishes by -returning ``Wizard.BACK``, so +opaque leaf screens on this same session; every long step — setup tails, +model downloads, server start/stop, and uninstall — runs inside the +``ui.taskview`` task view, so the user is never dropped to the console. +A leaf screen finishes by returning ``Wizard.BACK``, so the stack lands back on the menu that launched it. Esc therefore steps back exactly one screen everywhere — on the main menu (an empty stack) it quits. 'q' mirrors Esc on every screen that has no typed text. @@ -253,11 +253,41 @@ class _Hub: return self.screen_setup(info) def screen_uninstall(self): + """Pick a backend, confirm, then uninstall it inside the task view. + + Esc on the picker or the confirm (or answering No) backs out + untouched. A confirmed uninstall runs as one task-view step on this + session — no console drop: the backend's ``uninstall`` stops its + servers, pip-uninstalls, and deletes its files, honoring cancel + between phases only. A flash summarizes the result and the stack + lands back on the Configure menu, whose status table re-detects the + removal. + """ info = self._pick_backend(installed_only=True) if info is None: return tui.Wizard.BACK - with tui.suspend(self.stdscr): - info.uninstall() + answer = tui.confirm( + self.stdscr, f"Uninstall {info.label}?", + body=[f"This permanently removes {info.label} from this " + "machine: managed servers are stopped and every installed " + "file — including downloaded models — is deleted."], + default=False, cancel_value=tui.Wizard.BACK) + if answer is not True: + return tui.Wizard.BACK + title = f"Uninstall {info.label}" + step = taskview.TaskStep( + title, + lambda emit, cancel: info.uninstall(emit=emit, cancel=cancel)) + rc = taskview.run_steps(self.stdscr, title, [step], + wait_on_finish=False) + # The uninstallers warn-and-continue (a failed pip step still + # returns 0), so rc == 0 means "finished"; anything else covers a + # failure or an Esc-cancelled run between phases. + if rc == 0: + tui.flash(self.stdscr, f"{info.label} uninstalled.", "ok") + else: + tui.flash(self.stdscr, + f"Could not fully uninstall {info.label}.", "err") return tui.Wizard.BACK def _pick_backend(self, installed_only: bool): |
