lr-gladiator 0.5.0__tar.gz → 0.7.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.5.0
3
+ Version: 0.7.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.5.0"
7
+ version = "0.7.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,29 +137,107 @@ 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] = []
139
158
  for f in files:
159
+ # Skip associations with no blob
160
+ if not f.get("haveContent", True):
161
+ self._log(
162
+ f"Skip {item_number}: file {f.get('filename')} has no content"
163
+ )
164
+ continue
165
+
140
166
  url = f.get("downloadUrl") or f.get("url")
141
167
  filename = f.get("filename") or f.get("name")
142
168
  if not url or not filename:
143
169
  continue
170
+
144
171
  p = out_dir / filename
145
- with self.session.get(url, stream=True, headers={"arena_session_id": self.cfg.arena_session_id or ""}) as r:
146
- r.raise_for_status()
147
- with open(p, "wb") as fh:
148
- for chunk in r.iter_content(128 * 1024):
149
- fh.write(chunk)
150
- downloaded.append(p)
172
+ try:
173
+ with self.session.get(
174
+ url,
175
+ stream=True,
176
+ headers={"arena_session_id": self.cfg.arena_session_id or ""},
177
+ ) as r:
178
+ # If the blob is missing/forbidden, don’t abort the whole command
179
+ if r.status_code in (400, 403, 404):
180
+ self._log(
181
+ f"Skip {item_number}: {filename} content unavailable "
182
+ f"(HTTP {r.status_code})"
183
+ )
184
+ continue
185
+ r.raise_for_status()
186
+ with open(p, "wb") as fh:
187
+ for chunk in r.iter_content(128 * 1024):
188
+ fh.write(chunk)
189
+ downloaded.append(p)
190
+ except requests.HTTPError as e:
191
+ # Be resilient: log and continue
192
+ self._log(f"Download failed for {filename}: {e}")
193
+ continue
194
+ return downloaded
195
+
196
+ def download_files_recursive(
197
+ self,
198
+ item_number: str,
199
+ revision: Optional[str] = None,
200
+ out_dir: Path = Path("."),
201
+ *,
202
+ max_depth: Optional[int] = None,
203
+ ) -> List[Path]:
204
+ """
205
+ Download files for `item_number` AND, recursively, for all subassemblies
206
+ discovered via the BOM. Each child item is placed under a subdirectory:
207
+ <out_dir>/<child_item_number>/
208
+ Root files go directly in <out_dir>/.
209
+
210
+ Depth semantics match `get_bom(..., recursive=True, max_depth=...)`.
211
+ """
212
+ # Ensure the root directory exists
213
+ out_dir.mkdir(parents=True, exist_ok=True)
214
+
215
+ downloaded: List[Path] = []
216
+ # 1) Download files for the root item into out_dir
217
+ downloaded.extend(self.download_files(item_number, revision, out_dir=out_dir))
218
+
219
+ # 2) Expand BOM recursively and collect unique child item numbers
220
+ lines = self.get_bom(
221
+ item_number,
222
+ revision,
223
+ recursive=True,
224
+ max_depth=max_depth,
225
+ )
226
+ seen_children = set()
227
+ for ln in lines:
228
+ child_num = (ln or {}).get("itemNumber")
229
+ if not child_num or child_num == item_number:
230
+ continue
231
+ if child_num in seen_children:
232
+ continue
233
+ seen_children.add(child_num)
234
+
235
+ # Place each child's files under <out_dir>/<child_num>/
236
+ child_dir = out_dir / child_num
237
+ downloaded.extend(
238
+ self.download_files(child_num, revision, out_dir=child_dir)
239
+ )
240
+
151
241
  return downloaded
152
242
 
153
243
  def upload_file_to_working(
@@ -185,15 +275,52 @@ class ArenaClient:
185
275
  edition=edition,
186
276
  )
187
277
 
