linux observer
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(cli): add doctor subcommand for install prerequisite checks

+373
+9
src/solstone_linux/cli.py
··· 24 24 import sys 25 25 from pathlib import Path 26 26 27 + from . import doctor 27 28 from .config import load_config, save_config 28 29 from .streams import stream_name 29 30 ··· 135 136 "\nRun 'solstone-linux run' to start, or 'solstone-linux install-service' for systemd." 136 137 ) 137 138 return 0 139 + 140 + 141 + def cmd_doctor(args: argparse.Namespace) -> int: 142 + return doctor.run_doctor() 138 143 139 144 140 145 def cmd_install_service(args: argparse.Namespace) -> int: ··· 322 327 # setup 323 328 subparsers.add_parser("setup", help="Interactive configuration") 324 329 330 + # doctor 331 + subparsers.add_parser("doctor", help="Verify install prerequisites") 332 + 325 333 # install-service 326 334 subparsers.add_parser("install-service", help="Install systemd user service") 327 335 ··· 337 345 commands = { 338 346 "run": cmd_run, 339 347 "setup": cmd_setup, 348 + "doctor": cmd_doctor, 340 349 "install-service": cmd_install_service, 341 350 "status": cmd_status, 342 351 }
+215
src/solstone_linux/doctor.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Install prerequisite checks for solstone-linux. 5 + 6 + Exit code rule: fail anywhere -> 1; otherwise 0. Warn does not flip exit code. 7 + """ 8 + 9 + from __future__ import annotations 10 + 11 + import asyncio 12 + import os 13 + import shutil 14 + import subprocess 15 + import sys 16 + from typing import Callable, NamedTuple 17 + 18 + CheckResult = NamedTuple( 19 + "CheckResult", 20 + [("name", str), ("severity", str), ("detail", str)], 21 + ) 22 + 23 + 24 + def check_python_version() -> CheckResult: 25 + version = tuple(sys.version_info[:2]) 26 + if version >= (3, 10): 27 + return CheckResult("python version", "ok", f"{version[0]}.{version[1]}") 28 + return CheckResult( 29 + "python version", 30 + "fail", 31 + f"need >=3.10, got {version[0]}.{version[1]}", 32 + ) 33 + 34 + 35 + def check_gtk4_typelib() -> CheckResult: 36 + try: 37 + import gi 38 + 39 + gi.require_version("Gtk", "4.0") 40 + from gi.repository import Gtk # noqa: F401 41 + except (ImportError, ValueError): 42 + return CheckResult( 43 + "gtk4 typelib", 44 + "fail", 45 + "install gir1.2-gtk-4.0 (or distro equivalent)", 46 + ) 47 + return CheckResult("gtk4 typelib", "ok", "Gtk 4.0 available") 48 + 49 + 50 + def check_gstreamer() -> CheckResult: 51 + if shutil.which("gst-launch-1.0") is None: 52 + return CheckResult( 53 + "gstreamer", 54 + "fail", 55 + "gst-launch-1.0 not on PATH; install gstreamer1.0-tools or equivalent", 56 + ) 57 + try: 58 + import gi 59 + 60 + gi.require_version("Gst", "1.0") 61 + from gi.repository import Gst # noqa: F401 62 + except (ImportError, ValueError): 63 + return CheckResult("gstreamer", "fail", "gir1.2-gstreamer-1.0 missing") 64 + return CheckResult("gstreamer", "ok", "gst-launch-1.0 and Gst typelib available") 65 + 66 + 67 + def check_cairo() -> CheckResult: 68 + try: 69 + import cairo # noqa: F401 70 + except ImportError: 71 + return CheckResult( 72 + "cairo binding", 73 + "fail", 74 + "install python3-cairo (or distro equivalent)", 75 + ) 76 + return CheckResult("cairo binding", "ok", "cairo import ok") 77 + 78 + 79 + def check_pipewire() -> CheckResult: 80 + try: 81 + result = subprocess.run( 82 + ["pactl", "info"], 83 + capture_output=True, 84 + timeout=5, 85 + text=True, 86 + ) 87 + except FileNotFoundError: 88 + return CheckResult( 89 + "pipewire (pactl)", 90 + "fail", 91 + "pactl missing; install pipewire-pulse or pulseaudio-utils", 92 + ) 93 + if result.returncode != 0: 94 + detail = result.stderr.strip().splitlines()[0] if result.stderr.strip() else "" 95 + return CheckResult("pipewire (pactl)", "fail", detail) 96 + detail = result.stdout.strip().splitlines()[0] if result.stdout.strip() else "" 97 + return CheckResult("pipewire (pactl)", "ok", detail) 98 + 99 + 100 + async def check_portal() -> CheckResult: 101 + from dbus_next.aio import MessageBus 102 + from dbus_next.constants import BusType 103 + 104 + bus = None 105 + try: 106 + bus = await MessageBus(bus_type=BusType.SESSION).connect() 107 + await bus.introspect( 108 + "org.freedesktop.portal.Desktop", 109 + "/org/freedesktop/portal/desktop", 110 + ) 111 + except Exception: 112 + return CheckResult( 113 + "xdg-desktop-portal", 114 + "fail", 115 + "xdg-desktop-portal not responding on session bus", 116 + ) 117 + finally: 118 + if bus is not None: 119 + bus.disconnect() 120 + return CheckResult("xdg-desktop-portal", "ok", "portal responding") 121 + 122 + 123 + def check_user_systemd() -> CheckResult: 124 + try: 125 + result = subprocess.run( 126 + ["systemctl", "--user", "is-system-running"], 127 + capture_output=True, 128 + timeout=5, 129 + text=True, 130 + ) 131 + except FileNotFoundError: 132 + return CheckResult( 133 + "systemd --user", 134 + "fail", 135 + "systemctl --user not reachable", 136 + ) 137 + detail = result.stdout.strip().splitlines()[0] if result.stdout.strip() else "" 138 + if detail: 139 + return CheckResult("systemd --user", "ok", detail) 140 + return CheckResult("systemd --user", "fail", "systemctl --user not reachable") 141 + 142 + 143 + def check_pipx() -> CheckResult: 144 + if shutil.which("pipx") is None: 145 + return CheckResult( 146 + "pipx", 147 + "fail", 148 + "pipx missing; install via 'python3 -m pip install --user pipx' or distro package", 149 + ) 150 + return CheckResult("pipx", "ok", "pipx on PATH") 151 + 152 + 153 + def check_appindicator_ext() -> CheckResult: 154 + desktop = os.environ.get("XDG_CURRENT_DESKTOP", "") 155 + if "GNOME" not in desktop: 156 + return CheckResult( 157 + "appindicator ext (soft)", 158 + "ok", 159 + "not applicable (non-GNOME desktop)", 160 + ) 161 + try: 162 + result = subprocess.run( 163 + ["gnome-extensions", "list"], 164 + capture_output=True, 165 + timeout=5, 166 + text=True, 167 + ) 168 + except FileNotFoundError: 169 + return CheckResult( 170 + "appindicator ext (soft)", 171 + "warn", 172 + "install gnome-shell-extension-appindicator", 173 + ) 174 + if "appindicator" in result.stdout.lower(): 175 + return CheckResult( 176 + "appindicator ext (soft)", "ok", "appindicator extension present" 177 + ) 178 + return CheckResult( 179 + "appindicator ext (soft)", 180 + "warn", 181 + "install gnome-shell-extension-appindicator", 182 + ) 183 + 184 + 185 + def run_doctor() -> int: 186 + checks: list[tuple[str, Callable[[], CheckResult]]] = [ 187 + ("python version", check_python_version), 188 + ("gtk4 typelib", check_gtk4_typelib), 189 + ("gstreamer", check_gstreamer), 190 + ("cairo binding", check_cairo), 191 + ("pipewire (pactl)", check_pipewire), 192 + ("xdg-desktop-portal", lambda: asyncio.run(check_portal())), 193 + ("systemd --user", check_user_systemd), 194 + ("pipx", check_pipx), 195 + ("appindicator ext (soft)", check_appindicator_ext), 196 + ] 197 + fail_count = 0 198 + warn_count = 0 199 + 200 + for name, fn in checks: 201 + try: 202 + result = fn() 203 + except Exception as e: 204 + result = CheckResult(name, "fail", repr(e)) 205 + if not result.name or result.name != name: 206 + result = CheckResult(name, result.severity, result.detail) 207 + print(f"{result.severity:<4} {result.name:<28} {result.detail}") 208 + if result.severity == "fail": 209 + fail_count += 1 210 + elif result.severity == "warn": 211 + warn_count += 1 212 + 213 + print() 214 + print(f"doctor: {len(checks)} checks, {fail_count} failed, {warn_count} warnings") 215 + return 1 if fail_count else 0
+149
tests/test_doctor.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + import sys 5 + 6 + from solstone_linux import doctor 7 + 8 + 9 + def _set_all_checks( 10 + monkeypatch, 11 + *, 12 + python_result=None, 13 + gtk_result=None, 14 + gstreamer_result=None, 15 + cairo_result=None, 16 + pipewire_result=None, 17 + portal_result=None, 18 + systemd_result=None, 19 + pipx_result=None, 20 + appindicator_result=None, 21 + ): 22 + monkeypatch.setattr( 23 + doctor, 24 + "check_python_version", 25 + lambda: python_result or doctor.CheckResult("python version", "ok", ""), 26 + ) 27 + monkeypatch.setattr( 28 + doctor, 29 + "check_gtk4_typelib", 30 + lambda: gtk_result or doctor.CheckResult("gtk4 typelib", "ok", ""), 31 + ) 32 + monkeypatch.setattr( 33 + doctor, 34 + "check_gstreamer", 35 + lambda: gstreamer_result or doctor.CheckResult("gstreamer", "ok", ""), 36 + ) 37 + monkeypatch.setattr( 38 + doctor, 39 + "check_cairo", 40 + lambda: cairo_result or doctor.CheckResult("cairo binding", "ok", ""), 41 + ) 42 + monkeypatch.setattr( 43 + doctor, 44 + "check_pipewire", 45 + lambda: pipewire_result or doctor.CheckResult("pipewire (pactl)", "ok", ""), 46 + ) 47 + 48 + async def _portal(): 49 + return portal_result or doctor.CheckResult("xdg-desktop-portal", "ok", "") 50 + 51 + monkeypatch.setattr(doctor, "check_portal", _portal) 52 + monkeypatch.setattr( 53 + doctor, 54 + "check_user_systemd", 55 + lambda: systemd_result or doctor.CheckResult("systemd --user", "ok", ""), 56 + ) 57 + monkeypatch.setattr( 58 + doctor, 59 + "check_pipx", 60 + lambda: pipx_result or doctor.CheckResult("pipx", "ok", ""), 61 + ) 62 + monkeypatch.setattr( 63 + doctor, 64 + "check_appindicator_ext", 65 + lambda: ( 66 + appindicator_result 67 + or doctor.CheckResult("appindicator ext (soft)", "ok", "") 68 + ), 69 + ) 70 + 71 + 72 + def test_run_doctor_all_pass_returns_zero(monkeypatch, capsys): 73 + _set_all_checks(monkeypatch) 74 + 75 + assert doctor.run_doctor() == 0 76 + 77 + captured = capsys.readouterr() 78 + assert "python version" in captured.out 79 + assert "gtk4 typelib" in captured.out 80 + assert "doctor: 9 checks, 0 failed, 0 warnings" in captured.out 81 + 82 + 83 + def test_run_doctor_any_fail_returns_one(monkeypatch): 84 + _set_all_checks( 85 + monkeypatch, 86 + pipx_result=doctor.CheckResult("pipx", "fail", "missing"), 87 + ) 88 + 89 + assert doctor.run_doctor() == 1 90 + 91 + 92 + def test_run_doctor_warn_only_returns_zero(monkeypatch): 93 + _set_all_checks( 94 + monkeypatch, 95 + appindicator_result=doctor.CheckResult( 96 + "appindicator ext (soft)", 97 + "warn", 98 + "install gnome-shell-extension-appindicator", 99 + ), 100 + ) 101 + 102 + assert doctor.run_doctor() == 0 103 + 104 + 105 + def test_check_exception_renders_as_fail(monkeypatch, capsys): 106 + _set_all_checks(monkeypatch) 107 + 108 + def _boom(): 109 + raise RuntimeError("boom") 110 + 111 + monkeypatch.setattr(doctor, "check_pipx", _boom) 112 + 113 + assert doctor.run_doctor() == 1 114 + 115 + captured = capsys.readouterr() 116 + assert "RuntimeError" in captured.out 117 + assert "boom" in captured.out 118 + 119 + 120 + def test_python_version_old_fails(monkeypatch): 121 + monkeypatch.setattr(sys, "version_info", (3, 9, 0, "final", 0)) 122 + 123 + result = doctor.check_python_version() 124 + 125 + assert result.severity == "fail" 126 + assert "3.10" in result.detail 127 + 128 + 129 + def test_python_version_current_ok(): 130 + result = doctor.check_python_version() 131 + 132 + assert result.severity == "ok" 133 + 134 + 135 + def test_pipx_missing_fails(monkeypatch): 136 + monkeypatch.setattr(doctor.shutil, "which", lambda _: None) 137 + 138 + result = doctor.check_pipx() 139 + 140 + assert result.severity == "fail" 141 + 142 + 143 + def test_appindicator_non_gnome_is_ok_not_applicable(monkeypatch): 144 + monkeypatch.delenv("XDG_CURRENT_DESKTOP", raising=False) 145 + 146 + result = doctor.check_appindicator_ext() 147 + 148 + assert result.severity == "ok" 149 + assert "not applicable" in result.detail