lr-gladiator 0.4.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 CHANGED
@@ -4,4 +4,4 @@
4
4
 
5
5
  __all__ = ["ArenaClient", "load_config", "save_config"]
6
6
  from .arena import ArenaClient
7
- from .config import load_config, save_config
7
+ from .config import load_config, save_config
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
- "Accept": "application/json",
24
- "Content-Type": "application/json",
25
- "User-Agent": "gladiator-arena/0.1",
26
- "Arena-Usage-Reason": cfg.reason or "gladiator/cli",
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
- for k in ("modifiedAt", "updatedAt", "lastModified", "lastModifiedDate", "effectiveDate", "createdAt"):
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
- def list_files(self, item_number: str, revision: Optional[str] = None) -> List[Dict]:
130
- target_guid = self._api_resolve_revision_guid(item_number, revision or "EFFECTIVE")
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(self, item_number: str, revision: Optional[str] = None, out_dir: Path = Path(".")) -> List[Path]:
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(url, stream=True, headers={"arena_session_id": self.cfg.arena_session_id or ""}) as r:
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
- # ---------- API-mode (HTTP) ----------
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 for rv in revs
210
- if (str(rv.get("revisionStatus") or "").upper() == "EFFECTIVE") or (rv.get("status") == 1)
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((rv for rv in effective if not rv.get("supersededDateTime")), None)
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 = current.get("number") or current.get("name") or current.get("revision")
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(f"Could not determine revision label for item {item_number}")
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
- "id": row.get("guid") or row.get("id"), # association id
245
- "fileGuid": file_guid, # actual file id
246
- "name": f.get("name") or f.get("title"),
247
- "filename": f.get("name") or f.get("title"),
248
- "size": f.get("size"),
249
- "checksum": f.get("checksum") or f.get("md5"),
250
- "downloadUrl": f"{self._api_base()}/files/{file_guid}/content" if file_guid else None,
251
- # for “pick latest” helper:
252
- "version": f.get("version") or f.get("edition"),
253
- "updatedAt": f.get("lastModifiedDateTime") or f.get("lastModifiedDate") or f.get("creationDateTime"),
254
- "attachmentGroupGuid": row.get("guid"),
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); r.raise_for_status()
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(lambda rv: (rv.get("revisionStatus") or "").upper() == "WORKING" or rv.get("status") == 0)
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 = [rv for rv in revs if (rv.get("revisionStatus") or "").upper() == "EFFECTIVE" or rv.get("status") == 1]
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("No approved/effective revision exists for this item.")
291
- current = next((rv for rv in eff if not rv.get("supersededDateTime")), eff[-1])
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(lambda rv: (rv.get("number") or rv.get("name")) and str(rv.get("number") or rv.get("name")).upper() == sel)
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); r.raise_for_status()
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
- "id": row.get("guid") or row.get("id"),
313
- "fileGuid": file_guid,
314
- "name": f.get("name") or f.get("title"),
315
- "filename": f.get("name") or f.get("title"),
316
- "size": f.get("size"),
317
- "checksum": f.get("checksum") or f.get("md5"),
318
- "downloadUrl": f"{self._api_base()}/files/{file_guid}/content" if file_guid else None,
319
- "version": f.get("version") or f.get("edition"),
320
- "updatedAt": f.get("lastModifiedDateTime") or f.get("lastModifiedDate") or f.get("creationDateTime"),
321
- "attachmentGroupGuid": row.get("guid"),
322
- })
323
- return norm
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 (rv.get("status") == 0):
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 = [a for a in assocs if a.get("primary") and a.get("latestEditionAssociation")
378
- and ((a.get("file") or {}).get("name") == filename)]
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 = [a for a in assocs if (a.get("file") or {}).get("name") == filename]
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("name") in {"Internal File", None}:
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(f'File category "{category_name}" not found or not allowed.')
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 (file_path.suffix[1:].lower() if file_path.suffix else "bin")
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 = {"content": (file_path.name, open(file_path, "rb"), "application/octet-stream")}
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
- "file.title": title,
439
- "file.description": description,
440
- "file.category.guid": cat_guid,
441
- "file.format": file_format,
442
- "file.edition": str(edition),
443
- "file.storageMethodName": "FILE",
444
- "file.private": "false",
445
- "primary": "true" if primary else "false",
446
- "latestEditionAssociation": "true" if latest_edition_association else "false",
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": f"{self._api_base()}/files/{(f or {}).get('guid')}/content" if f.get("guid") else None,
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 = (results[0].get("guid") or results[0].get("id") or results[0].get("itemId"))
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(cmd, shell=True, check=False, capture_output=True, text=True)
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()
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(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"),
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(False, "--ci", help="Fail instead of prompting for missing values"),
26
- reason: Optional[str] = typer.Option("CI/CD integration", help="Arena-Usage-Reason header"),
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("Provide --username and --password (or set env vars) for --ci mode")
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(url, headers=headers, json={"email": username, "password": password})
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(f"Login failed: {e} Body: {resp.text[:400]}", fg=typer.colors.RED, err=True)
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
- "base_url": base_url,
61
- "verify_tls": verify_tls,
62
- "reason": reason,
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(None, "--format", "-f", help="Output format: human (default) or json"),
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:
@@ -83,6 +104,9 @@ def latest_approved(
83
104
  sys.stdout.write("\n")
84
105
  else:
85
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)
86
110
  except ArenaError as e:
87
111
  typer.secho(str(e), fg=typer.colors.RED, err=True)
88
112
  raise typer.Exit(2)
@@ -91,13 +115,23 @@ def latest_approved(
91
115
  @app.command("list-files")
92
116
  def list_files(
93
117
  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"),
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
+ ),
96
126
  ):
97
127
  try:
98
128
  files = _client().list_files(item, revision)
99
129
  if format == "json":
100
- json.dump({"article": item, "revision": revision, "files": files}, sys.stdout, indent=2)
130
+ json.dump(
131
+ {"article": item, "revision": revision, "files": files},
132
+ sys.stdout,
133
+ indent=2,
134
+ )
101
135
  sys.stdout.write("\n")
102
136
  return
103
137
 
@@ -106,8 +140,71 @@ def list_files(
106
140
  table.add_column("Size", justify="right")
107
141
  table.add_column("Checksum")
108
142
  for f in files:
109
- table.add_row(str(f.get("filename")), str(f.get("size")), str(f.get("checksum")))
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
+ )
110
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)
111
208
  except ArenaError as e:
