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 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,15 +208,52 @@ class ArenaClient:
185
208
  edition=edition,
186
209
  )
187
210
 
188
- def get_bom(self, item_number: str, revision: Optional[str] = None) -> List[Dict]:
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
- target_guid = self._api_resolve_revision_guid(item_number, revision or "EFFECTIVE")
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
- # association/line
211
- "guid": row.get("guid"),
212
- "lineNumber": row.get("lineNumber"),
213
- "notes": row.get("notes"),
214
- "quantity": row.get("quantity"),
215
- "refDes": row.get("refDes") or row.get("referenceDesignators") or "",
216
- # child item
217
- "itemGuid": itm.get("guid") or itm.get("id"),
218
- "itemNumber": itm.get("number"),
219
- "itemName": itm.get("name"),
220
- "itemRevision": itm.get("revisionNumber"),
221
- "itemRevisionStatus": itm.get("revisionStatus"),
222
- "itemUrl": (itm.get("url") or {}).get("api"),
223
- "itemAppUrl": (itm.get("url") or {}).get("app"),
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 for rv in revs
248
- 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)
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((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
+ )
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 = 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
+ )
265
389
  if not rev_label:
266
- 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
+ )
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
- "id": row.get("guid") or row.get("id"), # association id
283
- "fileGuid": file_guid, # actual file id
284
- "name": f.get("name") or f.get("title"),
285
- "filename": f.get("name") or f.get("title"),
286
- "size": f.get("size"),
287
- "checksum": f.get("checksum") or f.get("md5"),
288
- "downloadUrl": f"{self._api_base()}/files/{file_guid}/content" if file_guid else None,
289
- # for “pick latest” helper:
290
- "version": f.get("version") or f.get("edition"),
291
- "updatedAt": f.get("lastModifiedDateTime") or f.get("lastModifiedDate") or f.get("creationDateTime"),
292
- "attachmentGroupGuid": row.get("guid"),
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); r.raise_for_status()
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(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
+ )
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 = [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
+ ]
327
470
  if not eff:
328
- raise ArenaError("No approved/effective revision exists for this item. Try using revision 'WORKING'.")
329
- 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
+ )
330
477
  return current.get("guid")
331
478
 
332
479
  # Specific label (e.g., "A", "B2")
333
- 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
+ )
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); r.raise_for_status()
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
- "id": row.get("guid") or row.get("id"),
351
- "fileGuid": file_guid,
352
- "name": f.get("name") or f.get("title"),
353
- "filename": f.get("name") or f.get("title"),
354
- "size": f.get("size"),
355
- "checksum": f.get("checksum") or f.get("md5"),
356
- "downloadUrl": f"{self._api_base()}/files/{file_guid}/content" if file_guid else None,
357
- "version": f.get("version") or f.get("edition"),
358
- "updatedAt": f.get("lastModifiedDateTime") or f.get("lastModifiedDate") or f.get("creationDateTime"),
359
- "attachmentGroupGuid": row.get("guid"),
360
- })
361
- 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
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 (rv.get("status") == 0):
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 = [a for a in assocs if a.get("primary") and a.get("latestEditionAssociation")
416
- 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
+ ]
417
583
  if prim_latest:
418
584
  assoc = prim_latest[0]
419
585
  break
420
- 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
+ ]
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("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}:
463
633
  cat_guid = c.get("guid")
464
634
  break
465
635
  if not cat_guid:
466
- 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
+ )
467
639
 
468
640
  # 3) Prepare multipart (create association)
469
641
  title = title or file_path.stem
470
- 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
+ )
471
645
  description = description or "Uploaded via gladiator"
472
- 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
+ }
473
653
 
474
654
  # NOTE: nested field names are sent in `data`, not `files`
475
655
  data_form = {
476
- "file.title": title,
477
- "file.description": description,
478
- "file.category.guid": cat_guid,
479
- "file.format": file_format,
480
- "file.edition": str(edition),
481
- "file.storageMethodName": "FILE",
482
- "file.private": "false",
483
- "primary": "true" if primary else "false",
484
- "latestEditionAssociation": "true" if latest_edition_association else "false",
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": 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
+ ),
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 = (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
+ )
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(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
+ )
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(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:
@@ -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(None,"--rev",help='Revision selector: WORKING | EFFECTIVE | <label> (default: EFFECTIVE)',),
98
- 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
+ ),
99
126
  ):
100
127
  try:
101
128
  files = _client().list_files(item, revision)
102
129
  if format == "json":
103
- 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
+ )
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(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
+ )
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(None, "--rev", help='Revision selector: WORKING, EFFECTIVE (default), or label (e.g., "B2")'),
125
- output: str = typer.Option("table", "--output", help='Output format: "table" (default) or "json"'),
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(item, revision)
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("itemName") or ""),
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(None, "--rev", help="Revision (default: latest approved)"),
161
- 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
+ ),
162
224
  ):
163
225
  try:
164
- 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)
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(None, "--reference", help="Optional reference string"),
180
- title: Optional[str] = typer.Option(None, "--title", help="Override file title (default: filename without extension)"),
181
- category: str = typer.Option("CAD Data", "--category", help='File category name (default: "CAD Data")'),
182
- file_format: Optional[str] = typer.Option(None, "--format", help="File format (default: file extension)"),
183
- description: Optional[str] = typer.Option(None, "--desc", help="Optional description"),
184
- primary: bool = typer.Option(False, "--primary/--no-primary", help="Mark association as primary"),
185
- 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
+ ),
186
267
  ):
187
268
  """If a file with the same filename exists: update its content (new edition).
188
- Otherwise: create a new association on the WORKING revision (requires --edition)."""
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, file, reference,
192
- title=title, category_name=category, file_format=file_format,
193
- 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,
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(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.5.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,,