031cf0f25a
The workstation app lit an LED on GPIO 23 with the card. This brings that
across, with three deliberate differences: it never blocks the reader, it
cannot be fatal, and its state is reported.
WMT toggled the LED directly inside card_inserted, on the reader thread, where
a slow write stalls card detection. Here every change is queued to a worker, so
insert() returns immediately (tested with a deliberately slow pin).
Two modes, because the light can mean two things:
error (default) lit while the reader runs, dark while reading, blinks on a
bad read - a health indicator
presence lit only while a card is on the reader
gpiozero raises BadPinFactory on a machine with no GPIO, and that subclasses
ImportError - so a single except ImportError reports 'gpiozero is not
installed' when the library is present but has no usable pin provider. The
library check and the pin construction are therefore separate.
Vendors gpiozero + colorzero (~180 KB) so the LED installs offline too, and
deliberately not setuptools: gpiozero declares it via colorzero, but it is only
a packaging requirement and is never imported at runtime. Installing to the
bundled wheels therefore uses --no-deps, saving 818 KB.
Verified end to end over a pseudo-terminal with a fake pin: a real RDM6300
frame drives the LED through the app's own CLI in both modes.
202 lines
6.6 KiB
Python
202 lines
6.6 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
|
|
import tempfile
|
|
|
|
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, no_deps=False):
|
|
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):
|
|
"""An explicit --wheel-dir that covers everything is used as given."""
|
|
seen = {}
|
|
monkeypatch.setattr(
|
|
bootstrap, "missing_packages",
|
|
lambda packages=None: [] if seen.get("done") else [("x", "x>=1")],
|
|
)
|
|
|
|
def fake_install(requirements, wheel_dir=None, logger=None, timeout=300, no_deps=False):
|
|
seen["wheel_dir"] = wheel_dir
|
|
seen["done"] = True
|
|
return True
|
|
|
|
monkeypatch.setattr(bootstrap, "install", fake_install)
|
|
|
|
with tempfile.TemporaryDirectory() as folder:
|
|
open(os.path.join(folder, "x-1.0-py3-none-any.whl"), "wb").close()
|
|
ensure_dependencies(wheel_dir=folder)
|
|
assert seen["wheel_dir"] == folder
|
|
|
|
|
|
def test_incomplete_explicit_wheel_dir_is_flagged(monkeypatch):
|
|
"""An unusable --wheel-dir should be called out, not silently ignored."""
|
|
seen = {}
|
|
monkeypatch.setattr(
|
|
bootstrap, "missing_packages",
|
|
lambda packages=None: [] if seen.get("done") else [("x", "x>=1")],
|
|
)
|
|
|
|
def fake_install(requirements, wheel_dir=None, logger=None, timeout=300, no_deps=False):
|
|
seen["done"] = True
|
|
return True
|
|
|
|
monkeypatch.setattr(bootstrap, "install", fake_install)
|
|
lines = []
|
|
with tempfile.TemporaryDirectory() as folder:
|
|
ensure_dependencies(wheel_dir=folder, logger=lines.append)
|
|
assert any("you gave is incomplete" in line for line in lines)
|