Compare commits
8 Commits
v0.37.0
...
9fe9a6576f
| Author | SHA1 | Date | |
|---|---|---|---|
| 9fe9a6576f | |||
|
07bc722209
|
|||
| d405bf7caf | |||
|
9bb0f9a684
|
|||
| 4bbc0fa97e | |||
|
a0f8055328
|
|||
| 323451428b | |||
|
479189ee4e
|
@@ -5,6 +5,27 @@ 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.38.0] - 2026-05-22
|
||||
### Added
|
||||
- **PCIe link in the Inventory.** Each NVMe drive now shows its negotiated PCIe link next to the
|
||||
model — e.g. `Samsung SSD 980 PRO 1TB (931.5G) · PCIe Gen4 x4` — read from sysfs
|
||||
(`current/max_link_speed` + width). If a drive negotiates below its capability (a slower M.2
|
||||
slot, lane-sharing, or a downtrain) it's flagged: `PCIe Gen3 x4 (capable of Gen4 x4)`. So you
|
||||
can confirm a Gen4 SSD is actually in a Gen4 slot. (SATA disks show no PCIe link.)
|
||||
- **System Health flags downtrained NVMe links.** A new check warns when an NVMe drive negotiates
|
||||
fewer PCIe lanes than it supports (almost always motherboard **lane-sharing** — a GPU/second
|
||||
card or another M.2 stealing lanes) and notes speed-only reductions as info (a slower slot or
|
||||
idle ASPM). The GPU is deliberately excluded — NVIDIA drops its PCIe gen/width at idle, so a
|
||||
snapshot would false-alarm.
|
||||
|
||||
## [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
|
||||
|
||||
@@ -29,6 +29,16 @@ freeze are usually lost. RigDoctor pulls it together and keeps the evidence.
|
||||
or share a live **terminal session** for remote help.
|
||||
- **Self-updating** — `apt upgrade`, or the in-app updater.
|
||||
|
||||
## Screenshots
|
||||
|
||||
| Dashboard | Inventory |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|
||||
**Share** — a read-only or interactive terminal session over the relay, for remote help:
|
||||
|
||||