112
209
  typer.secho(str(e), fg=typer.colors.RED, err=True)
113
210
  raise typer.Exit(2)
@@ -116,13 +213,23 @@ def list_files(
116
213
  @app.command("get-files")
117
214
  def get_files(
118
215
  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"),
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
+ ),
121
224
  ):
122
225
  try:
123
- paths = _client().download_files(item, revision, out_dir=out)
226
+ out_dir = out or Path(item)
227
+ paths = _client().download_files(item, revision, out_dir=out_dir)
124
228
  for p in paths:
125
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)
126
233
  except ArenaError as e:
127
234
  typer.secho(str(e), fg=typer.colors.RED, err=True)
128
235
  raise typer.Exit(2)
@@ -132,23 +239,50 @@ def get_files(
132
239
  def upload_file(
133
240
  item: str = typer.Argument(...),
134
241
  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)"),
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
+ ),
142
267
  ):
143
268
  """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)."""
269
+ Otherwise: create a new association on the WORKING revision (requires --edition)."""
145
270
  try:
146
271
  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
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,
150
281
  )
151
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)
152
286
  except ArenaError as e:
153
287
  typer.secho(str(e), fg=typer.colors.RED, err=True)
154
288
 
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(os.environ.get("GLADIATOR_CONFIG_HOME", Path.home() / ".config" / "gladiator"))
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("https://api.arenasolutions.com/v1", description="Arena REST API base URL")
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.4.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=bRKbt4a4hp57XC6KqZixExxZ2Efp7nCMhpehfovgKRE,21593
3
- gladiator/cli.py,sha256=GP-sBRusZWwjnn5RtT0VhNps6xQzoIj2BFKUysi_lF0,6445
4
- gladiator/config.py,sha256=pnuVrcW8yafxMB7RU9wyi_4jS_oMBIuNryfet203Wng,1738
5
- lr_gladiator-0.4.0.dist-info/licenses/LICENSE,sha256=2CEtbEagerjoU3EDSk-eTM5LKgI_RpiVIOh3_CV4kms,1069
6
- lr_gladiator-0.4.0.dist-info/METADATA,sha256=X3TfGY-kQ5CtL0z2azFPtLCBmvXYQp3ov07I7iJJc64,1912
7
- lr_gladiator-0.4.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
8
- lr_gladiator-0.4.0.dist-info/entry_points.txt,sha256=SLka4w7iGS2B8HrbeZyNk5mxaIC6QKcv93us1OaWNwQ,48
9
- lr_gladiator-0.4.0.dist-info/top_level.txt,sha256=tfrcAmK7_7Lf63w7kWy0wv_Qg9RrcFWGoins1-jGUF4,10
10
- lr_gladiator-0.4.0.dist-info/RECORD,,