lr-gladiator 0.4.0__tar.gz → 0.6.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of lr-gladiator might be problematic. Click here for more details.

@@ -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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "lr-gladiator"
7
- version = "0.4.0"
7
+ version = "0.6.0"
8
8
  description = "CLI and Python client for Arena PLM (app.bom.com): login, get revisions, list/download attachments, and upload to working revisions."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -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
@@ -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()
@@ -0,0 +1,291 @@
1
+ #! /usr/bin/env python
2
+ # -*- coding: utf-8 -*-
3
+ # src/gladiator/cli.py
4
+ from __future__ import annotations
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Optional
8
+ import typer
9
+ from rich import print
10
+ from rich.table import Table
11
+ from getpass import getpass
12
+ import requests
13
+ import sys
14
+ from .config import LoginConfig, save_config, load_config, save_config_raw, CONFIG_PATH
15
+ from .arena import ArenaClient, ArenaError
16
+
17
+ app = typer.Typer(add_completion=False, help="Arena PLM command-line utility")
18
+
19
+
20
+ @app.command()
21
+ def login(
22
+ username: Optional[str] = typer.Option(
23
+ None, "--username", envvar="GLADIATOR_USERNAME"
24
+ ),
25
+ password: Optional[str] = typer.Option(
26
+ None, "--password", envvar="GLADIATOR_PASSWORD"
27
+ ),
28
+ base_url: Optional[str] = typer.Option(
29
+ "https://api.arenasolutions.com/v1", help="Arena API base URL"
30
+ ),
31
+ verify_tls: bool = typer.Option(True, help="Verify TLS certificates"),
32
+ non_interactive: bool = typer.Option(
33
+ False, "--ci", help="Fail instead of prompting for missing values"
34
+ ),
35
+ reason: Optional[str] = typer.Option(
36
+ "CI/CD integration", help="Arena-Usage-Reason header"
37
+ ),
38
+ ):
39
+ """Create or update ~/.config/gladiator/login.json for subsequent commands.
40
+
41
+ This performs a `/login` call against Arena and stores the JSON (including arenaSessionId) in login.json.
42
+ """
43
+ if not username and not non_interactive:
44
+ username = typer.prompt("Email/username")
45
+ if not password and not non_interactive:
46
+ password = getpass("Password: ")
47
+ if non_interactive and (not username or not password):
48
+ raise typer.BadParameter(
49
+ "Provide --username and --password (or set env vars) for --ci mode"
50
+ )
51
+
52
+ # Perform login
53
+ sess = requests.Session()
54
+ sess.verify = verify_tls
55
+ headers = {
56
+ "Content-Type": "application/json",
57
+ "Accept": "application/json",
58
+ "Arena-Usage-Reason": reason or "gladiator/cli",
59
+ "User-Agent": "gladiator-arena/0.1",
60
+ }
61
+ url = f"{(base_url or '').rstrip('/')}/login"
62
+ resp = sess.post(
63
+ url, headers=headers, json={"email": username, "password": password}
64
+ )
65
+ try:
66
+ resp.raise_for_status()
67
+ except Exception as e:
68
+ typer.secho(
69
+ f"Login failed: {e} Body: {resp.text[:400]}", fg=typer.colors.RED, err=True
70
+ )
71
+ raise typer.Exit(2)
72
+
73
+ data = resp.json()
74
+
75
+ # Merge our client settings alongside the session info into the same file (compatible with your bash scripts)
76
+ data.update(
77
+ {
78
+ "base_url": base_url,
79
+ "verify_tls": verify_tls,
80
+ "reason": reason,
81
+ }
82
+ )
83
+ save_config_raw(data)
84
+ print(f"[green]Saved session to {CONFIG_PATH}[/green]")
85
+
86
+
87
+ def _client() -> ArenaClient:
88
+ cfg = load_config()
89
+ return ArenaClient(cfg)
90
+
91
+
92
+ @app.command("latest-approved")
93
+ def latest_approved(
94
+ item: str = typer.Argument(..., help="Item/article number"),
95
+ format: Optional[str] = typer.Option(
96
+ None, "--format", "-f", help="Output format: human (default) or json"
97
+ ),
98
+ ):
99
+ """Print latest approved revision for the given item number."""
100
+ try:
101
+ rev = _client().get_latest_approved_revision(item)
102
+ if format == "json":
103
+ json.dump({"article": item, "revision": rev}, sys.stdout, indent=2)
104
+ sys.stdout.write("\n")
105
+ else:
106
+ print(rev)
107
+ except requests.HTTPError as e:
108
+ typer.secho(f"Arena request failed: {e}", fg=typer.colors.RED, err=True)
109
+ raise typer.Exit(2)
110
+ except ArenaError as e:
111
+ typer.secho(str(e), fg=typer.colors.RED, err=True)
112
+ raise typer.Exit(2)
113
+
114
+
115
+ @app.command("list-files")
116
+ def list_files(
117
+ item: str = typer.Argument(..., help="Item/article number"),
118
+ revision: Optional[str] = typer.Option(
119
+ None,
120
+ "--rev",
121
+ help="Revision selector: WORKING | EFFECTIVE | <label> (default: EFFECTIVE)",
122
+ ),
123
+ format: Optional[str] = typer.Option(
124
+ None, "--format", "-f", help="Output format: human (default) or json"
125
+ ),
126
+ ):
127
+ try:
128
+ files = _client().list_files(item, revision)
129
+ if format == "json":
130
+ json.dump(
131
+ {"article": item, "revision": revision, "files": files},
132
+ sys.stdout,
133
+ indent=2,
134
+ )
135
+ sys.stdout.write("\n")
136
+ return
137
+
138
+ table = Table(title=f"Files for {item} rev {revision or '(latest approved)'}")
139
+ table.add_column("Name")
140
+ table.add_column("Size", justify="right")
141
+ table.add_column("Checksum")
142
+ for f in files:
143
+ table.add_row(
144
+ str(f.get("filename")), str(f.get("size")), str(f.get("checksum"))
145
+ )
146
+ print(table)
147
+ except requests.HTTPError as e:
148
+ typer.secho(f"Arena request failed: {e}", fg=typer.colors.RED, err=True)
149
+ raise typer.Exit(2)
150
+ except ArenaError as e:
151
+ typer.secho(str(e), fg=typer.colors.RED, err=True)
152
+ raise typer.Exit(2)
153
+
154
+
155
+ @app.command("bom")
156
+ def bom(
157
+ item: str = typer.Argument(..., help="Item/article number (e.g., 890-1001)"),
158
+ revision: Optional[str] = typer.Option(
159
+ None,
160
+ "--rev",
161
+ help='Revision selector: WORKING, EFFECTIVE (default), or label (e.g., "B2")',
162
+ ),
163
+ output: str = typer.Option(
164
+ "table", "--output", help='Output format: "table" (default) or "json"'
165
+ ),
166
+ recursive: bool = typer.Option(
167
+ False, "--recursive/--no-recursive", help="Recursively expand subassemblies"
168
+ ),
169
+ max_depth: Optional[int] = typer.Option(
170
+ None,
171
+ "--max-depth",
172
+ min=1,
173
+ help="Maximum recursion depth (1 = only children). Omit for unlimited.",
174
+ ),
175
+ ):
176
+ """List the BOM lines for an item revision."""
177
+ try:
178
+ lines = _client().get_bom(
179
+ item, revision, recursive=recursive, max_depth=max_depth
180
+ )
181
+
182
+ if output.lower() == "json":
183
+ print(json.dumps({"count": len(lines), "results": lines}, indent=2))
184
+ return
185
+
186
+ title_rev = revision or "(latest approved)"
187
+ table = Table(title=f"BOM for {item} rev {title_rev}")
188
+ table.add_column("Line", justify="right")
189
+ table.add_column("Qty", justify="right")
190
+ table.add_column("Number")
191
+ table.add_column("Name")
192
+ table.add_column("RefDes")
193
+
194
+ for ln in lines:
195
+ lvl = int(ln.get("level", 0) or 0)
196
+ indent = " " * lvl # 2 spaces per level
197
+ table.add_row(
198
+ str(ln.get("lineNumber") or ""),
199
+ str(ln.get("quantity") or ""),
200
+ str(ln.get("itemNumber") or ""),
201
+ f"{indent}{str(ln.get('itemName') or '')}",
202
+ str(ln.get("refDes") or ""),
203
+ )
204
+ print(table)
205
+ except requests.HTTPError as e:
206
+ typer.secho(f"Arena request failed: {e}", fg=typer.colors.RED, err=True)
207
+ raise typer.Exit(2)
208
+ except ArenaError as e:
209
+ typer.secho(str(e), fg=typer.colors.RED, err=True)
210
+ raise typer.Exit(2)
211
+
212
+
213
+ @app.command("get-files")
214
+ def get_files(
215
+ item: str = typer.Argument(..., help="Item/article number"),
216
+ revision: Optional[str] = typer.Option(
217
+ None, "--rev", help="Revision (default: latest approved)"
218
+ ),
219
+ out: Optional[Path] = typer.Option(
220
+ None,
221
+ "--out",
222
+ help="Output directory (default: a folder named after the item number)",
223
+ ),
224
+ ):
225
+ try:
226
+ out_dir = out or Path(item)
227
+ paths = _client().download_files(item, revision, out_dir=out_dir)
228
+ for p in paths:
229
+ print(str(p))
230
+ except requests.HTTPError as e:
231
+ typer.secho(f"Arena request failed: {e}", fg=typer.colors.RED, err=True)
232
+ raise typer.Exit(2)
233
+ except ArenaError as e:
234
+ typer.secho(str(e), fg=typer.colors.RED, err=True)
235
+ raise typer.Exit(2)
236
+
237
+
238
+ @app.command("upload-file")
239
+ def upload_file(
240
+ item: str = typer.Argument(...),
241
+ file: Path = typer.Argument(...),
242
+ reference: Optional[str] = typer.Option(
243
+ None, "--reference", help="Optional reference string"
244
+ ),
245
+ title: Optional[str] = typer.Option(
246
+ None,
247
+ "--title",
248
+ help="Override file title (default: filename without extension)",
249
+ ),
250
+ category: str = typer.Option(
251
+ "CAD Data", "--category", help='File category name (default: "CAD Data")'
252
+ ),
253
+ file_format: Optional[str] = typer.Option(
254
+ None, "--format", help="File format (default: file extension)"
255
+ ),
256
+ description: Optional[str] = typer.Option(
257
+ None, "--desc", help="Optional description"
258
+ ),
259
+ primary: bool = typer.Option(
260
+ False, "--primary/--no-primary", help="Mark association as primary"
261
+ ),
262
+ edition: str = typer.Option(
263
+ "1",
264
+ "--edition",
265
+ help="Edition number when creating a new association (default: 1)",
266
+ ),
267
+ ):
268
+ """If a file with the same filename exists: update its content (new edition).
269
+ Otherwise: create a new association on the WORKING revision (requires --edition)."""
270
+ try:
271
+ result = _client().upload_file_to_working(
272
+ item,
273
+ file,
274
+ reference,
275
+ title=title,
276
+ category_name=category,
277
+ file_format=file_format,
278
+ description=description,
279
+ primary=primary,
280
+ edition=edition,
281
+ )
282
+ print(json.dumps(result, indent=2))
283
+ except requests.HTTPError as e:
284
+ typer.secho(f"Arena request failed: {e}", fg=typer.colors.RED, err=True)
285
+ raise typer.Exit(2)
286
+ except ArenaError as e:
287
+ typer.secho(str(e), fg=typer.colors.RED, err=True)
288
+
289
+
290
+ if __name__ == "__main__":
291
+ app()
@@ -8,12 +8,17 @@ from pathlib import Path
8
8
  from typing import Optional, Any, Dict
