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.
- lr_qrm-0.1.0.dist-info/METADATA +93 -0
- lr_qrm-0.1.0.dist-info/RECORD +10 -0
- lr_qrm-0.1.0.dist-info/WHEEL +5 -0
- lr_qrm-0.1.0.dist-info/entry_points.txt +2 -0
- lr_qrm-0.1.0.dist-info/licenses/LICENSE +21 -0
- lr_qrm-0.1.0.dist-info/top_level.txt +1 -0
- qrm/__init__.py +38 -0
- qrm/cli.py +349 -0
- qrm/client.py +183 -0
- qrm/config.py +26 -0
|
@@ -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,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
|
+
|