lr-qrm 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,93 @@
1
+ Metadata-Version: 2.4
2
+ Name: lr-qrm
3
+ Version: 0.1.0
4
+ Summary: CLI client for interacting with Qestit QRM data from LumenRadio tooling.
5
+ Author-email: Jonas Estberger <jonas.estberger@lumenradio.com>
6
+ License: MIT License
7
+ Requires-Python: >=3.9
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ Requires-Dist: requests>=2.32
11
+ Requires-Dist: typer>=0.12
12
+ Requires-Dist: rich>=13.7
13
+ Requires-Dist: pydantic>=2.8
14
+ Provides-Extra: dev
15
+ Requires-Dist: build>=1.2.1; extra == "dev"
16
+ Requires-Dist: twine>=5.1.1; extra == "dev"
17
+ Requires-Dist: wheel; extra == "dev"
18
+ Requires-Dist: pytest>=8.4.2; extra == "dev"
19
+ Requires-Dist: black>=25.9.0; extra == "dev"
20
+ Requires-Dist: pytest-html; extra == "dev"
21
+ Requires-Dist: pytest-cov; extra == "dev"
22
+ Dynamic: license-file
23
+
24
+ # QRM
25
+
26
+ CLI + Python client for interacting with the Qestit QRM.
27
+
28
+ ## Install
29
+
30
+ ```bash
31
+ pip install lr-qrm
32
+ ```
33
+
34
+ ## Quick start
35
+
36
+ ### Login
37
+
38
+ Interactive login (prompts for username/password):
39
+
40
+ ```bash
41
+ qrm login
42
+ ```
43
+
44
+ Non-interactive (for CI/CD):
45
+
46
+ ```bash
47
+ export QRM_USERNAME="<insert username>"
48
+ export QRM_PASSWORD="<insert password>"
49
+ qrm login --ci
50
+ ```
51
+
52
+ By default, this stores session details at:
53
+
54
+ ```
55
+ ~/.config/qrm/login.json
56
+ ```
57
+
58
+ ### Commands
59
+
60
+
61
+ Get test results for a serial number.
62
+
63
+ ```bash
64
+ qrm get-test-results P00Y00000001
65
+ ```
66
+
67
+
68
+ #### JSON output
69
+
70
+ Most commands support a JSON output mode.
71
+
72
+
73
+ ```bash
74
+ $ qrm get-test-results P00Y00000001 --format json
75
+ ```
76
+
77
+ ## Programmatic use
78
+
79
+ ```python
80
+ from qrm import QrmClient, load_config
81
+
82
+ client = QrmClient(load_config())
83
+ results = client.get_test_results("P00Y00000001")
84
+ ```
85
+
86
+
87
+ ## FAQ
88
+
89
+ - **Where is the config kept?**
90
+ `~/.config/qrm/login.json` (override with `QRM_CONFIG`)
91
+
92
+ - **How do I run non-interactively?**
93
+ Make sure to give all required arguments. Also pass `--ci` to stop output of sensitive information such as username or passwords.
@@ -0,0 +1,10 @@
1
+ lr_qrm-0.1.0.dist-info/licenses/LICENSE,sha256=cEnG3HeMPa3GKtuENkytOQaNJS9rwMMU_KWHObdWbOY,1070
2
+ qrm/__init__.py,sha256=vpsqqB9AKEhCcTdlSNeMF74vKSUG2NuUPEd5uE6u-ws,1069
3
+ qrm/cli.py,sha256=M3f6D5_ROssysCWsWhbdMgsNJOaDWQZux5u5D0LE6II,10602
4
+ qrm/client.py,sha256=-wA06kxdOx1y4xlPASfwFzltUZaFt4KeDqbgMBY8QCY,5979
5
+ qrm/config.py,sha256=TewatdLxqbCa7hBLbqj8hrKbposvbD-Wc1TSiUipzq4,623
6
+ lr_qrm-0.1.0.dist-info/METADATA,sha256=Xa_NmdCeaKlqLa3wqri7Nb8cmGQVeCWUuqcpUhkpnUw,1841
7
+ lr_qrm-0.1.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
8
+ lr_qrm-0.1.0.dist-info/entry_points.txt,sha256=9CaqYoIqOC9O40WoJ6SM1qYk9gP5VVShmMc51zBqas4,36
9
+ lr_qrm-0.1.0.dist-info/top_level.txt,sha256=URCUvNbN-7KWypbbmrCaie8JjKDowz0p4GNhBqnN99U,4
10
+ lr_qrm-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.10.2)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ qrm = qrm.cli:app
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 LumenRadio AB
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ qrm
qrm/__init__.py ADDED
@@ -0,0 +1,38 @@
1
+ """Top-level package for the lr-qrm CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from importlib.metadata import PackageNotFoundError, version as package_version
6
+ from pathlib import Path
7
+
8
+ from .client import LoginState, QrmClient, QrmClientError
9
+
10
+
11
+ def _load_version() -> str:
12
+ try:
13
+ return package_version("lr-qrm")
14
+ except PackageNotFoundError:
15
+ return _version_from_pyproject()
16
+
17
+
18
+ def _version_from_pyproject() -> str:
19
+ pyproject = Path(__file__).resolve().parents[2] / "pyproject.toml"
20
+ if not pyproject.exists(): # pragma: no cover - defensive guard
21
+ raise RuntimeError("pyproject.toml not found; unable to determine version")
22
+
23
+ for raw_line in pyproject.read_text(encoding="utf-8").splitlines():
24
+ line = raw_line.strip()
25
+ if line.startswith("version ="):
26
+ return line.split("=", 1)[1].strip().strip('"').strip("'")
27
+
28
+ raise RuntimeError("Version not declared in pyproject.toml")
29
+
30
+
31
+ __version__ = _load_version()
32
+
33
+ __all__ = [
34
+ "__version__",
35
+ "LoginState",
36
+ "QrmClient",
37
+ "QrmClientError",
38
+ ]
qrm/cli.py ADDED
@@ -0,0 +1,349 @@
1
+ """Typer-powered CLI entry point for lr-qrm."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from datetime import datetime, timedelta, timezone
7
+ from enum import Enum
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ import typer
12
+ from rich.console import Console
13
+ from rich.table import Table
14
+
15
+ from . import __version__
16
+ from .client import LoginState, QrmClient, QrmClientError
17
+ from .config import load_login_state
18
+
19
+ DEFAULT_BASE_URL = "https://qrm.lumenradio.com/api"
20
+ DEFAULT_CONFIG_PATH = Path.home() / ".config" / "qrm" / "login.json"
21
+
22
+
23
+ class OutputFormat(str, Enum):
24
+ rich = "rich"
25
+ json = "json"
26
+
27
+
28
+ app = typer.Typer(help="Interact with the Qestit QRM service.")
29
+ console = Console()
30
+ uut_app = typer.Typer(help="Inspect Units Under Test (UUTs).")
31
+ app.add_typer(uut_app, name="uut")
32
+
33
+ UUT_API_DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
34
+ UUT_CLI_DATE_FORMATS = [
35
+ UUT_API_DATE_FORMAT,
36
+ "%Y-%m-%dT%H:%M:%S",
37
+ "%Y-%m-%dT%H:%M:%S%z",
38
+ ]
39
+ UUT_DEFAULT_WINDOW = timedelta(days=1)
40
+
41
+
42
+ @app.callback(invoke_without_command=True)
43
+ def main(
44
+ ctx: typer.Context,
45
+ version: bool = typer.Option(
46
+ False,
47
+ "--version",
48
+ help="Display the lr-qrm version and exit.",
49
+ rich_help_panel="Global Options",
50
+ ),
51
+ ) -> None:
52
+ """Top-level callback showing help or version when requested."""
53
+
54
+ if version:
55
+ typer.echo(__version__)
56
+ raise typer.Exit()
57
+ if ctx.invoked_subcommand is None:
58
+ typer.echo(ctx.get_help())
59
+ raise typer.Exit()
60
+
61
+
62
+ @app.command()
63
+ def status(
64
+ config_path: Path = typer.Option(
65
+ None,
66
+ "--config-path",
67
+ help="Override the location of the login.json file.",
68
+ ),
69
+ base_url: str = typer.Option(
70
+ None,
71
+ "--base-url",
72
+ help="Override the base URL stored in the config (useful for testing).",
73
+ ),
74
+ insecure: bool = typer.Option(
75
+ False,
76
+ "--insecure",
77
+ help="Disable TLS verification when contacting QRM.",
78
+ ),
79
+ output: OutputFormat = typer.Option(
80
+ OutputFormat.rich,
81
+ "--output",
82
+ "-o",
83
+ case_sensitive=False,
84
+ help="Choose output format: rich (default) or json.",
85
+ ),
86
+ ) -> None:
87
+ """Check QRM health via the `/Result/Status` endpoint."""
88
+
89
+ state = _load_state_or_exit(config_path)
90
+
91
+ client = QrmClient(
92
+ base_url=base_url or str(state.base_url),
93
+ verify_tls=not insecure and state.verify_tls,
94
+ )
95
+
96
+ try:
97
+ payload = client.status(token=state.token)
98
+ except QrmClientError as exc:
99
+ typer.secho(f"Status check failed: {exc}", fg=typer.colors.RED)
100
+ raise typer.Exit(code=1) from exc
101
+
102
+ _emit(payload, output)
103
+
104
+
105
+ @app.command()
106
+ def login(
107
+ username: str = typer.Option(..., "--username", "-u", prompt=True, help="QRM username."),
108
+ password: str = typer.Option(
109
+ ..., "--password", "-p", prompt=True, hide_input=True, help="QRM password."
110
+ ),
111
+ base_url: str = typer.Option(
112
+ DEFAULT_BASE_URL,
113
+ "--base-url",
114
+ "-b",
115
+ help="Base URL for the QRM API.",
116
+ ),
117
+ config_path: Path = typer.Option(
118
+ None,
119
+ "--config-path",
120
+ help="Override the location of the login.json file.",
121
+ ),
122
+ insecure: bool = typer.Option(
123
+ False,
124
+ "--insecure",
125
+ help="Disable TLS verification when contacting QRM.",
126
+ ),
127
+ output: OutputFormat = typer.Option(
128
+ OutputFormat.rich,
129
+ "--output",
130
+ "-o",
131
+ case_sensitive=False,
132
+ help="Choose output format: rich (default) or json.",
133
+ ),
134
+ ) -> None:
135
+ """Authenticate against QRM and store the bearer token locally."""
136
+
137
+ destination = _resolve_config_path(config_path)
138
+ destination.parent.mkdir(parents=True, exist_ok=True)
139
+
140
+ client = QrmClient(base_url=base_url, verify_tls=not insecure)
141
+ try:
142
+ token = client.login(username=username, password=password)
143
+ except QrmClientError as exc:
144
+ typer.secho(f"Login failed: {exc}", fg=typer.colors.RED)
145
+ raise typer.Exit(code=1) from exc
146
+
147
+ state = LoginState(
148
+ token=token,
149
+ username=username,
150
+ base_url=base_url,
151
+ verify_tls=not insecure,
152
+ )
153
+ destination.write_text(state.model_dump_json(indent=2), encoding="utf-8")
154
+
155
+ message = f"[green]Stored login token for {username} at {destination}[/green]"
156
+ payload = {
157
+ "username": username,
158
+ "config_path": str(destination),
159
+ }
160
+ _emit(payload if output is OutputFormat.json else message, output)
161
+
162
+
163
+ @uut_app.command("status")
164
+ def uut_status(
165
+ serial_number: str = typer.Argument(..., help="Serial number to query."),
166
+ start: datetime | None = typer.Option(
167
+ None,
168
+ "--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.",
171
+ ),
172
+ stop: datetime | None = typer.Option(
173
+ None,
174
+ "--stop",
175
+ formats=UUT_CLI_DATE_FORMATS,
176
+ help="Filter until this timestamp (UTC). Format: YYYY-MM-DD HH:MM:SS. Default: now.",
177
+ ),
178
+ max_results: int = typer.Option(
179
+ 1000,
180
+ "--max-results",
181
+ "-m",
182
+ min=1,
183
+ max=5000,
184
+ help="Maximum number of rows to return (default 1000).",
185
+ ),
186
+ config_path: Path = typer.Option(
187
+ None,
188
+ "--config-path",
189
+ help="Override the location of the login.json file.",
190
+ ),
191
+ base_url: str = typer.Option(
192
+ None,
193
+ "--base-url",
194
+ help="Override the base URL stored in the config (useful for testing).",
195
+ ),
196
+ insecure: bool = typer.Option(
197
+ False,
198
+ "--insecure",
199
+ help="Disable TLS verification when contacting QRM.",
200
+ ),
201
+ output: OutputFormat = typer.Option(
202
+ OutputFormat.rich,
203
+ "--output",
204
+ "-o",
205
+ case_sensitive=False,
206
+ help="Choose output format: rich (default) or json.",
207
+ ),
208
+ ) -> None:
209
+ """Fetch UUT execution history using the `/Uut/Status` endpoint."""
210
+
211
+ state = _load_state_or_exit(config_path)
212
+ start, stop = _resolve_uut_window(start, stop)
213
+
214
+ client = QrmClient(
215
+ base_url=base_url or str(state.base_url),
216
+ verify_tls=not insecure and state.verify_tls,
217
+ )
218
+
219
+ try:
220
+ payload = client.uut_status(
221
+ token=state.token,
222
+ serial_number=serial_number,
223
+ start_datetime=_format_uut_datetime(start),
224
+ stop_datetime=_format_uut_datetime(stop),
225
+ max_results=max_results,
226
+ )
227
+ except QrmClientError as exc:
228
+ typer.secho(f"UUT status check failed: {exc}", fg=typer.colors.RED)
229
+ raise typer.Exit(code=1) from exc
230
+
231
+ if output is OutputFormat.json:
232
+ _emit(payload, output)
233
+ return
234
+
235
+ console.print(_render_uut_status(payload))
236
+
237
+
238
+ def _resolve_config_path(config_path: Path | None) -> Path:
239
+ return config_path.expanduser() if config_path else DEFAULT_CONFIG_PATH
240
+
241
+
242
+ def _load_state_or_exit(config_path: Path | None) -> LoginState:
243
+ try:
244
+ return load_login_state(_resolve_config_path(config_path))
245
+ except FileNotFoundError:
246
+ typer.secho(
247
+ "No login state found. Please run `qrm login` first or provide --config-path.",
248
+ fg=typer.colors.RED,
249
+ )
250
+ raise typer.Exit(code=1)
251
+ except Exception as exc: # pragma: no cover - defensive guard
252
+ typer.secho(f"Could not read login state: {exc}", fg=typer.colors.RED)
253
+ raise typer.Exit(code=1) from exc
254
+
255
+
256
+ def _emit(payload: Any, output: OutputFormat) -> None:
257
+ if output is OutputFormat.json:
258
+ typer.echo(json.dumps(payload))
259
+ return
260
+
261
+ if isinstance(payload, str):
262
+ console.print(payload)
263
+ return
264
+
265
+ if isinstance(payload, dict):
266
+ table = Table(show_header=True, header_style="bold")
267
+ table.add_column("Field", style="cyan", no_wrap=True)
268
+ table.add_column("Value", style="green")
269
+ for key, value in payload.items():
270
+ table.add_row(str(key), json.dumps(value) if isinstance(value, (dict, list)) else str(value))
271
+ console.print(table)
272
+ return
273
+
274
+ console.print(payload)
275
+
276
+
277
+ def _resolve_uut_window(
278
+ start: datetime | None,
279
+ stop: datetime | None,
280
+ ) -> tuple[datetime, datetime]:
281
+ stop_value = stop or datetime.now(timezone.utc)
282
+ start_value = start or (stop_value - UUT_DEFAULT_WINDOW)
283
+ return start_value, stop_value
284
+
285
+
286
+ def _format_uut_datetime(value: datetime | None) -> str | None:
287
+ if value is None:
288
+ return None
289
+ if value.tzinfo is None:
290
+ return value.strftime(UUT_API_DATE_FORMAT)
291
+ return value.astimezone(timezone.utc).strftime(UUT_API_DATE_FORMAT)
292
+
293
+
294
+ def _render_uut_status(payload: Any) -> Table:
295
+ table = Table(show_header=True, header_style="bold")
296
+ table.add_column("Result Set", style="cyan")
297
+ table.add_column("Serial", style="cyan")
298
+ table.add_column("Outcome", style="green")
299
+ table.add_column("Start", style="magenta")
300
+ table.add_column("Stop", style="magenta")
301
+ table.add_column("Station", style="yellow")
302
+ table.add_column("Operator", style="yellow")
303
+
304
+ rows = payload if isinstance(payload, list) else ([payload] if payload else [])
305
+
306
+ if not rows:
307
+ table.add_row("No results", "-", "-", "-", "-", "-", "-")
308
+ return table
309
+
310
+ for entry in rows:
311
+ if not isinstance(entry, dict):
312
+ table.add_row(str(entry), "-", "-", "-", "-", "-", "-")
313
+ continue
314
+
315
+ info = entry.get("Info") if isinstance(entry.get("Info"), dict) else {}
316
+ table.add_row(
317
+ str(entry.get("ResultSetName") or entry.get("Id", "-")),
318
+ str(info.get("UutSerialNumber") or entry.get("SerialNumber", _resolve_serial(entry))),
319
+ str(entry.get("Outcome", "-")),
320
+ _format_timestamp(entry.get("StartDateTime")),
321
+ _format_timestamp(entry.get("StopDateTime")),
322
+ str(info.get("StationName") or "-"),
323
+ str(info.get("Operator") or "-"),
324
+ )
325
+
326
+ return table
327
+
328
+
329
+ def _resolve_serial(entry: dict[str, Any]) -> str:
330
+ value = entry.get("serialNumber") or entry.get("SerialNumber")
331
+ return str(value) if value else "-"
332
+
333
+
334
+ def _format_timestamp(value: Any) -> str:
335
+ if not value:
336
+ return "-"
337
+ if isinstance(value, str):
338
+ sanitized = value.replace("Z", "+00:00") if value.endswith("Z") else value
339
+ try:
340
+ dt = datetime.fromisoformat(sanitized)
341
+ return dt.strftime("%Y-%m-%d %H:%M:%S")
342
+ except ValueError:
343
+ return value
344
+ if isinstance(value, datetime):
345
+ return value.strftime("%Y-%m-%d %H:%M:%S")
346
+ return str(value)
347
+
348
+
349
+ __all__ = ["app"]
qrm/client.py ADDED
@@ -0,0 +1,183 @@
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"]
qrm/config.py ADDED
@@ -0,0 +1,26 @@
1
+ """Helpers for loading persisted QRM CLI configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from .client import LoginState
10
+
11
+
12
+ def load_login_state(path: Path) -> LoginState:
13
+ """Load a previously stored :class:`LoginState` from ``path``."""
14
+
15
+ raw = path.read_text(encoding="utf-8")
16
+ data: Any
17
+ try:
18
+ data = json.loads(raw)
19
+ except json.JSONDecodeError as exc: # pragma: no cover - defensive guard
20
+ raise ValueError(f"Invalid JSON in {path}") from exc
21
+
22
+ return LoginState.model_validate(data)
23
+
24
+
25
+ __all__ = ["load_login_state"]
26
+