lr-gladiator 0.4.0__py3-none-any.whl → 0.14.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.
gladiator/arena.py CHANGED
@@ -6,25 +6,31 @@ import subprocess
6
6
  import shlex
7
7
  import os
8
8
  from pathlib import Path
9
- from typing import Dict, List, Optional, Tuple
9
+ from typing import Dict, List, Optional, Tuple, FrozenSet
10
10
  import requests
11
11
  from .config import LoginConfig
12
+ from .checksums import sha256_file
13
+ import hashlib
14
+
12
15
 
13
16
  class ArenaError(RuntimeError):
14
17
  pass
15
18
 
19
+
16
20
  class ArenaClient:
17
21
  def __init__(self, cfg: LoginConfig):
18
22
  self.cfg = cfg
19
23
  self.session = requests.Session()
20
24
  self.session.verify = cfg.verify_tls
21
25
  # 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
- })
26
+ self.session.headers.update(
27
+ {
28
+ "Accept": "application/json",
29
+ "Content-Type": "application/json",
30
+ "User-Agent": "gladiator-arena/0.1",
31
+ "Arena-Usage-Reason": cfg.reason or "gladiator/cli",
32
+ }
33
+ )
28
34
  if cfg.arena_session_id:
29
35
  self.session.headers.update({"arena_session_id": cfg.arena_session_id})
30
36
 
@@ -87,7 +93,15 @@ class ArenaClient:
87
93
  def _timestamp_of(f: Dict):
88
94
  from datetime import datetime
89
95
  from email.utils import parsedate_to_datetime
90
- for k in ("modifiedAt", "updatedAt", "lastModified", "lastModifiedDate", "effectiveDate", "createdAt"):
96
+
97
+ for k in (
98
+ "modifiedAt",
99
+ "updatedAt",
100
+ "lastModified",
101
+ "lastModifiedDate",
102
+ "effectiveDate",
103
+ "createdAt",
104
+ ):
91
105
  s = f.get(k)
92
106
  if not s:
93
107
  continue
@@ -125,29 +139,139 @@ class ArenaClient:
125
139
  def get_latest_approved_revision(self, item_number: str) -> str:
126
140
  return self._api_get_latest_approved(item_number)
127
141
 
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")
142
+ def list_files(
143
+ self, item_number: str, revision: Optional[str] = None
144
+ ) -> List[Dict]:
145
+ target_guid = self._api_resolve_revision_guid(
146
+ item_number, revision or "EFFECTIVE"
147
+ )
131
148
  raw = self._api_list_files_by_item_guid(target_guid)
132
149
  return self._latest_files(raw)
133
-
134
150
 
135
- def download_files(self, item_number: str, revision: Optional[str] = None, out_dir: Path = Path(".")) -> List[Path]:
151
+ def download_files(
152
+ self,
153
+ item_number: str,
154
+ revision: Optional[str] = None,
155
+ out_dir: Path = Path("."),
156
+ ) -> List[Path]:
136
157
  files = self.list_files(item_number, revision)
137
158
  out_dir.mkdir(parents=True, exist_ok=True)
138
159
  downloaded: List[Path] = []
139
160
  for f in files:
161
+ # Skip associations with no blob
162
+ if not f.get("haveContent", True):
163
+ self._log(
164
+ f"Skip {item_number}: file {f.get('filename')} has no content"
165
+ )
166
+ continue
167
+
140
168
  url = f.get("downloadUrl") or f.get("url")
141
169
  filename = f.get("filename") or f.get("name")
142
170
  if not url or not filename:
143
171
  continue
172
+
144
173
  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)