188
- def get_bom(self, item_number: str, revision: Optional[str] = None) -> List[Dict]:
278
+ def get_bom(
279
+ self,
280
+ item_number: str,
281
+ revision: Optional[str] = None,
282
+ *,
283
+ recursive: bool = False,
284
+ max_depth: Optional[int] = None,
285
+ ) -> List[Dict]:
189
286
  """
190
287
  Return a normalized list of BOM lines for the given item.
191
288
 
192
289
  By default this fetches the EFFECTIVE (approved) revision's BOM.
193
290
  Use revision="WORKING" or a specific label (e.g., "B2") to override.
291
+
292
+ If recursive=True, expand subassemblies depth-first. max_depth limits the recursion
293
+ depth (1 = only direct children). If omitted, recursion is unlimited.
294
+ """
295
+ selector = (revision or "EFFECTIVE").strip()
296
+ out: List[Dict] = []
297
+ self._bom_expand(
298
+ root_item=item_number,
299
+ selector=selector,
300
+ out=out,
301
+ recursive=recursive,
302
+ max_depth=max_depth,
303
+ _level=0,
304
+ _seen=set(),
305
+ )
306
+ return out
307
+
308
+ # === Internal: single fetch + normalization (your original logic) ===
309
+
310
+ def _fetch_bom_normalized(self, item_number: str, selector: str) -> List[Dict]:
311
+ """
312
+ Fetch and normalize the BOM for item_number with the given revision selector.
313
+ Falls back WORKING -> EFFECTIVE if selector is WORKING and no WORKING exists.
194
314
  """
195
315
  # 1) Resolve the exact revision GUID we want the BOM for
196
- target_guid = self._api_resolve_revision_guid(item_number, revision or "EFFECTIVE")
316
+ try:
317
+ target_guid = self._api_resolve_revision_guid(item_number, selector)
318
+ except ArenaError:
319
+ if selector.strip().upper() == "WORKING":
320
+ # fallback: try EFFECTIVE for children that don't have a WORKING revision
321
+ target_guid = self._api_resolve_revision_guid(item_number, "EFFECTIVE")
322
+ else:
323
+ raise
197
324
 
198
325
  # 2) GET /items/{guid}/bom
199
326
  url = f"{self._api_base()}/items/{target_guid}/bom"
@@ -206,24 +333,81 @@ class ArenaClient:
206
333
  norm: List[Dict] = []
207
334
  for row in rows:
208
335
  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
- })
336
+ norm.append(
337
+ {
338
+ # association/line
339
+ "guid": row.get("guid"),
340
+ "lineNumber": row.get("lineNumber"),
341
+ "notes": row.get("notes"),
342
+ "quantity": row.get("quantity"),
343
+ "refDes": row.get("refDes")
344
+ or row.get("referenceDesignators")
345
+ or "",
346
+ # child item
347
+ "itemGuid": itm.get("guid") or itm.get("id"),
348
+ "itemNumber": itm.get("number"),
349
+ "itemName": itm.get("name"),
350
+ "itemRevision": itm.get("revisionNumber"),
351
+ "itemRevisionStatus": itm.get("revisionStatus"),
352
+ "itemUrl": (itm.get("url") or {}).get("api"),
353
+ "itemAppUrl": (itm.get("url") or {}).get("app"),
354
+ }
355
+ )
225
356
  return norm
226
357
 
