From e7569a7bf779aa879e113d20aa3660ef233d0b4c Mon Sep 17 00:00:00 2001 From: Per-Arne Hellarvik Date: Tue, 3 Nov 2026 00:30:14 +0100 Subject: [PATCH] Warn on an untested cv4pve-cli version at startup At startup the app runs `cv4pve-cli --version`. If it differs from cli.TESTED_VERSION (2.4.0), a warning says which version was found and which one was tested. The app parses output formats that are not a stable API: the config list text, prune-backups as a JSON string, the CT ip-addresses JSON string and the exit codes with --wait. A newer cv4pve-cli could break them in confusing ways. If cv4pve-cli cannot be run at all, an error says so once, instead of every later call failing without explanation. The README notes the tested version. Closes #14 Co-Authored-By: Claude Opus 5.5 --- README.md | 3 ++- src/cv4pve_tui/app.py | 20 ++++++++++++++++++++ src/cv4pve_tui/cli.py | 8 ++++++++ tests/test_app.py | 40 ++++++++++++++++++++++++++++++++++++++++ tests/test_cli.py | 13 +++++++++++++ 5 files changed, 83 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 38da74f..b76f256 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,8 @@ disk size. The table shows CPU % and Mem % of running guests and the info screen in-use values (refresh with `g`). It shells out to `cv4pve-cli` and uses its current context (`cv4pve-cli config use `); -it stores no credentials itself. Tested with cv4pve-cli 2.4.0. +it stores no credentials itself. Tested with cv4pve-cli 2.4.0; with another version the app +warns at startup, because it parses output formats that are not a stable API. ## Run diff --git a/src/cv4pve_tui/app.py b/src/cv4pve_tui/app.py index d1d5cde..62371af 100644 --- a/src/cv4pve_tui/app.py +++ b/src/cv4pve_tui/app.py @@ -1040,6 +1040,7 @@ class GuestApp(App): def on_mount(self) -> None: # one place for every sub_title change: mount, switch and failed switch self.watch(self, "sub_title", self._show_context_frame) + self._check_version() table = self.query_one("#guests", DataTable) table.add_columns( "", # "!" = no backup job covers the guest @@ -1084,6 +1085,25 @@ class GuestApp(App): self.package_count = count self._update_frame() + @work(thread=True) + def _check_version(self) -> None: + try: + found = cli.version() + except Exception as exc: # noqa: BLE001 - every later call fails too; say why once + self.call_from_thread( + self.notify, + f"cv4pve-cli is not runnable: {_message(exc)}", + severity="error", + ) + return + if found != cli.TESTED_VERSION: + self.call_from_thread( + self.notify, + f"cv4pve-cli {found} found; this app was tested with {cli.TESTED_VERSION}. " + "Some screens may misread its output.", + severity="warning", + ) + def _show_context_frame(self, sub_title: str) -> None: self.context_name = sub_title.removeprefix("context: ") self._update_frame() diff --git a/src/cv4pve_tui/cli.py b/src/cv4pve_tui/cli.py index ca590ba..3e7d3e1 100644 --- a/src/cv4pve_tui/cli.py +++ b/src/cv4pve_tui/cli.py @@ -5,6 +5,10 @@ import json import re import subprocess +# Output formats this app parses (config list text, prune-backups as a JSON string, the CT +# ip-addresses JSON string, exit codes with --wait) were verified against this version. +TESTED_VERSION = "2.4.0" + class CliError(Exception): """cv4pve-cli failed; str() is its combined, stripped output.""" @@ -472,3 +476,7 @@ def task_log(upid: str) -> str: if not isinstance(upid, str) or not upid.startswith("UPID:"): raise ValueError("not a task id (UPID)") return run("task", "log", upid) + + +def version() -> str: + return run("--version").strip() diff --git a/tests/test_app.py b/tests/test_app.py index 5a9b040..3610698 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -50,6 +50,7 @@ class Fake: self.use_error = None self.use_hook = None self.guests_by_context = {} + self.cli_version, self.version_error = "2.4.0", None self.unprotected, self.unprotected_error = set(), None self.list_hook = None # delivery A (guest info screen) @@ -143,6 +144,11 @@ class Fake: raise self.unprotected_error return set(self.unprotected) + def version(self): + if self.version_error: + raise self.version_error + return self.cli_version + def contexts(self): self.contexts_calls += 1 if self.contexts_error: @@ -307,6 +313,7 @@ def fake(monkeypatch): "bridges", "set_net", "not_backed_up", + "version", "snapshots", "take_snapshot", "rollback_snapshot", @@ -3471,3 +3478,36 @@ async def test_frame_subtitle_singular_for_one_package_update(fake): async with app.run_test(size=SIZE) as pilot: await settle(app, pilot) assert frame_subtitle(app).endswith("ยท 1 package update") + + +# --- #14: warn on an untested cv4pve-cli version ------------------------------------- + + +async def test_no_warning_for_the_tested_cli_version(fake): + app = make_app() + async with app.run_test(size=SIZE) as pilot: + await settle(app, pilot) + assert not any("tested with" in m for m, _ in app.seen) + + +async def test_other_cli_version_warns_once(fake): + fake.cli_version = "2.5.0" + app = make_app() + async with app.run_test(size=SIZE) as pilot: + await settle(app, pilot) + warned = [m for m, sev in app.seen if sev == "warning" and "tested with" in m] + assert ( + len(warned) == 1 + and "2.5.0" in warned[0] + and cli.TESTED_VERSION in warned[0] + ) + + +async def test_cli_not_runnable_is_an_error_not_a_crash(fake): + fake.version_error = cli.CliError( + "[Errno 2] No such file or directory: 'cv4pve-cli'" + ) + app = make_app() + async with app.run_test(size=SIZE) as pilot: + await settle(app, pilot) + assert any("cv4pve-cli" in m for m in errors(app)) and app.is_running diff --git a/tests/test_cli.py b/tests/test_cli.py index e14a17f..bb10211 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1375,3 +1375,16 @@ def test_package_updates_failure_raises_clierror(monkeypatch): FakeRun(monkeypatch, "Error: node unreachable\n", returncode=1) with pytest.raises(cli.CliError): cli.package_updates("mainframe") + + +# --- #14 ---------------------------------------------------------------------------- + + +def test_version_argv_and_strips(fake): + fake.result = ("2.4.0\n", "", 0) + assert cli.version() == "2.4.0" + assert fake.argv == ["cv4pve-cli", "--version"] + + +def test_tested_version_constant(): + assert cli.TESTED_VERSION == "2.4.0" -- 2.39.5