lr-gladiator 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ #! /usr/bin/env python
2
+ # -*- coding: utf-8 -*-
3
+ # src/gladiator/__init__.py
4
+
5
+ __all__ = ["ArenaClient", "load_config", "save_config"]
6
+ from .arena import ArenaClient
7
+ from .config import load_config, save_config
gladiator/arena.py ADDED
@@ -0,0 +1,505 @@
1
+ #! /usr/bin/env python
2
+ # -*- coding: utf-8 -*-
3
+ # src/gladiator/arena.py
4
+ from __future__ import annotations
5
+ import subprocess
6
+ import shlex
7
+ import os
8
+ from pathlib import Path
9
+ from typing import Dict, List, Optional, Tuple
10
+ import requests
11
+ from .config import LoginConfig
12
+
13
+ class ArenaError(RuntimeError):
14
+ pass
15
+
16
+ class ArenaClient:
17
+ def __init__(self, cfg: LoginConfig):
18
+ self.cfg = cfg
19
+ self.session = requests.Session()
20
+ self.session.verify = cfg.verify_tls
21
+ # 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
+ })
28
+ if cfg.arena_session_id:
29
+ self.session.headers.update({"arena_session_id": cfg.arena_session_id})
30
+
31
+ self._debug = bool(int(os.environ.get("GLADIATOR_DEBUG", "0")))
32
+
33
+ # ---------- Utilities ----------
34
+ def _ensure_json(self, resp: requests.Response):
35
+ ctype = resp.headers.get("Content-Type", "").lower()
36
+ if "application/json" not in ctype:
37
+ snippet = resp.text[:400].replace("", " ")
38
+ raise ArenaError(
39
+ f"Expected JSON but got '{ctype or 'unknown'}' from {resp.url}. "
40
+ f"Status {resp.status_code}. Body starts with: {snippet}"
41
+ )
42
+ try:
43
+ return resp.json()
44
+ except Exception as e:
45
+ raise ArenaError(f"Failed to parse JSON from {resp.url}: {e}") from e
46
+
47
+ def _log(self, msg: str):
48
+ if self._debug:
49
+ print(f"[gladiator debug] {msg}")
50
+
51
+ def _try_json(self, resp: requests.Response) -> Optional[dict]:
52
+ """Best-effort JSON parse. Returns None if not JSON or parse fails."""
53
+ ctype = resp.headers.get("Content-Type", "").lower()
54
+ if "application/json" not in ctype:
55
+ return None
56
+ try:
57
+ data = resp.json()
58
+ return data if isinstance(data, dict) else {"data": data}
59
+ except Exception:
60
+ return None
61
+
62
+ # --- version picking helpers ---
63
+ @staticmethod
64
+ def _logical_key(f: Dict) -> str:
65
+ # Prefer any group-level id; fall back to normalized filename
66
+ return (
67
+ f.get("attachmentGroupGuid")
68
+ or f.get("attachmentGroupId")
69
+ or f.get("attachmentGuid")
70
+ or (f.get("name") or f.get("filename") or "").lower()
71
+ )
72
+
73
+ @staticmethod
74
+ def _version_of(f: Dict) -> int:
75
+ for k in ("version", "fileVersion", "versionNumber", "rev", "revision"):
76
+ v = f.get(k)
77
+ if v is None:
78
+ continue
79
+ try:
80
+ return int(v)
81
+ except Exception:
82
+ if isinstance(v, str) and len(v) == 1 and v.isalpha():
83
+ return ord(v.upper()) - 64 # A->1
84
+ return -1
85
+
86
+ @staticmethod
87
+ def _timestamp_of(f: Dict):
88
+ from datetime import datetime
89
+ from email.utils import parsedate_to_datetime
90
+ for k in ("modifiedAt", "updatedAt", "lastModified", "lastModifiedDate", "effectiveDate", "createdAt"):
91
+ s = f.get(k)
92
+ if not s:
93
+ continue
94
+ try:
95
+ return datetime.fromisoformat(s.replace("Z", "+00:00"))
96
+ except Exception:
97
+ try:
98
+ return parsedate_to_datetime(s)
99
+ except Exception:
100
+ continue
101
+ return None
102
+
103
+ def _latest_files(self, files: List[Dict]) -> List[Dict]:
104
+ best: Dict[str, Dict] = {}
105
+ for f in files:
106
+ key = self._logical_key(f)
107
+ if not key:
108
+ continue
109
+ score = (self._version_of(f), self._timestamp_of(f) or 0)
110
+ prev = best.get(key)
111
+ if not prev:
112
+ f["_score"] = score
113
+ best[key] = f
114
+ continue
115
+ if score > prev.get("_score", (-1, 0)):
116
+ f["_score"] = score
117
+ best[key] = f
118
+ out = []
119
+ for v in best.values():
120
+ v.pop("_score", None)
121
+ out.append(v)
122
+ return out
123
+
124
+ # ---------- Public high-level methods ----------
125
+ def get_latest_approved_revision(self, item_number: str) -> str:
126
+ return self._api_get_latest_approved(item_number)
127
+
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")
131
+ raw = self._api_list_files_by_item_guid(target_guid)
132
+ return self._latest_files(raw)
133
+
134
+
135
+ def download_files(self, item_number: str, revision: Optional[str] = None, out_dir: Path = Path(".")) -> List[Path]:
136
+ files = self.list_files(item_number, revision)
137
+ out_dir.mkdir(parents=True, exist_ok=True)
138
+ downloaded: List[Path] = []
139
+ for f in files:
140
+ url = f.get("downloadUrl") or f.get("url")
141
+ filename = f.get("filename") or f.get("name")
142
+ if not url or not filename:
143
+ continue
144
+ 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)
151
+ return downloaded
152
+
153
+ def upload_file_to_working(
154
+ self,
155
+ item_number: str,
156
+ file_path: Path,
157
+ reference: Optional[str] = None,
158
+ *,
159
+ title: Optional[str] = None,
160
+ category_name: str = "CAD Data",
161
+ file_format: Optional[str] = None,
162
+ description: Optional[str] = None,
163
+ primary: bool = True,
164
+ latest_edition_association: bool = True,
165
+ edition: str = "1",
166
+ ) -> Dict:
167
+ """
168
+ Update-if-exists-else-create semantics, matching the bash script:
169
+ 1) Resolve EFFECTIVE GUID from item number
170
+ 2) Resolve WORKING revision GUID (fail if none)
171
+ 3) Find existing file by exact filename (WORKING first, then EFFECTIVE)
172
+ - If found: POST /files/{fileGuid}/content (multipart)
173
+ - Else: POST /items/{workingGuid}/files (multipart) with file.edition
174
+ """
175
+ return self._api_upload_or_update_file(
176
+ item_number=item_number,
177
+ file_path=file_path,
178
+ reference=reference,
179
+ title=title,
180
+ category_name=category_name,
181
+ file_format=file_format,
182
+ description=description,
183
+ primary=primary,
184
+ latest_edition_association=latest_edition_association,
185
+ edition=edition,
186
+ )
187
+
188
+ # ---------- API-mode (HTTP) ----------
189
+ def _api_base(self) -> str:
190
+ return self.cfg.base_url.rstrip("/")
191
+
192
+ def _api_get_latest_approved(self, item_number: str) -> str:
193
+ item_guid = self._api_resolve_item_guid(item_number)
194
+ url = f"{self._api_base()}/items/{item_guid}/revisions"
195
+ self._log(f"GET {url}")
196
+ r = self.session.get(url)
197
+ if r.status_code == 404:
198
+ raise ArenaError(f"Item {item_number} not found")
199
+ r.raise_for_status()
200
+ data = self._ensure_json(r)
201
+ revs = data.get("results", data if isinstance(data, list) else [])
202
+ if not isinstance(revs, list):
203
+ raise ArenaError(f"Unexpected revisions payload for item {item_number}")
204
+
205
+ # Arena marks the currently effective (approved) revision as:
206
+ # - revisionStatus == "EFFECTIVE" (string)
207
+ # - OR status == 1 (numeric)
208
+ effective = [
209
+ rv for rv in revs
210
+ if (str(rv.get("revisionStatus") or "").upper() == "EFFECTIVE") or (rv.get("status") == 1)
211
+ ]
212
+ if not effective:
213
+ raise ArenaError(f"No approved/released revisions for item {item_number}")
214
+
215
+ # 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)
217
+ if not current:
218
+ # sort by supersededDateTime (None last) then by number/name as a stable tie-breaker
219
+ def _sd(rv):
220
+ dt = rv.get("supersededDateTime")
221
+ return dt or "0000-00-00T00:00:00Z"
222
+ effective.sort(key=_sd)
223
+ current = effective[-1]
224
+
225
+ # 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")
227
+ if not rev_label:
228
+ raise ArenaError(f"Could not determine revision label for item {item_number}")
229
+ return rev_label
230
+
231
+ def _api_list_files(self, item_number: str) -> List[Dict]:
232
+ item_guid = self._api_resolve_item_guid(item_number)
233
+ url = f"{self._api_base()}/items/{item_guid}/files"
234
+ self._log(f"GET {url}")
235
+ r = self.session.get(url)
236
+ r.raise_for_status()
237
+ data = self._ensure_json(r)
238
+ rows = data.get("results", data if isinstance(data, list) else [])
239
+ norm: List[Dict] = []
240
+ for row in rows:
241
+ f = row.get("file", {}) if isinstance(row, dict) else {}
242
+ 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
+ })
256
+ return norm
257
+
258
+ def _api_resolve_revision_guid(self, item_number: str, selector: str | None) -> str:
259
+ """Return the item GUID for the requested revision selector."""
260
+ # Resolve base item (effective) guid from number
261
+ effective_guid = self._api_resolve_item_guid(item_number)
262
+
263
+ # If no selector, we default to EFFECTIVE
264
+ sel = (selector or "EFFECTIVE").strip().upper()
265
+
266
+ # Fetch revisions
267
+ url = f"{self._api_base()}/items/{effective_guid}/revisions"
268
+ self._log(f"GET {url}")
269
+ r = self.session.get(url); r.raise_for_status()
270
+ data = self._ensure_json(r)
271
+ revs = data.get("results", data if isinstance(data, list) else [])
272
+
273
+ def pick(pred):
274
+ for rv in revs:
275
+ if pred(rv):
276
+ return rv.get("guid")
277
+ return None
278
+
279
+ # Named selectors
280
+ if sel in {"WORKING"}:
281
+ guid = pick(lambda rv: (rv.get("revisionStatus") or "").upper() == "WORKING" or rv.get("status") == 0)
282
+ if not guid:
283
+ raise ArenaError("No WORKING revision exists for this item.")
284
+ return guid
285
+
286
+ if sel in {"EFFECTIVE", "APPROVED", "RELEASED"}:
287
+ # Prefer the one not superseded
288
+ eff = [rv for rv in revs if (rv.get("revisionStatus") or "").upper() == "EFFECTIVE" or rv.get("status") == 1]
289
+ 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])
292
+ return current.get("guid")
293
+
294
+ # 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)
296
+ if not guid:
297
+ raise ArenaError(f'Revision "{selector}" not found for item {item_number}.')
298
+ return guid
299
+
300
+ def _api_list_files_by_item_guid(self, item_guid: str) -> list[dict]:
301
+ url = f"{self._api_base()}/items/{item_guid}/files"
302
+ self._log(f"GET {url}")
303
+ r = self.session.get(url); r.raise_for_status()
304
+ data = self._ensure_json(r)
305
+ rows = data.get("results", data if isinstance(data, list) else [])
306
+ # … keep existing normalization from _api_list_files() …
307
+ norm = []
308
+ for row in rows:
309
+ f = row.get("file", {}) if isinstance(row, dict) else {}
310
+ 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
324
+
325
+ def _api_upload_or_update_file(
326
+ self,
327
+ *,
328
+ item_number: str,
329
+ file_path: Path,
330
+ reference: Optional[str],
331
+ title: Optional[str],
332
+ category_name: str,
333
+ file_format: Optional[str],
334
+ description: Optional[str],
335
+ primary: bool,
336
+ latest_edition_association: bool,
337
+ edition: str,
338
+ ) -> Dict:
339
+ if not file_path.exists() or not file_path.is_file():
340
+ raise ArenaError(f"File not found: {file_path}")
341
+
342
+ # 0) Resolve EFFECTIVE revision guid from item number
343
+ effective_guid = self._api_resolve_item_guid(item_number)
344
+
345
+ # 1) Resolve WORKING revision guid
346
+ revs_url = f"{self._api_base()}/items/{effective_guid}/revisions"
347
+ self._log(f"GET {revs_url}")
348
+ r = self.session.get(revs_url)
349
+ r.raise_for_status()
350
+ data = self._ensure_json(r)
351
+ rows = data.get("results", data if isinstance(data, list) else [])
352
+ working_guid = None
353
+ for rv in rows:
354
+ if (str(rv.get("revisionStatus") or "").upper() == "WORKING") or (rv.get("status") == 0):
355
+ working_guid = rv.get("guid")
356
+ break
357
+ if not working_guid:
358
+ raise ArenaError(
359
+ "No WORKING revision exists for this item. Create a working revision in Arena, then retry."
360
+ )
361
+
362
+ # Helper to list associations for a given item/revision guid
363
+ def _list_assocs(item_guid: str) -> list:
364
+ url = f"{self._api_base()}/items/{item_guid}/files"
365
+ self._log(f"GET {url}")
366
+ lr = self.session.get(url)
367
+ lr.raise_for_status()
368
+ payload = self._ensure_json(lr)
369
+ return payload.get("results", payload if isinstance(payload, list) else [])
370
+
371
+ # Try to find existing association by exact filename (WORKING first, then EFFECTIVE)
372
+ filename = file_path.name
373
+ assoc = None
374
+ for guid in (working_guid, effective_guid):
375
+ assocs = _list_assocs(guid)
376
+ # 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)]
379
+ if prim_latest:
380
+ assoc = prim_latest[0]
381
+ break
382
+ any_by_name = [a for a in assocs if (a.get("file") or {}).get("name") == filename]
383
+ if any_by_name:
384
+ assoc = any_by_name[0]
385
+ break
386
+
387
+ # If an existing file is found: update its content (new edition)
388
+ if assoc:
389
+ file_guid = (assoc.get("file") or {}).get("guid")
390
+ if not file_guid:
391
+ raise ArenaError("Existing association found but no file.guid present.")
392
+ post_url = f"{self._api_base()}/files/{file_guid}/content"
393
+ self._log(f"POST {post_url} (multipart content update)")
394
+ with open(file_path, "rb") as fp:
395
+ files = {"content": (filename, fp, "application/octet-stream")}
396
+ existing_ct = self.session.headers.pop("Content-Type", None)
397
+ try:
398
+ ur = self.session.post(post_url, files=files)
399
+ finally:
400
+ if existing_ct is not None:
401
+ self.session.headers["Content-Type"] = existing_ct
402
+ ur.raise_for_status()
403
+ # Many tenants return 201 with no JSON for content updates. Be flexible.
404
+ data = self._try_json(ur)
405
+ if data is None:
406
+ # Synthesize a small success payload with whatever we can glean.
407
+ return {
408
+ "ok": True,
409
+ "status": ur.status_code,
410
+ "fileGuid": file_guid,
411
+ "location": ur.headers.get("Location"),
412
+ }
413
+ return data
414
+
415
+ # Else: create a new association on WORKING
416
+ # 2) Resolve file category guid by name (default: CAD Data) cats_url = f"{self._api_base()}/settings/files/categories"
417
+ self._log(f"GET {cats_url}")
418
+ r = self.session.get(cats_url)
419
+ r.raise_for_status()
420
+ cats = self._ensure_json(r).get("results", [])
421
+ cat_guid = None
422
+ for c in cats:
423
+ if c.get("name") == category_name and (c.get("parentCategory") or {}).get("name") in {"Internal File", None}:
424
+ cat_guid = c.get("guid")
425
+ break
426
+ if not cat_guid:
427
+ raise ArenaError(f'File category "{category_name}" not found or not allowed.')
428
+
429
+ # 3) Prepare multipart (create association)
430
+ title = title or file_path.stem
431
+ file_format = file_format or (file_path.suffix[1:].lower() if file_path.suffix else "bin")
432
+ description = description or "Uploaded via gladiator"
433
+
434
+ files = {
435
+ "content": (file_path.name, open(file_path, "rb"), "application/octet-stream"),
436
+ }
437
+ # NOTE: nested field names are sent in `data`, not `files`
438
+ data_form = {
439
+ "file.title": title,
440
+ "file.name": filename,
441
+ "file.description": description,
442
+ "file.category.guid": cat_guid,
443
+ "file.format": file_format,
444
+ "file.edition": str(edition),
445
+ "file.storageMethodName": "FILE",
446
+ "file.private": "false",
447
+ "primary": "true" if primary else "false",
448
+ "latestEditionAssociation": "true" if latest_edition_association else "false",
449
+ }
450
+ if reference:
451
+ data_form["reference"] = reference
452
+
453
+ # 4) POST to /items/{workingGuid}/files (multipart). Ensure Content-Type not pinned.
454
+ post_url = f"{self._api_base()}/items/{working_guid}/files"
455
+ self._log(f"POST {post_url} (multipart)")
456
+
457
+ with open(file_path, "rb") as fp:
458
+ files = {"content": (filename, fp, "application/octet-stream")}
459
+ existing_ct = self.session.headers.pop("Content-Type", None)
460
+ try:
461
+ cr = self.session.post(post_url, data=data_form, files=files)
462
+ finally:
463
+ if existing_ct is not None:
464
+ self.session.headers["Content-Type"] = existing_ct
465
+ cr.raise_for_status()
466
+ resp = self._ensure_json(cr)
467
+
468
+ # Normalize common fields we use elsewhere
469
+ row = resp if isinstance(resp, dict) else {}
470
+ f = row.get("file", {})
471
+ return {
472
+ "associationGuid": row.get("guid"),
473
+ "primary": row.get("primary"),
474
+ "latestEditionAssociation": row.get("latestEditionAssociation"),
475
+ "file": {
476
+ "guid": f.get("guid"),
477
+ "title": f.get("title"),
478
+ "name": f.get("name"),
479
+ "size": f.get("size"),
480
+ "format": f.get("format"),
481
+ "category": (f.get("category") or {}).get("name"),
482
+ "edition": f.get("edition"),
483
+ "lastModifiedDateTime": f.get("lastModifiedDateTime"),
484
+ },
485
+ "downloadUrl": f"{self._api_base()}/files/{(f or {}).get('guid')}/content" if f.get("guid") else None,
486
+ }
487
+
488
+ def _api_resolve_item_guid(self, item_number: str) -> str:
489
+ url = f"{self._api_base()}/items/"
490
+ params = {"number": item_number, "limit": 1, "responseview": "minimal"}
491
+ self._log(f"GET {url} params={params}")
492
+ r = self.session.get(url, params=params)
493
+ r.raise_for_status()
494
+ data = self._ensure_json(r)
495
+ results = data.get("results") if isinstance(data, dict) else data
496
+ if not results:
497
+ raise ArenaError(f"Item number {item_number} not found")
498
+ guid = (results[0].get("guid") or results[0].get("id") or results[0].get("itemId"))
499
+ if not guid:
500
+ raise ArenaError("API response missing item GUID")
501
+ return guid
502
+
503
+ def _run(self, cmd: str) -> Tuple[int, str, str]:
504
+ proc = subprocess.run(cmd, shell=True, check=False, capture_output=True, text=True)
505
+ return proc.returncode, proc.stdout.strip(), proc.stderr.strip()
gladiator/cli.py ADDED
@@ -0,0 +1,158 @@
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
+ format: Optional[str] = typer.Option(None, "--format", "-f", help="Output format: human (default) or json"),
122
+ ):
123
+ try:
124
+ paths = _client().download_files(item, revision, out_dir=out)
125
+ for p in paths:
126
+ print(str(p))
127
+ except ArenaError as e:
128
+ typer.secho(str(e), fg=typer.colors.RED, err=True)
129
+ raise typer.Exit(2)
130
+
131
+
132
+ @app.command("upload-file")
133
+ def upload_file(
134
+ item: str = typer.Argument(...),
135
+ file: Path = typer.Argument(...),
136
+ reference: Optional[str] = typer.Option(None, "--reference", help="Optional reference string"),
137
+ title: Optional[str] = typer.Option(None, "--title", help="Override file title (default: filename without extension)"),
138
+ category: str = typer.Option("CAD Data", "--category", help='File category name (default: "CAD Data")'),
139
+ file_format: Optional[str] = typer.Option(None, "--format", help="File format (default: file extension)"),
140
+ description: Optional[str] = typer.Option(None, "--desc", help="Optional description"),
141
+ primary: bool = typer.Option(True, "--primary/--no-primary", help="Mark association as primary"),
142
+ edition: str = typer.Option("1", "--edition", help="Edition number when creating a new association (default: 1)"),
143
+ ):
144
+ """If a file with the same filename exists: update its content (new edition).
145
+ Otherwise: create a new association on the WORKING revision (requires --edition)."""
146
+ try:
147
+ result = _client().upload_file_to_working(
148
+ item, file, reference,
149
+ title=title, category_name=category, file_format=file_format,
150
+ description=description, primary=primary, edition=edition
151
+ )
152
+ print(json.dumps(result, indent=2))
153
+ except ArenaError as e:
154
+ typer.secho(str(e), fg=typer.colors.RED, err=True)
155
+
156
+
157
+ if __name__ == "__main__":
158
+ app()
gladiator/config.py ADDED
@@ -0,0 +1,52 @@
1
+ #! /usr/bin/env python
2
+ # -*- coding: utf-8 -*-
3
+ # src/gladiator/config.py
4
+ from __future__ import annotations
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+ from typing import Optional, Any, Dict
9
+ from pydantic import BaseModel, Field
10
+
11
+ CONFIG_HOME = Path(os.environ.get("GLADIATOR_CONFIG_HOME", Path.home() / ".config" / "gladiator"))
12
+ CONFIG_PATH = Path(os.environ.get("GLADIATOR_CONFIG", CONFIG_HOME / "login.json"))
13
+
14
+ class LoginConfig(BaseModel):
15
+ # Primary connection settings
16
+ base_url: str = Field("https://api.arenasolutions.com/v1", description="Arena REST API base URL")
17
+ verify_tls: bool = True
18
+
19
+ # Auth options
20
+ api_key: Optional[str] = None # not used by Arena v1 but kept for future
21
+ username: Optional[str] = None
22
+ password: Optional[str] = None
23
+
24
+ # Session from `/login`
25
+ arena_session_id: Optional[str] = Field(None, alias="arenaSessionId")
26
+ workspace_id: Optional[int] = Field(None, alias="workspaceId")
27
+ workspace_name: Optional[str] = Field(None, alias="workspaceName")
28
+ workspace_request_limit: Optional[int] = Field(None, alias="workspaceRequestLimit")
29
+ reason: Optional[str] = None
30
+
31
+ class Config:
32
+ populate_by_name = True
33
+
34
+
35
+ def save_config_raw(data: Dict[str, Any], path: Path = CONFIG_PATH) -> None:
36
+ path.parent.mkdir(parents=True, exist_ok=True)
37
+ with open(path, "w") as f:
38
+ json.dump(data, f, indent=2)
39
+ try:
40
+ os.chmod(path, 0o600)
41
+ except PermissionError:
42
+ pass
43
+
44
+
45
+ def save_config(cfg: LoginConfig, path: Path = CONFIG_PATH) -> None:
46
+ save_config_raw(cfg.model_dump(by_alias=True))
47
+
48
+
49
+ def load_config(path: Path = CONFIG_PATH) -> LoginConfig:
50
+ with open(path, "r") as f:
51
+ raw = json.load(f)
52
+ return LoginConfig(**raw)
@@ -0,0 +1,104 @@
1
+ Metadata-Version: 2.4
2
+ Name: lr-gladiator
3
+ Version: 0.1.0
4
+ Summary: CLI and Python client for Arena PLM (app.bom.com): login, get revisions, list/download attachments, and upload to working revisions.
5
+ Author-email: Jonas Estberger <jonas.estberger@lumenradio.com>
6
+ License: MIT
7
+ Keywords: Arena,PLM,BOM,attachments,CLI
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Operating System :: OS Independent
11
+ Requires-Python: >=3.9
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Requires-Dist: requests>=2.32
15
+ Requires-Dist: typer>=0.12
16
+ Requires-Dist: rich>=13.7
17
+ Requires-Dist: pydantic>=2.8
18
+ Provides-Extra: dev
19
+ Requires-Dist: build>=1.2.1; extra == "dev"
20
+ Requires-Dist: twine>=5.1.1; extra == "dev"
21
+ Requires-Dist: wheel; extra == "dev"
22
+ Dynamic: license-file
23
+
24
+ # gladiator-arena
25
+
26
+ CLI + Python client for interacting with the Arena PLM.
27
+
28
+ ## Install
29
+
30
+ ```bash
31
+ pip install gladiator-arena
32
+ ```
33
+
34
+ ## Quick start
35
+
36
+ ### 1) Create `login.json`
37
+
38
+ Interactive:
39
+
40
+ ```bash
41
+ gladiator login --subdomain acme
42
+ ```
43
+
44
+ CI/CD friendly:
45
+
46
+ ```bash
47
+ gladiator login --subdomain acme --api-key "$ARENA_API_KEY" --ci
48
+ ```
49
+
50
+ To use your existing scripts while prototyping:
51
+
52
+ ```bash
53
+ gladiator login --subdomain acme --mode bash --scripts-root /path/to/scripts
54
+ ```
55
+
56
+ `login.json` is stored at `~/.config/gladiator/login.json` by default.
57
+
58
+ ### 2) Queries
59
+
60
+ Get latest approved revision for an item:
61
+
62
+ ```bash
63
+ gladiator latest-approved ABC-1234
64
+ ```
65
+
66
+ List files on an item (defaults to latest approved):
67
+
68
+ ```bash
69
+ gladiator list-files ABC-1234
70
+ ```
71
+
72
+ Download files:
73
+
74
+ ```bash
75
+ gladiator get-files ABC-1234 --out downloads/
76
+ ```
77
+
78
+ Upload a file to the working revision (will refuse if the revision is approved/released):
79
+
80
+ ```bash
81
+ gladiator upload-file ABC-1234 ./datasheet.pdf --reference datasheet
82
+ ```
83
+
84
+ ## Programmatic use
85
+
86
+ ```python
87
+ from gladiator import ArenaClient, load_config
88
+ client = ArenaClient(load_config())
89
+ rev = client.get_latest_approved_revision("ABC-1234")
90
+ files = client.list_files("ABC-1234", rev)
91
+ ```
92
+
93
+ ## Development
94
+
95
+ ```bash
96
+ python -m pip install -e .[dev]
97
+ python -m build
98
+ ```
99
+
100
+ ## FAQ
101
+
102
+ - **Where is the config kept?** `~/.config/gladiator/login.json` (override via `GLADIATOR_CONFIG`).
103
+ - **Auth method?** Prefer API key. Username/password is supported to be CI-friendly but avoid storing passwords when possible.
104
+ - **Approved states?** By default any state labelled `Approved` or `Released` is treated as approved. Adjust the code if your tenant uses custom labels.
@@ -0,0 +1,10 @@
1
+ gladiator/__init__.py,sha256=kVgJiGDD6714tJ3SN6mdao3rdVO57jlMvLMHAFjHX4A,207
2
+ gladiator/arena.py,sha256=3_Ry7_7YIUNHtlxo0WLwq0q4CVGwCmikzoYnQIZy-DE,21651
3
+ gladiator/cli.py,sha256=ugquxfry1Qx6iYbNVglXgYV9XPryAzYmXlLBxAF6JTc,6557
4
+ gladiator/config.py,sha256=pnuVrcW8yafxMB7RU9wyi_4jS_oMBIuNryfet203Wng,1738
5
+ lr_gladiator-0.1.0.dist-info/licenses/LICENSE,sha256=2CEtbEagerjoU3EDSk-eTM5LKgI_RpiVIOh3_CV4kms,1069
6
+ lr_gladiator-0.1.0.dist-info/METADATA,sha256=nOzlohzF7-daFGFF1jq9d1_dn9PxYQGtlefV75gplV0,2498
7
+ lr_gladiator-0.1.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
8
+ lr_gladiator-0.1.0.dist-info/entry_points.txt,sha256=SLka4w7iGS2B8HrbeZyNk5mxaIC6QKcv93us1OaWNwQ,48
9
+ lr_gladiator-0.1.0.dist-info/top_level.txt,sha256=tfrcAmK7_7Lf63w7kWy0wv_Qg9RrcFWGoins1-jGUF4,10
10
+ lr_gladiator-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ gladiator = gladiator.cli:app
@@ -0,0 +1,25 @@
1
+ MIT License
2
+
3
+
4
+ Copyright (c) 2025 Your Name
5
+
6
+
7
+ Permission is hereby granted, free of charge, to any person obtaining a copy
8
+ of this software and associated documentation files (the "Software"), to deal
9
+ in the Software without restriction, including without limitation the rights
10
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11
+ copies of the Software, and to permit persons to whom the Software is
12
+ furnished to do so, subject to the following conditions:
13
+
14
+
15
+ The above copyright notice and this permission notice shall be included in all
16
+ copies or substantial portions of the Software.
17
+
18
+
19
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
25
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ gladiator