358
+ # === Internal: recursive expansion ===
359
+
360
+ def _bom_expand(
361
+ self,
362
+ *,
363
+ root_item: str,
364
+ selector: str,
365
+ out: List[Dict],
366
+ recursive: bool,
367
+ max_depth: Optional[int],
368
+ _level: int,
369
+ _seen: set,
370
+ ) -> None:
371
+ # avoid cycles
372
+ if root_item in _seen:
373
+ return
374
+ _seen.add(root_item)
375
+
376
+ rows = self._fetch_bom_normalized(root_item, selector)
377
+
378
+ # attach level and parentNumber (useful in JSON + for debugging)
379
+ for r in rows:
380
+ r["level"] = _level
381
+ r["parentNumber"] = root_item
382
+ out.append(r)
383
+
384
+ if not recursive:
385
+ return
386
+
387
+ # depth check: if max_depth=1, only expand children once (level 0 -> level 1)
388
+ if max_depth is not None and _level >= max_depth:
389
+ return
390
+
391
+ # expand each child that looks like an assembly (if it has a BOM; empty BOM is okay)
392
+ for r in rows:
393
+ child_num = r.get("itemNumber")
394
+ if not child_num:
395
+ continue
396
+ try:
397
+ # Recurse; keep same selector, with WORKING->EFFECTIVE fallback handled in _fetch_bom_normalized
398
+ self._bom_expand(
399
+ root_item=child_num,
400
+ selector=selector,
401
+ out=out,
402
+ recursive=True,
403
+ max_depth=max_depth,
404
+ _level=_level + 1,
405
+ _seen=_seen,
406
+ )
407
+ except ArenaError:
408
+ # Child might not have a BOM; skip silently
409
+ continue
410
+
227
411
  def _api_base(self) -> str:
228
412
  return self.cfg.base_url.rstrip("/")
229
413
 
@@ -244,26 +428,35 @@ class ArenaClient:
244
428
  # - revisionStatus == "EFFECTIVE" (string)
245
429
  # - OR status == 1 (numeric)
246
430
  effective = [
247
- rv for rv in revs
248
- if (str(rv.get("revisionStatus") or "").upper() == "EFFECTIVE") or (rv.get("status") == 1)
431
+ rv
432
+ for rv in revs
433
+ if (str(rv.get("revisionStatus") or "").upper() == "EFFECTIVE")
434
+ or (rv.get("status") == 1)
249
435
  ]
250
436
  if not effective:
251
437
  raise ArenaError(f"No approved/released revisions for item {item_number}")
252
438
 
253
439
  # 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)
440
+ current = next(
441
+ (rv for rv in effective if not rv.get("supersededDateTime")), None
442
+ )
255
443
  if not current:
256
444
  # sort by supersededDateTime (None last) then by number/name as a stable tie-breaker
257
445
  def _sd(rv):
258
446
  dt = rv.get("supersededDateTime")
259
447
  return dt or "0000-00-00T00:00:00Z"
448
+
260
449
  effective.sort(key=_sd)
261
450
  current = effective[-1]
262
451
 
263
452
  # 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")
453
+ rev_label = (
454
+ current.get("number") or current.get("name") or current.get("revision")
455
+ )
265
456
  if not rev_label:
266
- raise ArenaError(f"Could not determine revision label for item {item_number}")
457
+ raise ArenaError(
458
+ f"Could not determine revision label for item {item_number}"
459
+ )
267
460
  return rev_label
268
461
 
269
462
  def _api_list_files(self, item_number: str) -> List[Dict]:
@@ -278,19 +471,27 @@ class ArenaClient:
278
471
  for row in rows:
279
472
  f = row.get("file", {}) if isinstance(row, dict) else {}
280
473
  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
- })
474
+ norm.append(
475
+ {
476
+ "id": row.get("guid") or row.get("id"),
477
+ "fileGuid": file_guid,
478
+ "name": f.get("name") or f.get("title"),
479
+ "filename": f.get("name") or f.get("title"),
480
+ "size": f.get("size"),
481
+ "checksum": f.get("checksum") or f.get("md5"),
482
+ "haveContent": f.get("haveContent", True),
483
+ "downloadUrl": (
484
+ f"{self._api_base()}/files/{file_guid}/content"
485
+ if file_guid
486
+ else None
487
+ ),
488
+ "version": f.get("version") or f.get("edition"),
489
+ "updatedAt": f.get("lastModifiedDateTime")
490
+ or f.get("lastModifiedDate")
491
+ or f.get("creationDateTime"),
492
+ "attachmentGroupGuid": row.get("guid"),
493
+ }
494
+ )
294
495
  return norm
