From eaf3f394fc5b1fdb64e8fbf9e4886538aa9c9725 Mon Sep 17 00:00:00 2001 From: scheianu Date: Thu, 1 Oct 2026 15:40:56 +0300 Subject: [PATCH] Install missing reader libraries on first start 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. --- README.md | 49 ++++++++- deploy/rdm-card-reader.service | 2 + pyproject.toml | 2 +- src/rdm_card_reader/__init__.py | 11 +- src/rdm_card_reader/bootstrap.py | 157 +++++++++++++++++++++++++++ src/rdm_card_reader/cli.py | 49 +++++++-- src/rdm_card_reader/reader.py | 18 ++++ tests/test_bootstrap.py | 177 +++++++++++++++++++++++++++++++ 8 files changed, 452 insertions(+), 13 deletions(-) create mode 100644 src/rdm_card_reader/bootstrap.py create mode 100644 tests/test_bootstrap.py diff --git a/README.md b/README.md index 7e30a7a..7034a3d 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,21 @@ $ rdm-card-reader (the reader library de-duplicates repeated reads while the card sits on the antenna) and once on removal. +### Startup sequence + +1. **Check dependencies.** If `rdm6300` / `pyserial` cannot be imported, install +them automatically and carry on. A fresh device needs no manual `pip` work. +2. **Find the reader.** Resolve the serial port and open it, identifying the +adapter by serial number or `by-id` rather than trusting `ttyUSB0`. +3. **Start reading.** Read frames in a loop; each valid one becomes a card +event. +4. **Publish it.** In `--serve` mode the event is latched so a web page can +claim it whenever it gets around to asking. + +Steps 1 and 2 both fail *loudly with a reason* rather than starting up +half-working, because a reader that silently picks the wrong port or runs +without its library is worse than one that refuses to start. + --- ## Install @@ -73,6 +88,8 @@ Also works as a module: `python -m rdm_card_reader`. | `--verbose-ports` | off | With `--list-ports`, show by-id / by-path detail | | `--demo` | – | Simulated reads; needs no hardware and no `rdm6300` | | `--demo-count` | `3` | Number of simulated reads in `--demo` | +| `--no-install-deps` | off | Never install missing libraries automatically | +| `--wheel-dir` | – | Install missing libraries from local wheels (offline) | | `--version` | – | Print the version | With `--serve`: `--host` (`127.0.0.1`), `--bridge-port` (`8765`), `--mode` @@ -96,13 +113,43 @@ is what you want when piping into another program: {"event": "card_removed", "timestamp": "2026-10-01 14:56:54", "id": 12886197, "hex": "0x00C4A0B5", "type": 15, "checksum": 222, "is_valid": true, "port": "/dev/ttyAMA0"} ``` +### Automatic dependency install + +On first start the reader checks that it can import its libraries and installs +any that are missing, so a freshly flashed device works without manual setup: + +``` +Reader library not found - checking dependencies... +Missing reader dependencies: rdm6300 +Installing from PyPI ... +Dependencies ready: rdm6300 +``` + +* Nothing is installed when the libraries are already present, so a normal start + does no network work at all. +* A failed install does not crash: it reports `exit 2` and tells you the exact + command to run by hand. +* Outside a virtualenv pip is given `--break-system-packages`, which Debian and + Raspberry Pi OS require before they will touch the system Python. + +For a device with **no internet**, put the wheels next to the app and install +from there — no index is contacted: + +```bash +pip download rdm6300 pyserial -d wheels # on a machine with internet +rdm-card-reader --serve --wheel-dir ./wheels +``` + +To manage dependencies yourself, pass `--no-install-deps`. + ### Exit codes | Code | Meaning | | --- | --- | | `0` | Ran normally, or stopped with Ctrl+C | | `1` | No serial port could be opened | -| `2` | The `rdm6300` library is not installed | +| `2` | The `rdm6300` library is missing and could not be installed | +| `3` | The port selector matched more than one adapter (refused to guess) | --- diff --git a/deploy/rdm-card-reader.service b/deploy/rdm-card-reader.service index 4eb30f5..eda0ddc 100644 --- a/deploy/rdm-card-reader.service +++ b/deploy/rdm-card-reader.service @@ -18,8 +18,10 @@ Group=dialout WorkingDirectory=/opt/rdm-card-reader # --serve exposes the card on http://127.0.0.1:8765/api/card +# --port points at the udev alias, so the reader is the same device every boot. # Autodetect refuses to guess if more than one USB adapter is present, which is # the safe behaviour: a wrong guess means authenticating the wrong person. +# Add --wheel-dir /opt/rdm-card-reader/wheels to install dependencies offline. ExecStart=/opt/rdm-card-reader/.venv/bin/rdm-card-reader \ --serve \ --port /dev/rdm-reader \ diff --git a/pyproject.toml b/pyproject.toml index 8fbc5af..f5de87b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "rdm-card-reader" -version = "0.2.0" +version = "0.3.0" description = "RDM6300 / EM4100 RFID card reader: prints card events, or serves them over HTTP so a web app can authenticate a card tap." readme = "README.md" requires-python = ">=3.8" diff --git a/src/rdm_card_reader/__init__.py b/src/rdm_card_reader/__init__.py index a02ce8f..a6dd172 100644 --- a/src/rdm_card_reader/__init__.py +++ b/src/rdm_card_reader/__init__.py @@ -8,8 +8,12 @@ Two ways to use this package: The bridge reports card ids only. Deciding which user a card belongs to is the server's job - see ``bridge`` for the reasoning. + +On first start the reader installs its own libraries if they are missing, so a +fresh device needs no manual setup - see ``bootstrap``. """ +from .bootstrap import ensure_dependencies, missing_packages from .card import ( CANDIDATE_PORTS, CONFIG_CARD_VALUE, @@ -26,9 +30,9 @@ from .ports import ( discover, resolve, ) -from .reader import available, list_ports, open_reader +from .reader import available, list_ports, open_reader, reload_library -__version__ = "0.2.0" +__version__ = "0.3.0" __all__ = [ "CANDIDATE_PORTS", @@ -43,9 +47,12 @@ __all__ = [ "available", "create_server", "discover", + "ensure_dependencies", "format_event", "list_ports", + "missing_packages", "open_reader", + "reload_library", "resolve", "__version__", ] diff --git a/src/rdm_card_reader/bootstrap.py b/src/rdm_card_reader/bootstrap.py new file mode 100644 index 0000000..1dbc7a5 --- /dev/null +++ b/src/rdm_card_reader/bootstrap.py @@ -0,0 +1,157 @@ +"""Make sure the reader library is present before we try to use it. + +On a fresh device the ``rdm6300`` package may not be installed. Rather than +dying with "please install this yourself", the reader installs it on first run +and then carries on - the same behaviour the workstation app had, but without +shelling out to a bare ``pip`` with no error handling. + +Two things this deliberately does *not* do: + +* It never installs anything when the library is already importable, so a + normal start does no network work at all. +* It never lets a failed install crash the process. A missing library is a + condition to report clearly, not a traceback. +""" + +from __future__ import annotations + +import importlib +import os +import subprocess +import sys +from typing import Callable, List, Optional, Sequence + +#: Import name -> pip requirement. The import name is what we check; the +#: requirement is what pip is asked for. +REQUIRED_PACKAGES = ( + ("rdm6300", "rdm6300>=0.1.1"), + ("serial", "pyserial>=3.5"), +) + +#: Shown when an install fails, so the operator knows what to do by hand. +MANUAL_HINT = ( + "Install manually with: {python} -m pip install {requirements}" +) + + +def missing_packages( + packages: Sequence[tuple] = REQUIRED_PACKAGES, +) -> List[tuple]: + """Return the ``(import_name, requirement)`` pairs that cannot be imported.""" + missing = [] + for import_name, requirement in packages: + try: + importlib.import_module(import_name) + except ImportError: + missing.append((import_name, requirement)) + return missing + + +def in_virtualenv() -> bool: + """True when running inside a venv, where PEP 668 does not apply.""" + if os.environ.get("VIRTUAL_ENV"): + return True + return sys.prefix != getattr(sys, "base_prefix", sys.prefix) + + +def _pip_command(requirements: Sequence[str], wheel_dir: Optional[str]) -> List[str]: + """Build the pip invocation, handling venv vs system Python.""" + command = [sys.executable, "-m", "pip", "install"] + + if wheel_dir: + # Offline install from a local wheel directory: no index, no network. + command += ["--no-index", "--find-links", wheel_dir] + else: + command += ["--disable-pip-version-check"] + + # Outside a virtualenv, pip on Debian/Raspberry Pi OS refuses to touch the + # system environment unless told explicitly (PEP 668). + if not in_virtualenv(): + command.append("--break-system-packages") + + command += list(requirements) + return command + + +def install( + requirements: Sequence[str], + wheel_dir: Optional[str] = None, + logger: Optional[Callable[[str], None]] = None, + timeout: int = 300, +) -> bool: + """Run pip. Returns True on success; never raises for a failed install.""" + say = logger or (lambda message: None) + command = _pip_command(requirements, wheel_dir) + say(" $ {}".format(" ".join(command))) + + try: + result = subprocess.run( + command, + capture_output=True, + text=True, + timeout=timeout, + ) + except subprocess.TimeoutExpired: + say(" x install timed out after {}s".format(timeout)) + return False + except OSError as exc: + say(" x could not run pip: {}".format(exc)) + return False + + if result.returncode != 0: + # pip writes the useful part to stderr; show the tail of it. + tail = (result.stderr or result.stdout or "").strip().splitlines()[-6:] + for line in tail: + say(" | {}".format(line)) + return False + + return True + + +def ensure_dependencies( + packages: Sequence[tuple] = REQUIRED_PACKAGES, + wheel_dir: Optional[str] = None, + auto_install: bool = True, + logger: Optional[Callable[[str], None]] = None, +) -> bool: + """Check for the reader libraries and install them if they are missing. + + Returns True when everything is importable at the end - either because it + already was, or because the install worked. Returns False when something is + still missing, so the caller can decide what to do. + """ + say = logger or (lambda message: None) + + missing = missing_packages(packages) + if not missing: + return True + + requirements = [requirement for _, requirement in missing] + names = ", ".join(name for name, _ in missing) + say("Missing reader dependencies: {}".format(names)) + + if not auto_install: + say("Automatic install is disabled (--no-install-deps).") + say(MANUAL_HINT.format( + python=sys.executable, requirements=" ".join(requirements))) + return False + + if wheel_dir: + say("Installing from local wheels in {} ...".format(wheel_dir)) + else: + say("Installing from PyPI ...") + + install(requirements, wheel_dir=wheel_dir, logger=say) + + # Re-check rather than trusting pip's exit code alone. + still_missing = missing_packages(packages) + if still_missing: + say("Still missing: {}".format( + ", ".join(name for name, _ in still_missing))) + say(MANUAL_HINT.format( + python=sys.executable, requirements=" ".join(requirements))) + return False + + say("Dependencies ready: {}".format( + ", ".join(name for name, _ in missing))) + return True diff --git a/src/rdm_card_reader/cli.py b/src/rdm_card_reader/cli.py index e5dcc6c..d481c3a 100644 --- a/src/rdm_card_reader/cli.py +++ b/src/rdm_card_reader/cli.py @@ -8,6 +8,7 @@ import threading from typing import Optional, Sequence from . import __version__ +from .bootstrap import ensure_dependencies from .bridge import ( DEFAULT_HOST, DEFAULT_PORT, @@ -26,7 +27,7 @@ from .card import ( ) from .demo import run_demo from .ports import AmbiguousPort, PortNotFound, discover, resolve -from .reader import available, explain_error, list_ports, open_reader +from .reader import available, explain_error, list_ports, open_reader, reload_library EXIT_OK = 0 EXIT_NO_PORT = 1 @@ -94,12 +95,40 @@ def _resolve_port(args) -> str: return device +def _ensure_library(args) -> bool: + """Make sure the reader library is importable, installing it if needed. + + A fresh device should not need manual pip work, so this installs on first + run. It is skipped entirely when the library is already present. + """ + if available(): + return True + + print("Reader library not found - checking dependencies...", flush=True) + installed = ensure_dependencies( + wheel_dir=args.wheel_dir, + auto_install=not args.no_install_deps, + logger=print, + ) + if installed: + # The import happened at process start, so refresh it now. + reload_library() + + if available(): + return True + + # Flush stdout first: stderr is unbuffered while stdout is block-buffered + # when piped, so without this the explanation can appear after the error. + sys.stdout.flush() + print("\nERROR: the 'rdm6300' library is not available.", file=sys.stderr) + print(" Retry with network access, or run with --demo to see the" + " output format.", file=sys.stderr) + return False + + def _run_bridge(args) -> int: """Serve what the reader sees over HTTP for a web app to consume.""" - if not available(): - print("ERROR: the 'rdm6300' library is not installed.", file=sys.stderr) - print(" Install it with: pip install rdm6300", file=sys.stderr) - print(" Or run with --demo to see the output format.", file=sys.stderr) + if not _ensure_library(args): return EXIT_NO_LIBRARY if not args.allow_remote and args.host not in ("127.0.0.1", "::1", "localhost"): @@ -197,6 +226,11 @@ def build_parser() -> argparse.ArgumentParser: help="simulate card reads (needs no hardware or library)") parser.add_argument("--demo-count", type=int, default=3, help="number of simulated reads in --demo (default: %(default)s)") + parser.add_argument("--no-install-deps", action="store_true", + help="do not install missing reader libraries automatically") + parser.add_argument("--wheel-dir", metavar="DIR", + help="install missing libraries from local wheels in DIR " + "instead of PyPI (offline install)") bridge = parser.add_argument_group("HTTP bridge (--serve)") bridge.add_argument("--serve", action="store_true", @@ -236,10 +270,7 @@ def main(argv: Optional[Sequence[str]] = None) -> int: if args.serve: return _run_bridge(args) - if not available(): - print("ERROR: the 'rdm6300' library is not installed.", file=sys.stderr) - print(" Install it with: pip install rdm6300", file=sys.stderr) - print(" Or run with --demo to see the output format.", file=sys.stderr) + if not _ensure_library(args): return EXIT_NO_LIBRARY print_event = _make_printer(args.json) diff --git a/src/rdm_card_reader/reader.py b/src/rdm_card_reader/reader.py index 97e8ff1..c10c816 100644 --- a/src/rdm_card_reader/reader.py +++ b/src/rdm_card_reader/reader.py @@ -6,6 +6,7 @@ that ``--demo`` and the unit tests keep working without it installed. from __future__ import annotations +import importlib import os from typing import Callable, Dict, List, Optional, Sequence @@ -29,6 +30,23 @@ def available() -> bool: return rdm6300 is not None +def reload_library() -> bool: + """Re-attempt the import, for when the library was installed at runtime. + + The module-level import above runs once at process start, so installing + afterwards is invisible until this is called. + """ + global rdm6300 + if rdm6300 is not None: + return True + try: + rdm6300 = importlib.import_module("rdm6300") + except ImportError: + rdm6300 = None + return False + return True + + def _make_reader_class(callback: EventCallback): """Build a ``BaseReader`` subclass that reports events via *callback*.""" if rdm6300 is None: diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py new file mode 100644 index 0000000..9a5ece8 --- /dev/null +++ b/tests/test_bootstrap.py @@ -0,0 +1,177 @@ +"""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"