diff --git a/CHANGELOG.md b/CHANGELOG.md index 4615cc7..970ebd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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` in diff --git a/pyproject.toml b/pyproject.toml index 37c944f..0e5d594 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/src/rigdoctor/__init__.py b/src/rigdoctor/__init__.py index 23a6340..3d2cff1 100644 --- a/src/rigdoctor/__init__.py +++ b/src/rigdoctor/__init__.py @@ -1,3 +1,3 @@ """RigDoctor — modular hardware monitoring & crash diagnostics for Linux gamers.""" -__version__ = "0.37.0" +__version__ = "0.37.1" diff --git a/src/rigdoctor/cli.py b/src/rigdoctor/cli.py index e83e530..a10e58c 100644 --- a/src/rigdoctor/cli.py +++ b/src/rigdoctor/cli.py @@ -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:]) diff --git a/src/rigdoctor/core/updates.py b/src/rigdoctor/core/updates.py index c00f7f1..7fe37af 100644 --- a/src/rigdoctor/core/updates.py +++ b/src/rigdoctor/core/updates.py @@ -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:@…/rigdoctor.git@` 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:@…/rigdoctor.git@`). 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`.") diff --git a/src/rigdoctor/gui/main_window.py b/src/rigdoctor/gui/main_window.py index d8d5881..5ce9247 100644 --- a/src/rigdoctor/gui/main_window.py +++ b/src/rigdoctor/gui/main_window.py @@ -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'{tag} available') - 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 diff --git a/tests/test_updates.py b/tests/test_updates.py new file mode 100644 index 0000000..9721435 --- /dev/null +++ b/tests/test_updates.py @@ -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()