295
496
 
296
497
  def _api_resolve_revision_guid(self, item_number: str, selector: str | None) -> str:
@@ -304,7 +505,8 @@ class ArenaClient:
304
505
  # Fetch revisions
305
506
  url = f"{self._api_base()}/items/{effective_guid}/revisions"
306
507
  self._log(f"GET {url}")
307
- r = self.session.get(url); r.raise_for_status()
508
+ r = self.session.get(url)
509
+ r.raise_for_status()
308
510
  data = self._ensure_json(r)
309
511
  revs = data.get("results", data if isinstance(data, list) else [])
310
512
 
@@ -316,21 +518,36 @@ class ArenaClient:
316
518
 
317
519
  # Named selectors
318
520
  if sel in {"WORKING"}:
319
- guid = pick(lambda rv: (rv.get("revisionStatus") or "").upper() == "WORKING" or rv.get("status") == 0)
521
+ guid = pick(
522
+ lambda rv: (rv.get("revisionStatus") or "").upper() == "WORKING"
523
+ or rv.get("status") == 0
524
+ )
320
525
  if not guid:
321
526
  raise ArenaError("No WORKING revision exists for this item.")
322
527
  return guid
323
528
 
324
529
  if sel in {"EFFECTIVE", "APPROVED", "RELEASED"}:
325
530
  # Prefer the one not superseded
326
- eff = [rv for rv in revs if (rv.get("revisionStatus") or "").upper() == "EFFECTIVE" or rv.get("status") == 1]
531
+ eff = [
532
+ rv
533
+ for rv in revs
534
+ if (rv.get("revisionStatus") or "").upper() == "EFFECTIVE"
535
+ or rv.get("status") == 1
536
+ ]
327
537
  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])
538
+ raise ArenaError(
539
+ "No approved/effective revision exists for this item. Try using revision 'WORKING'."
540
+ )
541
+ current = next(
542
+ (rv for rv in eff if not rv.get("supersededDateTime")), eff[-1]
543
+ )
330
544
  return current.get("guid")
331
545
 
332
546
  # 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)
547
+ guid = pick(
548
+ lambda rv: (rv.get("number") or rv.get("name"))
549
+ and str(rv.get("number") or rv.get("name")).upper() == sel
550
+ )
334
551
  if not guid:
335
552
  raise ArenaError(f'Revision "{selector}" not found for item {item_number}.')
336
553
  return guid
@@ -338,7 +555,8 @@ class ArenaClient:
338
555
  def _api_list_files_by_item_guid(self, item_guid: str) -> list[dict]:
339
556
  url = f"{self._api_base()}/items/{item_guid}/files"
340
557
  self._log(f"GET {url}")
341
- r = self.session.get(url); r.raise_for_status()
558
+ r = self.session.get(url)
559
+ r.raise_for_status()
342
560
  data = self._ensure_json(r)
343
561
  rows = data.get("results", data if isinstance(data, list) else [])
344
562
  # … keep existing normalization from _api_list_files() …
@@ -346,19 +564,28 @@ class ArenaClient:
346
564
  for row in rows:
347
565
  f = row.get("file", {}) if isinstance(row, dict) else {}
348
566
  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
567
+ norm.append(
568
+ {
569
+ "id": row.get("guid") or row.get("id"),
570
+ "fileGuid": file_guid,
571
+ "name": f.get("name") or f.get("title"),
572
+ "filename": f.get("name") or f.get("title"),
573
+ "size": f.get("size"),
574
+ "checksum": f.get("checksum") or f.get("md5"),
575
+ "haveContent": f.get("haveContent", True),
576
+ "downloadUrl": (
577
+ f"{self._api_base()}/files/{file_guid}/content"
578
+ if file_guid
579
+ else None
580
+ ),
581
+ "version": f.get("version") or f.get("edition"),
582
+ "updatedAt": f.get("lastModifiedDateTime")
583
+ or f.get("lastModifiedDate")
584
+ or f.get("creationDateTime"),
585
+ "attachmentGroupGuid": row.get("guid"),
586
+ }
587
+ )
588
+ return norm
362
589
 