174
+ try:
175
+ with self.session.get(
176
+ url,
177
+ stream=True,
178
+ headers={"arena_session_id": self.cfg.arena_session_id or ""},
179
+ ) as r:
180
+ # If the blob is missing/forbidden, don’t abort the whole command
181
+ if r.status_code in (400, 403, 404):
182
+ self._log(
183
+ f"Skip {item_number}: {filename} content unavailable "
184
+ f"(HTTP {r.status_code})"
185
+ )
186
+ continue
187
+ r.raise_for_status()
188
+ with open(p, "wb") as fh:
189
+ for chunk in r.iter_content(128 * 1024):
190
+ fh.write(chunk)
191
+ downloaded.append(p)
192
+ except requests.HTTPError as e:
193
+ # Be resilient: log and continue
194
+ self._log(f"Download failed for {filename}: {e}")
195
+ continue
196
+ return downloaded
197
+
198
+ def download_files_recursive(
199
+ self,
200
+ item_number: str,
201
+ revision: Optional[str] = None,
202
+ out_dir: Path = Path("."),
203
+ *,
204
+ max_depth: Optional[int] = None,
205
+ ) -> List[Path]:
206
+ """
207
+ Download files for `item_number` AND, recursively, for all subassemblies
208
+ discovered via the BOM. Each child item is placed under a subdirectory:
209
+ <out_dir>/<child_item_number>/
210
+ Root files go directly in <out_dir>/.
211
+
212
+ Depth semantics match `get_bom(..., recursive=True, max_depth=...)`.
213
+ """
214
+ # Ensure the root directory exists
215
+ out_dir.mkdir(parents=True, exist_ok=True)
216
+
217
+ downloaded: List[Path] = []
218
+ bom_cache: Dict[str, List[Dict]] = {}
219
+
220
+ def fetch_children(item: str) -> List[Dict]:
221
+ if item not in bom_cache:
222
+ bom_cache[item] = self.get_bom(
223
+ item,
224
+ revision,
225
+ recursive=False,
226
+ max_depth=None,
227
+ )
228
+ return bom_cache[item]
229
+
230
+ def walk(
231
+ current_item: str,
232
+ dest: Path,
233
+ depth: int,
234
+ ancestors: FrozenSet[str],
235
+ ) -> None:
236
+ if current_item in ancestors:
237
+ self._log(
238
+ "Detected BOM cycle involving "
239
+ f"{current_item} (ancestors: {', '.join(sorted(ancestors))})"
240
+ )
241
+ return
242
+
243
+ next_ancestors = ancestors | {current_item}
244
+
245
+ dest.mkdir(parents=True, exist_ok=True)
246
+ downloaded.extend(self.download_files(current_item, revision, out_dir=dest))
247
+
248
+ if max_depth is not None and depth >= max_depth:
249
+ return
250
+
251
+ children = fetch_children(current_item)
252
+ seen_children: set[str] = set()
253
+ for child in children:
254
+ if not child:
255
+ continue
256
+ child_num = child.get("itemNumber")
257
+ if not child_num:
258
+ continue
259
+ if child_num == current_item:
260
+ continue
261
+ if child_num in seen_children:
262
+ continue
263
+ if child_num in next_ancestors:
264
+ self._log(
265
+ "Detected BOM cycle involving "
266
+ f"{child_num} (ancestors: {', '.join(sorted(next_ancestors))})"
267
+ )
268
+ continue
269
+ seen_children.add(child_num)
270
+
271
+ child_dir = dest / child_num
272
+ walk(child_num, child_dir, depth + 1, next_ancestors)
273
+
274
+ walk(item_number, out_dir, depth=0, ancestors=frozenset())
151
275
  return downloaded
152
276
 
153
277
  def upload_file_to_working(
@@ -162,13 +286,13 @@ class ArenaClient:
162
286
  description: Optional[str] = None,
163
287
  primary: bool = True,
164
288
  latest_edition_association: bool = True,
165
- edition: str = "1",
289
+ edition: str = None,
166
290
  ) -> Dict:
167
291
  """
168
- Update-if-exists-else-create semantics, matching the bash script:
292
+ Update-if-exists-else-create semantics:
169
293
  1) Resolve EFFECTIVE GUID from item number
170
294
  2) Resolve WORKING revision GUID (fail if none)
171
- 3) Find existing file by exact filename (WORKING first, then EFFECTIVE)
295
+ 3) Find existing file by title orexact filename (WORKING first, then EFFECTIVE)
172
296
  - If found: POST /files/{fileGuid}/content (multipart)
173
297
  - Else: POST /items/{workingGuid}/files (multipart) with file.edition
174
298
  """
@@ -185,7 +309,139 @@ class ArenaClient:
185
309
  edition=edition,
186
310
  )
187
311
 