|
||||
|
||||
## Install
|
||||
|
||||
### Debian / Ubuntu — `.deb`
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 171 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 141 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 78 KiB |
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "rigdoctor"
|
||||
version = "0.37.0"
|
||||
version = "0.38.0"
|
||||
description = "Modular hardware monitoring & crash diagnostics for Linux gamers."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""RigDoctor — modular hardware monitoring & crash diagnostics for Linux gamers."""
|
||||
|
||||
__version__ = "0.37.0"
|
||||
__version__ = "0.38.0"
|
||||
|
||||
@@ -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:])
|
||||
|
||||
@@ -251,6 +251,38 @@ def check_live_temps() -> list[Finding]:
|
||||
)]
|
||||
|
||||
|
||||
def check_pcie_links() -> list[Finding]:
|
||||
"""Flag NVMe drives linked below their PCIe capability — a slower slot or, most often,
|
||||
motherboard lane-sharing where a GPU/second card or another M.2 steals lanes from the slot.
|
||||
|
||||
Width reductions are reliable (reported as warnings); speed-only reductions are info (they can
|
||||
also be normal link power management at idle). The GPU is intentionally not checked here:
|
||||
NVIDIA drops its PCIe gen *and* width at idle, so a point-in-time snapshot is misleading.
|
||||
"""
|
||||
from . import inventory
|
||||
|
||||
findings: list[Finding] = []
|
||||
for name, dev in inventory.nvme_controllers():
|
||||
cur_g, cur_w, max_g, max_w = inventory.read_link(dev)
|
||||
if not cur_g or not max_g:
|
||||
continue
|
||||
if max_w and cur_w and cur_w != max_w: # fewer lanes → almost always lane-sharing
|
||||
findings.append(Finding(
|
||||
WARNING, "PCIe", f"{name} linked at x{cur_w} (supports x{max_w})",
|
||||
f"{name} negotiated PCIe Gen{cur_g} x{cur_w}, but the drive supports "
|
||||
f"Gen{max_g} x{max_w}. Fewer lanes is usually motherboard lane-sharing — a GPU or a "
|
||||
"second card in a PCIe slot, or another populated M.2, can steal lanes from this slot.",
|
||||
"Check your board manual's lane-sharing table; move the drive to a full-x4 "
|
||||
"(often CPU-attached) M.2 slot."))
|
||||
elif cur_g < max_g: # full width but a lower generation → slower slot or idle ASPM
|
||||
findings.append(Finding(
|
||||
INFO, "PCIe", f"{name} linked at Gen{cur_g} (supports Gen{max_g})",
|
||||
f"{name} negotiated PCIe Gen{cur_g} but supports Gen{max_g}. This can be a slower "
|
||||
"(chipset or older) M.2 slot, or normal link power management (ASPM) at idle.",
|
||||
"If you expect full speed, check the slot and the BIOS PCIe/ASPM settings."))
|
||||
return findings
|
||||
|
||||
|
||||
def run_health_checks(include_journal: bool = True) -> list[Finding]:
|
||||
"""Run all checks and return findings sorted by severity (worst first).
|
||||
|
||||
@@ -273,5 +305,6 @@ def run_health_checks(include_journal: bool = True) -> list[Finding]:
|
||||
else:
|
||||
findings += check_smart()
|
||||
findings += check_live_temps()
|
||||
findings += check_pcie_links()
|
||||
findings.sort(key=lambda f: _ORDER.get(f.severity, 9))
|
||||
return findings
|
||||
|
||||
@@ -9,6 +9,7 @@ from __future__ import annotations
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
@@ -123,6 +124,64 @@ def _gpu() -> Section:
|
||||
return Section("GPU", [("Device", g) for g in gpus] or [("Device", "unknown")])
|
||||
|
||||
|
||||
# PCIe link speed (GT/s) → generation.
|
||||
_PCIE_GEN = {"2.5": 1, "5": 2, "5.0": 2, "8": 3, "8.0": 3, "16": 4, "16.0": 4, "32": 5, "32.0": 5}
|
||||
|
||||
|
||||
def _gen(speed: str) -> int | None:
|
||||
"""Map a sysfs link speed like '16.0 GT/s PCIe' to its PCIe generation (4)."""
|
||||
tok = speed.strip().split()[0] if speed.strip() else ""
|
||||
return _PCIE_GEN.get(tok)
|
||||
|
||||
|
||||
def read_link(dev: Path) -> tuple[int | None, str, int | None, str]:
|
||||
"""Negotiated/max PCIe link for a PCI device dir: (cur_gen, cur_width, max_gen, max_width).
|
||||
|
||||
Widths are the raw sysfs strings (e.g. '4'); gens are ints (4) or None when unreadable.
|
||||
"""
|
||||
def rd(name: str) -> str:
|
||||
try:
|
||||
return (dev / name).read_text().strip()
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
return (_gen(rd("current_link_speed")), rd("current_link_width"),
|
||||
_gen(rd("max_link_speed")), rd("max_link_width"))
|
||||
|
||||
|
||||
def _link_desc(dev: Path) -> str:
|
||||
"""Describe a PCI device's negotiated PCIe link, noting if it's below its max.
|
||||
|
||||
e.g. 'PCIe Gen4 x4', or 'PCIe Gen3 x4 (capable of Gen4 x4)' when downtrained / in a
|
||||
slower slot.
|
||||
"""
|
||||
cur_g, cur_w, max_g, max_w = read_link(dev)
|
||||
if not cur_g or not cur_w:
|
||||
return ""
|
||||
desc = f"PCIe Gen{cur_g} x{cur_w}"
|
||||
if max_g and (cur_g < max_g or (max_w and cur_w != max_w)):
|
||||
desc += f" (capable of Gen{max_g} x{max_w})"
|
||||
return desc
|
||||
|
||||
|
||||
def nvme_controllers() -> list[tuple[str, Path]]:
|
||||
"""Each NVMe controller as (name, pci-device-dir), e.g. ('nvme0', /sys/.../device)."""
|
||||
base = Path("/sys/class/nvme")
|
||||
try:
|
||||
entries = [p for p in base.iterdir() if re.fullmatch(r"nvme\d+", p.name)]
|
||||
except OSError:
|
||||
return []
|
||||
return sorted((p.name, p / "device") for p in entries)
|
||||
|
||||
|
||||
def _nvme_link(block_name: str) -> str:
|
||||
"""PCIe link for an NVMe block device (nvme0n1 → controller nvme0); '' for non-NVMe."""
|
||||
m = re.match(r"(nvme\d+)", block_name)
|
||||
if not m:
|
||||
return ""
|
||||
return _link_desc(Path("/sys/class/nvme") / m.group(1) / "device")
|
||||
|
||||
|
||||
def _storage() -> Section:
|
||||
items: list[tuple[str, str]] = []
|
||||
# TYPE first so MODEL (which can contain spaces) is the trailing field.
|
||||
@@ -133,7 +192,11 @@ def _storage() -> Section:
|
||||
continue
|
||||
name, size = parts[1], parts[2]
|
||||
model = parts[3] if len(parts) > 3 else ""
|
||||
items.append((name, f"{model} ({size})".strip()))
|
||||
desc = f"{model} ({size})".strip()
|
||||
link = _nvme_link(name) # NVMe PCIe gen/width (e.g. Gen4 x4), flags downtrains
|
||||
if link:
|
||||
desc += f" · {link}"
|
||||
items.append((name, desc))
|
||||
return Section("Storage", items or [("Disks", "unknown")])
|
||||
|
||||
|
||||
|
||||
@@ -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`.")
|
||||
|
||||
@@ -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
|
||||
|
||||
+40
-1
@@ -1,8 +1,18 @@
|
||||
"""Tests for the M4 health report's log scanner (synthetic input)."""
|
||||
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
from rigdoctor.core.health import CRITICAL, WARNING, run_health_checks, scan_journal_text
|
||||
from rigdoctor.core import health
|
||||
from rigdoctor.core.health import (
|
||||
CRITICAL,
|
||||
INFO,
|
||||
WARNING,
|
||||
check_pcie_links,
|
||||
run_health_checks,
|
||||
scan_journal_text,
|
||||
)
|
||||
|
||||
|
||||
class HealthScanTests(unittest.TestCase):
|
||||
@@ -42,5 +52,34 @@ class HealthScanTests(unittest.TestCase):
|
||||
self.assertEqual(ranks, sorted(ranks))
|
||||
|
||||
|
||||
class PcieLinkCheckTests(unittest.TestCase):
|
||||
def _with_link(self, cur_g, cur_w, max_g, max_w):
|
||||
# one fake NVMe controller returning the given link tuple
|
||||
return (mock.patch("rigdoctor.core.inventory.nvme_controllers",
|
||||
return_value=[("nvme0", Path("/x"))]),
|
||||
mock.patch("rigdoctor.core.inventory.read_link",
|
||||
return_value=(cur_g, cur_w, max_g, max_w)))
|
||||
|
||||
def test_reduced_width_is_a_warning_about_lane_sharing(self):
|
||||
ctrls, link = self._with_link(4, "2", 4, "4") # Gen4 x2 but supports x4
|
||||
with ctrls, link:
|
||||
findings = check_pcie_links()
|
||||
self.assertEqual(len(findings), 1)
|
||||
self.assertEqual(findings[0].severity, WARNING)
|
||||
self.assertIn("lane-sharing", findings[0].detail)
|
||||
|
||||
def test_reduced_speed_only_is_info(self):
|
||||
ctrls, link = self._with_link(3, "4", 4, "4") # Gen3 x4 but supports Gen4
|
||||
with ctrls, link:
|
||||
findings = check_pcie_links()
|
||||
self.assertEqual(len(findings), 1)
|
||||
self.assertEqual(findings[0].severity, INFO)
|
||||
|
||||
def test_full_speed_no_finding(self):
|
||||
ctrls, link = self._with_link(4, "4", 4, "4")
|
||||
with ctrls, link:
|
||||
self.assertEqual(check_pcie_links(), [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"""Tests for the M5 system inventory (render + dict round-trip; collect on real system)."""
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from rigdoctor.core import inventory
|
||||
from rigdoctor.core.inventory import Section
|
||||
@@ -26,5 +28,31 @@ class InventoryTests(unittest.TestCase):
|
||||
self.assertIn("- **Model:** Test CPU", md)
|
||||
|
||||
|
||||
class PcieLinkTests(unittest.TestCase):
|
||||
def test_gen_mapping(self):
|
||||
self.assertEqual(inventory._gen("16.0 GT/s PCIe"), 4)
|
||||
self.assertEqual(inventory._gen("8.0 GT/s PCIe"), 3)
|
||||
self.assertIsNone(inventory._gen(""))
|
||||
|
||||
def _fake_dev(self, cur_s, cur_w, max_s, max_w) -> Path:
|
||||
d = Path(tempfile.mkdtemp())
|
||||
(d / "current_link_speed").write_text(cur_s)
|
||||
(d / "current_link_width").write_text(cur_w)
|
||||
(d / "max_link_speed").write_text(max_s)
|
||||
(d / "max_link_width").write_text(max_w)
|
||||
return d
|
||||
|
||||
def test_link_at_full_speed(self):
|
||||
dev = self._fake_dev("16.0 GT/s PCIe", "4", "16.0 GT/s PCIe", "4")
|
||||
self.assertEqual(inventory._link_desc(dev), "PCIe Gen4 x4")
|
||||
|
||||
def test_link_downtrained_flags_capability(self):
|
||||
dev = self._fake_dev("8.0 GT/s PCIe", "4", "16.0 GT/s PCIe", "4")
|
||||
self.assertEqual(inventory._link_desc(dev), "PCIe Gen3 x4 (capable of Gen4 x4)")
|
||||
|
||||
def test_non_nvme_has_no_link(self):
|
||||
self.assertEqual(inventory._nvme_link("sda"), "")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user