lr-gladiator 0.4.0__tar.gz → 0.6.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of lr-gladiator might be problematic. Click here for more details.
- {lr_gladiator-0.4.0/src/lr_gladiator.egg-info → lr_gladiator-0.6.0}/PKG-INFO +1 -1
- {lr_gladiator-0.4.0 → lr_gladiator-0.6.0}/pyproject.toml +1 -1
- {lr_gladiator-0.4.0 → lr_gladiator-0.6.0}/src/gladiator/__init__.py +1 -1
- {lr_gladiator-0.4.0 → lr_gladiator-0.6.0}/src/gladiator/arena.py +301 -73
- lr_gladiator-0.6.0/src/gladiator/cli.py +291 -0
- {lr_gladiator-0.4.0 → lr_gladiator-0.6.0}/src/gladiator/config.py +8 -3
- {lr_gladiator-0.4.0 → lr_gladiator-0.6.0/src/lr_gladiator.egg-info}/PKG-INFO +1 -1
- {lr_gladiator-0.4.0 → lr_gladiator-0.6.0}/tests/test_smoke.py +1 -0
- lr_gladiator-0.4.0/src/gladiator/cli.py +0 -157
- {lr_gladiator-0.4.0 → lr_gladiator-0.6.0}/LICENSE +0 -0
- {lr_gladiator-0.4.0 → lr_gladiator-0.6.0}/README.md +0 -0
- {lr_gladiator-0.4.0 → lr_gladiator-0.6.0}/setup.cfg +0 -0
- {lr_gladiator-0.4.0 → lr_gladiator-0.6.0}/src/lr_gladiator.egg-info/SOURCES.txt +0 -0
- {lr_gladiator-0.4.0 → lr_gladiator-0.6.0}/src/lr_gladiator.egg-info/dependency_links.txt +0 -0
- {lr_gladiator-0.4.0 → lr_gladiator-0.6.0}/src/lr_gladiator.egg-info/entry_points.txt +0 -0
- {lr_gladiator-0.4.0 → lr_gladiator-0.6.0}/src/lr_gladiator.egg-info/requires.txt +0 -0
- {lr_gladiator-0.4.0 → lr_gladiator-0.6.0}/src/lr_gladiator.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: lr-gladiator
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.0
|
|
4
4
|
Summary: CLI and Python client for Arena PLM (app.bom.com): login, get revisions, list/download attachments, and upload to working revisions.
|
|
5
5
|
Author-email: Jonas Estberger <jonas.estberger@lumenradio.com>
|
|
6
6
|
License: MIT
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "lr-gladiator"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.6.0"
|
|
8
8
|
description = "CLI and Python client for Arena PLM (app.bom.com): login, get revisions, list/download attachments, and upload to working revisions."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
@@ -10,21 +10,25 @@ from typing import Dict, List, Optional, Tuple
|
|
|
10
10
|
import requests
|
|
11
11
|
from .config import LoginConfig
|
|
12
12
|
|
|
13
|
+
|
|
13
14
|
class ArenaError(RuntimeError):
|
|
14
15
|
pass
|
|
15
16
|
|
|
17
|
+
|
|
16
18
|
class ArenaClient:
|
|
17
19
|
def __init__(self, cfg: LoginConfig):
|
|
18
20
|
self.cfg = cfg
|
|
19
21
|
self.session = requests.Session()
|
|
20
22
|
self.session.verify = cfg.verify_tls
|
|
21
23
|
# Default headers: explicitly request/submit JSON
|
|
22
|
-
self.session.headers.update(
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
24
|
+
self.session.headers.update(
|
|
25
|
+
{
|
|
26
|
+
"Accept": "application/json",
|
|
27
|
+
"Content-Type": "application/json",
|
|
28
|
+
"User-Agent": "gladiator-arena/0.1",
|
|
29
|
+
"Arena-Usage-Reason": cfg.reason or "gladiator/cli",
|
|
30
|
+
}
|
|
31
|
+
)
|
|
28
32
|
if cfg.arena_session_id:
|
|
29
33
|
self.session.headers.update({"arena_session_id": cfg.arena_session_id})
|
|
30
34
|
|
|
@@ -87,7 +91,15 @@ class ArenaClient:
|
|
|
87
91
|
def _timestamp_of(f: Dict):
|
|
88
92
|
from datetime import datetime
|
|
89
93
|
from email.utils import parsedate_to_datetime
|
|
90
|
-
|
|
94
|
+
|
|
95
|
+
for k in (
|
|
96
|
+
"modifiedAt",
|
|
97
|
+
"updatedAt",
|
|
98
|
+
"lastModified",
|
|
99
|
+
"lastModifiedDate",
|
|
100
|
+
"effectiveDate",
|
|
101
|
+
"createdAt",
|
|
102
|
+
):
|
|
91
103
|
s = f.get(k)
|
|
92
104
|
if not s:
|
|
93
105
|
continue
|
|
@@ -125,14 +137,21 @@ class ArenaClient:
|
|
|
125
137
|
def get_latest_approved_revision(self, item_number: str) -> str:
|
|
126
138
|
return self._api_get_latest_approved(item_number)
|
|
127
139
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
140
|
+
def list_files(
|
|
141
|
+
self, item_number: str, revision: Optional[str] = None
|
|
142
|
+
) -> List[Dict]:
|
|
143
|
+
target_guid = self._api_resolve_revision_guid(
|
|
144
|
+
item_number, revision or "EFFECTIVE"
|
|
145
|
+
)
|
|
131
146
|
raw = self._api_list_files_by_item_guid(target_guid)
|
|
132
147
|
return self._latest_files(raw)
|
|
133
|
-
|
|
134
148
|
|
|
135
|
-
def download_files(
|
|
149
|
+
def download_files(
|
|
150
|
+
self,
|
|
151
|
+
item_number: str,
|
|
152
|
+
revision: Optional[str] = None,
|
|
153
|
+
out_dir: Path = Path("."),
|
|
154
|
+
) -> List[Path]:
|
|
136
155
|
files = self.list_files(item_number, revision)
|
|
137
156
|
out_dir.mkdir(parents=True, exist_ok=True)
|
|
138
157
|
downloaded: List[Path] = []
|
|
@@ -142,7 +161,11 @@ class ArenaClient:
|
|
|
142
161
|
if not url or not filename:
|
|
143
162
|
continue
|
|
144
163
|
p = out_dir / filename
|
|
145
|
-
with self.session.get(
|
|
164
|
+
with self.session.get(
|
|
165
|
+
url,
|
|
166
|
+
stream=True,
|
|
167
|
+
headers={"arena_session_id": self.cfg.arena_session_id or ""},
|
|
168
|
+
) as r:
|
|
146
169
|
r.raise_for_status()
|
|
147
170
|
with open(p, "wb") as fh:
|
|
148
171
|
for chunk in r.iter_content(128 * 1024):
|
|
@@ -185,7 +208,139 @@ class ArenaClient:
|
|
|
185
208
|
edition=edition,
|
|
186
209
|
)
|
|
187
210
|
|
|
188
|
-
|
|
211
|
+
def get_bom(
|
|
212
|
+
self,
|
|
213
|
+
item_number: str,
|
|
214
|
+
revision: Optional[str] = None,
|
|
215
|
+
*,
|
|
216
|
+
recursive: bool = False,
|
|
217
|
+
max_depth: Optional[int] = None,
|
|
218
|
+
) -> List[Dict]:
|
|
219
|
+
"""
|
|
220
|
+
Return a normalized list of BOM lines for the given item.
|
|
221
|
+
|
|
222
|
+
By default this fetches the EFFECTIVE (approved) revision's BOM.
|
|
223
|
+
Use revision="WORKING" or a specific label (e.g., "B2") to override.
|
|
224
|
+
|
|
225
|
+
If recursive=True, expand subassemblies depth-first. max_depth limits the recursion
|
|
226
|
+
depth (1 = only direct children). If omitted, recursion is unlimited.
|
|
227
|
+
"""
|
|
228
|
+
selector = (revision or "EFFECTIVE").strip()
|
|
229
|
+
out: List[Dict] = []
|
|
230
|
+
self._bom_expand(
|
|
231
|
+
root_item=item_number,
|
|
232
|
+
selector=selector,
|
|
233
|
+
out=out,
|
|
234
|
+
recursive=recursive,
|
|
235
|
+
max_depth=max_depth,
|
|
236
|
+
_level=0,
|
|
237
|
+
_seen=set(),
|
|
238
|
+
)
|
|
239
|
+
return out
|
|
240
|
+
|
|
241
|
+
# === Internal: single fetch + normalization (your original logic) ===
|
|
242
|
+
|
|
243
|
+
def _fetch_bom_normalized(self, item_number: str, selector: str) -> List[Dict]:
|
|
244
|
+
"""
|
|
245
|
+
Fetch and normalize the BOM for item_number with the given revision selector.
|
|
246
|
+
Falls back WORKING -> EFFECTIVE if selector is WORKING and no WORKING exists.
|
|
247
|
+
"""
|
|
248
|
+
# 1) Resolve the exact revision GUID we want the BOM for
|
|
249
|
+
try:
|
|
250
|
+
target_guid = self._api_resolve_revision_guid(item_number, selector)
|
|
251
|
+
except ArenaError:
|
|
252
|
+
if selector.strip().upper() == "WORKING":
|
|
253
|
+
# fallback: try EFFECTIVE for children that don't have a WORKING revision
|
|
254
|
+
target_guid = self._api_resolve_revision_guid(item_number, "EFFECTIVE")
|
|
255
|
+
else:
|
|
256
|
+
raise
|
|
257
|
+
|
|
258
|
+
# 2) GET /items/{guid}/bom
|
|
259
|
+
url = f"{self._api_base()}/items/{target_guid}/bom"
|
|
260
|
+
self._log(f"GET {url}")
|
|
261
|
+
r = self.session.get(url)
|
|
262
|
+
r.raise_for_status()
|
|
263
|
+
data = self._ensure_json(r)
|
|
264
|
+
|
|
265
|
+
rows = data.get("results", data if isinstance(data, list) else [])
|
|
266
|
+
norm: List[Dict] = []
|
|
267
|
+
for row in rows:
|
|
268
|
+
itm = row.get("item", {}) if isinstance(row, dict) else {}
|
|
269
|
+
norm.append(
|
|
270
|
+
{
|
|
271
|
+
# association/line
|
|
272
|
+
"guid": row.get("guid"),
|
|
273
|
+
"lineNumber": row.get("lineNumber"),
|
|
274
|
+
"notes": row.get("notes"),
|
|
275
|
+
"quantity": row.get("quantity"),
|
|
276
|
+
"refDes": row.get("refDes")
|
|
277
|
+
or row.get("referenceDesignators")
|
|
278
|
+
or "",
|
|
279
|
+
# child item
|
|
280
|
+
"itemGuid": itm.get("guid") or itm.get("id"),
|
|
281
|
+
"itemNumber": itm.get("number"),
|
|
282
|
+
"itemName": itm.get("name"),
|
|
283
|
+
"itemRevision": itm.get("revisionNumber"),
|
|
284
|
+
"itemRevisionStatus": itm.get("revisionStatus"),
|
|
285
|
+
"itemUrl": (itm.get("url") or {}).get("api"),
|
|
286
|
+
"itemAppUrl": (itm.get("url") or {}).get("app"),
|
|
287
|
+
}
|
|
288
|
+
)
|
|
289
|
+
return norm
|
|
290
|
+
|
|
291
|
+
# === Internal: recursive expansion ===
|
|
292
|
+
|
|
293
|
+
def _bom_expand(
|
|
294
|
+
self,
|
|
295
|
+
*,
|
|
296
|
+
root_item: str,
|
|
297
|
+
selector: str,
|
|
298
|
+
out: List[Dict],
|
|
299
|
+
recursive: bool,
|
|
300
|
+
max_depth: Optional[int],
|
|
301
|
+
_level: int,
|
|
302
|
+
_seen: set,
|
|
303
|
+
) -> None:
|
|
304
|
+
# avoid cycles
|
|
305
|
+
if root_item in _seen:
|
|
306
|
+
return
|
|
307
|
+
_seen.add(root_item)
|
|
308
|
+
|
|
309
|
+
rows = self._fetch_bom_normalized(root_item, selector)
|
|
310
|
+
|
|
311
|
+
# attach level and parentNumber (useful in JSON + for debugging)
|
|
312
|
+
for r in rows:
|
|
313
|
+
r["level"] = _level
|
|
314
|
+
r["parentNumber"] = root_item
|
|
315
|
+
out.append(r)
|
|
316
|
+
|
|
317
|
+
if not recursive:
|
|
318
|
+
return
|
|
319
|
+
|
|
320
|
+
# depth check: if max_depth=1, only expand children once (level 0 -> level 1)
|
|
321
|
+
if max_depth is not None and _level >= max_depth:
|
|
322
|
+
return
|
|
323
|
+
|
|
324
|
+
# expand each child that looks like an assembly (if it has a BOM; empty BOM is okay)
|
|
325
|
+
for r in rows:
|
|
326
|
+
child_num = r.get("itemNumber")
|
|
327
|
+
if not child_num:
|
|
328
|
+
continue
|
|
329
|
+
try:
|
|
330
|
+
# Recurse; keep same selector, with WORKING->EFFECTIVE fallback handled in _fetch_bom_normalized
|
|
331
|
+
self._bom_expand(
|
|
332
|
+
root_item=child_num,
|
|
333
|
+
selector=selector,
|
|
334
|
+
out=out,
|
|
335
|
+
recursive=True,
|
|
336
|
+
max_depth=max_depth,
|
|
337
|
+
_level=_level + 1,
|
|
338
|
+
_seen=_seen,
|
|
339
|
+
)
|
|
340
|
+
except ArenaError:
|
|
341
|
+
# Child might not have a BOM; skip silently
|
|
342
|
+
continue
|
|
343
|
+
|
|
189
344
|
def _api_base(self) -> str:
|
|
190
345
|
return self.cfg.base_url.rstrip("/")
|
|
191
346
|
|
|
@@ -206,26 +361,35 @@ class ArenaClient:
|
|
|
206
361
|
# - revisionStatus == "EFFECTIVE" (string)
|
|
207
362
|
# - OR status == 1 (numeric)
|
|
208
363
|
effective = [
|
|
209
|
-
rv
|
|
210
|
-
|
|
364
|
+
rv
|
|
365
|
+
for rv in revs
|
|
366
|
+
if (str(rv.get("revisionStatus") or "").upper() == "EFFECTIVE")
|
|
367
|
+
or (rv.get("status") == 1)
|
|
211
368
|
]
|
|
212
369
|
if not effective:
|
|
213
370
|
raise ArenaError(f"No approved/released revisions for item {item_number}")
|
|
214
371
|
|
|
215
372
|
# Prefer the one that is not superseded; otherwise fall back to the most recently superseded.
|
|
216
|
-
current = next(
|
|
373
|
+
current = next(
|
|
374
|
+
(rv for rv in effective if not rv.get("supersededDateTime")), None
|
|
375
|
+
)
|
|
217
376
|
if not current:
|
|
218
377
|
# sort by supersededDateTime (None last) then by number/name as a stable tie-breaker
|
|
219
378
|
def _sd(rv):
|
|
220
379
|
dt = rv.get("supersededDateTime")
|
|
221
380
|
return dt or "0000-00-00T00:00:00Z"
|
|
381
|
+
|
|
222
382
|
effective.sort(key=_sd)
|
|
223
383
|
current = effective[-1]
|
|
224
384
|
|
|
225
385
|
# The human-visible revision is under "number" (e.g., "B3"); fall back defensively.
|
|
226
|
-
rev_label =
|
|
386
|
+
rev_label = (
|
|
387
|
+
current.get("number") or current.get("name") or current.get("revision")
|
|
388
|
+
)
|
|
227
389
|
if not rev_label:
|
|
228
|
-
raise ArenaError(
|
|
390
|
+
raise ArenaError(
|
|
391
|
+
f"Could not determine revision label for item {item_number}"
|
|
392
|
+
)
|
|
229
393
|
return rev_label
|
|
230
394
|
|
|
231
395
|
def _api_list_files(self, item_number: str) -> List[Dict]:
|
|
@@ -240,19 +404,27 @@ class ArenaClient:
|
|
|
240
404
|
for row in rows:
|
|
241
405
|
f = row.get("file", {}) if isinstance(row, dict) else {}
|
|
242
406
|
file_guid = f.get("guid") or f.get("id")
|
|
243
|
-
norm.append(
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
407
|
+
norm.append(
|
|
408
|
+
{
|
|
409
|
+
"id": row.get("guid") or row.get("id"), # association id
|
|
410
|
+
"fileGuid": file_guid, # actual file id
|
|
411
|
+
"name": f.get("name") or f.get("title"),
|
|
412
|
+
"filename": f.get("name") or f.get("title"),
|
|
413
|
+
"size": f.get("size"),
|
|
414
|
+
"checksum": f.get("checksum") or f.get("md5"),
|
|
415
|
+
"downloadUrl": (
|
|
416
|
+
f"{self._api_base()}/files/{file_guid}/content"
|
|
417
|
+
if file_guid
|
|
418
|
+
else None
|
|
419
|
+
),
|
|
420
|
+
# for “pick latest” helper:
|
|
421
|
+
"version": f.get("version") or f.get("edition"),
|
|
422
|
+
"updatedAt": f.get("lastModifiedDateTime")
|
|
423
|
+
or f.get("lastModifiedDate")
|
|
424
|
+
or f.get("creationDateTime"),
|
|
425
|
+
"attachmentGroupGuid": row.get("guid"),
|
|
426
|
+
}
|
|
427
|
+
)
|
|
256
428
|
return norm
|
|
257
429
|
|
|
258
430
|
def _api_resolve_revision_guid(self, item_number: str, selector: str | None) -> str:
|
|
@@ -266,7 +438,8 @@ class ArenaClient:
|
|
|
266
438
|
# Fetch revisions
|
|
267
439
|
url = f"{self._api_base()}/items/{effective_guid}/revisions"
|
|
268
440
|
self._log(f"GET {url}")
|
|
269
|
-
r = self.session.get(url)
|
|
441
|
+
r = self.session.get(url)
|
|
442
|
+
r.raise_for_status()
|
|
270
443
|
data = self._ensure_json(r)
|
|
271
444
|
revs = data.get("results", data if isinstance(data, list) else [])
|
|
272
445
|
|
|
@@ -278,21 +451,36 @@ class ArenaClient:
|
|
|
278
451
|
|
|
279
452
|
# Named selectors
|
|
280
453
|
if sel in {"WORKING"}:
|
|
281
|
-
guid = pick(
|
|
454
|
+
guid = pick(
|
|
455
|
+
lambda rv: (rv.get("revisionStatus") or "").upper() == "WORKING"
|
|
456
|
+
or rv.get("status") == 0
|
|
457
|
+
)
|
|
282
458
|
if not guid:
|
|
283
459
|
raise ArenaError("No WORKING revision exists for this item.")
|
|
284
460
|
return guid
|
|
285
461
|
|
|
286
462
|
if sel in {"EFFECTIVE", "APPROVED", "RELEASED"}:
|
|
287
463
|
# Prefer the one not superseded
|
|
288
|
-
eff = [
|
|
464
|
+
eff = [
|
|
465
|
+
rv
|
|
466
|
+
for rv in revs
|
|
467
|
+
if (rv.get("revisionStatus") or "").upper() == "EFFECTIVE"
|
|
468
|
+
or rv.get("status") == 1
|
|
469
|
+
]
|
|
289
470
|
if not eff:
|
|
290
|
-
raise ArenaError(
|
|
291
|
-
|
|
471
|
+
raise ArenaError(
|
|
472
|
+
"No approved/effective revision exists for this item. Try using revision 'WORKING'."
|
|
473
|
+
)
|
|
474
|
+
current = next(
|
|
475
|
+
(rv for rv in eff if not rv.get("supersededDateTime")), eff[-1]
|
|
476
|
+
)
|
|
292
477
|
return current.get("guid")
|
|
293
478
|
|
|
294
479
|
# Specific label (e.g., "A", "B2")
|
|
295
|
-
guid = pick(
|
|
480
|
+
guid = pick(
|
|
481
|
+
lambda rv: (rv.get("number") or rv.get("name"))
|
|
482
|
+
and str(rv.get("number") or rv.get("name")).upper() == sel
|
|
483
|
+
)
|
|
296
484
|
if not guid:
|
|
297
485
|
raise ArenaError(f'Revision "{selector}" not found for item {item_number}.')
|
|
298
486
|
return guid
|
|
@@ -300,7 +488,8 @@ class ArenaClient:
|
|
|
300
488
|
def _api_list_files_by_item_guid(self, item_guid: str) -> list[dict]:
|
|
301
489
|
url = f"{self._api_base()}/items/{item_guid}/files"
|
|
302
490
|
self._log(f"GET {url}")
|
|
303
|
-
r = self.session.get(url)
|
|
491
|
+
r = self.session.get(url)
|
|
492
|
+
r.raise_for_status()
|
|
304
493
|
data = self._ensure_json(r)
|
|
305
494
|
rows = data.get("results", data if isinstance(data, list) else [])
|
|
306
495
|
# … keep existing normalization from _api_list_files() …
|
|
@@ -308,19 +497,27 @@ class ArenaClient:
|
|
|
308
497
|
for row in rows:
|
|
309
498
|
f = row.get("file", {}) if isinstance(row, dict) else {}
|
|
310
499
|
file_guid = f.get("guid") or f.get("id")
|
|
311
|
-
norm.append(
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
500
|
+
norm.append(
|
|
501
|
+
{
|
|
502
|
+
"id": row.get("guid") or row.get("id"),
|
|
503
|
+
"fileGuid": file_guid,
|
|
504
|
+
"name": f.get("name") or f.get("title"),
|
|
505
|
+
"filename": f.get("name") or f.get("title"),
|
|
506
|
+
"size": f.get("size"),
|
|
507
|
+
"checksum": f.get("checksum") or f.get("md5"),
|
|
508
|
+
"downloadUrl": (
|
|
509
|
+
f"{self._api_base()}/files/{file_guid}/content"
|
|
510
|
+
if file_guid
|
|
511
|
+
else None
|
|
512
|
+
),
|
|
513
|
+
"version": f.get("version") or f.get("edition"),
|
|
514
|
+
"updatedAt": f.get("lastModifiedDateTime")
|
|
515
|
+
or f.get("lastModifiedDate")
|
|
516
|
+
or f.get("creationDateTime"),
|
|
517
|
+
"attachmentGroupGuid": row.get("guid"),
|
|
518
|
+
}
|
|
519
|
+
)
|
|
520
|
+
return norm
|
|
324
521
|
|
|
325
522
|
def _api_upload_or_update_file(
|
|
326
523
|
self,
|
|
@@ -351,7 +548,9 @@ class ArenaClient:
|
|
|
351
548
|
rows = data.get("results", data if isinstance(data, list) else [])
|
|
352
549
|
working_guid = None
|
|
353
550
|
for rv in rows:
|
|
354
|
-
if (str(rv.get("revisionStatus") or "").upper() == "WORKING") or (
|
|
551
|
+
if (str(rv.get("revisionStatus") or "").upper() == "WORKING") or (
|
|
552
|
+
rv.get("status") == 0
|
|
553
|
+
):
|
|
355
554
|
working_guid = rv.get("guid")
|
|
356
555
|
break
|
|
357
556
|
if not working_guid:
|
|
@@ -374,12 +573,19 @@ class ArenaClient:
|
|
|
374
573
|
for guid in (working_guid, effective_guid):
|
|
375
574
|
assocs = _list_assocs(guid)
|
|
376
575
|
# prefer primary && latestEditionAssociation, then any by name
|
|
377
|
-
prim_latest = [
|
|
378
|
-
|
|
576
|
+
prim_latest = [
|
|
577
|
+
a
|
|
578
|
+
for a in assocs
|
|
579
|
+
if a.get("primary")
|
|
580
|
+
and a.get("latestEditionAssociation")
|
|
581
|
+
and ((a.get("file") or {}).get("name") == filename)
|
|
582
|
+
]
|
|
379
583
|
if prim_latest:
|
|
380
584
|
assoc = prim_latest[0]
|
|
381
585
|
break
|
|
382
|
-
any_by_name = [
|
|
586
|
+
any_by_name = [
|
|
587
|
+
a for a in assocs if (a.get("file") or {}).get("name") == filename
|
|
588
|
+
]
|
|
383
589
|
if any_by_name:
|
|
384
590
|
assoc = any_by_name[0]
|
|
385
591
|
break
|
|
@@ -421,30 +627,44 @@ class ArenaClient:
|
|
|
421
627
|
cats = self._ensure_json(r).get("results", [])
|
|
422
628
|
cat_guid = None
|
|
423
629
|
for c in cats:
|
|
424
|
-
if c.get("name") == category_name and (c.get("parentCategory") or {}).get(
|
|
630
|
+
if c.get("name") == category_name and (c.get("parentCategory") or {}).get(
|
|
631
|
+
"name"
|
|
632
|
+
) in {"Internal File", None}:
|
|
425
633
|
cat_guid = c.get("guid")
|
|
426
634
|
break
|
|
427
635
|
if not cat_guid:
|
|
428
|
-
raise ArenaError(
|
|
636
|
+
raise ArenaError(
|
|
637
|
+
f'File category "{category_name}" not found or not allowed.'
|
|
638
|
+
)
|
|
429
639
|
|
|
430
640
|
# 3) Prepare multipart (create association)
|
|
431
641
|
title = title or file_path.stem
|
|
432
|
-
file_format = file_format or (
|
|
642
|
+
file_format = file_format or (
|
|
643
|
+
file_path.suffix[1:].lower() if file_path.suffix else "bin"
|
|
644
|
+
)
|
|
433
645
|
description = description or "Uploaded via gladiator"
|
|
434
|
-
files = {
|
|
646
|
+
files = {
|
|
647
|
+
"content": (
|
|
648
|
+
file_path.name,
|
|
649
|
+
open(file_path, "rb"),
|
|
650
|
+
"application/octet-stream",
|
|
651
|
+
)
|
|
652
|
+
}
|
|
435
653
|
|
|
436
654
|
# NOTE: nested field names are sent in `data`, not `files`
|
|
437
655
|
data_form = {
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
656
|
+
"file.title": title,
|
|
657
|
+
"file.description": description,
|
|
658
|
+
"file.category.guid": cat_guid,
|
|
659
|
+
"file.format": file_format,
|
|
660
|
+
"file.edition": str(edition),
|
|
661
|
+
"file.storageMethodName": "FILE",
|
|
662
|
+
"file.private": "false",
|
|
663
|
+
"primary": "true" if primary else "false",
|
|
664
|
+
"latestEditionAssociation": (
|
|
665
|
+
"true" if latest_edition_association else "false"
|
|
666
|
+
),
|
|
667
|
+
}
|
|
448
668
|
if reference:
|
|
449
669
|
data_form["reference"] = reference
|
|
450
670
|
|
|
@@ -480,7 +700,11 @@ class ArenaClient:
|
|
|
480
700
|
"edition": f.get("edition"),
|
|
481
701
|
"lastModifiedDateTime": f.get("lastModifiedDateTime"),
|
|
482
702
|
},
|
|
483
|
-
"downloadUrl":
|
|
703
|
+
"downloadUrl": (
|
|
704
|
+
f"{self._api_base()}/files/{(f or {}).get('guid')}/content"
|
|
705
|
+
if f.get("guid")
|
|
706
|
+
else None
|
|
707
|
+
),
|
|
484
708
|
}
|
|
485
709
|
|
|
486
710
|
def _api_resolve_item_guid(self, item_number: str) -> str:
|
|
@@ -493,11 +717,15 @@ class ArenaClient:
|
|
|
493
717
|
results = data.get("results") if isinstance(data, dict) else data
|
|
494
718
|
if not results:
|
|
495
719
|
raise ArenaError(f"Item number {item_number} not found")
|
|
496
|
-
guid = (
|
|
720
|
+
guid = (
|
|
721
|
+
results[0].get("guid") or results[0].get("id") or results[0].get("itemId")
|
|
722
|
+
)
|
|
497
723
|
if not guid:
|
|
498
724
|
raise ArenaError("API response missing item GUID")
|
|
499
725
|
return guid
|
|
500
726
|
|
|
501
727
|
def _run(self, cmd: str) -> Tuple[int, str, str]:
|
|
502
|
-
proc = subprocess.run(
|
|
728
|
+
proc = subprocess.run(
|
|
729
|
+
cmd, shell=True, check=False, capture_output=True, text=True
|
|
730
|
+
)
|
|
503
731
|
return proc.returncode, proc.stdout.strip(), proc.stderr.strip()
|
|
@@ -0,0 +1,291 @@
|
|
|
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 getpass import getpass
|
|
12
|
+
import requests
|
|
13
|
+
import sys
|
|
14
|
+
from .config import LoginConfig, save_config, load_config, save_config_raw, CONFIG_PATH
|
|
15
|
+
from .arena import ArenaClient, ArenaError
|
|
16
|
+
|
|
17
|
+
app = typer.Typer(add_completion=False, help="Arena PLM command-line utility")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@app.command()
|
|
21
|
+
def login(
|
|
22
|
+
username: Optional[str] = typer.Option(
|
|
23
|
+
None, "--username", envvar="GLADIATOR_USERNAME"
|
|
24
|
+
),
|
|
25
|
+
password: Optional[str] = typer.Option(
|
|
26
|
+
None, "--password", envvar="GLADIATOR_PASSWORD"
|
|
27
|
+
),
|
|
28
|
+
base_url: Optional[str] = typer.Option(
|
|
29
|
+
"https://api.arenasolutions.com/v1", help="Arena API base URL"
|
|
30
|
+
),
|
|
31
|
+
verify_tls: bool = typer.Option(True, help="Verify TLS certificates"),
|
|
32
|
+
non_interactive: bool = typer.Option(
|
|
33
|
+
False, "--ci", help="Fail instead of prompting for missing values"
|
|
34
|
+
),
|
|
35
|
+
reason: Optional[str] = typer.Option(
|
|
36
|
+
"CI/CD integration", help="Arena-Usage-Reason header"
|
|
37
|
+
),
|
|
38
|
+
):
|
|
39
|
+
"""Create or update ~/.config/gladiator/login.json for subsequent commands.
|
|
40
|
+
|
|
41
|
+
This performs a `/login` call against Arena and stores the JSON (including arenaSessionId) in login.json.
|
|
42
|
+
"""
|
|
43
|
+
if not username and not non_interactive:
|
|
44
|
+
username = typer.prompt("Email/username")
|
|
45
|
+
if not password and not non_interactive:
|
|
46
|
+
password = getpass("Password: ")
|
|
47
|
+
if non_interactive and (not username or not password):
|
|
48
|
+
raise typer.BadParameter(
|
|
49
|
+
"Provide --username and --password (or set env vars) for --ci mode"
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
# Perform login
|
|
53
|
+
sess = requests.Session()
|
|
54
|
+
sess.verify = verify_tls
|
|
55
|
+
headers = {
|
|
56
|
+
"Content-Type": "application/json",
|
|
57
|
+
"Accept": "application/json",
|
|
58
|
+
"Arena-Usage-Reason": reason or "gladiator/cli",
|
|
59
|
+
"User-Agent": "gladiator-arena/0.1",
|
|
60
|
+
}
|
|
61
|
+
url = f"{(base_url or '').rstrip('/')}/login"
|
|
62
|
+
resp = sess.post(
|
|
63
|
+
url, headers=headers, json={"email": username, "password": password}
|
|
64
|
+
)
|
|
65
|
+
try:
|
|
66
|
+
resp.raise_for_status()
|
|
67
|
+
except Exception as e:
|
|
68
|
+
typer.secho(
|
|
69
|
+
f"Login failed: {e} Body: {resp.text[:400]}", fg=typer.colors.RED, err=True
|
|
70
|
+
)
|
|
71
|
+
raise typer.Exit(2)
|
|
72
|
+
|
|
73
|
+
data = resp.json()
|
|
74
|
+
|
|
75
|
+
# Merge our client settings alongside the session info into the same file (compatible with your bash scripts)
|
|
76
|
+
data.update(
|
|
77
|
+
{
|
|
78
|
+
"base_url": base_url,
|
|
79
|
+
"verify_tls": verify_tls,
|
|
80
|
+
"reason": reason,
|
|
81
|
+
}
|
|
82
|
+
)
|
|
83
|
+
save_config_raw(data)
|
|
84
|
+
print(f"[green]Saved session to {CONFIG_PATH}[/green]")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _client() -> ArenaClient:
|
|
88
|
+
cfg = load_config()
|
|
89
|
+
return ArenaClient(cfg)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@app.command("latest-approved")
|
|
93
|
+
def latest_approved(
|
|
94
|
+
item: str = typer.Argument(..., help="Item/article number"),
|
|
95
|
+
format: Optional[str] = typer.Option(
|
|
96
|
+
None, "--format", "-f", help="Output format: human (default) or json"
|
|
97
|
+
),
|
|
98
|
+
):
|
|
99
|
+
"""Print latest approved revision for the given item number."""
|
|
100
|
+
try:
|
|
101
|
+
rev = _client().get_latest_approved_revision(item)
|
|
102
|
+
if format == "json":
|
|
103
|
+
json.dump({"article": item, "revision": rev}, sys.stdout, indent=2)
|
|
104
|
+
sys.stdout.write("\n")
|
|
105
|
+
else:
|
|
106
|
+
print(rev)
|
|
107
|
+
except requests.HTTPError as e:
|
|
108
|
+
typer.secho(f"Arena request failed: {e}", fg=typer.colors.RED, err=True)
|
|
109
|
+
raise typer.Exit(2)
|
|
110
|
+
except ArenaError as e:
|
|
111
|
+
typer.secho(str(e), fg=typer.colors.RED, err=True)
|
|
112
|
+
raise typer.Exit(2)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@app.command("list-files")
|
|
116
|
+
def list_files(
|
|
117
|
+
item: str = typer.Argument(..., help="Item/article number"),
|
|
118
|
+
revision: Optional[str] = typer.Option(
|
|
119
|
+
None,
|
|
120
|
+
"--rev",
|
|
121
|
+
help="Revision selector: WORKING | EFFECTIVE | <label> (default: EFFECTIVE)",
|
|
122
|
+
),
|
|
123
|
+
format: Optional[str] = typer.Option(
|
|
124
|
+
None, "--format", "-f", help="Output format: human (default) or json"
|
|
125
|
+
),
|
|
126
|
+
):
|
|
127
|
+
try:
|
|
128
|
+
files = _client().list_files(item, revision)
|
|
129
|
+
if format == "json":
|
|
130
|
+
json.dump(
|
|
131
|
+
{"article": item, "revision": revision, "files": files},
|
|
132
|
+
sys.stdout,
|
|
133
|
+
indent=2,
|
|
134
|
+
)
|
|
135
|
+
sys.stdout.write("\n")
|
|
136
|
+
return
|
|
137
|
+
|
|
138
|
+
table = Table(title=f"Files for {item} rev {revision or '(latest approved)'}")
|
|
139
|
+
table.add_column("Name")
|
|
140
|
+
table.add_column("Size", justify="right")
|
|
141
|
+
table.add_column("Checksum")
|
|
142
|
+
for f in files:
|
|
143
|
+
table.add_row(
|
|
144
|
+
str(f.get("filename")), str(f.get("size")), str(f.get("checksum"))
|
|
145
|
+
)
|
|
146
|
+
print(table)
|
|
147
|
+
except requests.HTTPError as e:
|
|
148
|
+
typer.secho(f"Arena request failed: {e}", fg=typer.colors.RED, err=True)
|
|
149
|
+
raise typer.Exit(2)
|
|
150
|
+
except ArenaError as e:
|
|
151
|
+
typer.secho(str(e), fg=typer.colors.RED, err=True)
|
|
152
|
+
raise typer.Exit(2)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@app.command("bom")
|
|
156
|
+
def bom(
|
|
157
|
+
item: str = typer.Argument(..., help="Item/article number (e.g., 890-1001)"),
|
|
158
|
+
revision: Optional[str] = typer.Option(
|
|
159
|
+
None,
|
|
160
|
+
"--rev",
|
|
161
|
+
help='Revision selector: WORKING, EFFECTIVE (default), or label (e.g., "B2")',
|
|
162
|
+
),
|
|
163
|
+
output: str = typer.Option(
|
|
164
|
+
"table", "--output", help='Output format: "table" (default) or "json"'
|
|
165
|
+
),
|
|
166
|
+
recursive: bool = typer.Option(
|
|
167
|
+
False, "--recursive/--no-recursive", help="Recursively expand subassemblies"
|
|
168
|
+
),
|
|
169
|
+
max_depth: Optional[int] = typer.Option(
|
|
170
|
+
None,
|
|
171
|
+
"--max-depth",
|
|
172
|
+
min=1,
|
|
173
|
+
help="Maximum recursion depth (1 = only children). Omit for unlimited.",
|
|
174
|
+
),
|
|
175
|
+
):
|
|
176
|
+
"""List the BOM lines for an item revision."""
|
|
177
|
+
try:
|
|
178
|
+
lines = _client().get_bom(
|
|
179
|
+
item, revision, recursive=recursive, max_depth=max_depth
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
if output.lower() == "json":
|
|
183
|
+
print(json.dumps({"count": len(lines), "results": lines}, indent=2))
|
|
184
|
+
return
|
|
185
|
+
|
|
186
|
+
title_rev = revision or "(latest approved)"
|
|
187
|
+
table = Table(title=f"BOM for {item} rev {title_rev}")
|
|
188
|
+
table.add_column("Line", justify="right")
|
|
189
|
+
table.add_column("Qty", justify="right")
|
|
190
|
+
table.add_column("Number")
|
|
191
|
+
table.add_column("Name")
|
|
192
|
+
table.add_column("RefDes")
|
|
193
|
+
|
|
194
|
+
for ln in lines:
|
|
195
|
+
lvl = int(ln.get("level", 0) or 0)
|
|
196
|
+
indent = " " * lvl # 2 spaces per level
|
|
197
|
+
table.add_row(
|
|
198
|
+
str(ln.get("lineNumber") or ""),
|
|
199
|
+
str(ln.get("quantity") or ""),
|
|
200
|
+
str(ln.get("itemNumber") or ""),
|
|
201
|
+
f"{indent}{str(ln.get('itemName') or '')}",
|
|
202
|
+
str(ln.get("refDes") or ""),
|
|
203
|
+
)
|
|
204
|
+
print(table)
|
|
205
|
+
except requests.HTTPError as e:
|
|
206
|
+
typer.secho(f"Arena request failed: {e}", fg=typer.colors.RED, err=True)
|
|
207
|
+
raise typer.Exit(2)
|
|
208
|
+
except ArenaError as e:
|
|
209
|
+
typer.secho(str(e), fg=typer.colors.RED, err=True)
|
|
210
|
+
raise typer.Exit(2)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
@app.command("get-files")
|
|
214
|
+
def get_files(
|
|
215
|
+
item: str = typer.Argument(..., help="Item/article number"),
|
|
216
|
+
revision: Optional[str] = typer.Option(
|
|
217
|
+
None, "--rev", help="Revision (default: latest approved)"
|
|
218
|
+
),
|
|
219
|
+
out: Optional[Path] = typer.Option(
|
|
220
|
+
None,
|
|
221
|
+
"--out",
|
|
222
|
+
help="Output directory (default: a folder named after the item number)",
|
|
223
|
+
),
|
|
224
|
+
):
|
|
225
|
+
try:
|
|
226
|
+
out_dir = out or Path(item)
|
|
227
|
+
paths = _client().download_files(item, revision, out_dir=out_dir)
|
|
228
|
+
for p in paths:
|
|
229
|
+
print(str(p))
|
|
230
|
+
except requests.HTTPError as e:
|
|
231
|
+
typer.secho(f"Arena request failed: {e}", fg=typer.colors.RED, err=True)
|
|
232
|
+
raise typer.Exit(2)
|
|
233
|
+
except ArenaError as e:
|
|
234
|
+
typer.secho(str(e), fg=typer.colors.RED, err=True)
|
|
235
|
+
raise typer.Exit(2)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
@app.command("upload-file")
|
|
239
|
+
def upload_file(
|
|
240
|
+
item: str = typer.Argument(...),
|
|
241
|
+
file: Path = typer.Argument(...),
|
|
242
|
+
reference: Optional[str] = typer.Option(
|
|
243
|
+
None, "--reference", help="Optional reference string"
|
|
244
|
+
),
|
|
245
|
+
title: Optional[str] = typer.Option(
|
|
246
|
+
None,
|
|
247
|
+
"--title",
|
|
248
|
+
help="Override file title (default: filename without extension)",
|
|
249
|
+
),
|
|
250
|
+
category: str = typer.Option(
|
|
251
|
+
"CAD Data", "--category", help='File category name (default: "CAD Data")'
|
|
252
|
+
),
|
|
253
|
+
file_format: Optional[str] = typer.Option(
|
|
254
|
+
None, "--format", help="File format (default: file extension)"
|
|
255
|
+
),
|
|
256
|
+
description: Optional[str] = typer.Option(
|
|
257
|
+
None, "--desc", help="Optional description"
|
|
258
|
+
),
|
|
259
|
+
primary: bool = typer.Option(
|
|
260
|
+
False, "--primary/--no-primary", help="Mark association as primary"
|
|
261
|
+
),
|
|
262
|
+
edition: str = typer.Option(
|
|
263
|
+
"1",
|
|
264
|
+
"--edition",
|
|
265
|
+
help="Edition number when creating a new association (default: 1)",
|
|
266
|
+
),
|
|
267
|
+
):
|
|
268
|
+
"""If a file with the same filename exists: update its content (new edition).
|
|
269
|
+
Otherwise: create a new association on the WORKING revision (requires --edition)."""
|
|
270
|
+
try:
|
|
271
|
+
result = _client().upload_file_to_working(
|
|
272
|
+
item,
|
|
273
|
+
file,
|
|
274
|
+
reference,
|
|
275
|
+
title=title,
|
|
276
|
+
category_name=category,
|
|
277
|
+
file_format=file_format,
|
|
278
|
+
description=description,
|
|
279
|
+
primary=primary,
|
|
280
|
+
edition=edition,
|
|
281
|
+
)
|
|
282
|
+
print(json.dumps(result, indent=2))
|
|
283
|
+
except requests.HTTPError as e:
|
|
284
|
+
typer.secho(f"Arena request failed: {e}", fg=typer.colors.RED, err=True)
|
|
285
|
+
raise typer.Exit(2)
|
|
286
|
+
except ArenaError as e:
|
|
287
|
+
typer.secho(str(e), fg=typer.colors.RED, err=True)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
if __name__ == "__main__":
|
|
291
|
+
app()
|
|
@@ -8,12 +8,17 @@ from pathlib import Path
|
|
|
8
8
|
from typing import Optional, Any, Dict
|
|
9
9
|
from pydantic import BaseModel, Field
|
|
10
10
|
|
|
11
|
-
CONFIG_HOME = Path(
|
|
11
|
+
CONFIG_HOME = Path(
|
|
12
|
+
os.environ.get("GLADIATOR_CONFIG_HOME", Path.home() / ".config" / "gladiator")
|
|
13
|
+
)
|
|
12
14
|
CONFIG_PATH = Path(os.environ.get("GLADIATOR_CONFIG", CONFIG_HOME / "login.json"))
|
|
13
15
|
|
|
16
|
+
|
|
14
17
|
class LoginConfig(BaseModel):
|
|
15
18
|
# Primary connection settings
|
|
16
|
-
base_url: str = Field(
|
|
19
|
+
base_url: str = Field(
|
|
20
|
+
"https://api.arenasolutions.com/v1", description="Arena REST API base URL"
|
|
21
|
+
)
|
|
17
22
|
verify_tls: bool = True
|
|
18
23
|
|
|
19
24
|
# Auth options
|
|
@@ -49,4 +54,4 @@ def save_config(cfg: LoginConfig, path: Path = CONFIG_PATH) -> None:
|
|
|
49
54
|
def load_config(path: Path = CONFIG_PATH) -> LoginConfig:
|
|
50
55
|
with open(path, "r") as f:
|
|
51
56
|
raw = json.load(f)
|
|
52
|
-
return LoginConfig(**raw)
|
|
57
|
+
return LoginConfig(**raw)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: lr-gladiator
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.0
|
|
4
4
|
Summary: CLI and Python client for Arena PLM (app.bom.com): login, get revisions, list/download attachments, and upload to working revisions.
|
|
5
5
|
Author-email: Jonas Estberger <jonas.estberger@lumenradio.com>
|
|
6
6
|
License: MIT
|
|
@@ -1,157 +0,0 @@
|
|
|
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 getpass import getpass
|
|
12
|
-
import requests
|
|
13
|
-
import sys
|
|
14
|
-
from .config import LoginConfig, save_config, load_config, save_config_raw, CONFIG_PATH
|
|
15
|
-
from .arena import ArenaClient, ArenaError
|
|
16
|
-
|
|
17
|
-
app = typer.Typer(add_completion=False, help="Arena PLM command-line utility")
|
|
18
|
-
|
|
19
|
-
@app.command()
|
|
20
|
-
def login(
|
|
21
|
-
username: Optional[str] = typer.Option(None, "--username", envvar="GLADIATOR_USERNAME"),
|
|
22
|
-
password: Optional[str] = typer.Option(None, "--password", envvar="GLADIATOR_PASSWORD"),
|
|
23
|
-
base_url: Optional[str] = typer.Option("https://api.arenasolutions.com/v1", help="Arena API base URL"),
|
|
24
|
-
verify_tls: bool = typer.Option(True, help="Verify TLS certificates"),
|
|
25
|
-
non_interactive: bool = typer.Option(False, "--ci", help="Fail instead of prompting for missing values"),
|
|
26
|
-
reason: Optional[str] = typer.Option("CI/CD integration", help="Arena-Usage-Reason header"),
|
|
27
|
-
):
|
|
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
|
-
"""
|
|
32
|
-
if not username and not non_interactive:
|
|
33
|
-
username = typer.prompt("Email/username")
|
|
34
|
-
if not password and not non_interactive:
|
|
35
|
-
password = getpass("Password: ")
|
|
36
|
-
if non_interactive and (not username or not password):
|
|
37
|
-
raise typer.BadParameter("Provide --username and --password (or set env vars) for --ci mode")
|
|
38
|
-
|
|
39
|
-
# Perform login
|
|
40
|
-
sess = requests.Session()
|
|
41
|
-
sess.verify = verify_tls
|
|
42
|
-
headers = {
|
|
43
|
-
"Content-Type": "application/json",
|
|
44
|
-
"Accept": "application/json",
|
|
45
|
-
"Arena-Usage-Reason": reason or "gladiator/cli",
|
|
46
|
-
"User-Agent": "gladiator-arena/0.1",
|
|
47
|
-
}
|
|
48
|
-
url = f"{(base_url or '').rstrip('/')}/login"
|
|
49
|
-
resp = sess.post(url, headers=headers, json={"email": username, "password": password})
|
|
50
|
-
try:
|
|
51
|
-
resp.raise_for_status()
|
|
52
|
-
except Exception as e:
|
|
53
|
-
typer.secho(f"Login failed: {e} Body: {resp.text[:400]}", fg=typer.colors.RED, err=True)
|
|
54
|
-
raise typer.Exit(2)
|
|
55
|
-
|
|
56
|
-
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
|
-
})
|
|
64
|
-
save_config_raw(data)
|
|
65
|
-
print(f"[green]Saved session to {CONFIG_PATH}[/green]")
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
def _client() -> ArenaClient:
|
|
69
|
-
cfg = load_config()
|
|
70
|
-
return ArenaClient(cfg)
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
@app.command("latest-approved")
|
|
74
|
-
def latest_approved(
|
|
75
|
-
item: str = typer.Argument(..., help="Item/article number"),
|
|
76
|
-
format: Optional[str] = typer.Option(None, "--format", "-f", help="Output format: human (default) or json"),
|
|
77
|
-
):
|
|
78
|
-
"""Print latest approved revision for the given item number."""
|
|
79
|
-
try:
|
|
80
|
-
rev = _client().get_latest_approved_revision(item)
|
|
81
|
-
if format == "json":
|
|
82
|
-
json.dump({"article": item, "revision": rev}, sys.stdout, indent=2)
|
|
83
|
-
sys.stdout.write("\n")
|
|
84
|
-
else:
|
|
85
|
-
print(rev)
|
|
86
|
-
except ArenaError as e:
|
|
87
|
-
typer.secho(str(e), fg=typer.colors.RED, err=True)
|
|
88
|
-
raise typer.Exit(2)
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
@app.command("list-files")
|
|
92
|
-
def list_files(
|
|
93
|
-
item: str = typer.Argument(..., help="Item/article number"),
|
|
94
|
-
revision: Optional[str] = typer.Option(None,"--rev",help='Revision selector: WORKING | EFFECTIVE | <label> (default: EFFECTIVE)',),
|
|
95
|
-
format: Optional[str] = typer.Option(None, "--format", "-f", help="Output format: human (default) or json"),
|
|
96
|
-
):
|
|
97
|
-
try:
|
|
98
|
-
files = _client().list_files(item, revision)
|
|
99
|
-
if format == "json":
|
|
100
|
-
json.dump({"article": item, "revision": revision, "files": files}, sys.stdout, indent=2)
|
|
101
|
-
sys.stdout.write("\n")
|
|
102
|
-
return
|
|
103
|
-
|
|
104
|
-
table = Table(title=f"Files for {item} rev {revision or '(latest approved)'}")
|
|
105
|
-
table.add_column("Name")
|
|
106
|
-
table.add_column("Size", justify="right")
|
|
107
|
-
table.add_column("Checksum")
|
|
108
|
-
for f in files:
|
|
109
|
-
table.add_row(str(f.get("filename")), str(f.get("size")), str(f.get("checksum")))
|
|
110
|
-
print(table)
|
|
111
|
-
except ArenaError as e:
|
|
112
|
-
typer.secho(str(e), fg=typer.colors.RED, err=True)
|
|
113
|
-
raise typer.Exit(2)
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
@app.command("get-files")
|
|
117
|
-
def get_files(
|
|
118
|
-
item: str = typer.Argument(..., help="Item/article number"),
|
|
119
|
-
revision: Optional[str] = typer.Option(None, "--rev", help="Revision (default: latest approved)"),
|
|
120
|
-
out: Path = typer.Option(Path("downloads"), "--out", help="Output directory"),
|
|
121
|
-
):
|
|
122
|
-
try:
|
|
123
|
-
paths = _client().download_files(item, revision, out_dir=out)
|
|
124
|
-
for p in paths:
|
|
125
|
-
print(str(p))
|
|
126
|
-
except ArenaError as e:
|
|
127
|
-
typer.secho(str(e), fg=typer.colors.RED, err=True)
|
|
128
|
-
raise typer.Exit(2)
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
@app.command("upload-file")
|
|
132
|
-
def upload_file(
|
|
133
|
-
item: str = typer.Argument(...),
|
|
134
|
-
file: Path = typer.Argument(...),
|
|
135
|
-
reference: Optional[str] = typer.Option(None, "--reference", help="Optional reference string"),
|
|
136
|
-
title: Optional[str] = typer.Option(None, "--title", help="Override file title (default: filename without extension)"),
|
|
137
|
-
category: str = typer.Option("CAD Data", "--category", help='File category name (default: "CAD Data")'),
|
|
138
|
-
file_format: Optional[str] = typer.Option(None, "--format", help="File format (default: file extension)"),
|
|
139
|
-
description: Optional[str] = typer.Option(None, "--desc", help="Optional description"),
|
|
140
|
-
primary: bool = typer.Option(False, "--primary/--no-primary", help="Mark association as primary"),
|
|
141
|
-
edition: str = typer.Option("1", "--edition", help="Edition number when creating a new association (default: 1)"),
|
|
142
|
-
):
|
|
143
|
-
"""If a file with the same filename exists: update its content (new edition).
|
|
144
|
-
Otherwise: create a new association on the WORKING revision (requires --edition)."""
|
|
145
|
-
try:
|
|
146
|
-
result = _client().upload_file_to_working(
|
|
147
|
-
item, file, reference,
|
|
148
|
-
title=title, category_name=category, file_format=file_format,
|
|
149
|
-
description=description, primary=primary, edition=edition
|
|
150
|
-
)
|
|
151
|
-
print(json.dumps(result, indent=2))
|
|
152
|
-
except ArenaError as e:
|
|
153
|
-
typer.secho(str(e), fg=typer.colors.RED, err=True)
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
if __name__ == "__main__":
|
|
157
|
-
app()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|