188
- # ---------- API-mode (HTTP) ----------
312
+ def get_bom(
313
+ self,
314
+ item_number: str,
315
+ revision: Optional[str] = None,
316
+ *,
317
+ recursive: bool = False,
318
+ max_depth: Optional[int] = None,
319
+ ) -> List[Dict]:
320
+ """
321
+ Return a normalized list of BOM lines for the given item.
322
+
323
+ By default this fetches the EFFECTIVE (approved) revision's BOM.
324
+ Use revision="WORKING" or a specific label (e.g., "B2") to override.
325
+
326
+ If recursive=True, expand subassemblies depth-first. max_depth limits the recursion
327
+ depth (1 = only direct children). If omitted, recursion is unlimited.
328
+ """
329
+ selector = (revision or "EFFECTIVE").strip()
330
+ out: List[Dict] = []
331
+ self._bom_expand(
332
+ root_item=item_number,
333
+ selector=selector,
334
+ out=out,
335
+ recursive=recursive,
336
+ max_depth=max_depth,
337
+ _level=0,
338
+ _seen=set(),
339
+ )
340
+ return out
341
+
342
+ # === Internal: single fetch + normalization (your original logic) ===
343
+
344
+ def _fetch_bom_normalized(self, item_number: str, selector: str) -> List[Dict]:
345
+ """
346
+ Fetch and normalize the BOM for item_number with the given revision selector.
347
+ Falls back WORKING -> EFFECTIVE if selector is WORKING and no WORKING exists.
348
+ """
349
+ # 1) Resolve the exact revision GUID we want the BOM for
350
+ try:
351
+ target_guid = self._api_resolve_revision_guid(item_number, selector)
352
+ except ArenaError:
353
+ if selector.strip().upper() == "WORKING":
354
+ # fallback: try EFFECTIVE for children that don't have a WORKING revision
355
+ target_guid = self._api_resolve_revision_guid(item_number, "EFFECTIVE")
356
+ else:
357
+ raise
358
+
359
+ # 2) GET /items/{guid}/bom
360
+ url = f"{self._api_base()}/items/{target_guid}/bom"
361
+ self._log(f"GET {url}")
362
+ r = self.session.get(url)
363
+ r.raise_for_status()
364
+ data = self._ensure_json(r)
365
+
366
+ rows = data.get("results", data if isinstance(data, list) else [])
367
+ norm: List[Dict] = []
368
+ for row in rows:
369
+ itm = row.get("item", {}) if isinstance(row, dict) else {}
370
+ norm.append(
371
+ {
372
+ # association/line
373
+ "guid": row.get("guid"),
374
+ "lineNumber": row.get("lineNumber"),
375
+ "notes": row.get("notes"),
376
+ "quantity": row.get("quantity"),
377
+ "refDes": row.get("refDes")
378
+ or row.get("referenceDesignators")
379
+ or "",
380
+ # child item
381
+ "itemGuid": itm.get("guid") or itm.get("id"),
382
+ "itemNumber": itm.get("number"),
383
+ "itemName": itm.get("name"),
384
+ "itemRevision": itm.get("revisionNumber"),
385
+ "itemRevisionStatus": itm.get("revisionStatus"),
386
+ "itemUrl": (itm.get("url") or {}).get("api"),
387
+ "itemAppUrl": (itm.get("url") or {}).get("app"),
388
+ }
389
+ )
390
+ return norm
391
+
392
+ # === Internal: recursive expansion ===
393
+
394
+ def _bom_expand(
395
+ self,
396
+ *,
397
+ root_item: str,
398
+ selector: str,
399
+ out: List[Dict],
400
+ recursive: bool,
401
+ max_depth: Optional[int],
402
+ _level: int,
403
+ _seen: set,
404
+ ) -> None:
405
+ # avoid cycles
406
+ if root_item in _seen:
407
+ return
408
+ _seen.add(root_item)
409
+
410
+ rows = self._fetch_bom_normalized(root_item, selector)
411
+
412
+ # attach level and parentNumber (useful in JSON + for debugging)
413
+ for r in rows:
414
+ r["level"] = _level
415
+ r["parentNumber"] = root_item
416
+ out.append(r)
417
+
418
+ if not recursive:
419
+ return
420
+
421
+ # depth check: if max_depth=1, only expand children once (level 0 -> level 1)
422
+ if max_depth is not None and _level >= max_depth:
423
+ return
424
+
425
+ # expand each child that looks like an assembly (if it has a BOM; empty BOM is okay)
426
+ for r in rows:
427
+ child_num = r.get("itemNumber")
428
+ if not child_num:
429
+ continue
430
+ try:
431
+ # Recurse; keep same selector, with WORKING->EFFECTIVE fallback handled in _fetch_bom_normalized
432
+ self._bom_expand(
433
+ root_item=child_num,
434
+ selector=selector,
435
+ out=out,
436
+ recursive=True,
437
+ max_depth=max_depth,
438
+ _level=_level + 1,
439
+ _seen=_seen,
440
+ )
441
+ except ArenaError:
442
+ # Child might not have a BOM; skip silently
443
+ continue
444
+
189
445
  def _api_base(self) -> str:
190
446
  return self.cfg.base_url.rstrip("/")
191
447
 
@@ -206,26 +462,35 @@ class ArenaClient:
206
462
  # - revisionStatus == "EFFECTIVE" (string)
207
463
  # - OR status == 1 (numeric)
208
464
  effective = [
209
- rv for rv in revs
210
- if (str(rv.get("revisionStatus") or "").upper() == "EFFECTIVE") or (rv.get("status") == 1)
465
+ rv
466
+ for rv in revs
467
+ if (str(rv.get("revisionStatus") or "").upper() == "EFFECTIVE")
468
+ or (rv.get("status") == 1)
211
469
  ]
212
470
  if not effective:
213
471
  raise ArenaError(f"No approved/released revisions for item {item_number}")
214
472
 
215
473
  # 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)
474
+ current = next(
475
+ (rv for rv in effective if not rv.get("supersededDateTime")), None
476
+ )
217
477
  if not current:
218
478
  # sort by supersededDateTime (None last) then by number/name as a stable tie-breaker
219
479
  def _sd(rv):
220
480
  dt = rv.get("supersededDateTime")
221
481
  return dt or "0000-00-00T00:00:00Z"
482
+
222
483
  effective.sort(key=_sd)
223
484
  current = effective[-1]
224
485
 
225
486
  # 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")
487
+ rev_label = (
488
+ current.get("number") or current.get("name") or current.get("revision")
489
+ )
227
490
  if not rev_label:
228
- raise ArenaError(f"Could not determine revision label for item {item_number}")
491
+ raise ArenaError(
492
+ f"Could not determine revision label for item {item_number}"
493
+ )
229
494
  return rev_label