363
590
  def _api_upload_or_update_file(
364
591
  self,
@@ -389,7 +616,9 @@ class ArenaClient:
389
616
  rows = data.get("results", data if isinstance(data, list) else [])
390
617
  working_guid = None
391
618
  for rv in rows:
392
- if (str(rv.get("revisionStatus") or "").upper() == "WORKING") or (rv.get("status") == 0):
619
+ if (str(rv.get("revisionStatus") or "").upper() == "WORKING") or (
620
+ rv.get("status") == 0
621
+ ):
393
622
  working_guid = rv.get("guid")
394
623
  break
395
624
  if not working_guid:
@@ -412,12 +641,19 @@ class ArenaClient:
412
641
  for guid in (working_guid, effective_guid):
413
642
  assocs = _list_assocs(guid)
414
643
  # 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)]
644
+ prim_latest = [
645
+ a
646
+ for a in assocs
647
+ if a.get("primary")
648
+ and a.get("latestEditionAssociation")
649
+ and ((a.get("file") or {}).get("name") == filename)
650
+ ]
417
651
  if prim_latest:
418
652
  assoc = prim_latest[0]
419
653
  break
420
- any_by_name = [a for a in assocs if (a.get("file") or {}).get("name") == filename]
654
+ any_by_name = [
655
+ a for a in assocs if (a.get("file") or {}).get("name") == filename
656
+ ]
421
657
  if any_by_name:
422
658
  assoc = any_by_name[0]
423
659
  break
@@ -459,30 +695,44 @@ class ArenaClient:
459
695
  cats = self._ensure_json(r).get("results", [])
460
696
  cat_guid = None
461
697
  for c in cats:
462
- if c.get("name") == category_name and (c.get("parentCategory") or {}).get("name") in {"Internal File", None}:
698
+ if c.get("name") == category_name and (c.get("parentCategory") or {}).get(
699
+ "name"
700
+ ) in {"Internal File", None}:
463
701
  cat_guid = c.get("guid")
464
702
  break
465
703
  if not cat_guid:
466
- raise ArenaError(f'File category "{category_name}" not found or not allowed.')
704
+ raise ArenaError(
705
+ f'File category "{category_name}" not found or not allowed.'
706
+ )
467
707
 
468
708
  # 3) Prepare multipart (create association)
469
709
  title = title or file_path.stem
470
- file_format = file_format or (file_path.suffix[1:].lower() if file_path.suffix else "bin")
710
+ file_format = file_format or (
711
+ file_path.suffix[1:].lower() if file_path.suffix else "bin"
712
+ )
471
713
  description = description or "Uploaded via gladiator"
472
- files = {"content": (file_path.name, open(file_path, "rb"), "application/octet-stream")}
714
+ files = {
715
+ "content": (
716
+ file_path.name,
717
+ open(file_path, "rb"),
718
+ "application/octet-stream",
719
+ )
720
+ }
473
721
 
474
722
  # NOTE: nested field names are sent in `data`, not `files`
475
723
  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
- }
724
+ "file.title": title,
725
+ "file.description": description,
726
+ "file.category.guid": cat_guid,
727
+ "file.format": file_format,
728
+ "file.edition": str(edition),
729
+ "file.storageMethodName": "FILE",
730
+ "file.private": "false",
731
+ "primary": "true" if primary else "false",
732
+ "latestEditionAssociation": (
733
+ "true" if latest_edition_association else "false"
734
+ ),
735
+ }
486
736
  if reference:
487
737
  data_form["reference"] = reference
488
738
 
@@ -518,7 +768,11 @@ class ArenaClient:
518
768
  "edition": f.get("edition"),
519
769
  "lastModifiedDateTime": f.get("lastModifiedDateTime"),
520
770
  },
