fix(update): route the self-update by install kind (apt/pip/source) — 0.37.1 #38

Merged
jessey merged 1 commits from fix/updater-by-install into main 2026-05-22 14:40:20 +00:00
7 changed files with 137 additions and 6 deletions
Showing only changes of commit 479189ee4e - Show all commits
+8
View File
@@ -5,6 +5,14 @@ All notable changes to RigDoctor are recorded here. Format follows
(`MAJOR.MINOR.PATCH`, pre-1.0). `__version__` and `pyproject.toml` must match the git
release tag (so the auto-updater, D18, can compare versions).
## [0.37.1] - 2026-05-22
### Fixed
- **`rigdoctor update` now uses the right method for how RigDoctor was installed.** It detects
apt (`.deb`), pip (venv/`.run`), or source installs (`updates.install_kind()`); only pip
installs self-update in place. An apt install no longer fails with "No module named pip" —
it (and the GUI Update button) shows `sudo apt update && sudo apt install --only-upgrade
rigdoctor`; a source checkout points to `git pull`.
## [0.37.0] - 2026-05-22
### Added
- **Version footer** — a footer across the bottom of the window shows `RigDoctor v<version>` in
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "rigdoctor"
version = "0.37.0"
version = "0.37.1"
description = "Modular hardware monitoring & crash diagnostics for Linux gamers."
readme = "README.md"
requires-python = ">=3.11"
+1 -1
View File
@@ -1,3 +1,3 @@
"""RigDoctor — modular hardware monitoring & crash diagnostics for Linux gamers."""
__version__ = "0.37.0"
__version__ = "0.37.1"
+4
View File
@@ -263,6 +263,10 @@ def cmd_update(args) -> int:
print("\nWhat's new:\n" + "\n".join(" " + ln for ln in notes.splitlines()) + "\n")
if args.check:
return 0
kind = updates.install_kind()
if kind != "pip": # apt/source installs aren't pip-updatable — show the right command
print(updates.update_hint(kind))
return 0
print(f"Installing {tag}")
rc, out = updates.apply_update(tag)
print(out[-2000:])
+55 -3
View File
@@ -8,11 +8,14 @@ state for the UI; `apply_update` performs the no-root self-update.
from __future__ import annotations
import functools
import json
import shutil
import subprocess
import sys
import urllib.error
import urllib.request
from pathlib import Path
from .. import __version__
from ..config import load_token
@@ -31,6 +34,50 @@ UP_TO_DATE = "up-to-date"
AVAILABLE = "available"
APT_PACKAGE = "rigdoctor"
def _dpkg_owns(path: Path) -> bool:
"""True if dpkg reports `path` belongs to a package (i.e. an apt/.deb install)."""
if not shutil.which("dpkg"):
return False
try:
r = subprocess.run(["dpkg", "-S", str(path)], capture_output=True, text=True, timeout=5)
except (subprocess.SubprocessError, OSError):
return False
return r.returncode == 0 and APT_PACKAGE in r.stdout
@functools.lru_cache(maxsize=1)
def install_kind() -> str:
"""How RigDoctor was installed: 'apt' (.deb), 'pip' (venv/.run), or 'dev' (source checkout).
Decides which updater to use: only 'pip' can self-update in place; apt is root/dpkg-managed
and source is VCS-managed, so those are guided rather than auto-applied.
"""
pkg = Path(__file__).resolve().parents[1] # .../rigdoctor
if _dpkg_owns(pkg / "__init__.py"):
return "apt"
if sys.prefix != sys.base_prefix: # inside a venv → the pip/.run install
return "pip"
if (pkg.parents[1] / "pyproject.toml").exists(): # repo checkout
return "dev"
if str(pkg).startswith("/usr/") or "/dist-packages/" in str(pkg):
return "apt" # system-managed but no dpkg record — still don't pip
return "pip"
def update_hint(kind: str | None = None) -> str:
"""Human guidance for installs that can't self-update via pip (apt / source)."""
kind = kind or install_kind()
if kind == "apt":
return ("Installed via apt — update with:\n"
f" sudo apt update && sudo apt install --only-upgrade {APT_PACKAGE}")
if kind == "dev":
return "Running from a source checkout — update with `git pull`."
return ""
def _parse(version: str) -> tuple[int, ...]:
return tuple(int(p) for p in version.lstrip("vV").split(".") if p.isdigit())
@@ -100,11 +147,16 @@ def list_releases(limit: int = 15, timeout: float = 6.0) -> tuple[list[tuple[str
def apply_update(tag: str) -> tuple[int, str]:
"""Self-update the current (user-local) install to `tag` via authenticated pip.
"""Update to `tag` using the method matching how RigDoctor was installed.
Installs `rigdoctor[gui] @ git+https://oauth2:<token>@…/rigdoctor.git@<tag>` into
the running environment. Returns (exit_code, output) with the token scrubbed.
Only pip/venv installs are upgraded in place (authenticated pip install of
`rigdoctor[gui] @ git+https://oauth2:<token>@…/rigdoctor.git@<tag>`). apt and source
installs can't be (root/dpkg- or VCS-managed), so they return guidance instead of
attempting pip. Returns (exit_code, output) with the token scrubbed.
"""
kind = install_kind()
if kind != "pip":
return (1, update_hint(kind))
token = load_token()
if not token:
return (1, "No update token configured. Run `rigdoctor login`.")
+4 -1
View File
@@ -289,6 +289,9 @@ class MainWindow(QMainWindow):
def _apply_update(self) -> None:
if not self._latest_tag:
return
if updates.install_kind() != "pip": # apt/source: can't pip-update — show the command
QMessageBox.information(self, "Update RigDoctor", updates.update_hint())
return
box = QMessageBox(self)
box.setWindowTitle(f"Update to {self._latest_tag}")
box.setText(f"Update RigDoctor to {self._latest_tag}?")
@@ -454,7 +457,7 @@ class MainWindow(QMainWindow):
self._update_label.setText("update check unavailable")
elif state == updates.AVAILABLE:
self._update_label.setText(f'<span style="color:{GOOD};">{tag} available</span>')
self._update_btn.setText(f"Update to {tag}")
self._update_btn.setText(f"Update to {tag}" if updates.install_kind() == "pip" else "How to update")
self._update_btn.setVisible(True)
if self._alert_monitor.enabled and tag != self._notified_update_tag:
self._notified_update_tag = tag # once per version, not every poll
+64
View File
@@ -0,0 +1,64 @@
"""Tests for the M13 updater: install detection + routing the update to the right method."""
import unittest
from unittest import mock
from rigdoctor.core import updates
class InstallKindTests(unittest.TestCase):
def setUp(self):
updates.install_kind.cache_clear()
def tearDown(self):
updates.install_kind.cache_clear()
def test_apt_when_dpkg_owns_the_package(self):
with mock.patch.object(updates, "_dpkg_owns", return_value=True):
self.assertEqual(updates.install_kind(), "apt")
def test_pip_when_running_in_a_venv(self):
with mock.patch.object(updates, "_dpkg_owns", return_value=False), \
mock.patch.object(updates.sys, "prefix", "/opt/venv"), \
mock.patch.object(updates.sys, "base_prefix", "/usr"):
self.assertEqual(updates.install_kind(), "pip")
class ApplyUpdateRoutingTests(unittest.TestCase):
def test_apt_returns_guidance_and_never_runs_pip(self):
with mock.patch.object(updates, "install_kind", return_value="apt"), \
mock.patch("subprocess.run") as run:
rc, out = updates.apply_update("v9.9.9")
self.assertEqual(rc, 1)
self.assertIn("apt install --only-upgrade", out)
run.assert_not_called()
def test_dev_returns_guidance_and_never_runs_pip(self):
with mock.patch.object(updates, "install_kind", return_value="dev"), \
mock.patch("subprocess.run") as run:
rc, out = updates.apply_update("v9.9.9")
self.assertIn("git pull", out)
run.assert_not_called()
def test_pip_install_runs_pip(self):
proc = mock.Mock(returncode=0, stdout="Successfully installed", stderr="")
with mock.patch.object(updates, "install_kind", return_value="pip"), \
mock.patch.object(updates, "load_token", return_value="TOK"), \
mock.patch("subprocess.run", return_value=proc) as run:
rc, _out = updates.apply_update("v1.2.3")
self.assertEqual(rc, 0)
cmd = run.call_args[0][0]
self.assertIn("pip", cmd)
self.assertIn("install", cmd)
class UpdateHintTests(unittest.TestCase):
def test_apt_hint_names_the_apt_command(self):
self.assertIn("apt install --only-upgrade rigdoctor", updates.update_hint("apt"))
def test_dev_hint_says_git_pull(self):
self.assertIn("git pull", updates.update_hint("dev"))
if __name__ == "__main__":
unittest.main()