230
495
 
231
496
  def _api_list_files(self, item_number: str) -> List[Dict]:
@@ -240,19 +505,27 @@ class ArenaClient:
240
505
  for row in rows:
241
506
  f = row.get("file", {}) if isinstance(row, dict) else {}
242
507
  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
- })
508
+ norm.append(
509
+ {
510
+ "id": row.get("guid") or row.get("id"),
511
+ "fileGuid": file_guid,
512
+ "name": f.get("name") or f.get("title"),
513
+ "title": f.get("title"),
514
+ "filename": f.get("name") or f.get("title"),
515
+ "size": f.get("size"),
516
+ "haveContent": f.get("haveContent", True),
517
+ "downloadUrl": (
518
+ f"{self._api_base()}/files/{file_guid}/content"
519
+ if file_guid
520
+ else None
521
+ ),
522
+ "edition": f.get("edition"),
523
+ "updatedAt": f.get("lastModifiedDateTime")
524
+ or f.get("lastModifiedDate")
525
+ or f.get("creationDateTime"),
526
+ "attachmentGroupGuid": row.get("guid"),
527
+ }
528
+ )
256
529
  return norm
257
530
 
258
531
  def _api_resolve_revision_guid(self, item_number: str, selector: str | None) -> str:
@@ -266,7 +539,8 @@ class ArenaClient:
266
539
  # Fetch revisions
267
540
  url = f"{self._api_base()}/items/{effective_guid}/revisions"
268
541
  self._log(f"GET {url}")
269
- r = self.session.get(url); r.raise_for_status()
542
+ r = self.session.get(url)
543
+ r.raise_for_status()
270
544
  data = self._ensure_json(r)
271
545
  revs = data.get("results", data if isinstance(data, list) else [])
272
546
 
@@ -278,21 +552,36 @@ class ArenaClient:
278
552
 
279
553
  # Named selectors
280
554
  if sel in {"WORKING"}:
281
- guid = pick(lambda rv: (rv.get("revisionStatus") or "").upper() == "WORKING" or rv.get("status") == 0)
555
+ guid = pick(
556
+ lambda rv: (rv.get("revisionStatus") or "").upper() == "WORKING"
557
+ or rv.get("status") == 0
558
+ )
282
559
  if not guid:
283
560
  raise ArenaError("No WORKING revision exists for this item.")
284
561
  return guid
285
562
 
286
563
  if sel in {"EFFECTIVE", "APPROVED", "RELEASED"}:
287
564
  # Prefer the one not superseded
288
- eff = [rv for rv in revs if (rv.get("revisionStatus") or "").upper() == "EFFECTIVE" or rv.get("status") == 1]
565
+ eff = [
566
+ rv
567
+ for rv in revs
568
+ if (rv.get("revisionStatus") or "").upper() == "EFFECTIVE"
569
+ or rv.get("status") == 1
570
+ ]
289
571
  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])
572
+ raise ArenaError(
573
+ "No approved/effective revision exists for this item. Try using revision 'WORKING'."
574
+ )
575
+ current = next(
576
+ (rv for rv in eff if not rv.get("supersededDateTime")), eff[-1]
577
+ )
292
578
  return current.get("guid")
293
579
 
294
580
  # 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)
581
+ guid = pick(
582
+ lambda rv: (rv.get("number") or rv.get("name"))
583
+ and str(rv.get("number") or rv.get("name")).upper() == sel
584
+ )
296
585
  if not guid:
297
586
  raise ArenaError(f'Revision "{selector}" not found for item {item_number}.')
298
587
  return guid
@@ -300,7 +589,8 @@ class ArenaClient:
300
589
  def _api_list_files_by_item_guid(self, item_guid: str) -> list[dict]:
301
590
  url = f"{self._api_base()}/items/{item_guid}/files"
302
591
  self._log(f"GET {url}")
303
- r = self.session.get(url); r.raise_for_status()
592
+ r = self.session.get(url)
593
+ r.raise_for_status()
304
594
  data = self._ensure_json(r)
305
595
  rows = data.get("results", data if isinstance(data, list) else [])
306
596
  # … keep existing normalization from _api_list_files() …
@@ -308,19 +598,32 @@ class ArenaClient:
308
598
  for row in rows:
309
599
  f = row.get("file", {}) if isinstance(row, dict) else {}
310
600
  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
601
+ norm.append(
602
+ {
603
+ "id": row.get("guid") or row.get("id"),
604
+ "fileGuid": file_guid,
605
+ "title": f.get("title"),
606
+ "name": f.get("name"),
607
+ "filename": f.get("name"),
608
+ "size": f.get("size"),
609
+ "haveContent": f.get("haveContent", True),
610
+ "downloadUrl": (
611
+ f"{self._api_base()}/files/{file_guid}/content"
612
+ if file_guid
613
+ else None
614
+ ),
615
+ "edition": f.get("edition"),
616
+ "updatedAt": f.get("lastModifiedDateTime")
617
+ or f.get("lastModifiedDate")
618
+ or f.get("creationDateTime"),
619
+ "attachmentGroupGuid": row.get("guid"),
620
+ "storageMethodName": (
621
+ f.get("storageMethodName") or f.get("storageMethod")
622
+ ),
623
+ "location": f.get("location"),
624
+ }
625
+ )
626
+ return norm
324
627
 
