interface-test-kit 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,243 @@
1
+ """USB check with labelled test sticks: count, port, negotiated speed, write/read and throughput."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import random
8
+ import stat
9
+ from dataclasses import dataclass
10
+ from functools import lru_cache
11
+ from pathlib import Path
12
+
13
+ from ..model import Attempt, Measurement
14
+ from . import CheckType, fields, register
15
+
16
+ BLOCK = 1 << 20
17
+ TEST_FILE = "itk-test.bin"
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class UsbConfig:
22
+ name: str
23
+ count: int
24
+ label: str = "ITKTEST"
25
+ size_mb: int = 64
26
+ min_write_mbs: float | None = None
27
+ min_read_mbs: float | None = None
28
+ min_speed_mbps: int | None = None
29
+ ports: tuple[str, ...] = ()
30
+
31
+
32
+ @dataclass
33
+ class Stick:
34
+ device: str
35
+ label: str
36
+ mountpoint: str | None
37
+ usb_port: str | None
38
+ speed_mbps: int | None
39
+
40
+
41
+ def _positive(value, name: str, whole: bool = False):
42
+ ok_type = int if whole else (int, float)
43
+ if value is None:
44
+ return None
45
+ if isinstance(value, bool) or not isinstance(value, ok_type) or value <= 0:
46
+ raise ValueError(f"{name}: must be a positive {'whole ' if whole else ''}number")
47
+ return value
48
+
49
+
50
+ def parse(raw: dict) -> UsbConfig:
51
+ f = fields(raw, {"count"}, {"label": "ITKTEST", "size_mb": 64, "min_write_mbs": None,
52
+ "min_read_mbs": None, "min_speed_mbps": None, "ports": []})
53
+ _positive(f["count"], "count", whole=True)
54
+ _positive(f["size_mb"], "size_mb", whole=True)
55
+ for key in ("min_write_mbs", "min_read_mbs"):
56
+ _positive(f[key], key)
57
+ _positive(f["min_speed_mbps"], "min_speed_mbps", whole=True)
58
+ if not isinstance(f["label"], str) or not f["label"]:
59
+ raise ValueError("label: must be a non-empty text")
60
+ if not isinstance(f["ports"], list):
61
+ raise ValueError("ports: must be a list of USB port names like 1-2")
62
+ return UsbConfig(str(f["name"]), f["count"], f["label"], f["size_mb"], f["min_write_mbs"],
63
+ f["min_read_mbs"], f["min_speed_mbps"], tuple(str(p) for p in f["ports"]))
64
+
65
+
66
+ def _usb_device(ctx, kname: str) -> tuple[str | None, int | None]:
67
+ """Walk up from the block device in sysfs to the USB device (the dir with speed and devnum)."""
68
+ sysfs = ctx.path("/sys").resolve()
69
+ try:
70
+ p = ctx.path(f"/sys/class/block/{kname}").resolve(strict=True)
71
+ except OSError:
72
+ return None, None
73
+ while p != sysfs and p != p.parent:
74
+ if (p / "speed").is_file() and (p / "devnum").is_file():
75
+ try:
76
+ return p.name, int(float((p / "speed").read_text().strip()))
77
+ except (OSError, ValueError):
78
+ return p.name, None
79
+ p = p.parent
80
+ return None, None
81
+
82
+
83
+ def _walk(nodes: list[dict], tran: str | None = None):
84
+ for node in nodes:
85
+ t = node.get("tran") or tran
86
+ yield node, t
87
+ yield from _walk(node.get("children") or [], t)
88
+
89
+
90
+ def find_sticks(cfg: UsbConfig, ctx, ignored: list[str] | None = None) -> list[Stick]:
91
+ result = ctx.run(["lsblk", "-J", "-o", "NAME,KNAME,PATH,LABEL,MOUNTPOINT,TRAN"])
92
+ if result.returncode != 0:
93
+ raise RuntimeError(f"lsblk failed: {result.stderr.strip()}")
94
+ sticks = []
95
+ for node, tran in _walk(json.loads(result.stdout).get("blockdevices", [])):
96
+ label = node.get("label")
97
+ if tran != "usb" or not label:
98
+ continue
99
+ if not label.startswith(cfg.label):
100
+ if ignored is not None:
101
+ ignored.append(f"ignored: {node.get('path')} label {label}")
102
+ continue
103
+ port, speed = _usb_device(ctx, node["kname"])
104
+ sticks.append(Stick(node["path"], label, node.get("mountpoint"), port, speed))
105
+ return sorted(sticks, key=lambda s: s.device)
106
+
107
+
108
+ @lru_cache(maxsize=1)
109
+ def _payload() -> bytes:
110
+ return random.Random(0).randbytes(BLOCK - 8)
111
+
112
+
113
+ def _block(i: int) -> bytes:
114
+ return i.to_bytes(8, "big") + _payload()
115
+
116
+
117
+ def _same_device(ctx, stick: Stick) -> bool:
118
+ """True if the mountpoint really is the stick's filesystem right now."""
119
+ mountpoint = ctx.path(stick.mountpoint)
120
+ device = ctx.path(stick.device)
121
+ if not device.exists():
122
+ # Only the simulated worlds under a fake root have no device nodes.
123
+ return Path(ctx.root) != Path("/")
124
+ try:
125
+ return (stat.S_ISBLK(os.stat(device).st_mode) and os.path.ismount(mountpoint)
126
+ and os.stat(mountpoint).st_dev == os.stat(device).st_rdev)
127
+ except OSError:
128
+ return False
129
+
130
+
131
+ def _still_mounted(ctx, cfg: UsbConfig, stick: Stick) -> bool:
132
+ """Ask lsblk again right before writing: the stick may have been pulled meanwhile."""
133
+ try:
134
+ now = {s.device: s.mountpoint for s in find_sticks(cfg, ctx)}
135
+ except (RuntimeError, ValueError):
136
+ return False
137
+ return now.get(stick.device) == stick.mountpoint and _same_device(ctx, stick)
138
+
139
+
140
+ def _write_file(path: Path, size_mb: int, clock) -> float:
141
+ if os.path.lexists(path): # never follow a link someone left on the stick
142
+ os.unlink(path)
143
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o644)
144
+ try:
145
+ t0 = clock()
146
+ for i in range(size_mb):
147
+ os.write(fd, _block(i))
148
+ os.fsync(fd)
149
+ return clock() - t0
150
+ finally:
151
+ os.close(fd)
152
+
153
+
154
+ def _read_back(path: Path, size_mb: int, clock) -> tuple[float, int | None]:
155
+ """Returns (seconds, index of the first bad block or None)."""
156
+ fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW)
157
+ try:
158
+ os.posix_fadvise(fd, 0, 0, os.POSIX_FADV_DONTNEED)
159
+ bad = None
160
+ t0 = clock()
161
+ for i in range(size_mb):
162
+ data = os.read(fd, BLOCK)
163
+ if bad is None and data != _block(i):
164
+ bad = i
165
+ return clock() - t0, bad
166
+ finally:
167
+ os.close(fd)
168
+
169
+
170
+ def _test_stick(stick: Stick, cfg: UsbConfig, ctx, details: list[str]) -> tuple[str | None, list[Measurement]]:
171
+ """Returns (unit-mode problem or None, measurements). Never writes outside the mountpoint."""
172
+ where = stick.usb_port or stick.device
173
+ if not _still_mounted(ctx, cfg, stick):
174
+ details.append(f"{stick.device}: not mounted at {stick.mountpoint} any more")
175
+ return f"USB: test stick in port {where} is not mounted", []
176
+ path = ctx.path(stick.mountpoint) / TEST_FILE
177
+ measurements = []
178
+ try:
179
+ try:
180
+ t_write = _write_file(path, cfg.size_mb, ctx.clock)
181
+ except OSError as e:
182
+ details.append(f"{stick.device}: write failed: {e}")
183
+ return f"USB: could not write to the test stick ({where}): full or write-protected", []
184
+ t_read, bad = _read_back(path, cfg.size_mb, ctx.clock)
185
+ finally:
186
+ try:
187
+ path.unlink()
188
+ except OSError:
189
+ pass
190
+ write_mbs = round(cfg.size_mb / max(t_write, 1e-6), 1)
191
+ read_mbs = round(cfg.size_mb / max(t_read, 1e-6), 1)
192
+ measurements.append(Measurement(f"{where} write", write_mbs, "MB/s", low=cfg.min_write_mbs))
193
+ measurements.append(Measurement(f"{where} read", read_mbs, "MB/s", low=cfg.min_read_mbs))
194
+ if stick.speed_mbps is not None:
195
+ measurements.append(Measurement(f"{where} USB speed", stick.speed_mbps, "Mbit/s",
196
+ low=cfg.min_speed_mbps))
197
+ details.append(f"{stick.device} ({stick.label}) on {where}: write {write_mbs} MB/s, "
198
+ f"read {read_mbs} MB/s, USB {stick.speed_mbps} Mbit/s")
199
+ if bad is not None:
200
+ details.append(f"{stick.device}: block {bad} read back wrong")
201
+ return f"USB: stick data corrupted ({where})", measurements
202
+ if any(not m.passed for m in measurements[:2]):
203
+ return f"USB: port {where} too slow", measurements
204
+ if len(measurements) > 2 and not measurements[2].passed:
205
+ return (f"USB: port {where} runs at {stick.speed_mbps} Mbit/s, "
206
+ f"expected at least {cfg.min_speed_mbps}"), measurements
207
+ return None, measurements
208
+
209
+
210
+ def run(cfg: UsbConfig, ctx, ui) -> Attempt:
211
+ details: list[str] = []
212
+ try:
213
+ sticks = find_sticks(cfg, ctx, details)
214
+ except (RuntimeError, ValueError) as e:
215
+ return Attempt.fail("USB: could not look for test sticks. Tell the test engineer.",
216
+ details=[str(e)])
217
+ if len(sticks) != cfg.count:
218
+ details.append("found: " + (", ".join(f"{s.device} on {s.usb_port}" for s in sticks) or "none"))
219
+ return Attempt.fail(f"USB: found {len(sticks)} test stick(s), expected {cfg.count}. "
220
+ f"Insert the test sticks labelled {cfg.label}.", details=details)
221
+ ports = {s.usb_port for s in sticks}
222
+ for port in cfg.ports:
223
+ if port not in ports:
224
+ return Attempt.fail(f"USB: no test stick in port {port}", details=details)
225
+ measurements: list[Measurement] = []
226
+ problems: list[tuple[str, Stick]] = []
227
+ for stick in sticks:
228
+ if not stick.mountpoint:
229
+ problems.append((f"USB: test stick in port {stick.usb_port or stick.device} is not mounted", stick))
230
+ continue
231
+ problem, ms = _test_stick(stick, cfg, ctx, details)
232
+ measurements += ms
233
+ if problem:
234
+ problems.append((problem, stick))
235
+ if problems:
236
+ unit, stick = problems[0]
237
+ stick_fault = "not mounted" not in unit
238
+ message = ctx.say(unit, f"Test stick {stick.device} faulty or too slow") if stick_fault else unit
239
+ return Attempt.fail(message, details=details, measurements=measurements)
240
+ return Attempt.ok(measurements=measurements, details=details)
241
+
242
+
243
+ register("usb", CheckType(parse=parse, run=run))
@@ -0,0 +1,130 @@
1
+ """Command line interface: ``itk run``, ``itk selftest``, ``itk demo`` and helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ from pathlib import Path
7
+
8
+ from . import __version__
9
+ from .profile import ProfileError, load_profile
10
+ from .report import report_reachable, sync_spool
11
+ from .station import load_station, station_path
12
+ from .ui import ConsoleUI
13
+
14
+ _EXIT = {"PASS": 0, "FAIL": 1, "ABORTED": 2}
15
+
16
+
17
+ def _check_profile(files: list[str]) -> int:
18
+ rc = 0
19
+ for name in files:
20
+ try:
21
+ p = load_profile(name)
22
+ except (ProfileError, OSError) as e:
23
+ print(f"ERROR {name}: {e}")
24
+ rc = 2
25
+ else:
26
+ print(f"OK {name} {p.name} v{p.version}, {len(p.steps)} steps")
27
+ return rc
28
+
29
+
30
+ def _sync(station_arg: str | None) -> int:
31
+ try:
32
+ station = load_station(station_path(station_arg))
33
+ except (ValueError, OSError) as e:
34
+ print(f"Error: {e}")
35
+ return 2
36
+ if not report_reachable(station):
37
+ print(f"Network drive not reachable: {station.report_dir}")
38
+ return 2
39
+ for path in sync_spool(station):
40
+ print(f"Uploaded {path}")
41
+ return 0
42
+
43
+
44
+ def _session(args, mode: str) -> int:
45
+ from .session import real_context, run_session
46
+ try:
47
+ station = load_station(station_path(args.station))
48
+ only = [s.strip() for s in args.only.split(",") if s.strip()] if getattr(args, "only", None) else None
49
+ run = run_session(station, real_context(mode), ConsoleUI(),
50
+ rma=getattr(args, "rma", None), only=only)
51
+ except KeyboardInterrupt:
52
+ print("\nAborted.")
53
+ return 2
54
+ except (ValueError, ProfileError, OSError) as e:
55
+ print(f"Error: {e}")
56
+ return 2
57
+ return _EXIT[run.status]
58
+
59
+
60
+ def _demo(args) -> int:
61
+ import tempfile
62
+ from importlib import resources
63
+
64
+ from .runner import RETRY_PROMPT
65
+ from .session import run_session
66
+ from .sim.world import FAULTS, SERIAL_NUMBER, AutoUI, build_world
67
+ from .station import Station
68
+
69
+ if args.list_faults:
70
+ for name, text in FAULTS.items():
71
+ print(f" {name:<16} {text}")
72
+ return 0
73
+ fault = None if args.fault == "none" else args.fault
74
+ profile = load_profile(Path(str(resources.files("interface_test_kit"))) / "profiles" / "demo.yaml")
75
+ with tempfile.TemporaryDirectory(prefix="itk-demo-") as tmp:
76
+ root = Path(tmp)
77
+ out = Path(args.out)
78
+ out.mkdir(parents=True, exist_ok=True)
79
+ station = Station(report_dir=out, spool_dir=root / "spool", profile_dir=root, name="demo")
80
+ no_to = (RETRY_PROMPT,) + (("power LED",) if fault == "led_off" else ())
81
+ print(f"Simulated unit, fault: {args.fault}\n")
82
+ run = run_session(station, build_world(fault, root), AutoUI(SERIAL_NUMBER, no_to),
83
+ profiles=[profile])
84
+ return _EXIT[run.status]
85
+
86
+
87
+ def build_parser() -> argparse.ArgumentParser:
88
+ parser = argparse.ArgumentParser(prog="itk", description=__doc__)
89
+ parser.add_argument("--version", action="version", version=f"itk {__version__}")
90
+ sub = parser.add_subparsers(dest="cmd")
91
+
92
+ p_check = sub.add_parser("check-profile", help="validate profile files")
93
+ p_check.add_argument("files", nargs="+", metavar="FILE")
94
+
95
+ station_help = "station file (default: $ITK_STATION or /etc/itk/station.yaml)"
96
+ p_run = sub.add_parser("run", help="test a unit")
97
+ p_run.add_argument("--station", help=station_help)
98
+ p_run.add_argument("--rma", help="RMA number, stored in the report")
99
+ p_run.add_argument("--only", help="comma-separated step ids to run, e.g. serial-1,usb")
100
+
101
+ p_self = sub.add_parser("selftest", help="check the test equipment on a reference unit")
102
+ p_self.add_argument("--station", help=station_help)
103
+
104
+ from .sim.world import FAULTS
105
+ p_demo = sub.add_parser("demo", help="run a full session against a simulated unit")
106
+ p_demo.add_argument("--fault", default="none", choices=["none", *FAULTS], metavar="FAULT",
107
+ help="inject a fault (see --list-faults)")
108
+ p_demo.add_argument("--list-faults", action="store_true", help="list the faults and exit")
109
+ p_demo.add_argument("--out", default="reports", help="report folder (default: reports)")
110
+
111
+ p_sync = sub.add_parser("sync", help="upload reports saved locally while the network drive was down")
112
+ p_sync.add_argument("--station", help=station_help)
113
+ return parser
114
+
115
+
116
+ def main(argv: list[str] | None = None) -> int:
117
+ parser = build_parser()
118
+ args = parser.parse_args(argv)
119
+ if args.cmd == "check-profile":
120
+ return _check_profile(args.files)
121
+ if args.cmd == "run":
122
+ return _session(args, "unit")
123
+ if args.cmd == "selftest":
124
+ return _session(args, "selftest")
125
+ if args.cmd == "demo":
126
+ return _demo(args)
127
+ if args.cmd == "sync":
128
+ return _sync(args.station)
129
+ parser.print_help()
130
+ return 2
@@ -0,0 +1,43 @@
1
+ """Everything a check needs from the outside world, so tests can swap it out."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ import time
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import Any, Callable
10
+
11
+
12
+ @dataclass
13
+ class CommandResult:
14
+ returncode: int
15
+ stdout: str
16
+ stderr: str
17
+
18
+
19
+ def run_command(args: list[str], timeout: float = 30) -> CommandResult:
20
+ try:
21
+ p = subprocess.run(args, capture_output=True, text=True, timeout=timeout)
22
+ except FileNotFoundError:
23
+ return CommandResult(127, "", f"command not found: {args[0]}")
24
+ except subprocess.TimeoutExpired:
25
+ return CommandResult(124, "", f"timed out after {timeout} s: {' '.join(args)}")
26
+ return CommandResult(p.returncode, p.stdout, p.stderr)
27
+
28
+
29
+ @dataclass
30
+ class Context:
31
+ root: Path = Path("/")
32
+ run: Callable[[list[str]], CommandResult] = run_command
33
+ open_serial: Callable[[str, int], Any] | None = None
34
+ open_can: Callable[[str], Any] | None = None
35
+ clock: Callable[[], float] = time.monotonic
36
+ sleep: Callable[[float], None] = time.sleep
37
+ mode: str = "unit"
38
+
39
+ def path(self, p: str) -> Path:
40
+ return Path(self.root) / p.lstrip("/")
41
+
42
+ def say(self, unit: str, equipment: str) -> str:
43
+ return equipment if self.mode == "selftest" else unit
@@ -0,0 +1,22 @@
1
+ """Pick the profile that matches this device's DMI product name or device-tree model."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .context import Context
6
+ from .profile import Profile
7
+
8
+ _SOURCES = {"dmi_product_name": "/sys/class/dmi/id/product_name",
9
+ "devicetree_model": "/proc/device-tree/model"}
10
+
11
+
12
+ def _read(ctx: Context, path: str) -> str | None:
13
+ try:
14
+ return ctx.path(path).read_bytes().decode(errors="replace").strip().rstrip("\0").strip()
15
+ except OSError:
16
+ return None
17
+
18
+
19
+ def detect_profiles(profiles: list[Profile], ctx: Context) -> list[Profile]:
20
+ values = {key: _read(ctx, path) for key, path in _SOURCES.items()}
21
+ return [p for p in profiles
22
+ if p.detect and all(values.get(k) == v for k, v in p.detect.items())]
@@ -0,0 +1,108 @@
1
+ """Result data: measurements, attempts, step results and a whole run."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from datetime import datetime
7
+
8
+
9
+ @dataclass
10
+ class Measurement:
11
+ name: str
12
+ value: float
13
+ unit: str = ""
14
+ low: float | None = None
15
+ high: float | None = None
16
+
17
+ @property
18
+ def passed(self) -> bool:
19
+ if self.low is not None and self.value < self.low:
20
+ return False
21
+ if self.high is not None and self.value > self.high:
22
+ return False
23
+ return True
24
+
25
+ def to_dict(self) -> dict:
26
+ return {"name": self.name, "value": self.value, "unit": self.unit,
27
+ "low": self.low, "high": self.high, "passed": self.passed}
28
+
29
+
30
+ @dataclass
31
+ class Attempt:
32
+ status: str
33
+ message: str = ""
34
+ details: list[str] = field(default_factory=list)
35
+ measurements: list[Measurement] = field(default_factory=list)
36
+
37
+ @classmethod
38
+ def ok(cls, message: str = "", measurements: list[Measurement] | None = None,
39
+ details: list[str] | None = None) -> Attempt:
40
+ return cls("PASS", message, list(details or []), list(measurements or []))
41
+
42
+ @classmethod
43
+ def fail(cls, message: str, details: list[str] | None = None,
44
+ measurements: list[Measurement] | None = None) -> Attempt:
45
+ return cls("FAIL", message, list(details or []), list(measurements or []))
46
+
47
+ def to_dict(self) -> dict:
48
+ return {"status": self.status, "message": self.message, "details": list(self.details),
49
+ "measurements": [m.to_dict() for m in self.measurements]}
50
+
51
+
52
+ @dataclass
53
+ class StepResult:
54
+ id: str
55
+ name: str
56
+ type: str
57
+ status: str
58
+ attempts: list[Attempt]
59
+ equipment_id: str | None = None
60
+
61
+ @property
62
+ def message(self) -> str:
63
+ if not self.attempts:
64
+ return ""
65
+ last = self.attempts[-1]
66
+ if self.status == "FAIL" and last.status == "PASS":
67
+ failed = next(a for a in reversed(self.attempts) if a.status == "FAIL")
68
+ return f"{failed.message} (not stable: failed again on retry)"
69
+ return last.message
70
+
71
+ @property
72
+ def passed_after_retry(self) -> bool:
73
+ return self.status == "PASS" and len(self.attempts) > 1
74
+
75
+ def to_dict(self) -> dict:
76
+ return {"id": self.id, "name": self.name, "type": self.type, "status": self.status,
77
+ "message": self.message, "passed_after_retry": self.passed_after_retry,
78
+ "equipment_id": self.equipment_id,
79
+ "attempts": [a.to_dict() for a in self.attempts]}
80
+
81
+
82
+ def _iso(t: datetime | None) -> str | None:
83
+ return t.isoformat() if t is not None else None
84
+
85
+
86
+ @dataclass
87
+ class RunResult:
88
+ mode: str
89
+ serial_number: str
90
+ rma: str | None
91
+ profile_name: str
92
+ profile_version: int
93
+ station: str
94
+ operator: str | None
95
+ started: datetime
96
+ finished: datetime | None
97
+ tool_version: str
98
+ steps: list[StepResult]
99
+ status: str
100
+ only: list[str] | None = None
101
+
102
+ def to_dict(self) -> dict:
103
+ return {"mode": self.mode, "serial_number": self.serial_number, "rma": self.rma,
104
+ "profile": {"name": self.profile_name, "version": self.profile_version},
105
+ "station": self.station, "operator": self.operator,
106
+ "started": _iso(self.started), "finished": _iso(self.finished),
107
+ "tool_version": self.tool_version, "status": self.status, "only": self.only,
108
+ "steps": [s.to_dict() for s in self.steps]}