lr-qrm 0.1.2__tar.gz → 0.1.3__tar.gz
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.
- {lr_qrm-0.1.2 → lr_qrm-0.1.3}/PKG-INFO +23 -8
- {lr_qrm-0.1.2 → lr_qrm-0.1.3}/pyproject.toml +1 -1
- {lr_qrm-0.1.2 → lr_qrm-0.1.3}/src/README.md +22 -7
- {lr_qrm-0.1.2 → lr_qrm-0.1.3}/src/lr_qrm.egg-info/PKG-INFO +23 -8
- {lr_qrm-0.1.2 → lr_qrm-0.1.3}/src/qrm/cli.py +145 -16
- lr_qrm-0.1.3/src/qrm/client.py +366 -0
- {lr_qrm-0.1.2 → lr_qrm-0.1.3}/tests/test_cli.py +251 -5
- {lr_qrm-0.1.2 → lr_qrm-0.1.3}/tests/test_client.py +61 -0
- lr_qrm-0.1.2/src/qrm/client.py +0 -183
- {lr_qrm-0.1.2 → lr_qrm-0.1.3}/LICENSE +0 -0
- {lr_qrm-0.1.2 → lr_qrm-0.1.3}/README.md +0 -0
- {lr_qrm-0.1.2 → lr_qrm-0.1.3}/setup.cfg +0 -0
- {lr_qrm-0.1.2 → lr_qrm-0.1.3}/src/lr_qrm.egg-info/SOURCES.txt +0 -0
- {lr_qrm-0.1.2 → lr_qrm-0.1.3}/src/lr_qrm.egg-info/dependency_links.txt +0 -0
- {lr_qrm-0.1.2 → lr_qrm-0.1.3}/src/lr_qrm.egg-info/entry_points.txt +0 -0
- {lr_qrm-0.1.2 → lr_qrm-0.1.3}/src/lr_qrm.egg-info/requires.txt +0 -0
- {lr_qrm-0.1.2 → lr_qrm-0.1.3}/src/lr_qrm.egg-info/top_level.txt +0 -0
- {lr_qrm-0.1.2 → lr_qrm-0.1.3}/src/qrm/__init__.py +0 -0
- {lr_qrm-0.1.2 → lr_qrm-0.1.3}/src/qrm/config.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: lr-qrm
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.3
|
|
4
4
|
Summary: CLI client for interacting with Qestit QRM data from LumenRadio tooling.
|
|
5
5
|
Author-email: Jonas Estberger <jonas.estberger@lumenradio.com>
|
|
6
6
|
License: MIT License
|
|
@@ -66,10 +66,18 @@ qrm status
|
|
|
66
66
|
#### Inspect UUT history
|
|
67
67
|
|
|
68
68
|
```bash
|
|
69
|
-
qrm uut status 326115020010F2F1 --start "2026-02-
|
|
69
|
+
qrm uut status 326115020010F2F1 --start "2026-02-01T00:00:00Z" --stop "2026-02-06T23:59:00Z"
|
|
70
70
|
```
|
|
71
71
|
|
|
72
|
-
By default the command uses `1970-01-
|
|
72
|
+
By default the command uses `1970-01-01T00:00:00Z` as the start timestamp and lets QRM treat the stop as "now" (omit `--stop`). Pass `--base-url` and `--insecure` to override the stored settings when needed.
|
|
73
|
+
|
|
74
|
+
#### List production boxes
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
qrm box list --stop "2026-02-06T23:59:00Z"
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
If you omit `--start`, the command defaults to the Unix epoch (`1970-01-01T00:00:00Z`). Leave `--stop` out to let QRM use its current time.
|
|
73
81
|
|
|
74
82
|
#### JSON output
|
|
75
83
|
|
|
@@ -87,12 +95,19 @@ from qrm.client import QrmClient
|
|
|
87
95
|
|
|
88
96
|
state = load_login_state()
|
|
89
97
|
client = QrmClient(base_url=str(state.base_url), verify_tls=state.verify_tls)
|
|
98
|
+
|
|
90
99
|
uut_runs = client.uut_status(
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
100
|
+
token=state.token,
|
|
101
|
+
serial_number="326115020010F2F1",
|
|
102
|
+
start_datetime="2026-02-01T00:00:00Z",
|
|
103
|
+
stop_datetime="2026-02-06T23:59:00Z",
|
|
104
|
+
max_results=1000,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
boxes = client.box_list(
|
|
108
|
+
token=state.token,
|
|
109
|
+
start_datetime="1970-01-01T00:00:00Z",
|
|
110
|
+
stop_datetime="2026-02-06T23:59:00Z",
|
|
96
111
|
)
|
|
97
112
|
```
|
|
98
113
|
|
|
@@ -43,10 +43,18 @@ qrm status
|
|
|
43
43
|
#### Inspect UUT history
|
|
44
44
|
|
|
45
45
|
```bash
|
|
46
|
-
qrm uut status 326115020010F2F1 --start "2026-02-
|
|
46
|
+
qrm uut status 326115020010F2F1 --start "2026-02-01T00:00:00Z" --stop "2026-02-06T23:59:00Z"
|
|
47
47
|
```
|
|
48
48
|
|
|
49
|
-
By default the command uses `1970-01-
|
|
49
|
+
By default the command uses `1970-01-01T00:00:00Z` as the start timestamp and lets QRM treat the stop as "now" (omit `--stop`). Pass `--base-url` and `--insecure` to override the stored settings when needed.
|
|
50
|
+
|
|
51
|
+
#### List production boxes
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
qrm box list --stop "2026-02-06T23:59:00Z"
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
If you omit `--start`, the command defaults to the Unix epoch (`1970-01-01T00:00:00Z`). Leave `--stop` out to let QRM use its current time.
|
|
50
58
|
|
|
51
59
|
#### JSON output
|
|
52
60
|
|
|
@@ -64,12 +72,19 @@ from qrm.client import QrmClient
|
|
|
64
72
|
|
|
65
73
|
state = load_login_state()
|
|
66
74
|
client = QrmClient(base_url=str(state.base_url), verify_tls=state.verify_tls)
|
|
75
|
+
|
|
67
76
|
uut_runs = client.uut_status(
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
77
|
+
token=state.token,
|
|
78
|
+
serial_number="326115020010F2F1",
|
|
79
|
+
start_datetime="2026-02-01T00:00:00Z",
|
|
80
|
+
stop_datetime="2026-02-06T23:59:00Z",
|
|
81
|
+
max_results=1000,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
boxes = client.box_list(
|
|
85
|
+
token=state.token,
|
|
86
|
+
start_datetime="1970-01-01T00:00:00Z",
|
|
87
|
+
stop_datetime="2026-02-06T23:59:00Z",
|
|
73
88
|
)
|
|
74
89
|
```
|
|
75
90
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: lr-qrm
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.3
|
|
4
4
|
Summary: CLI client for interacting with Qestit QRM data from LumenRadio tooling.
|
|
5
5
|
Author-email: Jonas Estberger <jonas.estberger@lumenradio.com>
|
|
6
6
|
License: MIT License
|
|
@@ -66,10 +66,18 @@ qrm status
|
|
|
66
66
|
#### Inspect UUT history
|
|
67
67
|
|
|
68
68
|
```bash
|
|
69
|
-
qrm uut status 326115020010F2F1 --start "2026-02-
|
|
69
|
+
qrm uut status 326115020010F2F1 --start "2026-02-01T00:00:00Z" --stop "2026-02-06T23:59:00Z"
|
|
70
70
|
```
|
|
71
71
|
|
|
72
|
-
By default the command uses `1970-01-
|
|
72
|
+
By default the command uses `1970-01-01T00:00:00Z` as the start timestamp and lets QRM treat the stop as "now" (omit `--stop`). Pass `--base-url` and `--insecure` to override the stored settings when needed.
|
|
73
|
+
|
|
74
|
+
#### List production boxes
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
qrm box list --stop "2026-02-06T23:59:00Z"
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
If you omit `--start`, the command defaults to the Unix epoch (`1970-01-01T00:00:00Z`). Leave `--stop` out to let QRM use its current time.
|
|
73
81
|
|
|
74
82
|
#### JSON output
|
|
75
83
|
|
|
@@ -87,12 +95,19 @@ from qrm.client import QrmClient
|
|
|
87
95
|
|
|
88
96
|
state = load_login_state()
|
|
89
97
|
client = QrmClient(base_url=str(state.base_url), verify_tls=state.verify_tls)
|
|
98
|
+
|
|
90
99
|
uut_runs = client.uut_status(
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
100
|
+
token=state.token,
|
|
101
|
+
serial_number="326115020010F2F1",
|
|
102
|
+
start_datetime="2026-02-01T00:00:00Z",
|
|
103
|
+
stop_datetime="2026-02-06T23:59:00Z",
|
|
104
|
+
max_results=1000,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
boxes = client.box_list(
|
|
108
|
+
token=state.token,
|
|
109
|
+
start_datetime="1970-01-01T00:00:00Z",
|
|
110
|
+
stop_datetime="2026-02-06T23:59:00Z",
|
|
96
111
|
)
|
|
97
112
|
```
|
|
98
113
|
|
|
@@ -28,15 +28,18 @@ class OutputFormat(str, Enum):
|
|
|
28
28
|
app = typer.Typer(help="Interact with the Qestit QRM service.")
|
|
29
29
|
console = Console()
|
|
30
30
|
uut_app = typer.Typer(help="Inspect Units Under Test (UUTs).")
|
|
31
|
+
box_app = typer.Typer(help="Inspect production boxes.")
|
|
31
32
|
app.add_typer(uut_app, name="uut")
|
|
33
|
+
app.add_typer(box_app, name="box")
|
|
32
34
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
35
|
+
API_DATE_FORMAT = "%Y-%m-%dT%H:%M:%SZ"
|
|
36
|
+
CLI_DATE_FORMATS = [
|
|
37
|
+
API_DATE_FORMAT,
|
|
38
|
+
"%Y-%m-%d %H:%M:%S",
|
|
36
39
|
"%Y-%m-%dT%H:%M:%S",
|
|
37
40
|
"%Y-%m-%dT%H:%M:%S%z",
|
|
38
41
|
]
|
|
39
|
-
|
|
42
|
+
EPOCH_START = datetime(1970, 1, 1, tzinfo=timezone.utc)
|
|
40
43
|
|
|
41
44
|
|
|
42
45
|
@app.callback(invoke_without_command=True)
|
|
@@ -166,14 +169,20 @@ def uut_status(
|
|
|
166
169
|
start: datetime | None = typer.Option(
|
|
167
170
|
None,
|
|
168
171
|
"--start",
|
|
169
|
-
formats=
|
|
170
|
-
help=
|
|
172
|
+
formats=CLI_DATE_FORMATS,
|
|
173
|
+
help=(
|
|
174
|
+
"Filter from this timestamp (UTC). Accepts ISO-8601 (e.g. 2020-01-01T00:00:00Z) "
|
|
175
|
+
"or YYYY-MM-DD HH:MM:SS. Default: 1970-01-01T00:00:00Z."
|
|
176
|
+
),
|
|
171
177
|
),
|
|
172
178
|
stop: datetime | None = typer.Option(
|
|
173
179
|
None,
|
|
174
180
|
"--stop",
|
|
175
|
-
formats=
|
|
176
|
-
help=
|
|
181
|
+
formats=CLI_DATE_FORMATS,
|
|
182
|
+
help=(
|
|
183
|
+
"Filter until this timestamp (UTC). Accepts ISO-8601 (e.g. 2026-02-06T23:59:00Z) "
|
|
184
|
+
"or YYYY-MM-DD HH:MM:SS. Default: QRM current time."
|
|
185
|
+
),
|
|
177
186
|
),
|
|
178
187
|
max_results: int = typer.Option(
|
|
179
188
|
1000,
|
|
@@ -209,7 +218,7 @@ def uut_status(
|
|
|
209
218
|
"""Fetch UUT execution history using the `/Uut/Status` endpoint."""
|
|
210
219
|
|
|
211
220
|
state = _load_state_or_exit(config_path)
|
|
212
|
-
|
|
221
|
+
resolved_start, resolved_stop = _resolve_uut_window(start, stop)
|
|
213
222
|
|
|
214
223
|
client = QrmClient(
|
|
215
224
|
base_url=base_url or str(state.base_url),
|
|
@@ -220,8 +229,8 @@ def uut_status(
|
|
|
220
229
|
payload = client.uut_status(
|
|
221
230
|
token=state.token,
|
|
222
231
|
serial_number=serial_number,
|
|
223
|
-
start_datetime=
|
|
224
|
-
stop_datetime=
|
|
232
|
+
start_datetime=_format_api_datetime(resolved_start),
|
|
233
|
+
stop_datetime=_format_api_datetime(resolved_stop) if resolved_stop is not None else None,
|
|
225
234
|
max_results=max_results,
|
|
226
235
|
)
|
|
227
236
|
except QrmClientError as exc:
|
|
@@ -235,6 +244,76 @@ def uut_status(
|
|
|
235
244
|
console.print(_render_uut_status(payload))
|
|
236
245
|
|
|
237
246
|
|
|
247
|
+
@box_app.command("list")
|
|
248
|
+
def box_list(
|
|
249
|
+
start: datetime | None = typer.Option(
|
|
250
|
+
None,
|
|
251
|
+
"--start",
|
|
252
|
+
formats=CLI_DATE_FORMATS,
|
|
253
|
+
help=(
|
|
254
|
+
"Filter from this timestamp (UTC). Accepts ISO-8601 (e.g. 2020-01-01T00:00:00Z) "
|
|
255
|
+
"or YYYY-MM-DD HH:MM:SS. Default: 1970-01-01T00:00:00Z."
|
|
256
|
+
),
|
|
257
|
+
),
|
|
258
|
+
stop: datetime | None = typer.Option(
|
|
259
|
+
None,
|
|
260
|
+
"--stop",
|
|
261
|
+
formats=CLI_DATE_FORMATS,
|
|
262
|
+
help=(
|
|
263
|
+
"Filter until this timestamp (UTC). Accepts ISO-8601 (e.g. 2026-02-06T23:59:00Z) "
|
|
264
|
+
"or YYYY-MM-DD HH:MM:SS. Default: QRM current time."
|
|
265
|
+
),
|
|
266
|
+
),
|
|
267
|
+
config_path: Path = typer.Option(
|
|
268
|
+
None,
|
|
269
|
+
"--config-path",
|
|
270
|
+
help="Override the location of the login.json file.",
|
|
271
|
+
),
|
|
272
|
+
base_url: str = typer.Option(
|
|
273
|
+
None,
|
|
274
|
+
"--base-url",
|
|
275
|
+
help="Override the base URL stored in the config (useful for testing).",
|
|
276
|
+
),
|
|
277
|
+
insecure: bool = typer.Option(
|
|
278
|
+
False,
|
|
279
|
+
"--insecure",
|
|
280
|
+
help="Disable TLS verification when contacting QRM.",
|
|
281
|
+
),
|
|
282
|
+
output: OutputFormat = typer.Option(
|
|
283
|
+
OutputFormat.rich,
|
|
284
|
+
"--output",
|
|
285
|
+
"-o",
|
|
286
|
+
case_sensitive=False,
|
|
287
|
+
help="Choose output format: rich (default) or json.",
|
|
288
|
+
),
|
|
289
|
+
) -> None:
|
|
290
|
+
"""List production boxes via the `/Box/List` endpoint."""
|
|
291
|
+
|
|
292
|
+
state = _load_state_or_exit(config_path)
|
|
293
|
+
resolved_start, resolved_stop = _resolve_box_window(start, stop)
|
|
294
|
+
|
|
295
|
+
client = QrmClient(
|
|
296
|
+
base_url=base_url or str(state.base_url),
|
|
297
|
+
verify_tls=not insecure and state.verify_tls,
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
try:
|
|
301
|
+
payload = client.box_list(
|
|
302
|
+
token=state.token,
|
|
303
|
+
start_datetime=_format_api_datetime(resolved_start),
|
|
304
|
+
stop_datetime=_format_api_datetime(resolved_stop) if resolved_stop is not None else None,
|
|
305
|
+
)
|
|
306
|
+
except QrmClientError as exc:
|
|
307
|
+
typer.secho(f"Box list failed: {exc}", fg=typer.colors.RED)
|
|
308
|
+
raise typer.Exit(code=1) from exc
|
|
309
|
+
|
|
310
|
+
if output is OutputFormat.json:
|
|
311
|
+
_emit(payload, output)
|
|
312
|
+
return
|
|
313
|
+
|
|
314
|
+
console.print(_render_box_list(payload))
|
|
315
|
+
|
|
316
|
+
|
|
238
317
|
def _resolve_config_path(config_path: Path | None) -> Path:
|
|
239
318
|
return config_path.expanduser() if config_path else DEFAULT_CONFIG_PATH
|
|
240
319
|
|
|
@@ -278,17 +357,35 @@ def _resolve_uut_window(
|
|
|
278
357
|
start: datetime | None,
|
|
279
358
|
stop: datetime | None,
|
|
280
359
|
) -> tuple[datetime, datetime]:
|
|
281
|
-
|
|
282
|
-
|
|
360
|
+
return _resolve_window(start, stop, default_start=EPOCH_START)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _resolve_box_window(
|
|
364
|
+
start: datetime | None,
|
|
365
|
+
stop: datetime | None,
|
|
366
|
+
) -> tuple[datetime, datetime]:
|
|
367
|
+
return _resolve_window(start, stop, default_start=EPOCH_START)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _resolve_window(
|
|
371
|
+
start: datetime | None,
|
|
372
|
+
stop: datetime | None,
|
|
373
|
+
*,
|
|
374
|
+
default_start: datetime,
|
|
375
|
+
) -> tuple[datetime, datetime | None]:
|
|
376
|
+
stop_value = stop
|
|
377
|
+
start_value = start or default_start
|
|
378
|
+
if stop_value and start_value > stop_value:
|
|
379
|
+
raise typer.BadParameter("--start must be earlier than --stop")
|
|
283
380
|
return start_value, stop_value
|
|
284
381
|
|
|
285
382
|
|
|
286
|
-
def
|
|
383
|
+
def _format_api_datetime(value: datetime | None) -> str | None:
|
|
287
384
|
if value is None:
|
|
288
385
|
return None
|
|
289
386
|
if value.tzinfo is None:
|
|
290
|
-
|
|
291
|
-
return value.astimezone(timezone.utc).strftime(
|
|
387
|
+
value = value.replace(tzinfo=timezone.utc)
|
|
388
|
+
return value.astimezone(timezone.utc).strftime(API_DATE_FORMAT)
|
|
292
389
|
|
|
293
390
|
|
|
294
391
|
def _render_uut_status(payload: Any) -> Table:
|
|
@@ -326,6 +423,38 @@ def _render_uut_status(payload: Any) -> Table:
|
|
|
326
423
|
return table
|
|
327
424
|
|
|
328
425
|
|
|
426
|
+
def _render_box_list(payload: Any) -> Table:
|
|
427
|
+
table = Table(show_header=True, header_style="bold")
|
|
428
|
+
table.add_column("Box", style="cyan")
|
|
429
|
+
table.add_column("Name", style="cyan")
|
|
430
|
+
table.add_column("Status", style="green")
|
|
431
|
+
table.add_column("Start", style="magenta")
|
|
432
|
+
table.add_column("Stop", style="magenta")
|
|
433
|
+
table.add_column("Location", style="yellow")
|
|
434
|
+
|
|
435
|
+
rows = payload if isinstance(payload, list) else ([payload] if payload else [])
|
|
436
|
+
|
|
437
|
+
if not rows:
|
|
438
|
+
table.add_row("No results", "-", "-", "-", "-", "-")
|
|
439
|
+
return table
|
|
440
|
+
|
|
441
|
+
for entry in rows:
|
|
442
|
+
if not isinstance(entry, dict):
|
|
443
|
+
table.add_row(str(entry), "-", "-", "-", "-", "-")
|
|
444
|
+
continue
|
|
445
|
+
|
|
446
|
+
table.add_row(
|
|
447
|
+
str(entry.get("BoxId") or entry.get("Id", "-")),
|
|
448
|
+
str(entry.get("BoxName") or entry.get("Name", "-")),
|
|
449
|
+
str(entry.get("Status") or entry.get("Outcome", "-")),
|
|
450
|
+
_format_timestamp(entry.get("StartDateTime")),
|
|
451
|
+
_format_timestamp(entry.get("StopDateTime")),
|
|
452
|
+
str(entry.get("Location") or entry.get("StationName") or "-"),
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
return table
|
|
456
|
+
|
|
457
|
+
|
|
329
458
|
def _resolve_serial(entry: dict[str, Any]) -> str:
|
|
330
459
|
value = entry.get("serialNumber") or entry.get("SerialNumber")
|
|
331
460
|
return str(value) if value else "-"
|
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
"""HTTP client helpers for QRM services."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import pprint
|
|
8
|
+
import sys
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from typing import Any, Dict, Optional
|
|
12
|
+
from urllib.parse import urljoin
|
|
13
|
+
|
|
14
|
+
import requests
|
|
15
|
+
import urllib3
|
|
16
|
+
from pydantic import BaseModel, Field, HttpUrl
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class LoginCredentials(BaseModel):
|
|
20
|
+
"""Payload accepted by the `/User/Login` endpoint."""
|
|
21
|
+
|
|
22
|
+
username: str = Field(..., min_length=1)
|
|
23
|
+
password: str = Field(..., min_length=1)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class LoginState(BaseModel):
|
|
27
|
+
"""Serialized login state saved on disk by the CLI."""
|
|
28
|
+
|
|
29
|
+
token: str = Field(..., min_length=1)
|
|
30
|
+
username: str = Field(..., min_length=1)
|
|
31
|
+
base_url: HttpUrl
|
|
32
|
+
verify_tls: bool = True
|
|
33
|
+
stored_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class QrmClientError(RuntimeError):
|
|
37
|
+
"""Raised when the client cannot fulfill a request."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class QrmClient:
|
|
42
|
+
"""Thin wrapper around the QRM HTTP API."""
|
|
43
|
+
|
|
44
|
+
base_url: str
|
|
45
|
+
verify_tls: bool = True
|
|
46
|
+
timeout: float = 10.0
|
|
47
|
+
session: Optional[requests.Session] = None
|
|
48
|
+
debug: bool = False
|
|
49
|
+
|
|
50
|
+
def __post_init__(self) -> None:
|
|
51
|
+
self.debug = self.debug or os.getenv("QRM_DEBUG") == "1"
|
|
52
|
+
if not self.base_url:
|
|
53
|
+
raise ValueError("base_url must be provided")
|
|
54
|
+
self.base_url = self.base_url.rstrip("/")
|
|
55
|
+
self.session = self.session or requests.Session()
|
|
56
|
+
self.session.verify = self.verify_tls
|
|
57
|
+
if not self.verify_tls:
|
|
58
|
+
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
|
59
|
+
|
|
60
|
+
def login(self, username: str, password: str) -> str:
|
|
61
|
+
"""Authenticate against `/User/Login` and return the bearer token."""
|
|
62
|
+
|
|
63
|
+
credentials = LoginCredentials(username=username, password=password)
|
|
64
|
+
url = self._build_url("/User/Login")
|
|
65
|
+
body = credentials.model_dump()
|
|
66
|
+
self._debug_request(
|
|
67
|
+
method="POST",
|
|
68
|
+
url=url,
|
|
69
|
+
headers=None,
|
|
70
|
+
payload=body,
|
|
71
|
+
)
|
|
72
|
+
try:
|
|
73
|
+
response = self.session.post(
|
|
74
|
+
url,
|
|
75
|
+
json=body,
|
|
76
|
+
timeout=self.timeout,
|
|
77
|
+
)
|
|
78
|
+
self._debug_response(method="POST", url=url, response=response)
|
|
79
|
+
except requests.RequestException as exc: # pragma: no cover - network failure
|
|
80
|
+
self._debug_error(method="POST", url=url, error=exc)
|
|
81
|
+
raise QrmClientError("Failed to contact QRM login endpoint") from exc
|
|
82
|
+
|
|
83
|
+
self._ensure_success(
|
|
84
|
+
response=response,
|
|
85
|
+
method="POST",
|
|
86
|
+
url=url,
|
|
87
|
+
error_message="Failed to contact QRM login endpoint",
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
token = self._extract_token(response)
|
|
91
|
+
if not token:
|
|
92
|
+
raise QrmClientError("QRM login response did not contain a token")
|
|
93
|
+
return token
|
|
94
|
+
|
|
95
|
+
def status(self, token: Optional[str] = None) -> Any:
|
|
96
|
+
"""Query the `/Result/Status` endpoint to verify service health."""
|
|
97
|
+
|
|
98
|
+
headers = {"Authorization": f"Bearer {token}"} if token else None
|
|
99
|
+
url = self._build_url("/Result/Status")
|
|
100
|
+
self._debug_request(
|
|
101
|
+
method="GET",
|
|
102
|
+
url=url,
|
|
103
|
+
headers=headers,
|
|
104
|
+
payload=None,
|
|
105
|
+
)
|
|
106
|
+
try:
|
|
107
|
+
response = self.session.get(
|
|
108
|
+
url,
|
|
109
|
+
headers=headers,
|
|
110
|
+
timeout=self.timeout,
|
|
111
|
+
)
|
|
112
|
+
self._debug_response(method="GET", url=url, response=response)
|
|
113
|
+
except requests.RequestException as exc: # pragma: no cover - network failure
|
|
114
|
+
self._debug_error(method="GET", url=url, error=exc)
|
|
115
|
+
raise QrmClientError("Failed to contact QRM status endpoint") from exc
|
|
116
|
+
|
|
117
|
+
self._ensure_success(
|
|
118
|
+
response=response,
|
|
119
|
+
method="GET",
|
|
120
|
+
url=url,
|
|
121
|
+
error_message="Failed to contact QRM status endpoint",
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
try:
|
|
125
|
+
return response.json()
|
|
126
|
+
except ValueError:
|
|
127
|
+
return response.text.strip()
|
|
128
|
+
|
|
129
|
+
def uut_status(
|
|
130
|
+
self,
|
|
131
|
+
*,
|
|
132
|
+
token: str,
|
|
133
|
+
serial_number: str,
|
|
134
|
+
start_datetime: Optional[str] = None,
|
|
135
|
+
stop_datetime: Optional[str] = None,
|
|
136
|
+
max_results: Optional[int] = None,
|
|
137
|
+
) -> Any:
|
|
138
|
+
"""Query the `/Uut/Status` endpoint for unit test history."""
|
|
139
|
+
|
|
140
|
+
if not serial_number:
|
|
141
|
+
raise ValueError("serial_number must be provided")
|
|
142
|
+
headers = {"Authorization": f"Bearer {token}"}
|
|
143
|
+
payload: Dict[str, Any] = {
|
|
144
|
+
"serialNumber": serial_number,
|
|
145
|
+
}
|
|
146
|
+
if start_datetime:
|
|
147
|
+
payload["startDateTime"] = start_datetime
|
|
148
|
+
if stop_datetime:
|
|
149
|
+
payload["stopDateTime"] = stop_datetime
|
|
150
|
+
if max_results is not None:
|
|
151
|
+
payload["maxNumberOfResults"] = max_results
|
|
152
|
+
|
|
153
|
+
url = self._build_url("/Uut/Status")
|
|
154
|
+
self._debug_request(
|
|
155
|
+
method="POST",
|
|
156
|
+
url=url,
|
|
157
|
+
headers=headers,
|
|
158
|
+
payload=payload,
|
|
159
|
+
)
|
|
160
|
+
try:
|
|
161
|
+
response = self.session.post(
|
|
162
|
+
url,
|
|
163
|
+
headers=headers,
|
|
164
|
+
json=payload,
|
|
165
|
+
timeout=self.timeout,
|
|
166
|
+
)
|
|
167
|
+
self._debug_response(method="POST", url=url, response=response)
|
|
168
|
+
except requests.RequestException as exc: # pragma: no cover - network failure
|
|
169
|
+
self._debug_error(method="POST", url=url, error=exc)
|
|
170
|
+
raise QrmClientError("Failed to contact QRM UUT status endpoint") from exc
|
|
171
|
+
|
|
172
|
+
self._ensure_success(
|
|
173
|
+
response=response,
|
|
174
|
+
method="POST",
|
|
175
|
+
url=url,
|
|
176
|
+
error_message="Failed to contact QRM UUT status endpoint",
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
try:
|
|
180
|
+
return response.json()
|
|
181
|
+
except ValueError:
|
|
182
|
+
return response.text.strip()
|
|
183
|
+
|
|
184
|
+
def box_list(
|
|
185
|
+
self,
|
|
186
|
+
*,
|
|
187
|
+
token: str,
|
|
188
|
+
start_datetime: Optional[str] = None,
|
|
189
|
+
stop_datetime: Optional[str] = None,
|
|
190
|
+
) -> Any:
|
|
191
|
+
"""Query the `/Box/List` endpoint for production boxes."""
|
|
192
|
+
|
|
193
|
+
if not token:
|
|
194
|
+
raise ValueError("token must be provided")
|
|
195
|
+
|
|
196
|
+
headers = {"Authorization": f"Bearer {token}"}
|
|
197
|
+
payload: Dict[str, Any] = {}
|
|
198
|
+
if start_datetime:
|
|
199
|
+
payload["startDateTime"] = start_datetime
|
|
200
|
+
if stop_datetime:
|
|
201
|
+
payload["stopDateTime"] = stop_datetime
|
|
202
|
+
|
|
203
|
+
body = payload or None
|
|
204
|
+
url = self._build_url("/Box/List")
|
|
205
|
+
self._debug_request(
|
|
206
|
+
method="POST",
|
|
207
|
+
url=url,
|
|
208
|
+
headers=headers,
|
|
209
|
+
payload=body,
|
|
210
|
+
)
|
|
211
|
+
try:
|
|
212
|
+
response = self.session.post(
|
|
213
|
+
url,
|
|
214
|
+
headers=headers,
|
|
215
|
+
json=body,
|
|
216
|
+
timeout=self.timeout,
|
|
217
|
+
)
|
|
218
|
+
self._debug_response(method="POST", url=url, response=response)
|
|
219
|
+
except requests.RequestException as exc: # pragma: no cover - network failure
|
|
220
|
+
self._debug_error(method="POST", url=url, error=exc)
|
|
221
|
+
raise QrmClientError("Failed to contact QRM box list endpoint") from exc
|
|
222
|
+
|
|
223
|
+
self._ensure_success(
|
|
224
|
+
response=response,
|
|
225
|
+
method="POST",
|
|
226
|
+
url=url,
|
|
227
|
+
error_message="Failed to contact QRM box list endpoint",
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
try:
|
|
231
|
+
return response.json()
|
|
232
|
+
except ValueError:
|
|
233
|
+
return response.text.strip()
|
|
234
|
+
|
|
235
|
+
def _ensure_success(
|
|
236
|
+
self,
|
|
237
|
+
*,
|
|
238
|
+
response: requests.Response,
|
|
239
|
+
method: str,
|
|
240
|
+
url: str,
|
|
241
|
+
error_message: str,
|
|
242
|
+
) -> None:
|
|
243
|
+
if response.status_code == 401:
|
|
244
|
+
message = (
|
|
245
|
+
"Authentication failed (HTTP 401). Your credentials may be invalid or expired. "
|
|
246
|
+
"Please run `qrm login` and try again."
|
|
247
|
+
)
|
|
248
|
+
self._debug_error(
|
|
249
|
+
method=method,
|
|
250
|
+
url=url,
|
|
251
|
+
error=RuntimeError(message),
|
|
252
|
+
)
|
|
253
|
+
raise QrmClientError(message)
|
|
254
|
+
|
|
255
|
+
try:
|
|
256
|
+
response.raise_for_status()
|
|
257
|
+
except requests.HTTPError as exc:
|
|
258
|
+
self._debug_error(method=method, url=url, error=exc)
|
|
259
|
+
raise QrmClientError(error_message) from exc
|
|
260
|
+
|
|
261
|
+
def _build_url(self, path: str) -> str:
|
|
262
|
+
return urljoin(f"{self.base_url}/", path.lstrip("/"))
|
|
263
|
+
|
|
264
|
+
@staticmethod
|
|
265
|
+
def _extract_token(response: requests.Response) -> Optional[str]:
|
|
266
|
+
payload: Any
|
|
267
|
+
try:
|
|
268
|
+
payload = response.json()
|
|
269
|
+
except ValueError:
|
|
270
|
+
payload = None
|
|
271
|
+
|
|
272
|
+
token = QrmClient._token_from_payload(payload)
|
|
273
|
+
if token:
|
|
274
|
+
return token
|
|
275
|
+
|
|
276
|
+
fallback = response.text.strip()
|
|
277
|
+
if fallback:
|
|
278
|
+
token = QrmClient._token_from_payload(fallback)
|
|
279
|
+
if token:
|
|
280
|
+
return token
|
|
281
|
+
return fallback
|
|
282
|
+
return None
|
|
283
|
+
|
|
284
|
+
@staticmethod
|
|
285
|
+
def _token_from_payload(payload: Any) -> Optional[str]:
|
|
286
|
+
if isinstance(payload, Dict):
|
|
287
|
+
for key in (
|
|
288
|
+
"token",
|
|
289
|
+
"Token",
|
|
290
|
+
"accessToken",
|
|
291
|
+
"access_token",
|
|
292
|
+
"bearerToken",
|
|
293
|
+
"value",
|
|
294
|
+
):
|
|
295
|
+
value = payload.get(key)
|
|
296
|
+
if isinstance(value, str) and value.strip():
|
|
297
|
+
return value.strip()
|
|
298
|
+
if isinstance(payload, str):
|
|
299
|
+
trimmed = payload.strip()
|
|
300
|
+
if not trimmed:
|
|
301
|
+
return None
|
|
302
|
+
if trimmed.startswith("{") and trimmed.endswith("}"):
|
|
303
|
+
try:
|
|
304
|
+
return QrmClient._token_from_payload(json.loads(trimmed))
|
|
305
|
+
except json.JSONDecodeError:
|
|
306
|
+
pass
|
|
307
|
+
return trimmed
|
|
308
|
+
return None
|
|
309
|
+
|
|
310
|
+
def _debug_request(
|
|
311
|
+
self,
|
|
312
|
+
*,
|
|
313
|
+
method: str,
|
|
314
|
+
url: str,
|
|
315
|
+
headers: Optional[Dict[str, Any]],
|
|
316
|
+
payload: Any,
|
|
317
|
+
) -> None:
|
|
318
|
+
if not self.debug:
|
|
319
|
+
return
|
|
320
|
+
data: Dict[str, Any] = {"method": method, "url": url}
|
|
321
|
+
if headers is not None:
|
|
322
|
+
data["headers"] = headers
|
|
323
|
+
if payload is not None:
|
|
324
|
+
data["body"] = payload
|
|
325
|
+
self._debug_http("REQUEST", data)
|
|
326
|
+
|
|
327
|
+
def _debug_response(
|
|
328
|
+
self,
|
|
329
|
+
*,
|
|
330
|
+
method: str,
|
|
331
|
+
url: str,
|
|
332
|
+
response: requests.Response,
|
|
333
|
+
) -> None:
|
|
334
|
+
if not self.debug:
|
|
335
|
+
return
|
|
336
|
+
headers = getattr(response, "headers", None)
|
|
337
|
+
data: Dict[str, Any] = {
|
|
338
|
+
"method": method,
|
|
339
|
+
"url": url,
|
|
340
|
+
"status_code": response.status_code,
|
|
341
|
+
"body": response.text,
|
|
342
|
+
}
|
|
343
|
+
if headers is not None:
|
|
344
|
+
data["headers"] = dict(headers)
|
|
345
|
+
self._debug_http("RESPONSE", data)
|
|
346
|
+
|
|
347
|
+
def _debug_error(self, *, method: str, url: str, error: Exception) -> None:
|
|
348
|
+
if not self.debug:
|
|
349
|
+
return
|
|
350
|
+
self._debug_http(
|
|
351
|
+
"ERROR",
|
|
352
|
+
{
|
|
353
|
+
"method": method,
|
|
354
|
+
"url": url,
|
|
355
|
+
"error": f"{error.__class__.__name__}: {error}",
|
|
356
|
+
},
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
def _debug_http(self, stage: str, payload: Dict[str, Any]) -> None:
|
|
360
|
+
if not self.debug:
|
|
361
|
+
return
|
|
362
|
+
formatted = pprint.pformat(payload)
|
|
363
|
+
print(f"[QRM DEBUG] {stage}: {formatted}", file=sys.stderr)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
__all__ = ["LoginCredentials", "LoginState", "QrmClient", "QrmClientError"]
|
|
@@ -238,8 +238,8 @@ def test_uut_status_command_calls_client(monkeypatch, tmp_path: Path) -> None:
|
|
|
238
238
|
"verify_tls": True,
|
|
239
239
|
"token": "TOKEN123",
|
|
240
240
|
"serial_number": "326115020010F2F1",
|
|
241
|
-
"start": "2026-02-
|
|
242
|
-
"stop": "2026-02-
|
|
241
|
+
"start": "2026-02-01T00:00:00Z",
|
|
242
|
+
"stop": "2026-02-06T23:59:00Z",
|
|
243
243
|
"max_results": 50,
|
|
244
244
|
}
|
|
245
245
|
|
|
@@ -297,7 +297,7 @@ def test_uut_status_defaults_time_window(monkeypatch, tmp_path: Path) -> None:
|
|
|
297
297
|
config_path.write_text(state.model_dump_json())
|
|
298
298
|
|
|
299
299
|
fake_start = datetime(1970, 1, 1, 0, 0, tzinfo=timezone.utc)
|
|
300
|
-
fake_stop =
|
|
300
|
+
fake_stop = None
|
|
301
301
|
|
|
302
302
|
def fake_resolver(start, stop): # noqa: ANN001
|
|
303
303
|
assert start is None and stop is None
|
|
@@ -331,8 +331,8 @@ def test_uut_status_defaults_time_window(monkeypatch, tmp_path: Path) -> None:
|
|
|
331
331
|
|
|
332
332
|
assert result.exit_code == 0
|
|
333
333
|
assert captured == {
|
|
334
|
-
"start": "1970-01-
|
|
335
|
-
"stop":
|
|
334
|
+
"start": "1970-01-01T00:00:00Z",
|
|
335
|
+
"stop": None,
|
|
336
336
|
}
|
|
337
337
|
|
|
338
338
|
|
|
@@ -431,6 +431,252 @@ def test_uut_status_handles_irregular_rows(monkeypatch, tmp_path: Path) -> None:
|
|
|
431
431
|
assert "12345" in result.stdout
|
|
432
432
|
|
|
433
433
|
|
|
434
|
+
def test_box_list_command_calls_client(monkeypatch, tmp_path: Path) -> None:
|
|
435
|
+
"""The box list subcommand should call its API helper."""
|
|
436
|
+
|
|
437
|
+
state = LoginState( # type: ignore[call-arg]
|
|
438
|
+
token="TOKEN123",
|
|
439
|
+
username="alice",
|
|
440
|
+
base_url="https://qrm.local/api",
|
|
441
|
+
verify_tls=True,
|
|
442
|
+
)
|
|
443
|
+
config_path = tmp_path / "login.json"
|
|
444
|
+
config_path.write_text(state.model_dump_json())
|
|
445
|
+
|
|
446
|
+
captured = {}
|
|
447
|
+
|
|
448
|
+
class FakeClient:
|
|
449
|
+
def __init__(self, base_url: str, verify_tls: bool) -> None: # noqa: D401
|
|
450
|
+
captured["base_url"] = base_url
|
|
451
|
+
captured["verify_tls"] = verify_tls
|
|
452
|
+
|
|
453
|
+
def box_list(
|
|
454
|
+
self,
|
|
455
|
+
*,
|
|
456
|
+
token: str,
|
|
457
|
+
start_datetime: str | None,
|
|
458
|
+
stop_datetime: str | None,
|
|
459
|
+
):
|
|
460
|
+
captured["token"] = token
|
|
461
|
+
captured["start"] = start_datetime
|
|
462
|
+
captured["stop"] = stop_datetime
|
|
463
|
+
return [
|
|
464
|
+
{
|
|
465
|
+
"BoxId": "BOX-1",
|
|
466
|
+
"BoxName": "Fixture A",
|
|
467
|
+
"Status": "Active",
|
|
468
|
+
"StartDateTime": "2026-02-01T00:00:00Z",
|
|
469
|
+
"StopDateTime": "2026-02-02T00:00:00Z",
|
|
470
|
+
"Location": "OrbitOne",
|
|
471
|
+
}
|
|
472
|
+
]
|
|
473
|
+
|
|
474
|
+
monkeypatch.setattr(cli, "QrmClient", FakeClient)
|
|
475
|
+
|
|
476
|
+
result = runner.invoke(
|
|
477
|
+
cli.app,
|
|
478
|
+
[
|
|
479
|
+
"box",
|
|
480
|
+
"list",
|
|
481
|
+
"--start",
|
|
482
|
+
"2026-02-01 00:00:00",
|
|
483
|
+
"--stop",
|
|
484
|
+
"2026-02-06 23:59:00",
|
|
485
|
+
"--config-path",
|
|
486
|
+
str(config_path),
|
|
487
|
+
],
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
assert result.exit_code == 0
|
|
491
|
+
assert "Fixture A" in result.stdout
|
|
492
|
+
assert captured == {
|
|
493
|
+
"base_url": "https://qrm.local/api",
|
|
494
|
+
"verify_tls": True,
|
|
495
|
+
"token": "TOKEN123",
|
|
496
|
+
"start": "2026-02-01T00:00:00Z",
|
|
497
|
+
"stop": "2026-02-06T23:59:00Z",
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def test_box_list_command_supports_json_output(monkeypatch, tmp_path: Path) -> None:
|
|
502
|
+
"""Box list should honor --output json."""
|
|
503
|
+
|
|
504
|
+
state = LoginState( # type: ignore[call-arg]
|
|
505
|
+
token="TOKEN123",
|
|
506
|
+
username="alice",
|
|
507
|
+
base_url="https://qrm.local/api",
|
|
508
|
+
verify_tls=True,
|
|
509
|
+
)
|
|
510
|
+
config_path = tmp_path / "login.json"
|
|
511
|
+
config_path.write_text(state.model_dump_json())
|
|
512
|
+
|
|
513
|
+
class FakeClient:
|
|
514
|
+
def __init__(self, *_: str, **__: str) -> None: # noqa: D401, ANN001, ANN002, ANN003
|
|
515
|
+
pass
|
|
516
|
+
|
|
517
|
+
def box_list(self, **__: str): # noqa: ANN003
|
|
518
|
+
return [{"BoxId": "BOX-1"}]
|
|
519
|
+
|
|
520
|
+
monkeypatch.setattr(cli, "QrmClient", FakeClient)
|
|
521
|
+
|
|
522
|
+
result = runner.invoke(
|
|
523
|
+
cli.app,
|
|
524
|
+
[
|
|
525
|
+
"box",
|
|
526
|
+
"list",
|
|
527
|
+
"--config-path",
|
|
528
|
+
str(config_path),
|
|
529
|
+
"--output",
|
|
530
|
+
"json",
|
|
531
|
+
],
|
|
532
|
+
)
|
|
533
|
+
|
|
534
|
+
assert result.exit_code == 0
|
|
535
|
+
assert json.loads(result.stdout) == [{"BoxId": "BOX-1"}]
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def test_box_list_defaults_time_window(monkeypatch, tmp_path: Path) -> None:
|
|
539
|
+
"""Box list should default start to the epoch."""
|
|
540
|
+
|
|
541
|
+
state = LoginState( # type: ignore[call-arg]
|
|
542
|
+
token="TOKEN123",
|
|
543
|
+
username="alice",
|
|
544
|
+
base_url="https://qrm.local/api",
|
|
545
|
+
verify_tls=True,
|
|
546
|
+
)
|
|
547
|
+
config_path = tmp_path / "login.json"
|
|
548
|
+
config_path.write_text(state.model_dump_json())
|
|
549
|
+
|
|
550
|
+
fake_start = datetime(1970, 1, 1, 0, 0, tzinfo=timezone.utc)
|
|
551
|
+
fake_stop = None
|
|
552
|
+
|
|
553
|
+
def fake_resolver(start, stop): # noqa: ANN001
|
|
554
|
+
assert start is None and stop is None
|
|
555
|
+
return fake_start, fake_stop
|
|
556
|
+
|
|
557
|
+
monkeypatch.setattr(cli, "_resolve_box_window", fake_resolver)
|
|
558
|
+
|
|
559
|
+
captured = {}
|
|
560
|
+
|
|
561
|
+
class FakeClient:
|
|
562
|
+
def __init__(self, *_: str, **__: str) -> None: # noqa: D401, ANN001, ANN002, ANN003
|
|
563
|
+
pass
|
|
564
|
+
|
|
565
|
+
def box_list(self, **kwargs): # noqa: ANN003
|
|
566
|
+
captured["start"] = kwargs["start_datetime"]
|
|
567
|
+
captured["stop"] = kwargs["stop_datetime"]
|
|
568
|
+
return []
|
|
569
|
+
|
|
570
|
+
monkeypatch.setattr(cli, "QrmClient", FakeClient)
|
|
571
|
+
|
|
572
|
+
result = runner.invoke(
|
|
573
|
+
cli.app,
|
|
574
|
+
[
|
|
575
|
+
"box",
|
|
576
|
+
"list",
|
|
577
|
+
"--config-path",
|
|
578
|
+
str(config_path),
|
|
579
|
+
],
|
|
580
|
+
)
|
|
581
|
+
|
|
582
|
+
assert result.exit_code == 0
|
|
583
|
+
assert captured == {
|
|
584
|
+
"start": "1970-01-01T00:00:00Z",
|
|
585
|
+
"stop": None,
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def test_resolve_box_window_defaults_epoch() -> None:
|
|
590
|
+
"""Helper should mirror the epoch default behavior."""
|
|
591
|
+
|
|
592
|
+
stop = datetime(2026, 2, 6, 0, 0, tzinfo=timezone.utc)
|
|
593
|
+
|
|
594
|
+
start, resolved_stop = cli._resolve_box_window(None, stop)
|
|
595
|
+
|
|
596
|
+
assert start == datetime(1970, 1, 1, 0, 0, tzinfo=timezone.utc)
|
|
597
|
+
assert resolved_stop == stop
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def test_box_list_handles_empty_payload(monkeypatch, tmp_path: Path) -> None:
|
|
601
|
+
"""Empty responses should render a helpful message."""
|
|
602
|
+
|
|
603
|
+
state = LoginState( # type: ignore[call-arg]
|
|
604
|
+
token="TOKEN123",
|
|
605
|
+
username="alice",
|
|
606
|
+
base_url="https://qrm.local/api",
|
|
607
|
+
verify_tls=True,
|
|
608
|
+
)
|
|
609
|
+
config_path = tmp_path / "login.json"
|
|
610
|
+
config_path.write_text(state.model_dump_json())
|
|
611
|
+
|
|
612
|
+
class FakeClient:
|
|
613
|
+
def __init__(self, *_: str, **__: str) -> None: # noqa: D401, ANN001, ANN002, ANN003
|
|
614
|
+
pass
|
|
615
|
+
|
|
616
|
+
def box_list(self, **__: str): # noqa: ANN003
|
|
617
|
+
return []
|
|
618
|
+
|
|
619
|
+
monkeypatch.setattr(cli, "QrmClient", FakeClient)
|
|
620
|
+
|
|
621
|
+
result = runner.invoke(
|
|
622
|
+
cli.app,
|
|
623
|
+
[
|
|
624
|
+
"box",
|
|
625
|
+
"list",
|
|
626
|
+
"--config-path",
|
|
627
|
+
str(config_path),
|
|
628
|
+
],
|
|
629
|
+
)
|
|
630
|
+
|
|
631
|
+
assert result.exit_code == 0
|
|
632
|
+
assert "No results" in result.stdout
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def test_box_list_handles_irregular_rows(monkeypatch, tmp_path: Path) -> None:
|
|
636
|
+
"""Irregular list entries should still render."""
|
|
637
|
+
|
|
638
|
+
state = LoginState( # type: ignore[call-arg]
|
|
639
|
+
token="TOKEN123",
|
|
640
|
+
username="alice",
|
|
641
|
+
base_url="https://qrm.local/api",
|
|
642
|
+
verify_tls=True,
|
|
643
|
+
)
|
|
644
|
+
config_path = tmp_path / "login.json"
|
|
645
|
+
config_path.write_text(state.model_dump_json())
|
|
646
|
+
|
|
647
|
+
class FakeClient:
|
|
648
|
+
def __init__(self, *_: str, **__: str) -> None: # noqa: D401, ANN001, ANN002, ANN003
|
|
649
|
+
pass
|
|
650
|
+
|
|
651
|
+
def box_list(self, **__: str): # noqa: ANN003
|
|
652
|
+
return [
|
|
653
|
+
{
|
|
654
|
+
"Id": "BOX-1",
|
|
655
|
+
"Location": "OrbitOne",
|
|
656
|
+
"StartDateTime": datetime(2026, 2, 1, 12, 0, tzinfo=timezone.utc),
|
|
657
|
+
"StopDateTime": None,
|
|
658
|
+
"Status": "Active",
|
|
659
|
+
},
|
|
660
|
+
123,
|
|
661
|
+
]
|
|
662
|
+
|
|
663
|
+
monkeypatch.setattr(cli, "QrmClient", FakeClient)
|
|
664
|
+
|
|
665
|
+
result = runner.invoke(
|
|
666
|
+
cli.app,
|
|
667
|
+
[
|
|
668
|
+
"box",
|
|
669
|
+
"list",
|
|
670
|
+
"--config-path",
|
|
671
|
+
str(config_path),
|
|
672
|
+
],
|
|
673
|
+
)
|
|
674
|
+
|
|
675
|
+
assert result.exit_code == 0
|
|
676
|
+
assert "BOX-1" in result.stdout
|
|
677
|
+
assert "123" in result.stdout
|
|
678
|
+
|
|
679
|
+
|
|
434
680
|
def test_cli_displays_help_when_no_command() -> None:
|
|
435
681
|
"""Invoking the CLI without arguments returns the Typer help text."""
|
|
436
682
|
|
|
@@ -19,6 +19,7 @@ class FakeResponse:
|
|
|
19
19
|
text_data: str | None = None,
|
|
20
20
|
status_code: int = 200,
|
|
21
21
|
json_error: bool = False,
|
|
22
|
+
headers: dict | None = None,
|
|
22
23
|
) -> None:
|
|
23
24
|
self._json_data = json_data
|
|
24
25
|
self._json_error = json_error
|
|
@@ -26,6 +27,7 @@ class FakeResponse:
|
|
|
26
27
|
text_data = json.dumps(json_data)
|
|
27
28
|
self._text_data = text_data or ""
|
|
28
29
|
self.status_code = status_code
|
|
30
|
+
self.headers = headers or {}
|
|
29
31
|
|
|
30
32
|
def json(self) -> object:
|
|
31
33
|
if self._json_error:
|
|
@@ -143,6 +145,17 @@ def test_status_returns_plain_text_when_json_invalid() -> None:
|
|
|
143
145
|
assert session.get_kwargs["headers"] is None
|
|
144
146
|
|
|
145
147
|
|
|
148
|
+
def test_status_prompts_relogin_on_401() -> None:
|
|
149
|
+
response = FakeResponse(status_code=401)
|
|
150
|
+
session = FakeSession(response)
|
|
151
|
+
client = QrmClient(base_url="https://qrm.example.com/api", session=session)
|
|
152
|
+
|
|
153
|
+
with pytest.raises(QrmClientError) as excinfo:
|
|
154
|
+
client.status(token="TOKEN123")
|
|
155
|
+
|
|
156
|
+
assert "qrm login" in str(excinfo.value)
|
|
157
|
+
|
|
158
|
+
|
|
146
159
|
def test_client_requires_base_url() -> None:
|
|
147
160
|
with pytest.raises(ValueError):
|
|
148
161
|
QrmClient(base_url="")
|
|
@@ -213,3 +226,51 @@ def test_client_disables_warnings_when_insecure(monkeypatch) -> None:
|
|
|
213
226
|
|
|
214
227
|
assert client.session.verify is False
|
|
215
228
|
assert captured["warning"] is client_module.urllib3.exceptions.InsecureRequestWarning
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def test_box_list_posts_filters() -> None:
|
|
232
|
+
response = FakeResponse(json_data=[{"BoxId": 1}])
|
|
233
|
+
session = FakeSession(response)
|
|
234
|
+
client = QrmClient(base_url="https://qrm.example.com/api", session=session)
|
|
235
|
+
|
|
236
|
+
payload = client.box_list(
|
|
237
|
+
token="TOKEN123",
|
|
238
|
+
start_datetime="1970-01-01 00:00:00",
|
|
239
|
+
stop_datetime="2026-02-06 00:00:00",
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
assert payload == [{"BoxId": 1}]
|
|
243
|
+
assert session.post_kwargs == {
|
|
244
|
+
"url": "https://qrm.example.com/api/Box/List",
|
|
245
|
+
"json": {
|
|
246
|
+
"startDateTime": "1970-01-01 00:00:00",
|
|
247
|
+
"stopDateTime": "2026-02-06 00:00:00",
|
|
248
|
+
},
|
|
249
|
+
"timeout": 10.0,
|
|
250
|
+
"headers": {"Authorization": "Bearer TOKEN123"},
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def test_box_list_requires_token() -> None:
|
|
255
|
+
client = QrmClient(
|
|
256
|
+
base_url="https://qrm.example.com/api",
|
|
257
|
+
session=FakeSession(FakeResponse(json_data=[])),
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
with pytest.raises(ValueError):
|
|
261
|
+
client.box_list(token="")
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def test_debug_logging_captures_request_and_response(monkeypatch, capsys) -> None: # type: ignore[no-untyped-def]
|
|
265
|
+
monkeypatch.setenv("QRM_DEBUG", "1")
|
|
266
|
+
response = FakeResponse(json_data={"healthy": True}, headers={"X-Test": "1"})
|
|
267
|
+
session = FakeSession(response)
|
|
268
|
+
client = QrmClient(base_url="https://qrm.example.com/api", session=session)
|
|
269
|
+
|
|
270
|
+
client.status(token="TOKEN123")
|
|
271
|
+
|
|
272
|
+
stderr = capsys.readouterr().err
|
|
273
|
+
assert "[QRM DEBUG] REQUEST" in stderr
|
|
274
|
+
assert "[QRM DEBUG] RESPONSE" in stderr
|
|
275
|
+
assert "/Result/Status" in stderr
|
|
276
|
+
assert "\"healthy\"" in stderr
|
lr_qrm-0.1.2/src/qrm/client.py
DELETED
|
@@ -1,183 +0,0 @@
|
|
|
1
|
-
"""HTTP client helpers for QRM services."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import json
|
|
6
|
-
from dataclasses import dataclass
|
|
7
|
-
from datetime import datetime, timezone
|
|
8
|
-
from typing import Any, Dict, Optional
|
|
9
|
-
from urllib.parse import urljoin
|
|
10
|
-
|
|
11
|
-
import requests
|
|
12
|
-
import urllib3
|
|
13
|
-
from pydantic import BaseModel, Field, HttpUrl
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
class LoginCredentials(BaseModel):
|
|
17
|
-
"""Payload accepted by the `/User/Login` endpoint."""
|
|
18
|
-
|
|
19
|
-
username: str = Field(..., min_length=1)
|
|
20
|
-
password: str = Field(..., min_length=1)
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
class LoginState(BaseModel):
|
|
24
|
-
"""Serialized login state saved on disk by the CLI."""
|
|
25
|
-
|
|
26
|
-
token: str = Field(..., min_length=1)
|
|
27
|
-
username: str = Field(..., min_length=1)
|
|
28
|
-
base_url: HttpUrl
|
|
29
|
-
verify_tls: bool = True
|
|
30
|
-
stored_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
class QrmClientError(RuntimeError):
|
|
34
|
-
"""Raised when the client cannot fulfill a request."""
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
@dataclass
|
|
38
|
-
class QrmClient:
|
|
39
|
-
"""Thin wrapper around the QRM HTTP API."""
|
|
40
|
-
|
|
41
|
-
base_url: str
|
|
42
|
-
verify_tls: bool = True
|
|
43
|
-
timeout: float = 10.0
|
|
44
|
-
session: Optional[requests.Session] = None
|
|
45
|
-
|
|
46
|
-
def __post_init__(self) -> None:
|
|
47
|
-
if not self.base_url:
|
|
48
|
-
raise ValueError("base_url must be provided")
|
|
49
|
-
self.base_url = self.base_url.rstrip("/")
|
|
50
|
-
self.session = self.session or requests.Session()
|
|
51
|
-
self.session.verify = self.verify_tls
|
|
52
|
-
if not self.verify_tls:
|
|
53
|
-
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
|
54
|
-
|
|
55
|
-
def login(self, username: str, password: str) -> str:
|
|
56
|
-
"""Authenticate against `/User/Login` and return the bearer token."""
|
|
57
|
-
|
|
58
|
-
credentials = LoginCredentials(username=username, password=password)
|
|
59
|
-
try:
|
|
60
|
-
response = self.session.post(
|
|
61
|
-
self._build_url("/User/Login"),
|
|
62
|
-
json=credentials.model_dump(),
|
|
63
|
-
timeout=self.timeout,
|
|
64
|
-
)
|
|
65
|
-
response.raise_for_status()
|
|
66
|
-
except requests.RequestException as exc: # pragma: no cover - network failure
|
|
67
|
-
raise QrmClientError("Failed to contact QRM login endpoint") from exc
|
|
68
|
-
|
|
69
|
-
token = self._extract_token(response)
|
|
70
|
-
if not token:
|
|
71
|
-
raise QrmClientError("QRM login response did not contain a token")
|
|
72
|
-
return token
|
|
73
|
-
|
|
74
|
-
def status(self, token: Optional[str] = None) -> Any:
|
|
75
|
-
"""Query the `/Result/Status` endpoint to verify service health."""
|
|
76
|
-
|
|
77
|
-
headers = {"Authorization": f"Bearer {token}"} if token else None
|
|
78
|
-
try:
|
|
79
|
-
response = self.session.get(
|
|
80
|
-
self._build_url("/Result/Status"),
|
|
81
|
-
headers=headers,
|
|
82
|
-
timeout=self.timeout,
|
|
83
|
-
)
|
|
84
|
-
response.raise_for_status()
|
|
85
|
-
except requests.RequestException as exc: # pragma: no cover - network failure
|
|
86
|
-
raise QrmClientError("Failed to contact QRM status endpoint") from exc
|
|
87
|
-
|
|
88
|
-
try:
|
|
89
|
-
return response.json()
|
|
90
|
-
except ValueError:
|
|
91
|
-
return response.text.strip()
|
|
92
|
-
|
|
93
|
-
def uut_status(
|
|
94
|
-
self,
|
|
95
|
-
*,
|
|
96
|
-
token: str,
|
|
97
|
-
serial_number: str,
|
|
98
|
-
start_datetime: Optional[str] = None,
|
|
99
|
-
stop_datetime: Optional[str] = None,
|
|
100
|
-
max_results: Optional[int] = None,
|
|
101
|
-
) -> Any:
|
|
102
|
-
"""Query the `/Uut/Status` endpoint for unit test history."""
|
|
103
|
-
|
|
104
|
-
if not serial_number:
|
|
105
|
-
raise ValueError("serial_number must be provided")
|
|
106
|
-
headers = {"Authorization": f"Bearer {token}"}
|
|
107
|
-
payload: Dict[str, Any] = {
|
|
108
|
-
"serialNumber": serial_number,
|
|
109
|
-
}
|
|
110
|
-
if start_datetime:
|
|
111
|
-
payload["startDateTime"] = start_datetime
|
|
112
|
-
if stop_datetime:
|
|
113
|
-
payload["stopDateTime"] = stop_datetime
|
|
114
|
-
if max_results is not None:
|
|
115
|
-
payload["maxNumberOfResults"] = max_results
|
|
116
|
-
|
|
117
|
-
try:
|
|
118
|
-
response = self.session.post(
|
|
119
|
-
self._build_url("/Uut/Status"),
|
|
120
|
-
headers=headers,
|
|
121
|
-
json=payload,
|
|
122
|
-
timeout=self.timeout,
|
|
123
|
-
)
|
|
124
|
-
response.raise_for_status()
|
|
125
|
-
except requests.RequestException as exc: # pragma: no cover - network failure
|
|
126
|
-
raise QrmClientError("Failed to contact QRM UUT status endpoint") from exc
|
|
127
|
-
|
|
128
|
-
try:
|
|
129
|
-
return response.json()
|
|
130
|
-
except ValueError:
|
|
131
|
-
return response.text.strip()
|
|
132
|
-
|
|
133
|
-
def _build_url(self, path: str) -> str:
|
|
134
|
-
return urljoin(f"{self.base_url}/", path.lstrip("/"))
|
|
135
|
-
|
|
136
|
-
@staticmethod
|
|
137
|
-
def _extract_token(response: requests.Response) -> Optional[str]:
|
|
138
|
-
payload: Any
|
|
139
|
-
try:
|
|
140
|
-
payload = response.json()
|
|
141
|
-
except ValueError:
|
|
142
|
-
payload = None
|
|
143
|
-
|
|
144
|
-
token = QrmClient._token_from_payload(payload)
|
|
145
|
-
if token:
|
|
146
|
-
return token
|
|
147
|
-
|
|
148
|
-
fallback = response.text.strip()
|
|
149
|
-
if fallback:
|
|
150
|
-
token = QrmClient._token_from_payload(fallback)
|
|
151
|
-
if token:
|
|
152
|
-
return token
|
|
153
|
-
return fallback
|
|
154
|
-
return None
|
|
155
|
-
|
|
156
|
-
@staticmethod
|
|
157
|
-
def _token_from_payload(payload: Any) -> Optional[str]:
|
|
158
|
-
if isinstance(payload, Dict):
|
|
159
|
-
for key in (
|
|
160
|
-
"token",
|
|
161
|
-
"Token",
|
|
162
|
-
"accessToken",
|
|
163
|
-
"access_token",
|
|
164
|
-
"bearerToken",
|
|
165
|
-
"value",
|
|
166
|
-
):
|
|
167
|
-
value = payload.get(key)
|
|
168
|
-
if isinstance(value, str) and value.strip():
|
|
169
|
-
return value.strip()
|
|
170
|
-
if isinstance(payload, str):
|
|
171
|
-
trimmed = payload.strip()
|
|
172
|
-
if not trimmed:
|
|
173
|
-
return None
|
|
174
|
-
if trimmed.startswith("{") and trimmed.endswith("}"):
|
|
175
|
-
try:
|
|
176
|
-
return QrmClient._token_from_payload(json.loads(trimmed))
|
|
177
|
-
except json.JSONDecodeError:
|
|
178
|
-
pass
|
|
179
|
-
return trimmed
|
|
180
|
-
return None
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
__all__ = ["LoginCredentials", "LoginState", "QrmClient", "QrmClientError"]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|