lr-qrm 0.1.1__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lr-qrm
3
- Version: 0.1.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-01 00:00:00" --stop "2026-02-06 23:59:00"
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 queries the last 24 hours ending now. Pass `--base-url` and `--insecure` to override the stored settings when needed.
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
- token=state.token,
92
- serial_number="326115020010F2F1",
93
- start_datetime="2026-02-01 00:00:00",
94
- stop_datetime="2026-02-06 23:59:00",
95
- max_results=1000,
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
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "lr-qrm"
7
- version = "0.1.1"
7
+ version = "0.1.3"
8
8
  description = "CLI client for interacting with Qestit QRM data from LumenRadio tooling."
9
9
  readme = "src/README.md"
10
10
  requires-python = ">=3.9"
@@ -43,10 +43,18 @@ qrm status
43
43
  #### Inspect UUT history
44
44
 
45
45
  ```bash
46
- qrm uut status 326115020010F2F1 --start "2026-02-01 00:00:00" --stop "2026-02-06 23:59:00"
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 queries the last 24 hours ending now. Pass `--base-url` and `--insecure` to override the stored settings when needed.
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
- token=state.token,
69
- serial_number="326115020010F2F1",
70
- start_datetime="2026-02-01 00:00:00",
71
- stop_datetime="2026-02-06 23:59:00",
72
- max_results=1000,
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.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-01 00:00:00" --stop "2026-02-06 23:59:00"
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 queries the last 24 hours ending now. Pass `--base-url` and `--insecure` to override the stored settings when needed.
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
- token=state.token,
92
- serial_number="326115020010F2F1",
93
- start_datetime="2026-02-01 00:00:00",
94
- stop_datetime="2026-02-06 23:59:00",
95
- max_results=1000,
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
 
@@ -3,7 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import json
6
- from datetime import datetime, timedelta, timezone
6
+ from datetime import datetime, timezone
7
7
  from enum import Enum
8
8
  from pathlib import Path
9
9
  from typing import Any
@@ -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
- UUT_API_DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
34
- UUT_CLI_DATE_FORMATS = [
35
- UUT_API_DATE_FORMAT,
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
- UUT_DEFAULT_WINDOW = timedelta(days=1)
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=UUT_CLI_DATE_FORMATS,
170
- help="Filter from this timestamp (UTC). Format: YYYY-MM-DD HH:MM:SS. Default: stop - 1 day.",
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=UUT_CLI_DATE_FORMATS,
176
- help="Filter until this timestamp (UTC). Format: YYYY-MM-DD HH:MM:SS. Default: now.",
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
- start, stop = _resolve_uut_window(start, stop)
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=_format_uut_datetime(start),
224
- stop_datetime=_format_uut_datetime(stop),
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
- stop_value = stop or datetime.now(timezone.utc)
282
- start_value = start or (stop_value - UUT_DEFAULT_WINDOW)
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 _format_uut_datetime(value: datetime | None) -> str | None:
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
- return value.strftime(UUT_API_DATE_FORMAT)
291
- return value.astimezone(timezone.utc).strftime(UUT_API_DATE_FORMAT)
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-01 00:00:00",
242
- "stop": "2026-02-06 23:59:00",
241
+ "start": "2026-02-01T00:00:00Z",
242
+ "stop": "2026-02-06T23:59:00Z",
243
243
  "max_results": 50,
244
244
  }
245
245
 
@@ -285,7 +285,7 @@ def test_uut_status_command_supports_json_output(monkeypatch, tmp_path: Path) ->
285
285
 
286
286
 
287
287
  def test_uut_status_defaults_time_window(monkeypatch, tmp_path: Path) -> None:
288
- """Start/stop defaults should cover the last 24 hours ending now."""
288
+ """Start defaults to the epoch while stop defaults to the provided value."""
289
289
 
290
290
  state = LoginState( # type: ignore[call-arg]
291
291
  token="TOKEN123",
@@ -296,8 +296,8 @@ def test_uut_status_defaults_time_window(monkeypatch, tmp_path: Path) -> None:
296
296
  config_path = tmp_path / "login.json"
297
297
  config_path.write_text(state.model_dump_json())
298
298
 
299
- fake_start = datetime(2026, 2, 5, 0, 0, tzinfo=timezone.utc)
300
- fake_stop = datetime(2026, 2, 6, 0, 0, tzinfo=timezone.utc)
299
+ fake_start = datetime(1970, 1, 1, 0, 0, tzinfo=timezone.utc)
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,11 +331,22 @@ 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": "2026-02-05 00:00:00",
335
- "stop": "2026-02-06 00:00:00",
334
+ "start": "1970-01-01T00:00:00Z",
335
+ "stop": None,
336
336
  }
337
337
 
338
338
 
339
+ def test_resolve_uut_window_defaults_epoch() -> None:
340
+ """When only stop is provided, start should fall back to the epoch."""
341
+
342
+ stop = datetime(2026, 2, 6, 0, 0, tzinfo=timezone.utc)
343
+
344
+ start, resolved_stop = cli._resolve_uut_window(None, stop)
345
+
346
+ assert start == datetime(1970, 1, 1, 0, 0, tzinfo=timezone.utc)
347
+ assert resolved_stop == stop
348
+
349
+
339
350
  def test_uut_status_handles_empty_payload(monkeypatch, tmp_path: Path) -> None:
340
351
  """An empty list should render the placeholder row."""
341
352
 
@@ -420,6 +431,252 @@ def test_uut_status_handles_irregular_rows(monkeypatch, tmp_path: Path) -> None:
420
431
  assert "12345" in result.stdout
421
432
 
422
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
+
423
680
  def test_cli_displays_help_when_no_command() -> None:
424
681
  """Invoking the CLI without arguments returns the Typer help text."""
425
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
@@ -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