eaf3f394fc
A freshly flashed device needed manual pip work before the reader would start. It now checks its dependencies, installs whatever is missing, and carries on. - bootstrap.py: import check, pip invocation, and a re-check that does not trust pip's exit code alone. Installs nothing when the libraries are already present, so a normal start does no network work. - --wheel-dir installs from local wheels with --no-index, for devices with no internet. --no-install-deps opts out entirely. - Outside a venv pip gets --break-system-packages, which Debian and Raspberry Pi OS require before touching the system Python. - reload_library() re-imports rdm6300 when it was installed at runtime, since the module-level import already ran and failed. - A failed install reports exit 2 and the exact manual command rather than raising; stdout is flushed so the explanation is not printed after the error when piped.
178 lines
5.7 KiB
Python
178 lines
5.7 KiB
Python
"""Tests for the dependency bootstrap.
|
|
|
|
The behaviour that matters: a normal start must not touch the network, a
|
|
missing library should be installed once, and a failed install must report
|
|
clearly instead of crashing.
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
|
|
SRC = os.path.join(
|
|
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src"
|
|
)
|
|
if SRC not in sys.path:
|
|
sys.path.insert(0, SRC)
|
|
|
|
import pytest # noqa: E402
|
|
|
|
from rdm_card_reader import bootstrap # noqa: E402
|
|
from rdm_card_reader.bootstrap import ( # noqa: E402
|
|
_pip_command,
|
|
ensure_dependencies,
|
|
install,
|
|
missing_packages,
|
|
)
|
|
|
|
|
|
class FakeCompleted:
|
|
def __init__(self, returncode=0, stdout="", stderr=""):
|
|
self.returncode = returncode
|
|
self.stdout = stdout
|
|
self.stderr = stderr
|
|
|
|
|
|
# -- detection --------------------------------------------------------------
|
|
|
|
def test_nothing_missing_for_installed_packages():
|
|
assert missing_packages([("sys", "sys")]) == []
|
|
|
|
|
|
def test_importable_name_is_reported_missing():
|
|
result = missing_packages([("definitely_not_a_real_module_xyz", "pkg>=1")])
|
|
assert result == [("definitely_not_a_real_module_xyz", "pkg>=1")]
|
|
|
|
|
|
# -- pip command ------------------------------------------------------------
|
|
|
|
def test_pip_command_uses_this_interpreter():
|
|
command = _pip_command(["pkg>=1"], wheel_dir=None)
|
|
assert command[:3] == [sys.executable, "-m", "pip"]
|
|
assert "pkg>=1" in command
|
|
|
|
|
|
def test_pip_command_prefers_local_wheels_when_given():
|
|
command = _pip_command(["pkg>=1"], wheel_dir="/opt/wheels")
|
|
assert "--no-index" in command
|
|
assert "--find-links" in command
|
|
assert "/opt/wheels" in command
|
|
|
|
|
|
def test_pip_command_adds_break_system_packages_outside_venv(monkeypatch):
|
|
"""Debian's pip refuses to touch system Python without this flag."""
|
|
monkeypatch.delenv("VIRTUAL_ENV", raising=False)
|
|
monkeypatch.setattr(bootstrap, "in_virtualenv", lambda: False)
|
|
assert "--break-system-packages" in _pip_command(["pkg"], None)
|
|
|
|
|
|
def test_pip_command_omits_flag_inside_venv(monkeypatch):
|
|
monkeypatch.setattr(bootstrap, "in_virtualenv", lambda: True)
|
|
assert "--break-system-packages" not in _pip_command(["pkg"], None)
|
|
|
|
|
|
def test_in_virtualenv_detects_env_var(monkeypatch):
|
|
monkeypatch.setenv("VIRTUAL_ENV", "/tmp/venv")
|
|
assert bootstrap.in_virtualenv() is True
|
|
|
|
|
|
# -- install ----------------------------------------------------------------
|
|
|
|
def test_install_reports_success(monkeypatch):
|
|
monkeypatch.setattr(
|
|
bootstrap.subprocess, "run", lambda *a, **k: FakeCompleted(0)
|
|
)
|
|
assert install(["pkg"]) is True
|
|
|
|
|
|
def test_install_returns_false_on_nonzero_exit(monkeypatch):
|
|
monkeypatch.setattr(
|
|
bootstrap.subprocess, "run",
|
|
lambda *a, **k: FakeCompleted(1, stderr="ERROR: no matching distribution"),
|
|
)
|
|
lines = []
|
|
assert install(["pkg"], logger=lines.append) is False
|
|
assert any("no matching distribution" in line for line in lines)
|
|
|
|
|
|
def test_install_survives_timeout(monkeypatch):
|
|
def boom(*args, **kwargs):
|
|
raise bootstrap.subprocess.TimeoutExpired(cmd="pip", timeout=1)
|
|
|
|
monkeypatch.setattr(bootstrap.subprocess, "run", boom)
|
|
assert install(["pkg"]) is False
|
|
|
|
|
|
def test_install_survives_pip_being_unrunnable(monkeypatch):
|
|
def boom(*args, **kwargs):
|
|
raise OSError("no such file or directory")
|
|
|
|
monkeypatch.setattr(bootstrap.subprocess, "run", boom)
|
|
assert install(["pkg"]) is False
|
|
|
|
|
|
# -- ensure_dependencies ----------------------------------------------------
|
|
|
|
def test_no_install_when_everything_present(monkeypatch):
|
|
"""A normal start must not shell out to pip at all."""
|
|
called = []
|
|
monkeypatch.setattr(bootstrap, "missing_packages", lambda packages=None: [])
|
|
monkeypatch.setattr(bootstrap, "install", lambda *a, **k: called.append(1))
|
|
assert ensure_dependencies() is True
|
|
assert called == []
|
|
|
|
|
|
def test_installs_when_missing(monkeypatch):
|
|
state = {"installed": False}
|
|
monkeypatch.setattr(
|
|
bootstrap, "missing_packages",
|
|
lambda packages=None: [] if state["installed"] else [("rdm6300", "rdm6300>=0.1.1")],
|
|
)
|
|
|
|
def fake_install(requirements, wheel_dir=None, logger=None, timeout=300):
|
|
state["installed"] = True
|
|
return True
|
|
|
|
monkeypatch.setattr(bootstrap, "install", fake_install)
|
|
assert ensure_dependencies() is True
|
|
|
|
|
|
def test_reports_failure_when_still_missing(monkeypatch):
|
|
monkeypatch.setattr(
|
|
bootstrap, "missing_packages",
|
|
lambda packages=None: [("rdm6300", "rdm6300>=0.1.1")],
|
|
)
|
|
monkeypatch.setattr(bootstrap, "install", lambda *a, **k: False)
|
|
lines = []
|
|
assert ensure_dependencies(logger=lines.append) is False
|
|
assert any("Install manually with" in line for line in lines)
|
|
|
|
|
|
def test_no_install_flag_reports_manual_hint(monkeypatch):
|
|
monkeypatch.setattr(
|
|
bootstrap, "missing_packages",
|
|
lambda packages=None: [("rdm6300", "rdm6300>=0.1.1")],
|
|
)
|
|
called = []
|
|
monkeypatch.setattr(bootstrap, "install", lambda *a, **k: called.append(1))
|
|
lines = []
|
|
assert ensure_dependencies(auto_install=False, logger=lines.append) is False
|
|
assert called == [] # never shelled out
|
|
assert any("disabled" in line for line in lines)
|
|
|
|
|
|
def test_wheel_dir_is_passed_through(monkeypatch):
|
|
seen = {}
|
|
monkeypatch.setattr(
|
|
bootstrap, "missing_packages",
|
|
lambda packages=None: [] if seen.get("done") else [("x", "x")],
|
|
)
|
|
|
|
def fake_install(requirements, wheel_dir=None, logger=None, timeout=300):
|
|
seen["wheel_dir"] = wheel_dir
|
|
seen["done"] = True
|
|
return True
|
|
|
|
monkeypatch.setattr(bootstrap, "install", fake_install)
|
|
ensure_dependencies(wheel_dir="/opt/wheels")
|
|
assert seen["wheel_dir"] == "/opt/wheels"
|