tools/manage.py: stop when the TV doesn't answer instead of reporting an empty TV

A nonzero exit from tv.py now ends the command with its diagnostic, so status
never says "launcher: not installed" for a TV it couldn't read. Tests cover a
failed package query, partial output then failure, a failed config query, a
failure with no diagnostic, a genuinely empty TV and an installed launcher with
a theme. Reported by @SaviorOneZero.
This commit is contained in:
מקס חיים committed 2026-10-23 20:13:59 -04:00
1 parent 5726f765d7
commit 2b62fb6a68
2 files changed
+43

No files matched your search

+40
View File
@@ -1,7 +1,12 @@
"""tools/manage.py reads the add-ons, themes and switches from the repo's own READMEs; check it finds them all, and
that every add-on has a way in: its own install script or the steps in addons/README.md."""
import contextlib
import io
import os
import subprocess
import sys
import unittest
from unittest import mock
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
@@ -43,5 +48,40 @@ class ManageTest(unittest.TestCase):
manage.cmd_set("columns", "6", str(ROOT / "themes" / "aurora"))
def status(self, *answers):
"""Runs `status` with tv.py's answers (stdout, returncode, stderr) in order; returns what it printed."""
replies = iter(subprocess.CompletedProcess([], code, out, err) for out, code, err in answers)
out = io.StringIO()
with mock.patch.dict(os.environ, {"TV_HOST": "192.0.2.1"}), \
mock.patch.object(manage, "tv_python", return_value="python3"), \
mock.patch.object(manage.subprocess, "run", side_effect=lambda *a, **k: next(replies)), \
contextlib.redirect_stdout(out):
code = manage.main(["status"])
return code, out.getvalue()
def test_status_stops_when_the_tv_does_not_answer(self):
for answers, message in [
([("", 1, "failed to connect")], "failed to connect"),
([("package:com.a\n", 1, "")], "package:com.a"),
([("", 255, "")], "tv.py exited with status 255"),
([("", 0, ""), ("", 1, "unauthorized")], "unauthorized"),
]:
with self.subTest(message=message), self.assertRaises(SystemExit) as stop:
self.status(*answers)
self.assertEqual(stop.exception.code, message)
def test_status_reads_an_empty_tv(self):
code, out = self.status(("", 0, ""), ("", 0, ""))
self.assertEqual(code, 0)
self.assertIn("launcher: not installed", out)
self.assertIn("theme: none", out)
def test_status_finds_the_launcher_and_a_theme(self):
theme = ROOT / "themes" / manage.themes()[0]["id"] / "config.json"
code, out = self.status(("package:" + manage.LAUNCHER_PKG + "\n", 0, ""), (theme.read_text(encoding="utf-8"), 0, ""))
self.assertEqual(code, 0)
self.assertIn("launcher: installed", out)
self.assertIn("theme: " + manage.themes()[0]["id"], out)
if __name__ == "__main__":
unittest.main()
+3
View File
@@ -147,6 +147,9 @@ def tv(command):
if not os.environ.get("TV_HOST"):
sys.exit("set TV_HOST to the TV IP address")
r = subprocess.run([tv_python(), "-I", str(TOOLS / "tv.py"), command], capture_output=True, text=True)
# A failed read is unknown, not empty: never report "not installed" for a TV that didn't answer.
if r.returncode:
sys.exit(r.stderr.strip() or r.stdout.strip() or f"tv.py exited with status {r.returncode}")
return r.stdout