325
628
  def _api_upload_or_update_file(
326
629
  self,
@@ -339,6 +642,14 @@ class ArenaClient:
339
642
  if not file_path.exists() or not file_path.is_file():
340
643
  raise ArenaError(f"File not found: {file_path}")
341
644
 
645
+ filename = file_path.name # Use truncated SHA256 hash if no edition is provided
646
+ if not edition:
647
+ # Arena seems to only accept 16 characters of edition information.
648
+ # The hex digest gives 16 hex × 4 bits = 64 bits of entropy.
649
+ # Less than a million files, collision risk is practically zero (~1 / 10^8).
650
+ edition = sha256_file(file_path)
651
+ edition = str(edition)[:16]
652
+
342
653
  # 0) Resolve EFFECTIVE revision guid from item number
343
654
  effective_guid = self._api_resolve_item_guid(item_number)
344
655
 
@@ -351,7 +662,9 @@ class ArenaClient:
351
662
  rows = data.get("results", data if isinstance(data, list) else [])
352
663
  working_guid = None
353
664
  for rv in rows:
354
- if (str(rv.get("revisionStatus") or "").upper() == "WORKING") or (rv.get("status") == 0):
665
+ if (str(rv.get("revisionStatus") or "").upper() == "WORKING") or (
666
+ rv.get("status") == 0
667
+ ):
355
668
  working_guid = rv.get("guid")
356
669
  break
357
670
  if not working_guid:
@@ -371,15 +684,45 @@ class ArenaClient:
371
684
  # Try to find existing association by exact filename (WORKING first, then EFFECTIVE)
372
685
  filename = file_path.name
373
686
  assoc = None
687
+ if title:
688
+ candidates = _list_assocs(working_guid)
689
+
690
+ def _a_title(a):
691
+ f = a.get("file") or {}
692
+ return (f.get("title") or a.get("title") or "").strip().casefold()
693
+
694
+ tnorm = title.strip().casefold()
695
+ # Prefer primary + latestEditionAssociation if duplicates exist
696
+ preferred = [
697
+ a
698
+ for a in candidates
699
+ if _a_title(a) == tnorm
700
+ and a.get("primary")
701
+ and a.get("latestEditionAssociation")
702
+ ]
703
+ if preferred:
704
+ assoc = preferred[0]
705
+ else:
706
+ any_match = [a for a in candidates if _a_title(a) == tnorm]
707
+ if any_match:
708
+ assoc = any_match[0]
709
+
374
710
  for guid in (working_guid, effective_guid):
375
711
  assocs = _list_assocs(guid)
376
712
  # 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)]
713
+ prim_latest = [
714
+ a
715
+ for a in assocs
716
+ if a.get("primary")
717
+ and a.get("latestEditionAssociation")
718
+ and ((a.get("file") or {}).get("name") == filename)
719
+ ]
379
720
  if prim_latest:
380
721
  assoc = prim_latest[0]
381
722
  break
382
- any_by_name = [a for a in assocs if (a.get("file") or {}).get("name") == filename]
723
+ any_by_name = [
724
+ a for a in assocs if (a.get("file") or {}).get("name") == filename
725
+ ]
383
726
  if any_by_name:
384
727
  assoc = any_by_name[0]
385
728
  break
@@ -400,6 +743,17 @@ class ArenaClient:
400
743
  if existing_ct is not None:
401
744
  self.session.headers["Content-Type"] = existing_ct
402
745
  ur.raise_for_status()
746
+
747
+ # Update the edition label on the File itself
748
+ try:
749
+ put_url = f"{self._api_base()}/files/{file_guid}"
750
+ self._log(f"PUT {put_url} (set edition={edition})")
751
+ pr = self.session.put(put_url, json={"edition": str(edition)})
752
+ pr.raise_for_status()
753
+ except requests.HTTPError as e:
754
+ # Don't fail the whole operation if the label update is rejected
755
+ self._log(f"Edition update failed for {file_guid}: {e}")
756
+
403
757
  # Many tenants return 201 with no JSON for content updates. Be flexible.
404
758
  data = self._try_json(ur)
405
759
  if data is None:
@@ -409,6 +763,7 @@ class ArenaClient:
409
763
  "status": ur.status_code,
410
764
  "fileGuid": file_guid,
411
765
  "location": ur.headers.get("Location"),
766
+ "edition": str(edition),
412
767
  }
413
768
  return data
414
769
 
@@ -421,30 +776,44 @@ class ArenaClient:
421
776
  cats = self._ensure_json(r).get("results", [])
422
777
  cat_guid = None
423
778
  for c in cats:
424
- if c.get("name") == category_name and (c.get("parentCategory") or {}).get("name") in {"Internal File", None}:
779
+ if c.get("name") == category_name and (c.get("parentCategory") or {}).get(
780
+ "name"
781
+ ) in {"Internal File", None}:
425
782
  cat_guid = c.get("guid")
426
783
  break
427
784
  if not cat_guid:
428
- raise ArenaError(f'File category "{category_name}" not found or not allowed.')
785
+ raise ArenaError(
786
+ f'File category "{category_name}" not found or not allowed.'
787
+ )
429
788
 
430
789
  # 3) Prepare multipart (create association)
431
- title = title or file_path.stem
432
- file_format = file_format or (file_path.suffix[1:].lower() if file_path.suffix else "bin")
790
+ title = title or file_path.name
791
+ file_format = file_format or (
792
+ file_path.suffix[1:].lower() if file_path.suffix else "bin"
793
+ )
433
794
  description = description or "Uploaded via gladiator"
434
- files = {"content": (file_path.name, open(file_path, "rb"), "application/octet-stream")}
795
+ files = {
796
+ "content": (
797
+ file_path.name,
798
+ open(file_path, "rb"),
799
+ "application/octet-stream",
800
+ )
801
+ }
435
802
 
436
803
  # NOTE: nested field names are sent in `data`, not `files`
437
804
  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
- }
805
+ "file.title": title,
806
+ "file.description": description,
807
+ "file.category.guid": cat_guid,
808
+ "file.format": file_format,
809
+ "file.edition": str(edition),
810
+ "file.storageMethodName": "FILE",
811
+ "file.private": "false",
812
+ "primary": "true" if primary else "false",
813
+ "latestEditionAssociation": (
814
+ "true" if latest_edition_association else "false"
815
+ ),
816
+ }
448
817
  if reference:
449
818
  data_form["reference"] = reference
450
819
 
@@ -466,6 +835,22 @@ class ArenaClient:
466
835
  # Normalize common fields we use elsewhere
467
836
  row = resp if isinstance(resp, dict) else {}
468
837
  f = row.get("file", {})
838
+
839
+ # Ensure the edition label is exactly what we asked for (some tenants ignore form edition)
840
+ try:
841
+ file_guid_created = (f or {}).get("guid")
842
+ if file_guid_created and str(edition):
843
+ put_url = f"{self._api_base()}/files/{file_guid_created}"
844
+ self._log(f"PUT {put_url} (set edition={edition})")
845
+ pr = self.session.put(put_url, json={"edition": str(edition)})
846
+ pr.raise_for_status()
847
+ # Update local 'f' edition if the PUT succeeded
848
+ f["edition"] = str(edition)
849
+ except requests.HTTPError as e:
850
+ self._log(
851
+ f"Edition update after create failed for {file_guid_created}: {e}"
852
+ )
853
+
469
854
  return {
470
855
  "associationGuid": row.get("guid"),
471
856
  "primary": row.get("primary"),
@@ -477,10 +862,14 @@ class ArenaClient:
477
862
  "size": f.get("size"),
478
863
  "format": f.get("format"),
479
864
  "category": (f.get("category") or {}).get("name"),
480
- "edition": f.get("edition"),
865
+ "edition": f.get("edition") or str(edition),
481
866
  "lastModifiedDateTime": f.get("lastModifiedDateTime"),
482
867
  },
483
- "downloadUrl": f"{self._api_base()}/files/{(f or {}).get('guid')}/content" if f.get("guid") else None,
868
+ "downloadUrl": (
869
+ f"{self._api_base()}/files/{(f or {}).get('guid')}/content"
870
+ if f.get("guid")
871
+ else None
872
+ ),
484
873
  }
485
874
 
486
875
  def _api_resolve_item_guid(self, item_number: str) -> str:
@@ -493,11 +882,272 @@ class ArenaClient:
493
882
  results = data.get("results") if isinstance(data, dict) else data
494
883
  if not results:
495
884
  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"))
885
+ guid = (
886
+ results[0].get("guid") or results[0].get("id") or results[0].get("itemId")
887
+ )
497
888
  if not guid:
498
889
  raise ArenaError("API response missing item GUID")
499
890
  return guid
500
891
 
