diff options
| author | historia <historiavg@proton.me> | 2026-09-02 17:16:05 -0400 |
|---|---|---|
| committer | historia <historiavg@proton.me> | 2026-09-02 17:16:05 -0400 |
| commit | 717a3dc112519ae7cf39a212a4e3ec9e1ba17f28 (patch) | |
| tree | 119ad113ddce14414ae3293316cf7ed8c57cd5d3 | |
| parent | 8579517a35ef1865fc9b428899d73d52dcb27a14 (diff) | |
| download | tts-audiobook-generator-717a3dc112519ae7cf39a212a4e3ec9e1ba17f28.tar.gz | |
feat: auto-update via settings
| -rw-r--r-- | README.md | 19 | ||||
| -rw-r--r-- | app/backends/common.py | 4 | ||||
| -rw-r--r-- | app/selfupdate.py | 180 | ||||
| -rw-r--r-- | app/tests/test_backends_common.py | 8 | ||||
| -rw-r--r-- | app/tests/test_hub.py | 159 | ||||
| -rw-r--r-- | app/tests/test_selfupdate.py | 241 | ||||
| -rw-r--r-- | app/ui/hub.py | 70 |
7 files changed, 673 insertions, 8 deletions
@@ -64,6 +64,25 @@ Voxtral TTS, Fish Speech, and more — see an NVIDIA GPU; one model is hosted per server process, so the Generate form's Model pick decides which server boots. +## Updating + +Run `Configure Backends > Update Generator` in the hub: it fetches the +latest code and moves the checkout to the remote's default branch (the +same fetch + hard-reset flow the backend checkouts use), then asks you to +restart so the new code loads. Nothing you generate or download is at risk +— `input/`, `output/`, `voices/`, `app/envs/`, model weights, and logs are +untracked and outside the reset's reach — and **locally modified tracked +files are preserved**: `app/converter/config.py` (your settings) and any +other file you edited are written back after the update, with a notice +when upstream changed them too (merge new options by hand in that case). +A dirty checkout asks before proceeding; an up-to-date checkout skips the +reset entirely. + +After the generator itself updates, run **Update Backends** to refresh the +backend packages (`pip -U` / git + rebuild). When a release changes an +update's requirements, the next launch re-checks the app venv +automatically; backend venvs follow their own wizards. + ## CLI Options Without `--api-url` the CLI manages the server itself, just like the TUI: it starts the selected backend's managed instance (installed via the TUI), converts, and stops it again. A server already running at the configured endpoint is used as-is and left running when the run ends. `--api-url` points at an external server instead, and never touches server state. diff --git a/app/backends/common.py b/app/backends/common.py index 811f13a..b5d6576 100644 --- a/app/backends/common.py +++ b/app/backends/common.py @@ -631,12 +631,12 @@ def git_update(checkout: Path, *, emit=None, cancel=None) -> int: fetch_rc = run_console_subprocess(fetch_argv, emit=emit, cancel=cancel) if fetch_rc != 0: return fetch_rc - branch = _origin_default_branch(checkout) + 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: +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", diff --git a/app/selfupdate.py b/app/selfupdate.py new file mode 100644 index 0000000..239db92 --- /dev/null +++ b/app/selfupdate.py @@ -0,0 +1,180 @@ +#!/usr/bin/env python3 +"""Self-update for the audiobook generator's own git checkout. + +The generator ships as a plain ``git clone`` with no tags or releases, so +updating means moving the checkout to the remote's default-branch HEAD — +the same fetch + hard-reset flow the backend checkouts use +(``backends.common.git_update``). Everything stateful lives in untracked, +gitignored paths (``app/envs/``, ``input/``, ``output/``, ``voices/``, the +backend checkouts, logs), which a hard reset never touches. + +The one tracked file users are expected to edit — ``app/converter/ +config.py`` — gets explicit protection: every locally modified tracked +file is snapshotted before the reset and written back afterwards, so an +update can never silently overwrite user configuration with upstream's +new version. A kept file whose upstream version changed is reported, so +new upstream options can be merged by hand. + +Run via the hub's Configure Backends > Update Generator action; the +module is stdlib-only like ``backends.common`` / ``backends.envs``. +""" + +from pathlib import Path +from typing import Dict, List, Optional + +from backends import common +from backends.envs import TTS_ROOT + + +def _root(root: Optional[Path]) -> Path: + return Path(root) if root is not None else TTS_ROOT + + +def is_git_checkout(root: Optional[Path] = None) -> bool: + """True when ROOT (the install by default) is a git working copy. + + ``.git`` is a directory for a normal clone and a file for a linked + worktree or submodule checkout — both count; a zip download or a copy + without git history does not. + """ + return (_root(root) / ".git").exists() + + +def current_commit(root: Optional[Path] = None) -> Optional[str]: + """The checked-out commit's short SHA, or None when git cannot answer.""" + proc = common.run_console_subprocess_quiet( + ["git", "-C", str(_root(root)), "rev-parse", "--short", "HEAD"]) + if proc is None or proc.returncode != 0: + return None + sha = proc.stdout.decode("ascii", errors="replace").strip() + return sha or None + + +def modified_tracked_files(root: Optional[Path] = None) -> List[str]: + """Tracked files with local changes (staged or unstaged), repo-relative. + + Untracked files are excluded (``--untracked-files=no``): they are + invisible to ``git reset --hard`` anyway. Deletions are included — + the reset restores a deleted tracked file, so they show up in the + update confirm as state the reset will undo. An empty list also + results when git itself fails (no checkout), so callers must gate on + is_git_checkout for the error case. + """ + proc = common.run_console_subprocess_quiet( + ["git", "-C", str(_root(root)), "status", "--porcelain", + "--untracked-files=no"]) + if proc is None or proc.returncode != 0: + return [] + files: List[str] = [] + for line in proc.stdout.decode("utf-8", errors="replace").splitlines(): + # Porcelain: "XY<space>path"; staged renames read "old -> new". + if len(line) < 4: + continue + path = line[3:] + if " -> " in path: + path = path.rsplit(" -> ", 1)[1] + path = path.strip().strip('"') + if path and path not in files: + files.append(path) + return files + + +def _say(message: str, emit=None) -> None: + if emit is None: + print(message) + else: + emit(message) + + +def _snapshot(rel_paths: List[str], root: Path) -> Dict[str, bytes]: + """Read every existing file in REL_PATHS (missing ones — deletions — + are skipped: the reset restores those by itself).""" + snapshot: Dict[str, bytes] = {} + for rel in rel_paths: + path = root / rel + try: + if path.is_file(): + snapshot[rel] = path.read_bytes() + except OSError: + continue + return snapshot + + +def _restore(snapshot: Dict[str, bytes], root: Path) -> List[str]: + """Write the user's file contents back over the reset's result. + + A file whose post-reset content already matches the snapshot (the + user's version is what upstream now ships) is left untouched, so only + genuinely skipped upstream changes are reported. Returns the + repo-relative paths that were actually written. + """ + restored: List[str] = [] + for rel, data in snapshot.items(): + path = root / rel + try: + if path.is_file() and path.read_bytes() == data: + continue + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(data) + except OSError: + continue + restored.append(rel) + return restored + + +def _rev_parse(root: Path, ref: str) -> Optional[str]: + proc = common.run_console_subprocess_quiet( + ["git", "-C", str(root), "rev-parse", "--short", ref]) + if proc is None or proc.returncode != 0: + return None + sha = proc.stdout.decode("ascii", errors="replace").strip() + return sha or None + + +def update_generator(*, emit=None, cancel=None, + root: Optional[Path] = None) -> int: + """Update the generator's own checkout to the remote's default branch. + + Fetch, then — only when the remote moved — hard-reset to + ``origin/<default-branch>`` (an up-to-date checkout skips the reset + entirely, so a no-op update can never discard anything). Locally + modified tracked files are snapshotted first and written back after + the reset attempt (even a cancelled or failed one — the tree may be + mid-reset when CANCEL fires), which is what keeps user config intact. + EMIT/CANCEL behave like git_update's. Returns the exit code of the + first failing step (0 when the checkout now matches the remote, or + already did). + """ + base = _root(root) + snapshot = _snapshot(modified_tracked_files(base), base) + + _say("[INFO] Fetching the latest generator code...", emit) + fetch_argv = ["git", "-C", str(base), "fetch"] + 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 — same as git_update. + fetch_argv.append("--progress") + fetch_argv.append("origin") + fetch_rc = common.run_console_subprocess(fetch_argv, emit=emit, + cancel=cancel) + if fetch_rc != 0: + _restore(snapshot, base) + return fetch_rc + + branch = common.origin_default_branch(base) + remote = _rev_parse(base, f"origin/{branch}") + if remote is not None and _rev_parse(base, "HEAD") == remote: + _say(f"[INFO] The generator is already up to date ({remote}).", emit) + _restore(snapshot, base) + return 0 + + _say(f"[INFO] Updating the checkout to origin/{branch} " + f"({remote or 'unknown commit'})...", emit) + reset_rc = common.run_console_subprocess( + ["git", "-C", str(base), "reset", "--hard", f"origin/{branch}"], + emit=emit, cancel=cancel) + for rel in _restore(snapshot, base): + _say(f"[OK] Kept your local {rel} (upstream's changes to it were " + "skipped — merge new options by hand if needed).", emit) + return reset_rc diff --git a/app/tests/test_backends_common.py b/app/tests/test_backends_common.py index 68a5543..71f0c17 100644 --- a/app/tests/test_backends_common.py +++ b/app/tests/test_backends_common.py @@ -161,7 +161,7 @@ class GitUpdateTests(unittest.TestCase): """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", + mock.patch.object(common, "origin_default_branch", return_value=branch).start() return run @@ -207,20 +207,20 @@ class OriginDefaultBranchTests(unittest.TestCase): 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( + 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( + 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( + self.assertEqual(common.origin_default_branch( common.Path("/co")), "main") diff --git a/app/tests/test_hub.py b/app/tests/test_hub.py index 485a37f..228a103 100644 --- a/app/tests/test_hub.py +++ b/app/tests/test_hub.py @@ -394,6 +394,14 @@ class SubmenuStatusTableTests(unittest.TestCase): servers, showing running/stopped inline instead of the table. """ + def setUp(self): + # These tests concern the backend actions; the host's git-checkout + # state (which gates the "Update Generator" entry) must not leak in. + patcher = patch.object(hub.selfupdate, "is_git_checkout", + return_value=False) + patcher.start() + self.addCleanup(patcher.stop) + def _capture_menu(self, captured): def fake_menu(stdscr, title, options, **kwargs): captured["title"] = title @@ -4102,6 +4110,157 @@ class ConfigureBackendsDispatchTests(unittest.TestCase): # freshly detected status table. self.assertEqual(titles, ["Configure Backends", "Configure Backends"]) + def test_configure_menu_offers_update_generator_for_git_checkouts(self): + def options_for(git_checkout): + captured = {} + + def fake_menu(stdscr, title, options, **kwargs): + captured["options"] = options + return tui.Wizard.BACK + + info = BackendInfo("qwen", "qwen-tts", lambda: None, lambda: 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.selfupdate, "is_git_checkout", + return_value=git_checkout): + hub._Hub(None).screen_configure() + return [opt[0] for opt in captured["options"]] + + self.assertIn("Update Generator", options_for(True)) + self.assertNotIn("Update Generator", options_for(False)) + + def test_selecting_update_generator_runs_the_action_and_reshows(self): + titles = [] + ran = [] + + def fake_menu(stdscr, title, options, **kwargs): + titles.append(title) + return "update_self" if len(titles) == 1 else tui.Wizard.BACK + + info = BackendInfo("qwen", "qwen-tts", lambda: None, lambda: 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_self_action", + side_effect=lambda scr: ran.append(True)): + result = hub._Hub(None).screen_configure() + self.assertIs(result, tui.Wizard.BACK) + self.assertEqual(ran, [True]) + # An inline action: the same menu re-shows after the update. + self.assertEqual(titles, ["Configure Backends", "Configure Backends"]) + + def test_update_self_action_flashes_err_without_git_checkout(self): + patch_flash, flashes = self._capture_flashes() + with patch.object(hub.selfupdate, "is_git_checkout", + return_value=False), \ + patch.object(hub.taskview, "run_steps") as mk_run, \ + patch_flash: + hub._update_self_action(None) + mk_run.assert_not_called() + self.assertEqual(len(flashes), 1) + self.assertEqual(flashes[0][1], "err") + self.assertIn("not a git checkout", flashes[0][0]) + + def test_update_self_action_declined_confirm_aborts_before_running(self): + patch_flash, flashes = self._capture_flashes() + with patch.object(hub.selfupdate, "is_git_checkout", + return_value=True), \ + patch.object(hub.selfupdate, "current_commit", + return_value="aaa"), \ + patch.object(hub.selfupdate, "modified_tracked_files", + return_value=["app/converter/config.py"]), \ + patch.object(hub.tui, "confirm", + return_value=False) as mk_confirm, \ + patch.object(hub.taskview, "run_steps") as mk_run, \ + patch_flash: + hub._update_self_action(None) + mk_run.assert_not_called() + self.assertEqual(flashes, []) + # The confirm names the files that will be preserved. + body = mk_confirm.call_args[1]["body"] + self.assertIn("app/converter/config.py", body) + + def test_update_self_action_runs_step_and_flashes_update(self): + patch_flash, flashes = self._capture_flashes() + with patch.object(hub.selfupdate, "is_git_checkout", + return_value=True), \ + patch.object(hub.selfupdate, "current_commit", + side_effect=["aaa", "bbb"]), \ + patch.object(hub.selfupdate, "modified_tracked_files", + return_value=[]), \ + patch.object(hub.taskview, "run_steps", + return_value=0) as mk_run, \ + patch.object(hub.selfupdate, "update_generator", + return_value=0) as mk_update, \ + patch_flash: + hub._update_self_action(None) + mk_run.assert_called_once() + self.assertEqual(mk_run.call_args[0][0], None) + self.assertEqual(mk_run.call_args[0][1], "Update Generator") + steps = mk_run.call_args[0][2] + self.assertEqual([step.title for step in steps], + ["Fetch and reset the checkout"]) + + def emit(line): + pass + + steps[0].work(emit, "CANCEL") + mk_update.assert_called_once_with(emit=emit, cancel="CANCEL") + self.assertEqual(len(flashes), 1) + text, kind = flashes[0] + self.assertEqual(kind, "ok") + self.assertIn("aaa", text) + self.assertIn("bbb", text) + self.assertIn("Restart to apply", text) + + def test_update_self_action_flashes_already_up_to_date(self): + patch_flash, flashes = self._capture_flashes() + with patch.object(hub.selfupdate, "is_git_checkout", + return_value=True), \ + patch.object(hub.selfupdate, "current_commit", + side_effect=["aaa", "aaa"]), \ + patch.object(hub.selfupdate, "modified_tracked_files", + return_value=[]), \ + patch.object(hub.taskview, "run_steps", return_value=0), \ + patch_flash: + hub._update_self_action(None) + self.assertEqual(flashes, + [("The generator is already up to date (aaa).", + "ok")]) + + def test_update_self_action_flashes_warn_when_cancelled(self): + patch_flash, flashes = self._capture_flashes() + with patch.object(hub.selfupdate, "is_git_checkout", + return_value=True), \ + patch.object(hub.selfupdate, "current_commit", + side_effect=["aaa", "aaa"]), \ + patch.object(hub.selfupdate, "modified_tracked_files", + return_value=[]), \ + patch.object(hub.taskview, "run_steps", return_value=130), \ + patch_flash: + hub._update_self_action(None) + self.assertEqual(flashes[-1][1], "warn") + self.assertIn("cancelled", flashes[-1][0]) + + def test_update_self_action_flashes_err_when_failed(self): + patch_flash, flashes = self._capture_flashes() + with patch.object(hub.selfupdate, "is_git_checkout", + return_value=True), \ + patch.object(hub.selfupdate, "current_commit", + side_effect=["aaa", "aaa"]), \ + patch.object(hub.selfupdate, "modified_tracked_files", + return_value=[]), \ + patch.object(hub.taskview, "run_steps", return_value=1), \ + patch_flash: + hub._update_self_action(None) + self.assertEqual(flashes[-1][1], "err") + self.assertIn("did not complete", flashes[-1][0]) + def test_pick_backend_install_lists_uninstalled_only(self): captured = {} diff --git a/app/tests/test_selfupdate.py b/app/tests/test_selfupdate.py new file mode 100644 index 0000000..bf2f6b3 --- /dev/null +++ b/app/tests/test_selfupdate.py @@ -0,0 +1,241 @@ +"""Tests for the generator's self-update (selfupdate.py). + +The update is a fetch + conditional hard reset of the generator's own +checkout; its defining property is preservation: locally modified tracked +files (app/converter/config.py above all) are snapshotted before the reset +and written back afterwards, so an update never overwrites user config. +These tests simulate the reset's effect on the working tree via the +subprocess mocks' side effects and assert what survives. +""" + +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +import selfupdate + + +def _proc(returncode=0, stdout=b""): + proc = mock.Mock() + proc.returncode = returncode + proc.stdout = stdout + return proc + + +class IsGitCheckoutTests(unittest.TestCase): + def test_git_directory_and_worktree_file_count(self): + with tempfile.TemporaryDirectory() as td: + root = Path(td) + self.assertFalse(selfupdate.is_git_checkout(root)) + (root / ".git").mkdir() + self.assertTrue(selfupdate.is_git_checkout(root)) + (root / ".git").rmdir() + (root / ".git").write_text("gitdir: /elsewhere\n", + encoding="utf-8") + self.assertTrue(selfupdate.is_git_checkout(root)) + + def test_defaults_to_the_install_root(self): + # The repo running the tests is a git checkout. + self.assertTrue(selfupdate.is_git_checkout()) + + +class CurrentCommitTests(unittest.TestCase): + def test_short_sha_is_parsed(self): + proc = _proc(0, b"abc1234\n") + with mock.patch.object(selfupdate.common, + "run_console_subprocess_quiet", + return_value=proc): + self.assertEqual(selfupdate.current_commit(Path("/co")), + "abc1234") + + def test_git_failure_returns_none(self): + proc = _proc(128, b"") + with mock.patch.object(selfupdate.common, + "run_console_subprocess_quiet", + return_value=proc): + self.assertIsNone(selfupdate.current_commit(Path("/co"))) + with mock.patch.object(selfupdate.common, + "run_console_subprocess_quiet", + return_value=None): + self.assertIsNone(selfupdate.current_commit(Path("/co"))) + + +class ModifiedTrackedFilesTests(unittest.TestCase): + def test_porcelain_lines_are_parsed(self): + # --untracked-files=no: git never reports "??" entries here. + proc = _proc(0, b" M app/converter/config.py\n" + b"M staged.py\n" + b" D deleted.py\n" + b"R old.py -> new.py\n") + with mock.patch.object(selfupdate.common, + "run_console_subprocess_quiet", + return_value=proc): + self.assertEqual( + selfupdate.modified_tracked_files(Path("/co")), + ["app/converter/config.py", "staged.py", "deleted.py", + "new.py"]) + + def test_git_failure_returns_empty(self): + proc = _proc(128, b"") + with mock.patch.object(selfupdate.common, + "run_console_subprocess_quiet", + return_value=proc): + self.assertEqual(selfupdate.modified_tracked_files(Path("/co")), + []) + + +class UpdateGeneratorTests(unittest.TestCase): + """update_generator against a fake checkout in a temp directory.""" + + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.root = Path(self._tmp.name) + self.config = self.root / "app" / "converter" / "config.py" + self.config.parent.mkdir(parents=True) + self.config.write_bytes(b"# user settings\n") + self.emitted = [] + + def tearDown(self): + mock.patch.stopall() + self._tmp.cleanup() + + def _patch_git(self, *, fetch_rc=0, reset_rc=0, head=b"aaa\n", + remote=b"bbb\n", status=b" M app/converter/config.py\n", + on_reset=None): + """Patch the subprocess layer; returns the run_console mock. + + run_console_subprocess serves fetch (first call) and reset (second); + ON_RESET, when given, runs before the reset's return code so a test + can simulate the working-tree damage a real reset does (rewriting or + deleting files). + """ + run = mock.patch.object( + selfupdate.common, "run_console_subprocess", + side_effect=self._run_side_effect(fetch_rc, reset_rc, + on_reset)).start() + + def quiet(argv, **kwargs): + if "status" in argv: + return _proc(0, status) + if "rev-parse" in argv: + ref = argv[argv.index("rev-parse") + 2] + return _proc(0, remote if ref.startswith("origin/") + else head) + return _proc(0, b"") + + mock.patch.object(selfupdate.common, + "run_console_subprocess_quiet", + side_effect=quiet).start() + mock.patch.object(selfupdate.common, "origin_default_branch", + return_value="main").start() + return run + + @staticmethod + def _run_side_effect(fetch_rc, reset_rc, on_reset): + def run(argv, **kwargs): + if "fetch" in argv: + return fetch_rc + if "reset" in argv: + if on_reset is not None: + on_reset() + return reset_rc + raise AssertionError(f"unexpected subprocess call: {argv}") + return run + + # -- up to date / fetch failure: the tree must never be touched ------ + + def test_already_up_to_date_skips_the_reset(self): + run = self._patch_git(head=b"bbb\n", remote=b"bbb\n") + rc = selfupdate.update_generator(root=self.root, + emit=self.emitted.append) + self.assertEqual(rc, 0) + # Only the fetch ran — a no-op update cannot discard anything. + self.assertEqual(run.call_count, 1) + self.assertEqual(self.config.read_bytes(), b"# user settings\n") + self.assertTrue(any("already up to date" in line + for line in self.emitted)) + + def test_fetch_failure_short_circuits(self): + run = self._patch_git(fetch_rc=128) + rc = selfupdate.update_generator(root=self.root) + self.assertEqual(rc, 128) + self.assertEqual(run.call_count, 1) + self.assertEqual(self.config.read_bytes(), b"# user settings\n") + + def test_console_mode_prints_instead_of_emitting(self): + with mock.patch("builtins.print") as mk_print: + self._patch_git() + selfupdate.update_generator(root=self.root) + self.assertTrue(mk_print.called) + + # -- the reset path --------------------------------------------------- + + def test_updates_and_preserves_the_modified_file(self): + run = self._patch_git(on_reset=lambda: self.config.write_bytes( + b"# upstream settings\n")) + rc = selfupdate.update_generator(root=self.root, + emit=self.emitted.append) + self.assertEqual(rc, 0) + # Fetch first, then the reset to origin's default branch. + self.assertEqual( + run.call_args_list[1][0][0], + ["git", "-C", str(self.root), "reset", "--hard", + "origin/main"]) + # The user's config survived the reset verbatim... + self.assertEqual(self.config.read_bytes(), b"# user settings\n") + # ...and the skip is reported. + self.assertTrue(any("Kept your local app/converter/config.py" + in line for line in self.emitted)) + + def test_streaming_adds_progress_and_passes_emit(self): + run = self._patch_git() + selfupdate.update_generator(root=self.root, emit=self.emitted.append) + fetch_argv = run.call_args_list[0][0][0] + self.assertEqual(fetch_argv[:4], + ["git", "-C", str(self.root), "fetch"]) + self.assertIn("--progress", fetch_argv) + self.assertEqual(run.call_args_list[0][1]["emit"], + self.emitted.append) + self.assertEqual(run.call_args_list[1][1]["emit"], + self.emitted.append) + + def test_reset_failure_still_restores_the_snapshot(self): + self._patch_git(reset_rc=1, on_reset=lambda: self.config.write_bytes( + b"# upstream settings\n")) + rc = selfupdate.update_generator(root=self.root) + self.assertEqual(rc, 1) + self.assertEqual(self.config.read_bytes(), b"# user settings\n") + + def test_staged_added_file_is_restored_after_the_reset_removes_it(self): + added = self.root / "new_config.py" + added.write_bytes(b"# user additions\n") + def damage(): + added.unlink() + self._patch_git(status=b"A new_config.py\n", on_reset=damage) + rc = selfupdate.update_generator(root=self.root) + self.assertEqual(rc, 0) + self.assertEqual(added.read_bytes(), b"# user additions\n") + + def test_deleted_tracked_file_is_not_resurrected(self): + # A deletion is not a modification with content: the reset's + # restore of the file stands (it shows up in the confirm instead). + self._patch_git(status=b" D gone.py\n") + rc = selfupdate.update_generator(root=self.root) + self.assertEqual(rc, 0) + self.assertFalse((self.root / "gone.py").exists()) + + def test_identical_upstream_version_is_not_reported_as_kept(self): + # Upstream's new file content equals what the user already has: + # the restore is a no-op and says nothing. + self._patch_git(on_reset=lambda: self.config.write_bytes( + b"# user settings\n")) + rc = selfupdate.update_generator(root=self.root, + emit=self.emitted.append) + self.assertEqual(rc, 0) + self.assertFalse(any("Kept your local" in line + for line in self.emitted)) + + +if __name__ == "__main__": + unittest.main() diff --git a/app/ui/hub.py b/app/ui/hub.py index c003b16..67680db 100644 --- a/app/ui/hub.py +++ b/app/ui/hub.py @@ -30,6 +30,7 @@ from pathlib import Path from typing import Callable, Optional, Tuple import logging_kit +import selfupdate from backends import ( REGISTRY, @@ -170,7 +171,9 @@ class _Hub: backend (qwen offers its per-model weight (un)installer there), start/stop the installed backends' local servers, install (backends with nothing on disk), update (every installed backend refreshed to - the latest upstream version in one task-view run), and uninstall. + the latest upstream version in one task-view run), update the + generator itself (git checkout moved to the remote's HEAD, keeping + locally modified files such as config.py), 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 @@ -222,6 +225,8 @@ class _Hub: options.append(("Install Backend", "install")) if any(_updatable(info, by_key) for info in REGISTRY): options.append(("Update Backends", "update")) + if selfupdate.is_git_checkout(): + options.append(("Update Generator", "update_self")) if any(_uninstallable(info, by_key) for info in REGISTRY): options.append(("Uninstall Backend", "uninstall")) @@ -229,7 +234,8 @@ class _Hub: self.stdscr, "Configure Backends", options, back_value=tui.Wizard.BACK, help_lines=["Install, update, configure, or remove a TTS " - "backend."], + "backend. 'Update Generator' refreshes this " + "tool's own checkout instead."], table_rows=_status_rows(statuses), notice_lines=_notice_lines()) if choice is tui.Wizard.BACK: @@ -242,6 +248,10 @@ class _Hub: _update_backends_action(self.stdscr) invalidate_detect_cache() continue # an inline action: re-show this same menu + if choice == "update_self": + _update_self_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": @@ -759,6 +769,62 @@ def _update_backends_action(stdscr) -> None: "Backends' to retry.", "err") +def _update_self_action(stdscr) -> None: + """Run the "Update Generator" action inside the TUI. + + Moves the generator's own git checkout to the remote's default-branch + HEAD (fetch + hard reset — the same flow the backend checkouts use). + User state cannot be overwritten: everything untracked (app/envs, + input/output/voices, the backend checkouts, logs) is outside the + reset's reach, and locally modified tracked files — the user-editable + app/converter/config.py above all — are snapshotted before the reset + and written back afterwards (a kept file whose upstream version + changed is reported for a manual merge). A dirty checkout confirms + first (declining, Esc included, aborts before anything runs); the run + itself streams in the task view like the other inline actions. After: + a successful run flashes the moved commit range (or "already up to + date") plus the restart reminder — the fresh code loads on relaunch, + where requirements re-install runs automatically — while cancel and + failure flash their usual warn/err. The menu re-shows either way. + """ + if not selfupdate.is_git_checkout(): + tui.flash(stdscr, "This install is not a git checkout — update by " + "re-cloning the repository.", "err") + return + before = selfupdate.current_commit() + modified = selfupdate.modified_tracked_files() + if modified: + body = ["These locally modified files are kept as they are:", + ""] + body += modified[:6] + if len(modified) > 6: + body.append(f"...and {len(modified) - 6} more") + if tui.confirm(stdscr, "Update the generator?", body=body, + default=False, cancel_value=False) is not True: + return + + def work(emit, cancel): + return selfupdate.update_generator(emit=emit, cancel=cancel) + + rc = taskview.run_steps( + stdscr, "Update Generator", + [taskview.TaskStep("Fetch and reset the checkout", work)]) + after = selfupdate.current_commit() + if rc == 130: + tui.flash(stdscr, "Update cancelled — re-run 'Update Generator' " + "any time.", "warn") + elif rc: + tui.flash(stdscr, "The generator update did not complete — see the " + "log above and retry.", "err") + elif before is not None and before == after: + tui.flash(stdscr, f"The generator is already up to date ({before}).", + "ok") + else: + tui.flash(stdscr, f"Generator updated {before or '?'} → {after}. " + "Restart to apply — requirements re-install runs " + "automatically on next launch.", "ok") + + def _status_mark(status: Optional[BackendStatus]) -> Tuple[str, str, str]: """Map a backend's state to (status_text, status_kind, name_kind). |
