lr-gladiator 0.5.0__py3-none-any.whl → 0.6.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 +1 -1
- gladiator/arena.py +280 -90
- gladiator/cli.py +123 -36
- gladiator/config.py +8 -3
- {lr_gladiator-0.5.0.dist-info → lr_gladiator-0.6.0.dist-info}/METADATA +1 -1
- lr_gladiator-0.6.0.dist-info/RECORD +10 -0
- lr_gladiator-0.5.0.dist-info/RECORD +0 -10
- {lr_gladiator-0.5.0.dist-info → lr_gladiator-0.6.0.dist-info}/WHEEL +0 -0
- {lr_gladiator-0.5.0.dist-info → lr_gladiator-0.6.0.dist-info}/entry_points.txt +0 -0
- {lr_gladiator-0.5.0.dist-info → lr_gladiator-0.6.0.dist-info}/licenses/LICENSE +0 -0
- {lr_gladiator-0.5.0.dist-info → lr_gladiator-0.6.0.dist-info}/top_level.txt +0 -0
gladiator/__init__.py
CHANGED
gladiator/arena.py
CHANGED
|
@@ -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,15 +208,52 @@ class ArenaClient:
|
|
|
185
208
|
edition=edition,
|
|
186
209
|
)
|
|
187
210
|
|
|
188
|
-
def get_bom(
|
|
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]:
|
|
189
219
|
"""
|
|
190
220
|
Return a normalized list of BOM lines for the given item.
|
|
191
221
|
|
|
192
222
|
By default this fetches the EFFECTIVE (approved) revision's BOM.
|
|
193
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.
|
|
194
247
|
"""
|
|
195
248
|
# 1) Resolve the exact revision GUID we want the BOM for
|
|
196
|
-
|
|
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
|
|
197
257
|
|
|
198
258
|
# 2) GET /items/{guid}/bom
|
|
199
259
|
url = f"{self._api_base()}/items/{target_guid}/bom"
|
|
@@ -206,24 +266,81 @@ class ArenaClient:
|
|
|
206
266
|
norm: List[Dict] = []
|
|
207
267
|
for row in rows:
|
|
208
268
|
itm = row.get("item", {}) if isinstance(row, dict) else {}
|
|
209
|
-
norm.append(
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
+
)
|
|
225
289
|
return norm
|
|
226
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
|
+
|
|
227
344
|
def _api_base(self) -> str:
|
|
228
345
|
return self.cfg.base_url.rstrip("/")
|
|
229
346
|
|
|
@@ -244,26 +361,35 @@ class ArenaClient:
|
|
|
244
361
|
# - revisionStatus == "EFFECTIVE" (string)
|
|
245
362
|
# - OR status == 1 (numeric)
|
|
246
363
|
effective = [
|
|
247
|
-
rv
|
|
248
|
-
|
|
364
|
+
rv
|
|
365
|
+
for rv in revs
|
|
366
|
+
if (str(rv.get("revisionStatus") or "").upper() == "EFFECTIVE")
|
|
367
|
+
or (rv.get("status") == 1)
|
|
249
368
|
]
|
|
250
369
|
if not effective:
|
|
251
370
|
raise ArenaError(f"No approved/released revisions for item {item_number}")
|
|
252
371
|
|
|
253
372
|
# Prefer the one that is not superseded; otherwise fall back to the most recently superseded.
|
|
254
|
-
current = next(
|
|
373
|
+
current = next(
|
|
374
|
+
(rv for rv in effective if not rv.get("supersededDateTime")), None
|
|
375
|
+
)
|
|
255
376
|
if not current:
|
|
256
377
|
# sort by supersededDateTime (None last) then by number/name as a stable tie-breaker
|
|
257
378
|
def _sd(rv):
|
|
258
379
|
dt = rv.get("supersededDateTime")
|
|
259
380
|
return dt or "0000-00-00T00:00:00Z"
|
|
381
|
+
|
|
260
382
|
effective.sort(key=_sd)
|
|
261
383
|
current = effective[-1]
|
|
262
384
|
|
|
263
385
|
# The human-visible revision is under "number" (e.g., "B3"); fall back defensively.
|
|
264
|
-
rev_label =
|
|
386
|
+
rev_label = (
|
|
387
|
+
current.get("number") or current.get("name") or current.get("revision")
|
|
388
|
+
)
|
|
265
389
|
if not rev_label:
|
|
266
|
-
raise ArenaError(
|
|
390
|
+
raise ArenaError(
|
|
391
|
+
f"Could not determine revision label for item {item_number}"
|
|
392
|
+
)
|
|
267
393
|
return rev_label
|
|
268
394
|
|
|
269
395
|
def _api_list_files(self, item_number: str) -> List[Dict]:
|
|
@@ -278,19 +404,27 @@ class ArenaClient:
|
|
|
278
404
|
for row in rows:
|
|
279
405
|
f = row.get("file", {}) if isinstance(row, dict) else {}
|
|
280
406
|
file_guid = f.get("guid") or f.get("id")
|
|
281
|
-
norm.append(
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
+
)
|
|
294
428
|
return norm
|
|
295
429
|
|
|
296
430
|
def _api_resolve_revision_guid(self, item_number: str, selector: str | None) -> str:
|
|
@@ -304,7 +438,8 @@ class ArenaClient:
|
|
|
304
438
|
# Fetch revisions
|
|
305
439
|
url = f"{self._api_base()}/items/{effective_guid}/revisions"
|
|
306
440
|
self._log(f"GET {url}")
|
|
307
|
-
r = self.session.get(url)
|
|
441
|
+
r = self.session.get(url)
|
|
442
|
+
r.raise_for_status()
|
|
308
443
|
data = self._ensure_json(r)
|
|
309
444
|
revs = data.get("results", data if isinstance(data, list) else [])
|
|
310
445
|
|
|
@@ -316,21 +451,36 @@ class ArenaClient:
|
|
|
316
451
|
|
|
317
452
|
# Named selectors
|
|
318
453
|
if sel in {"WORKING"}:
|
|
319
|
-
guid = pick(
|
|
454
|
+
guid = pick(
|
|
455
|
+
lambda rv: (rv.get("revisionStatus") or "").upper() == "WORKING"
|
|
456
|
+
or rv.get("status") == 0
|
|
457
|
+
)
|
|
320
458
|
if not guid:
|
|
321
459
|
raise ArenaError("No WORKING revision exists for this item.")
|
|
322
460
|
return guid
|
|
323
461
|
|
|
324
462
|
if sel in {"EFFECTIVE", "APPROVED", "RELEASED"}:
|
|
325
463
|
# Prefer the one not superseded
|
|
326
|
-
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
|
+
]
|
|
327
470
|
if not eff:
|
|
328
|
-
raise ArenaError(
|
|
329
|
-
|
|
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
|
+
)
|
|
330
477
|
return current.get("guid")
|
|
331
478
|
|
|
332
479
|
# Specific label (e.g., "A", "B2")
|
|
333
|
-
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
|
+
)
|
|
334
484
|
if not guid:
|
|
335
485
|
raise ArenaError(f'Revision "{selector}" not found for item {item_number}.')
|
|
336
486
|
return guid
|
|
@@ -338,7 +488,8 @@ class ArenaClient:
|
|
|
338
488
|
def _api_list_files_by_item_guid(self, item_guid: str) -> list[dict]:
|
|
339
489
|
url = f"{self._api_base()}/items/{item_guid}/files"
|
|
340
490
|
self._log(f"GET {url}")
|
|
341
|
-
r = self.session.get(url)
|
|
491
|
+
r = self.session.get(url)
|
|
492
|
+
r.raise_for_status()
|
|
342
493
|
data = self._ensure_json(r)
|
|
343
494
|
rows = data.get("results", data if isinstance(data, list) else [])
|
|
344
495
|
# … keep existing normalization from _api_list_files() …
|
|
@@ -346,19 +497,27 @@ class ArenaClient:
|
|
|
346
497
|
for row in rows:
|
|
347
498
|
f = row.get("file", {}) if isinstance(row, dict) else {}
|
|
348
499
|
file_guid = f.get("guid") or f.get("id")
|
|
349
|
-
norm.append(
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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
|
|
362
521
|
|
|
363
522
|
def _api_upload_or_update_file(
|
|
364
523
|
self,
|
|
@@ -389,7 +548,9 @@ class ArenaClient:
|
|
|
389
548
|
rows = data.get("results", data if isinstance(data, list) else [])
|
|
390
549
|
working_guid = None
|
|
391
550
|
for rv in rows:
|
|
392
|
-
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
|
+
):
|
|
393
554
|
working_guid = rv.get("guid")
|
|
394
555
|
break
|
|
395
556
|
if not working_guid:
|
|
@@ -412,12 +573,19 @@ class ArenaClient:
|
|
|
412
573
|
for guid in (working_guid, effective_guid):
|
|
413
574
|
assocs = _list_assocs(guid)
|
|
414
575
|
# prefer primary && latestEditionAssociation, then any by name
|
|
415
|
-
prim_latest = [
|
|
416
|
-
|
|
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
|
+
]
|
|
417
583
|
if prim_latest:
|
|
418
584
|
assoc = prim_latest[0]
|
|
419
585
|
break
|
|
420
|
-
any_by_name = [
|
|
586
|
+
any_by_name = [
|
|
587
|
+
a for a in assocs if (a.get("file") or {}).get("name") == filename
|
|
588
|
+
]
|
|
421
589
|
if any_by_name:
|
|
422
590
|
assoc = any_by_name[0]
|
|
423
591
|
break
|
|
@@ -459,30 +627,44 @@ class ArenaClient:
|
|
|
459
627
|
cats = self._ensure_json(r).get("results", [])
|
|
460
628
|
cat_guid = None
|
|
461
629
|
for c in cats:
|
|
462
|
-
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}:
|
|
463
633
|
cat_guid = c.get("guid")
|
|
464
634
|
break
|
|
465
635
|
if not cat_guid:
|
|
466
|
-
raise ArenaError(
|
|
636
|
+
raise ArenaError(
|
|
637
|
+
f'File category "{category_name}" not found or not allowed.'
|
|
638
|
+
)
|
|
467
639
|
|
|
468
640
|
# 3) Prepare multipart (create association)
|
|
469
641
|
title = title or file_path.stem
|
|
470
|
-
file_format = file_format or (
|
|
642
|
+
file_format = file_format or (
|
|
643
|
+
file_path.suffix[1:].lower() if file_path.suffix else "bin"
|
|
644
|
+
)
|
|
471
645
|
description = description or "Uploaded via gladiator"
|
|
472
|
-
files = {
|
|
646
|
+
files = {
|
|
647
|
+
"content": (
|
|
648
|
+
file_path.name,
|
|
649
|
+
open(file_path, "rb"),
|
|
650
|
+
"application/octet-stream",
|
|
651
|
+
)
|
|
652
|
+
}
|
|
473
653
|
|
|
474
654
|
# NOTE: nested field names are sent in `data`, not `files`
|
|
475
655
|
data_form = {
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
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
|
+
}
|
|
486
668
|
if reference:
|
|
487
669
|
data_form["reference"] = reference
|
|
488
670
|
|
|
@@ -518,7 +700,11 @@ class ArenaClient:
|
|
|
518
700
|
"edition": f.get("edition"),
|
|
519
701
|
"lastModifiedDateTime": f.get("lastModifiedDateTime"),
|
|
520
702
|
},
|
|
521
|
-
"downloadUrl":
|
|
703
|
+
"downloadUrl": (
|
|
704
|
+
f"{self._api_base()}/files/{(f or {}).get('guid')}/content"
|
|
705
|
+
if f.get("guid")
|
|
706
|
+
else None
|
|
707
|
+
),
|
|
522
708
|
}
|
|
523
709
|
|
|
524
710
|
def _api_resolve_item_guid(self, item_number: str) -> str:
|
|
@@ -531,11 +717,15 @@ class ArenaClient:
|
|
|
531
717
|
results = data.get("results") if isinstance(data, dict) else data
|
|
532
718
|
if not results:
|
|
533
719
|
raise ArenaError(f"Item number {item_number} not found")
|
|
534
|
-
guid = (
|
|
720
|
+
guid = (
|
|
721
|
+
results[0].get("guid") or results[0].get("id") or results[0].get("itemId")
|
|
722
|
+
)
|
|
535
723
|
if not guid:
|
|
536
724
|
raise ArenaError("API response missing item GUID")
|
|
537
725
|
return guid
|
|
538
726
|
|
|
539
727
|
def _run(self, cmd: str) -> Tuple[int, str, str]:
|
|
540
|
-
proc = subprocess.run(
|
|
728
|
+
proc = subprocess.run(
|
|
729
|
+
cmd, shell=True, check=False, capture_output=True, text=True
|
|
730
|
+
)
|
|
541
731
|
return proc.returncode, proc.stdout.strip(), proc.stderr.strip()
|
gladiator/cli.py
CHANGED
|
@@ -16,14 +16,25 @@ from .arena import ArenaClient, ArenaError
|
|
|
16
16
|
|
|
17
17
|
app = typer.Typer(add_completion=False, help="Arena PLM command-line utility")
|
|
18
18
|
|
|
19
|
+
|
|
19
20
|
@app.command()
|
|
20
21
|
def login(
|
|
21
|
-
username: Optional[str] = typer.Option(
|
|
22
|
-
|
|
23
|
-
|
|
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
|
+
),
|
|
24
31
|
verify_tls: bool = typer.Option(True, help="Verify TLS certificates"),
|
|
25
|
-
non_interactive: bool = typer.Option(
|
|
26
|
-
|
|
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
|
+
),
|
|
27
38
|
):
|
|
28
39
|
"""Create or update ~/.config/gladiator/login.json for subsequent commands.
|
|
29
40
|
|
|
@@ -34,7 +45,9 @@ def login(
|
|
|
34
45
|
if not password and not non_interactive:
|
|
35
46
|
password = getpass("Password: ")
|
|
36
47
|
if non_interactive and (not username or not password):
|
|
37
|
-
raise typer.BadParameter(
|
|
48
|
+
raise typer.BadParameter(
|
|
49
|
+
"Provide --username and --password (or set env vars) for --ci mode"
|
|
50
|
+
)
|
|
38
51
|
|
|
39
52
|
# Perform login
|
|
40
53
|
sess = requests.Session()
|
|
@@ -46,21 +59,27 @@ def login(
|
|
|
46
59
|
"User-Agent": "gladiator-arena/0.1",
|
|
47
60
|
}
|
|
48
61
|
url = f"{(base_url or '').rstrip('/')}/login"
|
|
49
|
-
resp = sess.post(
|
|
62
|
+
resp = sess.post(
|
|
63
|
+
url, headers=headers, json={"email": username, "password": password}
|
|
64
|
+
)
|
|
50
65
|
try:
|
|
51
66
|
resp.raise_for_status()
|
|
52
67
|
except Exception as e:
|
|
53
|
-
typer.secho(
|
|
68
|
+
typer.secho(
|
|
69
|
+
f"Login failed: {e} Body: {resp.text[:400]}", fg=typer.colors.RED, err=True
|
|
70
|
+
)
|
|
54
71
|
raise typer.Exit(2)
|
|
55
72
|
|
|
56
73
|
data = resp.json()
|
|
57
74
|
|
|
58
75
|
# Merge our client settings alongside the session info into the same file (compatible with your bash scripts)
|
|
59
|
-
data.update(
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
76
|
+
data.update(
|
|
77
|
+
{
|
|
78
|
+
"base_url": base_url,
|
|
79
|
+
"verify_tls": verify_tls,
|
|
80
|
+
"reason": reason,
|
|
81
|
+
}
|
|
82
|
+
)
|
|
64
83
|
save_config_raw(data)
|
|
65
84
|
print(f"[green]Saved session to {CONFIG_PATH}[/green]")
|
|
66
85
|
|
|
@@ -73,7 +92,9 @@ def _client() -> ArenaClient:
|
|
|
73
92
|
@app.command("latest-approved")
|
|
74
93
|
def latest_approved(
|
|
75
94
|
item: str = typer.Argument(..., help="Item/article number"),
|
|
76
|
-
format: Optional[str] = typer.Option(
|
|
95
|
+
format: Optional[str] = typer.Option(
|
|
96
|
+
None, "--format", "-f", help="Output format: human (default) or json"
|
|
97
|
+
),
|
|
77
98
|
):
|
|
78
99
|
"""Print latest approved revision for the given item number."""
|
|
79
100
|
try:
|
|
@@ -94,13 +115,23 @@ def latest_approved(
|
|
|
94
115
|
@app.command("list-files")
|
|
95
116
|
def list_files(
|
|
96
117
|
item: str = typer.Argument(..., help="Item/article number"),
|
|
97
|
-
revision: Optional[str] = typer.Option(
|
|
98
|
-
|
|
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
|
+
),
|
|
99
126
|
):
|
|
100
127
|
try:
|
|
101
128
|
files = _client().list_files(item, revision)
|
|
102
129
|
if format == "json":
|
|
103
|
-
json.dump(
|
|
130
|
+
json.dump(
|
|
131
|
+
{"article": item, "revision": revision, "files": files},
|
|
132
|
+
sys.stdout,
|
|
133
|
+
indent=2,
|
|
134
|
+
)
|
|
104
135
|
sys.stdout.write("\n")
|
|
105
136
|
return
|
|
106
137
|
|
|
@@ -109,7 +140,9 @@ def list_files(
|
|
|
109
140
|
table.add_column("Size", justify="right")
|
|
110
141
|
table.add_column("Checksum")
|
|
111
142
|
for f in files:
|
|
112
|
-
table.add_row(
|
|
143
|
+
table.add_row(
|
|
144
|
+
str(f.get("filename")), str(f.get("size")), str(f.get("checksum"))
|
|
145
|
+
)
|
|
113
146
|
print(table)
|
|
114
147
|
except requests.HTTPError as e:
|
|
115
148
|
typer.secho(f"Arena request failed: {e}", fg=typer.colors.RED, err=True)
|
|
@@ -118,15 +151,34 @@ def list_files(
|
|
|
118
151
|
typer.secho(str(e), fg=typer.colors.RED, err=True)
|
|
119
152
|
raise typer.Exit(2)
|
|
120
153
|
|
|
154
|
+
|
|
121
155
|
@app.command("bom")
|
|
122
156
|
def bom(
|
|
123
157
|
item: str = typer.Argument(..., help="Item/article number (e.g., 890-1001)"),
|
|
124
|
-
revision: Optional[str] = typer.Option(
|
|
125
|
-
|
|
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
|
+
),
|
|
126
175
|
):
|
|
127
176
|
"""List the BOM lines for an item revision."""
|
|
128
177
|
try:
|
|
129
|
-
lines = _client().get_bom(
|
|
178
|
+
lines = _client().get_bom(
|
|
179
|
+
item, revision, recursive=recursive, max_depth=max_depth
|
|
180
|
+
)
|
|
181
|
+
|
|
130
182
|
if output.lower() == "json":
|
|
131
183
|
print(json.dumps({"count": len(lines), "results": lines}, indent=2))
|
|
132
184
|
return
|
|
@@ -138,12 +190,15 @@ def bom(
|
|
|
138
190
|
table.add_column("Number")
|
|
139
191
|
table.add_column("Name")
|
|
140
192
|
table.add_column("RefDes")
|
|
193
|
+
|
|
141
194
|
for ln in lines:
|
|
195
|
+
lvl = int(ln.get("level", 0) or 0)
|
|
196
|
+
indent = " " * lvl # 2 spaces per level
|
|
142
197
|
table.add_row(
|
|
143
198
|
str(ln.get("lineNumber") or ""),
|
|
144
199
|
str(ln.get("quantity") or ""),
|
|
145
200
|
str(ln.get("itemNumber") or ""),
|
|
146
|
-
str(ln.get(
|
|
201
|
+
f"{indent}{str(ln.get('itemName') or '')}",
|
|
147
202
|
str(ln.get("refDes") or ""),
|
|
148
203
|
)
|
|
149
204
|
print(table)
|
|
@@ -154,14 +209,22 @@ def bom(
|
|
|
154
209
|
typer.secho(str(e), fg=typer.colors.RED, err=True)
|
|
155
210
|
raise typer.Exit(2)
|
|
156
211
|
|
|
212
|
+
|
|
157
213
|
@app.command("get-files")
|
|
158
214
|
def get_files(
|
|
159
215
|
item: str = typer.Argument(..., help="Item/article number"),
|
|
160
|
-
revision: Optional[str] = typer.Option(
|
|
161
|
-
|
|
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
|
+
),
|
|
162
224
|
):
|
|
163
225
|
try:
|
|
164
|
-
|
|
226
|
+
out_dir = out or Path(item)
|
|
227
|
+
paths = _client().download_files(item, revision, out_dir=out_dir)
|
|
165
228
|
for p in paths:
|
|
166
229
|
print(str(p))
|
|
167
230
|
except requests.HTTPError as e:
|
|
@@ -176,21 +239,45 @@ def get_files(
|
|
|
176
239
|
def upload_file(
|
|
177
240
|
item: str = typer.Argument(...),
|
|
178
241
|
file: Path = typer.Argument(...),
|
|
179
|
-
reference: Optional[str] = typer.Option(
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
+
),
|
|
186
267
|
):
|
|
187
268
|
"""If a file with the same filename exists: update its content (new edition).
|
|
188
|
-
|
|
269
|
+
Otherwise: create a new association on the WORKING revision (requires --edition)."""
|
|
189
270
|
try:
|
|
190
271
|
result = _client().upload_file_to_working(
|
|
191
|
-
item,
|
|
192
|
-
|
|
193
|
-
|
|
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,
|
|
194
281
|
)
|
|
195
282
|
print(json.dumps(result, indent=2))
|
|
196
283
|
except requests.HTTPError as e:
|
gladiator/config.py
CHANGED
|
@@ -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
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
gladiator/__init__.py,sha256=ZeHpVdzARFyIp9QbdTkX0jNqnbRFX5nFQ5RkEFzSRL0,208
|
|
2
|
+
gladiator/arena.py,sha256=fy26XTNbJnC8XdeALZYNrzUhfbncQGUAU_hNvB3onDs,27747
|
|
3
|
+
gladiator/cli.py,sha256=B8zHulDQUldC-WQ37jhkCfyxEPV1KvY3FEsF-Af25Wk,9716
|
|
4
|
+
gladiator/config.py,sha256=oe2UpFv1HcrP1-lVWs_nnex444Igq18BW3nTs9wL__k,1760
|
|
5
|
+
lr_gladiator-0.6.0.dist-info/licenses/LICENSE,sha256=2CEtbEagerjoU3EDSk-eTM5LKgI_RpiVIOh3_CV4kms,1069
|
|
6
|
+
lr_gladiator-0.6.0.dist-info/METADATA,sha256=ohbCWnqTS1baU_cZiMtyBiYiqOtDj8AlkclIPzzl0U0,1912
|
|
7
|
+
lr_gladiator-0.6.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
8
|
+
lr_gladiator-0.6.0.dist-info/entry_points.txt,sha256=SLka4w7iGS2B8HrbeZyNk5mxaIC6QKcv93us1OaWNwQ,48
|
|
9
|
+
lr_gladiator-0.6.0.dist-info/top_level.txt,sha256=tfrcAmK7_7Lf63w7kWy0wv_Qg9RrcFWGoins1-jGUF4,10
|
|
10
|
+
lr_gladiator-0.6.0.dist-info/RECORD,,
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
gladiator/__init__.py,sha256=kVgJiGDD6714tJ3SN6mdao3rdVO57jlMvLMHAFjHX4A,207
|
|
2
|
-
gladiator/arena.py,sha256=Dr6FVDrcsxI98KVPzQY1LaZS2Ixch0BU4qe1qqqez60,23297
|
|
3
|
-
gladiator/cli.py,sha256=EqoIjmnoSPzjckgeiUXjJBt6rEU723bbYOIiEsbhMSY,8530
|
|
4
|
-
gladiator/config.py,sha256=pnuVrcW8yafxMB7RU9wyi_4jS_oMBIuNryfet203Wng,1738
|
|
5
|
-
lr_gladiator-0.5.0.dist-info/licenses/LICENSE,sha256=2CEtbEagerjoU3EDSk-eTM5LKgI_RpiVIOh3_CV4kms,1069
|
|
6
|
-
lr_gladiator-0.5.0.dist-info/METADATA,sha256=Pikc_vdgYG1KFYss8y4pLTsflM7W6d5wgZ5TvDIckC0,1912
|
|
7
|
-
lr_gladiator-0.5.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
8
|
-
lr_gladiator-0.5.0.dist-info/entry_points.txt,sha256=SLka4w7iGS2B8HrbeZyNk5mxaIC6QKcv93us1OaWNwQ,48
|
|
9
|
-
lr_gladiator-0.5.0.dist-info/top_level.txt,sha256=tfrcAmK7_7Lf63w7kWy0wv_Qg9RrcFWGoins1-jGUF4,10
|
|
10
|
-
lr_gladiator-0.5.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|