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