lr-gladiator 0.12.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.
Potentially problematic release.
This version of lr-gladiator might be problematic. Click here for more details.
- gladiator/__init__.py +7 -0
- gladiator/arena.py +1119 -0
- gladiator/checksums.py +31 -0
- gladiator/cli.py +433 -0
- gladiator/config.py +60 -0
- lr_gladiator-0.12.0.dist-info/METADATA +198 -0
- lr_gladiator-0.12.0.dist-info/RECORD +11 -0
- lr_gladiator-0.12.0.dist-info/WHEEL +5 -0
- lr_gladiator-0.12.0.dist-info/entry_points.txt +2 -0
- lr_gladiator-0.12.0.dist-info/licenses/LICENSE +25 -0
- lr_gladiator-0.12.0.dist-info/top_level.txt +1 -0
gladiator/checksums.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
#! /usr/bin/env python
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
# src/gladiator/checksums.py
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import hashlib
|
|
7
|
+
import base64
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def sha256_file(path: Path, chunk_size: int = 128 * 1024) -> str:
|
|
11
|
+
"""
|
|
12
|
+
Return the lowercase hex SHA-256 of the file at `path`.
|
|
13
|
+
Streams the file in chunks to support large files.
|
|
14
|
+
"""
|
|
15
|
+
h = hashlib.sha256()
|
|
16
|
+
with open(path, "rb") as f:
|
|
17
|
+
for chunk in iter(lambda: f.read(chunk_size), b""):
|
|
18
|
+
h.update(chunk)
|
|
19
|
+
return h.hexdigest()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def md5_base64_file(path: Path, chunk_size: int = 128 * 1024) -> str:
|
|
23
|
+
"""
|
|
24
|
+
Return base64-encoded MD5 digest of the file at `path`,
|
|
25
|
+
suitable for the Content-MD5 header per RFC 1864.
|
|
26
|
+
"""
|
|
27
|
+
h = hashlib.md5()
|
|
28
|
+
with open(path, "rb") as f:
|
|
29
|
+
for chunk in iter(lambda: f.read(chunk_size), b""):
|
|
30
|
+
h.update(chunk)
|
|
31
|
+
return base64.b64encode(h.digest()).decode("ascii")
|
gladiator/cli.py
ADDED
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
#! /usr/bin/env python
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
# src/gladiator/cli.py
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional
|
|
8
|
+
import typer
|
|
9
|
+
from rich import print
|
|
10
|
+
from rich.table import Table
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
from rich.status import Status
|
|
13
|
+
from getpass import getpass
|
|
14
|
+
import requests
|
|
15
|
+
import sys
|
|
16
|
+
import os
|
|
17
|
+
from urllib.parse import urlparse
|
|
18
|
+
from .config import LoginConfig, save_config, load_config, save_config_raw, CONFIG_PATH
|
|
19
|
+
from .arena import ArenaClient, ArenaError
|
|
20
|
+
|
|
21
|
+
app = typer.Typer(add_completion=False, help="Arena PLM command-line utility")
|
|
22
|
+
console = Console()
|
|
23
|
+
|
|
24
|
+
# --- tiny helper to show a spinner when appropriate ---
|
|
25
|
+
from contextlib import contextmanager
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@contextmanager
|
|
29
|
+
def spinner(message: str, enabled: bool = True):
|
|
30
|
+
"""
|
|
31
|
+
Show a Rich spinner while the body executes.
|
|
32
|
+
Auto-disables if stdout is not a TTY (e.g., CI) or enabled=False.
|
|
33
|
+
"""
|
|
34
|
+
if enabled and sys.stdout.isatty():
|
|
35
|
+
with console.status(message, spinner="dots"):
|
|
36
|
+
yield
|
|
37
|
+
else:
|
|
38
|
+
yield
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@app.command()
|
|
42
|
+
def login(
|
|
43
|
+
username: Optional[str] = typer.Option(
|
|
44
|
+
None, "--username", envvar="GLADIATOR_USERNAME"
|
|
45
|
+
),
|
|
46
|
+
password: Optional[str] = typer.Option(
|
|
47
|
+
None, "--password", envvar="GLADIATOR_PASSWORD"
|
|
48
|
+
),
|
|
49
|
+
base_url: Optional[str] = typer.Option(
|
|
50
|
+
"https://api.arenasolutions.com/v1", help="Arena API base URL"
|
|
51
|
+
),
|
|
52
|
+
verify_tls: bool = typer.Option(True, help="Verify TLS certificates"),
|
|
53
|
+
non_interactive: bool = typer.Option(
|
|
54
|
+
False, "--ci", help="Fail instead of prompting for missing values"
|
|
55
|
+
),
|
|
56
|
+
reason: Optional[str] = typer.Option(
|
|
57
|
+
"CI/CD integration", help="Arena-Usage-Reason header"
|
|
58
|
+
),
|
|
59
|
+
):
|
|
60
|
+
"""Create or update ~/.config/gladiator/login.json for subsequent commands."""
|
|
61
|
+
if not username and not non_interactive:
|
|
62
|
+
username = typer.prompt("Email/username")
|
|
63
|
+
if not password and not non_interactive:
|
|
64
|
+
password = getpass("Password: ")
|
|
65
|
+
if non_interactive and (not username or not password):
|
|
66
|
+
raise typer.BadParameter(
|
|
67
|
+
"Provide --username and --password (or set env vars) for --ci mode"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
# Perform login
|
|
71
|
+
sess = requests.Session()
|
|
72
|
+
sess.verify = verify_tls
|
|
73
|
+
headers = {
|
|
74
|
+
"Content-Type": "application/json",
|
|
75
|
+
"Accept": "application/json",
|
|
76
|
+
"Arena-Usage-Reason": reason or "gladiator/cli",
|
|
77
|
+
"User-Agent": "gladiator-arena/0.1",
|
|
78
|
+
}
|
|
79
|
+
url = f"{(base_url or '').rstrip('/')}/login"
|
|
80
|
+
try:
|
|
81
|
+
with spinner("Logging in…", enabled=sys.stdout.isatty()):
|
|
82
|
+
resp = sess.post(
|
|
83
|
+
url, headers=headers, json={"email": username, "password": password}
|
|
84
|
+
)
|
|
85
|
+
resp.raise_for_status()
|
|
86
|
+
except Exception as e:
|
|
87
|
+
typer.secho(
|
|
88
|
+
f"Login failed: {e} Body: {getattr(resp, 'text', '')[:400]}",
|
|
89
|
+
fg=typer.colors.RED,
|
|
90
|
+
err=True,
|
|
91
|
+
)
|
|
92
|
+
raise typer.Exit(2)
|
|
93
|
+
|
|
94
|
+
data = resp.json()
|
|
95
|
+
data.update({"base_url": base_url, "verify_tls": verify_tls, "reason": reason})
|
|
96
|
+
save_config_raw(data)
|
|
97
|
+
print(f"[green]Saved session to {CONFIG_PATH}[/green]")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _client() -> ArenaClient:
|
|
101
|
+
cfg = load_config()
|
|
102
|
+
return ArenaClient(cfg)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@app.command("latest-approved")
|
|
106
|
+
def latest_approved(
|
|
107
|
+
item: str = typer.Argument(..., help="Item/article number"),
|
|
108
|
+
format: Optional[str] = typer.Option(
|
|
109
|
+
None, "--format", "-f", help="Output format: human (default) or json"
|
|
110
|
+
),
|
|
111
|
+
):
|
|
112
|
+
"""Print latest approved revision for the given item number."""
|
|
113
|
+
json_mode = (format or "").lower() == "json"
|
|
114
|
+
try:
|
|
115
|
+
with spinner(
|
|
116
|
+
f"Resolving latest approved revision for {item}…", enabled=not json_mode
|
|
117
|
+
):
|
|
118
|
+
rev = _client().get_latest_approved_revision(item)
|
|
119
|
+
if json_mode:
|
|
120
|
+
json.dump({"article": item, "revision": rev}, sys.stdout, indent=2)
|
|
121
|
+
sys.stdout.write("\n")
|
|
122
|
+
else:
|
|
123
|
+
print(rev)
|
|
124
|
+
except requests.HTTPError as e:
|
|
125
|
+
typer.secho(f"Arena request failed: {e}", fg=typer.colors.RED, err=True)
|
|
126
|
+
raise typer.Exit(2)
|
|
127
|
+
except ArenaError as e:
|
|
128
|
+
typer.secho(str(e), fg=typer.colors.RED, err=True)
|
|
129
|
+
raise typer.Exit(2)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@app.command("list-files")
|
|
133
|
+
def list_files(
|
|
134
|
+
item: str = typer.Argument(..., help="Item/article number"),
|
|
135
|
+
revision: Optional[str] = typer.Option(
|
|
136
|
+
None,
|
|
137
|
+
"--rev",
|
|
138
|
+
help="Revision selector: WORKING | EFFECTIVE | <label> (default: EFFECTIVE)",
|
|
139
|
+
),
|
|
140
|
+
format: Optional[str] = typer.Option(
|
|
141
|
+
None, "--format", "-f", help="Output format: human (default) or json"
|
|
142
|
+
),
|
|
143
|
+
):
|
|
144
|
+
json_mode = (format or "").lower() == "json"
|
|
145
|
+
try:
|
|
146
|
+
with spinner(
|
|
147
|
+
f"Listing files for {item} ({revision or 'EFFECTIVE'})…",
|
|
148
|
+
enabled=not json_mode,
|
|
149
|
+
):
|
|
150
|
+
files = _client().list_files(item, revision)
|
|
151
|
+
|
|
152
|
+
if json_mode:
|
|
153
|
+
json.dump(
|
|
154
|
+
{"article": item, "revision": revision, "files": files},
|
|
155
|
+
sys.stdout,
|
|
156
|
+
indent=2,
|
|
157
|
+
)
|
|
158
|
+
sys.stdout.write("\n")
|
|
159
|
+
return
|
|
160
|
+
|
|
161
|
+
table = Table(title=f"Files for {item} rev {revision or '(latest approved)'}")
|
|
162
|
+
table.add_column("Title")
|
|
163
|
+
table.add_column("Filename")
|
|
164
|
+
table.add_column("Size", justify="right")
|
|
165
|
+
table.add_column("Edition")
|
|
166
|
+
table.add_column("Type")
|
|
167
|
+
table.add_column("Location")
|
|
168
|
+
for f in files:
|
|
169
|
+
table.add_row(
|
|
170
|
+
str(f.get("title")),
|
|
171
|
+
str(f.get("name")),
|
|
172
|
+
str(f.get("size")),
|
|
173
|
+
str(f.get("edition")),
|
|
174
|
+
str(f.get("storageMethodName") or ""),
|
|
175
|
+
str(f.get("location") or ""),
|
|
176
|
+
)
|
|
177
|
+
print(table)
|
|
178
|
+
except requests.HTTPError as e:
|
|
179
|
+
typer.secho(f"Arena request failed: {e}", fg=typer.colors.RED, err=True)
|
|
180
|
+
raise typer.Exit(2)
|
|
181
|
+
except ArenaError as e:
|
|
182
|
+
typer.secho(str(e), fg=typer.colors.RED, err=True)
|
|
183
|
+
raise typer.Exit(2)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@app.command("bom")
|
|
187
|
+
def bom(
|
|
188
|
+
item: str = typer.Argument(..., help="Item/article number (e.g., 890-1001)"),
|
|
189
|
+
revision: Optional[str] = typer.Option(
|
|
190
|
+
None,
|
|
191
|
+
"--rev",
|
|
192
|
+
help='Revision selector: WORKING, EFFECTIVE (default), or label (e.g., "B2")',
|
|
193
|
+
),
|
|
194
|
+
output: str = typer.Option(
|
|
195
|
+
"table", "--output", help='Output format: "table" (default) or "json"'
|
|
196
|
+
),
|
|
197
|
+
recursive: bool = typer.Option(
|
|
198
|
+
False, "--recursive/--no-recursive", help="Recursively expand subassemblies"
|
|
199
|
+
),
|
|
200
|
+
max_depth: Optional[int] = typer.Option(
|
|
201
|
+
None,
|
|
202
|
+
"--max-depth",
|
|
203
|
+
min=1,
|
|
204
|
+
help="Maximum recursion depth (1 = only children). Omit for unlimited.",
|
|
205
|
+
),
|
|
206
|
+
):
|
|
207
|
+
"""List the BOM lines for an item revision."""
|
|
208
|
+
json_mode = output.lower() == "json"
|
|
209
|
+
try:
|
|
210
|
+
with spinner(
|
|
211
|
+
f"Fetching BOM for {item} ({revision or 'EFFECTIVE'})"
|
|
212
|
+
+ (" [recursive]" if recursive else "")
|
|
213
|
+
+ "…",
|
|
214
|
+
enabled=not json_mode,
|
|
215
|
+
):
|
|
216
|
+
lines = _client().get_bom(
|
|
217
|
+
item, revision, recursive=recursive, max_depth=max_depth
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
if json_mode:
|
|
221
|
+
print(json.dumps({"count": len(lines), "results": lines}, indent=2))
|
|
222
|
+
return
|
|
223
|
+
|
|
224
|
+
title_rev = revision or "(latest approved)"
|
|
225
|
+
table = Table(title=f"BOM for {item} rev {title_rev}")
|
|
226
|
+
table.add_column("Line", justify="right")
|
|
227
|
+
table.add_column("Qty", justify="right")
|
|
228
|
+
table.add_column("Number")
|
|
229
|
+
table.add_column("Name")
|
|
230
|
+
table.add_column("RefDes")
|
|
231
|
+
|
|
232
|
+
for ln in lines:
|
|
233
|
+
lvl = int(ln.get("level", 0) or 0)
|
|
234
|
+
indent = " " * lvl
|
|
235
|
+
table.add_row(
|
|
236
|
+
str(ln.get("lineNumber") or ""),
|
|
237
|
+
str(ln.get("quantity") or ""),
|
|
238
|
+
str(ln.get("itemNumber") or ""),
|
|
239
|
+
f"{indent}{str(ln.get('itemName') or '')}",
|
|
240
|
+
str(ln.get("refDes") or ""),
|
|
241
|
+
)
|
|
242
|
+
print(table)
|
|
243
|
+
except requests.HTTPError as e:
|
|
244
|
+
typer.secho(f"Arena request failed: {e}", fg=typer.colors.RED, err=True)
|
|
245
|
+
raise typer.Exit(2)
|
|
246
|
+
except ArenaError as e:
|
|
247
|
+
typer.secho(str(e), fg=typer.colors.RED, err=True)
|
|
248
|
+
raise typer.Exit(2)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
@app.command("get-files")
|
|
252
|
+
def get_files(
|
|
253
|
+
item: str = typer.Argument(..., help="Item/article number"),
|
|
254
|
+
revision: Optional[str] = typer.Option(
|
|
255
|
+
None, "--rev", help="Revision (default: latest approved)"
|
|
256
|
+
),
|
|
257
|
+
out: Optional[Path] = typer.Option(
|
|
258
|
+
None,
|
|
259
|
+
"--out",
|
|
260
|
+
help="Output directory (default: a folder named after the item number)",
|
|
261
|
+
),
|
|
262
|
+
recursive: bool = typer.Option(
|
|
263
|
+
False,
|
|
264
|
+
"--recursive/--no-recursive",
|
|
265
|
+
help="Recursively download files from subassemblies",
|
|
266
|
+
),
|
|
267
|
+
max_depth: Optional[int] = typer.Option(
|
|
268
|
+
None,
|
|
269
|
+
"--max-depth",
|
|
270
|
+
min=1,
|
|
271
|
+
help="Maximum recursion depth for --recursive (1 = only direct children).",
|
|
272
|
+
),
|
|
273
|
+
):
|
|
274
|
+
json_mode = False # this command prints file paths line-by-line (no JSON mode here)
|
|
275
|
+
try:
|
|
276
|
+
out_dir = out or Path(item)
|
|
277
|
+
with spinner(
|
|
278
|
+
f"Downloading files for {item} ({revision or 'EFFECTIVE'})"
|
|
279
|
+
+ (" [recursive]" if recursive else "")
|
|
280
|
+
+ f" → {out_dir}…",
|
|
281
|
+
enabled=not json_mode,
|
|
282
|
+
):
|
|
283
|
+
if recursive:
|
|
284
|
+
paths = _client().download_files_recursive(
|
|
285
|
+
item, revision, out_dir=out_dir, max_depth=max_depth
|
|
286
|
+
)
|
|
287
|
+
else:
|
|
288
|
+
paths = _client().download_files(item, revision, out_dir=out_dir)
|
|
289
|
+
|
|
290
|
+
for p in paths:
|
|
291
|
+
print(str(p))
|
|
292
|
+
except requests.HTTPError as e:
|
|
293
|
+
typer.secho(f"Arena request failed: {e}", fg=typer.colors.RED, err=True)
|
|
294
|
+
raise typer.Exit(2)
|
|
295
|
+
except ArenaError as e:
|
|
296
|
+
typer.secho(str(e), fg=typer.colors.RED, err=True)
|
|
297
|
+
raise typer.Exit(2)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
@app.command("upload-file")
|
|
301
|
+
def upload_file(
|
|
302
|
+
item: str = typer.Argument(...),
|
|
303
|
+
file: Path = typer.Argument(...),
|
|
304
|
+
reference: Optional[str] = typer.Option(
|
|
305
|
+
None, "--reference", help="Optional reference string"
|
|
306
|
+
),
|
|
307
|
+
title: Optional[str] = typer.Option(
|
|
308
|
+
None,
|
|
309
|
+
"--title",
|
|
310
|
+
help="Override file title (default: filename without extension)",
|
|
311
|
+
),
|
|
312
|
+
category: str = typer.Option(
|
|
313
|
+
"Firmware", "--category", help='File category name (default: "Firmware")'
|
|
314
|
+
),
|
|
315
|
+
file_format: Optional[str] = typer.Option(
|
|
316
|
+
None, "--format", help="File format (default: file extension)"
|
|
317
|
+
),
|
|
318
|
+
description: Optional[str] = typer.Option(
|
|
319
|
+
None, "--desc", help="Optional description"
|
|
320
|
+
),
|
|
321
|
+
primary: bool = typer.Option(
|
|
322
|
+
False, "--primary/--no-primary", help="Mark association as primary"
|
|
323
|
+
),
|
|
324
|
+
edition: str = typer.Option(
|
|
325
|
+
None,
|
|
326
|
+
"--edition",
|
|
327
|
+
help="Edition number when creating a new association (default: SHA256 checksum of file)",
|
|
328
|
+
),
|
|
329
|
+
):
|
|
330
|
+
"""
|
|
331
|
+
Create or update a file.
|
|
332
|
+
If a file with the same filename exists: update its content (new edition).
|
|
333
|
+
Otherwise: create a new association on the WORKING revision (requires --edition)."""
|
|
334
|
+
try:
|
|
335
|
+
with spinner(f"Uploading {file.name} to {item}…", enabled=sys.stdout.isatty()):
|
|
336
|
+
result = _client().upload_file_to_working(
|
|
337
|
+
item,
|
|
338
|
+
file,
|
|
339
|
+
reference,
|
|
340
|
+
title=title,
|
|
341
|
+
category_name=category,
|
|
342
|
+
file_format=file_format,
|
|
343
|
+
description=description,
|
|
344
|
+
primary=primary,
|
|
345
|
+
edition=edition,
|
|
346
|
+
)
|
|
347
|
+
print(json.dumps(result, indent=2))
|
|
348
|
+
except requests.HTTPError as e:
|
|
349
|
+
typer.secho(f"Arena request failed: {e}", fg=typer.colors.RED, err=True)
|
|
350
|
+
raise typer.Exit(2)
|
|
351
|
+
except ArenaError as e:
|
|
352
|
+
typer.secho(str(e), fg=typer.colors.RED, err=True)
|
|
353
|
+
raise typer.Exit(2)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
@app.command("upload-weblink")
|
|
357
|
+
def upload_weblink(
|
|
358
|
+
item: str = typer.Argument(..., help="Item/article number"),
|
|
359
|
+
url: str = typer.Argument(..., help="HTTP(S) URL to associate as a web link"),
|
|
360
|
+
reference: Optional[str] = typer.Option(
|
|
361
|
+
None, "--reference", help="Optional reference string on the association"
|
|
362
|
+
),
|
|
363
|
+
title: Optional[str] = typer.Option(
|
|
364
|
+
None,
|
|
365
|
+
"--title",
|
|
366
|
+
help="File title (default: derived from URL hostname/path)",
|
|
367
|
+
),
|
|
368
|
+
category: str = typer.Option(
|
|
369
|
+
"Source Code", "--category", help='File category name (default: "Source Code")'
|
|
370
|
+
),
|
|
371
|
+
file_format: Optional[str] = typer.Option(
|
|
372
|
+
"url",
|
|
373
|
+
"--format",
|
|
374
|
+
help='File format/extension label (default: "url")',
|
|
375
|
+
),
|
|
376
|
+
description: Optional[str] = typer.Option(
|
|
377
|
+
"None", "--description", help="Optional description"
|
|
378
|
+
),
|
|
379
|
+
primary: bool = typer.Option(
|
|
380
|
+
False,
|
|
381
|
+
"--primary/--no-primary",
|
|
382
|
+
help="Mark association as primary (default: false)",
|
|
383
|
+
),
|
|
384
|
+
edition: Optional[str] = typer.Option(
|
|
385
|
+
None,
|
|
386
|
+
"--edition",
|
|
387
|
+
help="Edition label (default: SHA256(url)[:16])",
|
|
388
|
+
),
|
|
389
|
+
latest_edition_association: bool = typer.Option(
|
|
390
|
+
True,
|
|
391
|
+
"--latest/--no-latest",
|
|
392
|
+
help="Keep association pointed to the latest edition (default: true)",
|
|
393
|
+
),
|
|
394
|
+
):
|
|
395
|
+
"""
|
|
396
|
+
Create or update a 'web link' file on the WORKING revision.
|
|
397
|
+
If a matching link (by URL or title) exists, its File is updated in-place.
|
|
398
|
+
Otherwise a new File is created and associated.
|
|
399
|
+
"""
|
|
400
|
+
# Best-effort default title from URL if not provided
|
|
401
|
+
if not title:
|
|
402
|
+
try:
|
|
403
|
+
u = urlparse(url)
|
|
404
|
+
base = (u.netloc + u.path).rstrip("/") or u.netloc or url
|
|
405
|
+
title = base.split("/")[-1] or base
|
|
406
|
+
except Exception:
|
|
407
|
+
title = url
|
|
408
|
+
|
|
409
|
+
try:
|
|
410
|
+
with spinner(f"Uploading web-link to {item}…", enabled=sys.stdout.isatty()):
|
|
411
|
+
result = _client().upload_weblink_to_working(
|
|
412
|
+
item_number=item,
|
|
413
|
+
url=url,
|
|
414
|
+
reference=reference,
|
|
415
|
+
title=title,
|
|
416
|
+
category_name=category,
|
|
417
|
+
file_format=file_format,
|
|
418
|
+
description=description,
|
|
419
|
+
primary=primary,
|
|
420
|
+
latest_edition_association=latest_edition_association,
|
|
421
|
+
edition=edition,
|
|
422
|
+
)
|
|
423
|
+
print(json.dumps(result, indent=2))
|
|
424
|
+
except requests.HTTPError as e:
|
|
425
|
+
typer.secho(f"Arena request failed: {e}", fg=typer.colors.RED, err=True)
|
|
426
|
+
raise typer.Exit(2)
|
|
427
|
+
except ArenaError as e:
|
|
428
|
+
typer.secho(str(e), fg=typer.colors.RED, err=True)
|
|
429
|
+
raise typer.Exit(2)
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
if __name__ == "__main__":
|
|
433
|
+
app()
|
gladiator/config.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
#! /usr/bin/env python
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
# src/gladiator/config.py
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Optional, Any, Dict
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel, Field, ConfigDict
|
|
12
|
+
|
|
13
|
+
CONFIG_HOME = Path(
|
|
14
|
+
os.environ.get("GLADIATOR_CONFIG_HOME", Path.home() / ".config" / "gladiator")
|
|
15
|
+
)
|
|
16
|
+
CONFIG_PATH = Path(os.environ.get("GLADIATOR_CONFIG", CONFIG_HOME / "login.json"))
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class LoginConfig(BaseModel):
|
|
20
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
21
|
+
|
|
22
|
+
# Primary connection settings
|
|
23
|
+
base_url: str = Field(
|
|
24
|
+
"https://api.arenasolutions.com/v1", description="Arena REST API base URL"
|
|
25
|
+
)
|
|
26
|
+
verify_tls: bool = True
|
|
27
|
+
|
|
28
|
+
# Auth options
|
|
29
|
+
api_key: Optional[str] = None # not used by Arena v1 but kept for future
|
|
30
|
+
username: Optional[str] = None
|
|
31
|
+
password: Optional[str] = None
|
|
32
|
+
|
|
33
|
+
# Session from `/login`
|
|
34
|
+
arena_session_id: Optional[str] = Field(None, alias="arenaSessionId")
|
|
35
|
+
workspace_id: Optional[int] = Field(None, alias="workspaceId")
|
|
36
|
+
workspace_name: Optional[str] = Field(None, alias="workspaceName")
|
|
37
|
+
workspace_request_limit: Optional[int] = Field(None, alias="workspaceRequestLimit")
|
|
38
|
+
reason: Optional[str] = None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def save_config_raw(data: Dict[str, Any], path: Path = CONFIG_PATH) -> None:
|
|
42
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
with open(path, "w") as f:
|
|
44
|
+
json.dump(data, f, indent=2)
|
|
45
|
+
try:
|
|
46
|
+
os.chmod(path, 0o600)
|
|
47
|
+
except PermissionError:
|
|
48
|
+
# Best-effort on non-POSIX filesystems
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def save_config(cfg: LoginConfig, path: Path = CONFIG_PATH) -> None:
|
|
53
|
+
# Respect the provided path (bug fix); keep aliases for compatibility with bash scripts.
|
|
54
|
+
save_config_raw(cfg.model_dump(by_alias=True), path=path)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def load_config(path: Path = CONFIG_PATH) -> LoginConfig:
|
|
58
|
+
with open(path, "r") as f:
|
|
59
|
+
raw = json.load(f)
|
|
60
|
+
return LoginConfig.model_validate(raw)
|