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:
@@ -30,6 +30,21 @@ $ rdm-card-reader
|
|||||||
(the reader library de-duplicates repeated reads while the card sits on the
|
(the reader library de-duplicates repeated reads while the card sits on the
|
||||||
antenna) and once on removal.
|
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
|
## 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 |
|
| `--verbose-ports` | off | With `--list-ports`, show by-id / by-path detail |
|
||||||
| `--demo` | – | Simulated reads; needs no hardware and no `rdm6300` |
|
| `--demo` | – | Simulated reads; needs no hardware and no `rdm6300` |
|
||||||
| `--demo-count` | `3` | Number of simulated reads in `--demo` |
|
| `--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 |
|
| `--version` | – | Print the version |
|
||||||
|
|
||||||
With `--serve`: `--host` (`127.0.0.1`), `--bridge-port` (`8765`), `--mode`
|
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"}
|
{"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
|
### Exit codes
|
||||||
|
|
||||||
| Code | Meaning |
|
| Code | Meaning |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `0` | Ran normally, or stopped with Ctrl+C |
|
| `0` | Ran normally, or stopped with Ctrl+C |
|
||||||
| `1` | No serial port could be opened |
|
| `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) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -18,8 +18,10 @@ Group=dialout
|
|||||||
WorkingDirectory=/opt/rdm-card-reader
|
WorkingDirectory=/opt/rdm-card-reader
|
||||||
|
|
||||||
# --serve exposes the card on http://127.0.0.1:8765/api/card
|
# --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
|
# 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.
|
# 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 \
|
ExecStart=/opt/rdm-card-reader/.venv/bin/rdm-card-reader \
|
||||||
--serve \
|
--serve \
|
||||||
--port /dev/rdm-reader \
|
--port /dev/rdm-reader \
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "rdm-card-reader"
|
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."
|
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"
|
readme = "README.md"
|
||||||
requires-python = ">=3.8"
|
requires-python = ">=3.8"
|
||||||
|
|||||||
@@ -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
|
The bridge reports card ids only. Deciding which user a card belongs to is the
|
||||||
server's job - see ``bridge`` for the reasoning.
|
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 (
|
from .card import (
|
||||||
CANDIDATE_PORTS,
|
CANDIDATE_PORTS,
|
||||||
CONFIG_CARD_VALUE,
|
CONFIG_CARD_VALUE,
|
||||||
@@ -26,9 +30,9 @@ from .ports import (
|
|||||||
discover,
|
discover,
|
||||||
resolve,
|
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__ = [
|
__all__ = [
|
||||||
"CANDIDATE_PORTS",
|
"CANDIDATE_PORTS",
|
||||||
@@ -43,9 +47,12 @@ __all__ = [
|
|||||||
"available",
|
"available",
|
||||||
"create_server",
|
"create_server",
|
||||||
"discover",
|
"discover",
|
||||||
|
"ensure_dependencies",
|
||||||
"format_event",
|
"format_event",
|
||||||
"list_ports",
|
"list_ports",
|
||||||
|
"missing_packages",
|
||||||
"open_reader",
|
"open_reader",
|
||||||
|
"reload_library",
|
||||||
"resolve",
|
"resolve",
|
||||||
"__version__",
|
"__version__",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -8,6 +8,7 @@ import threading
|
|||||||
from typing import Optional, Sequence
|
from typing import Optional, Sequence
|
||||||
|
|
||||||
from . import __version__
|
from . import __version__
|
||||||
|
from .bootstrap import ensure_dependencies
|
||||||
from .bridge import (
|
from .bridge import (
|
||||||
DEFAULT_HOST,
|
DEFAULT_HOST,
|
||||||
DEFAULT_PORT,
|
DEFAULT_PORT,
|
||||||
@@ -26,7 +27,7 @@ from .card import (
|
|||||||
)
|
)
|
||||||
from .demo import run_demo
|
from .demo import run_demo
|
||||||
from .ports import AmbiguousPort, PortNotFound, discover, resolve
|
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_OK = 0
|
||||||
EXIT_NO_PORT = 1
|
EXIT_NO_PORT = 1
|
||||||
@@ -94,12 +95,40 @@ def _resolve_port(args) -> str:
|
|||||||
return device
|
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:
|
def _run_bridge(args) -> int:
|
||||||
"""Serve what the reader sees over HTTP for a web app to consume."""
|
"""Serve what the reader sees over HTTP for a web app to consume."""
|
||||||
if not available():
|
if not _ensure_library(args):
|
||||||
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)
|
|
||||||
return EXIT_NO_LIBRARY
|
return EXIT_NO_LIBRARY
|
||||||
|
|
||||||
if not args.allow_remote and args.host not in ("127.0.0.1", "::1", "localhost"):
|
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)")
|
help="simulate card reads (needs no hardware or library)")
|
||||||
parser.add_argument("--demo-count", type=int, default=3,
|
parser.add_argument("--demo-count", type=int, default=3,
|
||||||
help="number of simulated reads in --demo (default: %(default)s)")
|
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 = parser.add_argument_group("HTTP bridge (--serve)")
|
||||||
bridge.add_argument("--serve", action="store_true",
|
bridge.add_argument("--serve", action="store_true",
|
||||||
@@ -236,10 +270,7 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
|
|||||||
if args.serve:
|
if args.serve:
|
||||||
return _run_bridge(args)
|
return _run_bridge(args)
|
||||||
|
|
||||||
if not available():
|
if not _ensure_library(args):
|
||||||
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)
|
|
||||||
return EXIT_NO_LIBRARY
|
return EXIT_NO_LIBRARY
|
||||||
|
|
||||||
print_event = _make_printer(args.json)
|
print_event = _make_printer(args.json)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ that ``--demo`` and the unit tests keep working without it installed.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib
|
||||||
import os
|
import os
|
||||||
from typing import Callable, Dict, List, Optional, Sequence
|
from typing import Callable, Dict, List, Optional, Sequence
|
||||||
|
|
||||||
@@ -29,6 +30,23 @@ def available() -> bool:
|
|||||||
return rdm6300 is not None
|
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):
|
def _make_reader_class(callback: EventCallback):
|
||||||
"""Build a ``BaseReader`` subclass that reports events via *callback*."""
|
"""Build a ``BaseReader`` subclass that reports events via *callback*."""
|
||||||
if rdm6300 is None:
|
if rdm6300 is None:
|
||||||
|
|||||||
@@ -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"
|
||||||
Reference in New Issue
Block a user