521
- "downloadUrl": f"{self._api_base()}/files/{(f or {}).get('guid')}/content" if f.get("guid") else None,
771
+ "downloadUrl": (
772
+ f"{self._api_base()}/files/{(f or {}).get('guid')}/content"
773
+ if f.get("guid")
774
+ else None
775
+ ),
522
776
  }
523
777
 
524
778
  def _api_resolve_item_guid(self, item_number: str) -> str:
@@ -531,11 +785,15 @@ class ArenaClient:
531
785
  results = data.get("results") if isinstance(data, dict) else data
532
786
  if not results:
533
787
  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"))
788
+ guid = (
789
+ results[0].get("guid") or results[0].get("id") or results[0].get("itemId")
790
+ )
535
791
  if not guid:
536
792
  raise ArenaError("API response missing item GUID")
537
793
  return guid
538
794
 
539
795
  def _run(self, cmd: str) -> Tuple[int, str, str]:
540
- proc = subprocess.run(cmd, shell=True, check=False, capture_output=True, text=True)
796
+ proc = subprocess.run(
797
+ cmd, shell=True, check=False, capture_output=True, text=True
798
+ )
541
799
  return proc.returncode, proc.stdout.strip(), proc.stderr.strip()
@@ -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,39 @@ 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
+ ),
224
+ recursive: bool = typer.Option(
225
+ False,
226
+ "--recursive/--no-recursive",
227
+ help="Recursively download files from subassemblies",
228
+ ),
229
+ max_depth: Optional[int] = typer.Option(
230
+ None,
231
+ "--max-depth",
232
+ min=1,
233
+ help="Maximum recursion depth for --recursive (1 = only direct children).",
234
+ ),
162
235
  ):
163
236
  try:
164
- paths = _client().download_files(item, revision, out_dir=out)
237
+ out_dir = out or Path(item) # article numbers are never files; safe as dir name
238
+ if recursive:
239
+ paths = _client().download_files_recursive(
240
+ item, revision, out_dir=out_dir, max_depth=max_depth
241
+ )
242
+ else:
243
+ paths = _client().download_files(item, revision, out_dir=out_dir)
244
+
165
245
  for p in paths:
166
246
  print(str(p))
167
247
  except requests.HTTPError as e:
@@ -176,21 +256,45 @@ def get_files(
176
256
  def upload_file(
177
257
  item: str = typer.Argument(...),
178
258
  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)"),
259
+ reference: Optional[str] = typer.Option(
260
+ None, "--reference", help="Optional reference string"
261
+ ),
262
+ title: Optional[str] = typer.Option(
263
+ None,
264
+ "--title",
265
+ help="Override file title (default: filename without extension)",
266
+ ),
267
+ category: str = typer.Option(
268
+ "CAD Data", "--category", help='File category name (default: "CAD Data")'
269
+ ),
270
+ file_format: Optional[str] = typer.Option(
271
+ None, "--format", help="File format (default: file extension)"
272
+ ),
273
+ description: Optional[str] = typer.Option(
274
+ None, "--desc", help="Optional description"
275
+ ),
276
+ primary: bool = typer.Option(
277
+ False, "--primary/--no-primary", help="Mark association as primary"
278
+ ),
279
+ edition: str = typer.Option(
280
+ "1",
281
+ "--edition",
282
+ help="Edition number when creating a new association (default: 1)",
283
+ ),
186
284
  ):
187
285
  """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)."""
286
+ Otherwise: create a new association on the WORKING revision (requires --edition)."""
189
287
  try:
190
288
  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
289
+ item,
290
+ file,
291
+ reference,
292
+ title=title,
293
+ category_name=category,
294
+ file_format=file_format,
295
+ description=description,
296
+ primary=primary,
297
+ edition=edition,
194
298
  )
195
299
  print(json.dumps(result, indent=2))
196
300
  except requests.HTTPError as e:
@@ -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.7.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)
File without changes
File without changes
File without changes