9
9
  from pydantic import BaseModel, Field
10
10
 
11
- CONFIG_HOME = Path(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
@@ -1,6 +1,7 @@
1
1
  from gladiator.config import LoginConfig
2
2
  from gladiator.arena import ArenaClient
3
3
 
4
+
4
5
  def test_client_instantiates():
5
6
  cfg = LoginConfig(arena_subdomain="dummy", api_key="x")
6
7
  ArenaClient(cfg)
@@ -1,157 +0,0 @@
1
- #! /usr/bin/env python
2
- # -*- coding: utf-8 -*-
3
- # src/gladiator/cli.py
4
- from __future__ import annotations
5
- import json
6
- from pathlib import Path
7
- from typing import Optional
8
- import typer
9
- from rich import print
10
- from rich.table import Table
11
- from getpass import getpass
12
- import requests
13
- import sys
14
- from .config import LoginConfig, save_config, load_config, save_config_raw, CONFIG_PATH
15
- from .arena import ArenaClient, ArenaError
16
-
17
- app = typer.Typer(add_completion=False, help="Arena PLM command-line utility")
18
-
19
- @app.command()
20
- def login(
21
- username: Optional[str] = typer.Option(None, "--username", envvar="GLADIATOR_USERNAME"),
22
- password: Optional[str] = typer.Option(None, "--password", envvar="GLADIATOR_PASSWORD"),
23
- base_url: Optional[str] = typer.Option("https://api.arenasolutions.com/v1", help="Arena API base URL"),
24
- verify_tls: bool = typer.Option(True, help="Verify TLS certificates"),
25
- non_interactive: bool = typer.Option(False, "--ci", help="Fail instead of prompting for missing values"),
26
- reason: Optional[str] = typer.Option("CI/CD integration", help="Arena-Usage-Reason header"),
27
- ):
28
- """Create or update ~/.config/gladiator/login.json for subsequent commands.
29
-
30
- This performs a `/login` call against Arena and stores the JSON (including arenaSessionId) in login.json.
31
- """
32
- if not username and not non_interactive:
33
- username = typer.prompt("Email/username")
34
- if not password and not non_interactive:
35
- password = getpass("Password: ")
36
- if non_interactive and (not username or not password):
37
- raise typer.BadParameter("Provide --username and --password (or set env vars) for --ci mode")
38
-
39
- # Perform login
40
- sess = requests.Session()
41
- sess.verify = verify_tls
42
- headers = {
43
- "Content-Type": "application/json",
44
- "Accept": "application/json",
45
- "Arena-Usage-Reason": reason or "gladiator/cli",
46
- "User-Agent": "gladiator-arena/0.1",
47
- }
48
- url = f"{(base_url or '').rstrip('/')}/login"
49
- resp = sess.post(url, headers=headers, json={"email": username, "password": password})
50
- try:
51
- resp.raise_for_status()
52
- except Exception as e:
53
- typer.secho(f"Login failed: {e} Body: {resp.text[:400]}", fg=typer.colors.RED, err=True)
54
- raise typer.Exit(2)
55
-
56
- data = resp.json()
57
-
58
- # Merge our client settings alongside the session info into the same file (compatible with your bash scripts)
59
- data.update({
60
- "base_url": base_url,
61
- "verify_tls": verify_tls,
62
- "reason": reason,
63
- })
64
- save_config_raw(data)
65
- print(f"[green]Saved session to {CONFIG_PATH}[/green]")
66
-
67
-
68
- def _client() -> ArenaClient:
69
- cfg = load_config()
70
- return ArenaClient(cfg)
71
-
72
-
73
- @app.command("latest-approved")
74
- def latest_approved(
75
- item: str = typer.Argument(..., help="Item/article number"),
76
- format: Optional[str] = typer.Option(None, "--format", "-f", help="Output format: human (default) or json"),
77
- ):
78
- """Print latest approved revision for the given item number."""
79
- try:
80
- rev = _client().get_latest_approved_revision(item)
81
- if format == "json":
82
- json.dump({"article": item, "revision": rev}, sys.stdout, indent=2)
83
- sys.stdout.write("\n")
84
- else:
85
- print(rev)
86
- except ArenaError as e:
87
- typer.secho(str(e), fg=typer.colors.RED, err=True)
88
- raise typer.Exit(2)
89
-
90
-
91
- @app.command("list-files")
92
- def list_files(
93
- item: str = typer.Argument(..., help="Item/article number"),
94
- revision: Optional[str] = typer.Option(None,"--rev",help='Revision selector: WORKING | EFFECTIVE | <label> (default: EFFECTIVE)',),
95
- format: Optional[str] = typer.Option(None, "--format", "-f", help="Output format: human (default) or json"),
96
- ):
97
- try:
98
- files = _client().list_files(item, revision)
99
- if format == "json":
100
- json.dump({"article": item, "revision": revision, "files": files}, sys.stdout, indent=2)
101
- sys.stdout.write("\n")
102
- return
103
-
104
- table = Table(title=f"Files for {item} rev {revision or '(latest approved)'}")
105
- table.add_column("Name")
106
- table.add_column("Size", justify="right")
107
- table.add_column("Checksum")
108
- for f in files:
109
- table.add_row(str(f.get("filename")), str(f.get("size")), str(f.get("checksum")))
110
- print(table)
111
- except ArenaError as e:
112
- typer.secho(str(e), fg=typer.colors.RED, err=True)
113
- raise typer.Exit(2)
114
-
115
-
116
- @app.command("get-files")
117
- def get_files(
118
- item: str = typer.Argument(..., help="Item/article number"),
119
- revision: Optional[str] = typer.Option(None, "--rev", help="Revision (default: latest approved)"),
120
- out: Path = typer.Option(Path("downloads"), "--out", help="Output directory"),
121
- ):
122
- try:
123
- paths = _client().download_files(item, revision, out_dir=out)
124
- for p in paths:
125
- print(str(p))
126
- except ArenaError as e:
127
- typer.secho(str(e), fg=typer.colors.RED, err=True)
128
- raise typer.Exit(2)
129
-
130
-
131
- @app.command("upload-file")
132
- def upload_file(
133
- item: str = typer.Argument(...),
134
- file: Path = typer.Argument(...),
135
- reference: Optional[str] = typer.Option(None, "--reference", help="Optional reference string"),
136
- title: Optional[str] = typer.Option(None, "--title", help="Override file title (default: filename without extension)"),
137
- category: str = typer.Option("CAD Data", "--category", help='File category name (default: "CAD Data")'),
138
- file_format: Optional[str] = typer.Option(None, "--format", help="File format (default: file extension)"),
139
- description: Optional[str] = typer.Option(None, "--desc", help="Optional description"),
140
- primary: bool = typer.Option(False, "--primary/--no-primary", help="Mark association as primary"),
141
- edition: str = typer.Option("1", "--edition", help="Edition number when creating a new association (default: 1)"),
142
- ):
143
- """If a file with the same filename exists: update its content (new edition).
144
- Otherwise: create a new association on the WORKING revision (requires --edition)."""
145
- try:
146
- result = _client().upload_file_to_working(
147
- item, file, reference,
148
- title=title, category_name=category, file_format=file_format,
149
- description=description, primary=primary, edition=edition
150
- )
151
- print(json.dumps(result, indent=2))
152
- except ArenaError as e:
153
- typer.secho(str(e), fg=typer.colors.RED, err=True)
154
-
155
-
156
- if __name__ == "__main__":
157
- app()
File without changes
File without changes
File without changes