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.
This commit is contained in:
2026-10-01 15:40:56 +03:00
parent 576f22268f
commit eaf3f394fc
8 changed files with 452 additions and 13 deletions
+48 -1
View File
@@ -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) |
---
+2
View File
@@ -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 \
+1 -1
View File
@@ -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"
+9 -2
View File
@@ -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__",
]
+157
View File
@@ -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
+40 -9
View File
@@ -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)
+18
View File
@@ -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:
+177
View File
@@ -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"