892
+ # --- helper: resolve File Category GUID by name (exact match under Settings) ---
893
+ def _api_resolve_file_category_guid(self, category_name: str) -> str:
894
+ cats_url = f"{self._api_base()}/settings/files/categories"
895
+ self._log(f"GET {cats_url}")
896
+ r = self.session.get(cats_url)
897
+ r.raise_for_status()
898
+ cats = self._ensure_json(r).get("results", [])
899
+ for c in cats:
900
+ if c.get("name") == category_name:
901
+ return c.get("guid")
902
+ raise ArenaError(f'File category "{category_name}" not found.')
903
+
904
+ # --- helper: create a WEB File (no binary content) and return its GUID ---
905
+ def _api_create_web_file(
906
+ self,
907
+ *,
908
+ category_guid: str,
909
+ title: str,
910
+ location_url: str,
911
+ edition: str,
912
+ description: Optional[str],
913
+ file_format: Optional[str],
914
+ private: bool = False,
915
+ ) -> dict:
916
+ """
917
+ POST /files (create File record with storageMethodName=WEB and a 'location')
918
+ """
919
+ url = f"{self._api_base()}/files"
920
+ payload = {
921
+ "category": {"guid": category_guid},
922
+ "title": title,
923
+ "description": description or "",
924
+ "edition": str(edition),
925
+ "format": file_format or "url",
926
+ "private": bool(private),
927
+ "storageMethodName": "WEB",
928
+ "location": location_url,
929
+ }
930
+ self._log(f"POST {url} (create web file)")
931
+ r = self.session.post(url, json=payload)
932
+ r.raise_for_status()
933
+ data = self._ensure_json(r)
934
+ if not isinstance(data, dict) or not data.get("guid"):
935
+ raise ArenaError("File create (WEB) returned no GUID")
936
+ return data # includes "guid", "number", etc.
937
+
938
+ # --- helper: PUT File (update WEB metadata/location/edition) ---
939
+ def _api_update_web_file(
940
+ self,
941
+ *,
942
+ file_guid: str,
943
+ category_guid: str,
944
+ title: str,
945
+ location_url: str,
946
+ edition: str,
947
+ description: Optional[str],
948
+ file_format: Optional[str],
949
+ private: bool = False,
950
+ ) -> dict:
951
+ """
952
+ PUT /files/{guid} (update summary). For WEB/FTP/PLACE_HOLDER, include 'location'.
953
+ """
954
+ url = f"{self._api_base()}/files/{file_guid}"
955
+ payload = {
956
+ "category": {"guid": category_guid},
957
+ "title": title,
958
+ "description": description or "",
959
+ "edition": str(edition),
960
+ "format": file_format or "url",
961
+ "private": bool(private),
962
+ "storageMethodName": "WEB",
963
+ "location": location_url,
964
+ }
965
+ self._log(f"PUT {url} (update web file)")
966
+ r = self.session.put(url, json=payload)
967
+ r.raise_for_status()
968
+ return self._ensure_json(r)
969
+
970
+ def _api_item_add_existing_file(
971
+ self,
972
+ *,
973
+ item_guid: str,
974
+ file_guid: str,
975
+ primary: bool,
976
+ latest_edition_association: bool,
977
+ reference: Optional[str] = None,
978
+ ) -> dict:
979
+ url = f"{self._api_base()}/items/{item_guid}/files"
980
+ payload = {
981
+ "primary": bool(primary),
982
+ "latestEditionAssociation": bool(latest_edition_association),
983
+ "file": {"guid": file_guid},
984
+ }
985
+ if reference:
986
+ payload["reference"] = reference
987
+ r = self.session.post(url, json=payload)
988
+ r.raise_for_status()
989
+ return self._ensure_json(r)
990
+
991
+ def upload_weblink_to_working(
992
+ self,
993
+ *,
994
+ item_number: str,
995
+ url: str,
996
+ reference: Optional[str] = None, # (unused by "add existing"; kept for parity)
997
+ title: str,
998
+ category_name: str = "Web Link",
999
+ file_format: Optional[str] = "url",
1000
+ description: Optional[str] = None,
1001
+ primary: bool = True,
1002
+ latest_edition_association: bool = True,
1003
+ edition: Optional[str] = None,
1004
+ ) -> Dict:
1005
+ """
1006
+ Idempotent "upsert" of a WEB-link File on the WORKING revision of `item_number`.
1007
+
1008
+ Match rules (WORKING first, then EFFECTIVE):
1009
+ - any association whose File has storageMethodName in {"WEB","FTP"} AND
1010
+ (File.title == title OR File.location == url)
1011
+
1012
+ If found -> PUT /files/{fileGuid} with storageMethodName=WEB + location + edition.
1013
+ Else -> POST /files (create) + POST /items/{workingGuid}/files (add existing).
1014
+ """
1015
+ # Compute an edition if none is provided (SHA256 of the URL, truncated to 16)
1016
+ if not edition:
1017
+ edition = hashlib.sha256(url.encode("utf-8")).hexdigest()
1018
+ edition = str(edition)[:16]
1019
+
1020
+ # Resolve item GUIDs
1021
+ effective_guid = self._api_resolve_item_guid(item_number)
1022
+ revs_url = f"{self._api_base()}/items/{effective_guid}/revisions"
1023
+ self._log(f"GET {revs_url}")
1024
+ r = self.session.get(revs_url)
1025
+ r.raise_for_status()
1026
+ revs = self._ensure_json(r).get("results", [])
1027
+ working_guid = None
1028
+ for rv in revs:
1029
+ if (str(rv.get("revisionStatus") or "").upper() == "WORKING") or (
1030
+ rv.get("status") == 0
1031
+ ):
1032
+ working_guid = rv.get("guid")
1033
+ break
1034
+ if not working_guid:
1035
+ raise ArenaError(
1036
+ "No WORKING revision exists for this item. Create a working revision in Arena, then retry."
1037
+ )
1038
+
1039
+ # Resolve category GUID
1040
+ cat_guid = self._api_resolve_file_category_guid(category_name)
1041
+
1042
+ # Helper to list associations for a given item/revision guid
1043
+ def _list_assocs(guid: str) -> list[dict]:
1044
+ url2 = f"{self._api_base()}/items/{guid}/files"
1045
+ self._log(f"GET {url2}")
1046
+ lr = self.session.get(url2)
1047
+ lr.raise_for_status()
1048
+ payload = self._ensure_json(lr)
1049
+ return payload.get("results", payload if isinstance(payload, list) else [])
1050
+
1051
+ # Try to find an existing WEB/FTP style file by title or URL
1052
+ def _pick_assoc_by_title_or_url(assocs: list[dict]) -> Optional[dict]:
1053
+ pick = None
1054
+ for a in assocs:
1055
+ f = a.get("file") or {}
1056
+ smn = str(
1057
+ f.get("storageMethodName") or f.get("storageMethod") or ""
1058
+ ).upper()
1059
+ if smn not in {"WEB", "FTP"}:
1060
+ continue
1061
+ f_title = (f.get("title") or "").strip()
1062
+ f_loc = (f.get("location") or "").strip()
1063
+ if (f_title and f_title == title) or (f_loc and f_loc == url):
1064
+ if not pick:
1065
+ pick = a
1066
+ continue
1067
+ # prefer latestEditionAssociation + primary
1068
+ if (
1069
+ a.get("latestEditionAssociation") and a.get("primary")
1070
+ ) and not (
1071
+ pick.get("latestEditionAssociation") and pick.get("primary")
1072
+ ):
1073
+ pick = a
1074
+ return pick
1075
+
1076
+ assoc = _pick_assoc_by_title_or_url(
1077
+ _list_assocs(working_guid)
1078
+ ) or _pick_assoc_by_title_or_url(_list_assocs(effective_guid))
1079
+
1080
+ # If found: update the File summary (ensures storageMethodName=WEB + new location/edition)
1081
+ if assoc:
1082
+ file_guid = (assoc.get("file") or {}).get("guid")
1083
+ if not file_guid:
1084
+ raise ArenaError(
1085
+ "Existing web-link association found but missing file.guid"
1086
+ )
1087
+ updated = self._api_update_web_file(
1088
+ file_guid=file_guid,
1089
+ category_guid=cat_guid,
1090
+ title=title,
1091
+ location_url=url,
1092
+ edition=str(edition),
1093
+ description=description,
1094
+ file_format=file_format,
1095
+ private=False,
1096
+ )
1097
+ # Normalize to a consistent response
1098
+ return {
1099
+ "ok": True,
1100
+ "action": "updated",
1101
+ "file": {
1102
+ "guid": updated.get("guid"),
1103
+ "number": updated.get("number"),
1104
+ "title": updated.get("title"),
1105
+ "edition": updated.get("edition"),
1106
+ "storageMethodName": updated.get("storageMethodName"),
1107
+ "location": updated.get("location"),
1108
+ },
1109
+ "associationGuid": assoc.get("guid"),
1110
+ "primary": assoc.get("primary"),
1111
+ "latestEditionAssociation": assoc.get("latestEditionAssociation"),
1112
+ }
1113
+
1114
+ # Else: create a new WEB file, then associate it on WORKING
1115
+ created = self._api_create_web_file(
1116
+ category_guid=cat_guid,
1117
+ title=title,
1118
+ location_url=url,
1119
+ edition=str(edition),
1120
+ description=description,
1121
+ file_format=file_format,
1122
+ private=False,
1123
+ )
1124
+ file_guid = created.get("guid")
1125
+ assoc_resp = self._api_item_add_existing_file(
1126
+ item_guid=working_guid,
1127
+ file_guid=file_guid,
1128
+ primary=primary,
1129
+ latest_edition_association=latest_edition_association,
1130
+ reference=reference,
1131
+ )
1132
+
1133
+ return {
1134
+ "ok": True,
1135
+ "action": "created",
1136
+ "associationGuid": assoc_resp.get("guid"),
1137
+ "primary": assoc_resp.get("primary"),
1138
+ "latestEditionAssociation": assoc_resp.get("latestEditionAssociation"),
1139
+ "file": {
1140
+ "guid": file_guid,
1141
+ "number": created.get("number"),
1142
+ "title": created.get("title"),
1143
+ "edition": created.get("edition"),
1144
+ "storageMethodName": created.get("storageMethodName") or "WEB",
1145
+ "location": created.get("location") or url,
1146
+ },
1147
+ }
1148
+
501
1149
  def _run(self, cmd: str) -> Tuple[int, str, str]:
502
- proc = subprocess.run(cmd, shell=True, check=False, capture_output=True, text=True)
1150
+ proc = subprocess.run(
1151
+ cmd, shell=True, check=False, capture_output=True, text=True
1152
+ )
503
1153
  return proc.returncode, proc.stdout.strip(), proc.stderr.strip()