lr-gladiator 0.5.0__py3-none-any.whl → 0.7.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 +1 -1
- gladiator/arena.py +353 -95
- gladiator/cli.py +140 -36
- gladiator/config.py +8 -3
- {lr_gladiator-0.5.0.dist-info → lr_gladiator-0.7.0.dist-info}/METADATA +1 -1
- lr_gladiator-0.7.0.dist-info/RECORD +10 -0
- lr_gladiator-0.5.0.dist-info/RECORD +0 -10
- {lr_gladiator-0.5.0.dist-info → lr_gladiator-0.7.0.dist-info}/WHEEL +0 -0
- {lr_gladiator-0.5.0.dist-info → lr_gladiator-0.7.0.dist-info}/entry_points.txt +0 -0
- {lr_gladiator-0.5.0.dist-info → lr_gladiator-0.7.0.dist-info}/licenses/LICENSE +0 -0
- {lr_gladiator-0.5.0.dist-info → lr_gladiator-0.7.0.dist-info}/top_level.txt +0 -0
gladiator/__init__.py
CHANGED
gladiator/arena.py
CHANGED
|
@@ -10,21 +10,25 @@ from typing import Dict, List, Optional, Tuple
|
|
|
10
10
|
import requests
|
|
11
11
|
from .config import LoginConfig
|
|
12
12
|
|
|
13
|
+
|
|
13
14
|
class ArenaError(RuntimeError):
|
|
14
15
|
pass
|
|
15
16
|
|
|
17
|
+
|
|
16
18
|
class ArenaClient:
|
|
17
19
|
def __init__(self, cfg: LoginConfig):
|
|
18
20
|
self.cfg = cfg
|
|
19
21
|
self.session = requests.Session()
|
|
20
22
|
self.session.verify = cfg.verify_tls
|
|
21
23
|
# Default headers: explicitly request/submit JSON
|
|
22
|
-
self.session.headers.update(
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
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
|
-
|
|
130
|
-
|
|
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(
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
|
248
|
-
|
|
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(
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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)
|
|
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(
|
|
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 = [
|
|
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(
|
|
329
|
-
|
|
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(
|
|
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)
|
|
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
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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 (
|
|
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 = [
|
|
416
|
-
|
|
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 = [
|
|
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(
|
|
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(
|
|
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 (
|
|
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 = {
|
|
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
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
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":
|
|
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 = (
|
|
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(
|
|
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()
|
gladiator/cli.py
CHANGED
|
@@ -16,14 +16,25 @@ from .arena import ArenaClient, ArenaError
|
|
|
16
16
|
|
|
17
17
|
app = typer.Typer(add_completion=False, help="Arena PLM command-line utility")
|
|
18
18
|
|
|
19
|
+
|
|
19
20
|
@app.command()
|
|
20
21
|
def login(
|
|
21
|
-
username: Optional[str] = typer.Option(
|
|
22
|
-
|
|
23
|
-
|
|
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(
|
|
26
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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(
|
|
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(
|
|
98
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
125
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
161
|
-
|
|
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
|
-
|
|
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(
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
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,
|
|
192
|
-
|
|
193
|
-
|
|
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:
|
gladiator/config.py
CHANGED
|
@@ -8,12 +8,17 @@ from pathlib import Path
|
|
|
8
8
|
from typing import Optional, Any, Dict
|
|
9
9
|
from pydantic import BaseModel, Field
|
|
10
10
|
|
|
11
|
-
CONFIG_HOME = Path(
|
|
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(
|
|
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.
|
|
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
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
gladiator/__init__.py,sha256=ZeHpVdzARFyIp9QbdTkX0jNqnbRFX5nFQ5RkEFzSRL0,208
|
|
2
|
+
gladiator/arena.py,sha256=x2dg-JImb9Lw1fIj0ptm48GF1m4zIj2pmg4GqmWBPbA,30285
|
|
3
|
+
gladiator/cli.py,sha256=Pi1yjRZDr2s4izq51xQgpUiKN-Z7wl7yQjHiht9J9BU,10295
|
|
4
|
+
gladiator/config.py,sha256=oe2UpFv1HcrP1-lVWs_nnex444Igq18BW3nTs9wL__k,1760
|
|
5
|
+
lr_gladiator-0.7.0.dist-info/licenses/LICENSE,sha256=2CEtbEagerjoU3EDSk-eTM5LKgI_RpiVIOh3_CV4kms,1069
|
|
6
|
+
lr_gladiator-0.7.0.dist-info/METADATA,sha256=RckucPmR2KG5YwcP9gq3yNf-21JYDhEtT_uAdnl32BM,1912
|
|
7
|
+
lr_gladiator-0.7.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
8
|
+
lr_gladiator-0.7.0.dist-info/entry_points.txt,sha256=SLka4w7iGS2B8HrbeZyNk5mxaIC6QKcv93us1OaWNwQ,48
|
|
9
|
+
lr_gladiator-0.7.0.dist-info/top_level.txt,sha256=tfrcAmK7_7Lf63w7kWy0wv_Qg9RrcFWGoins1-jGUF4,10
|
|
10
|
+
lr_gladiator-0.7.0.dist-info/RECORD,,
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
gladiator/__init__.py,sha256=kVgJiGDD6714tJ3SN6mdao3rdVO57jlMvLMHAFjHX4A,207
|
|
2
|
-
gladiator/arena.py,sha256=Dr6FVDrcsxI98KVPzQY1LaZS2Ixch0BU4qe1qqqez60,23297
|
|
3
|
-
gladiator/cli.py,sha256=EqoIjmnoSPzjckgeiUXjJBt6rEU723bbYOIiEsbhMSY,8530
|
|
4
|
-
gladiator/config.py,sha256=pnuVrcW8yafxMB7RU9wyi_4jS_oMBIuNryfet203Wng,1738
|
|
5
|
-
lr_gladiator-0.5.0.dist-info/licenses/LICENSE,sha256=2CEtbEagerjoU3EDSk-eTM5LKgI_RpiVIOh3_CV4kms,1069
|
|
6
|
-
lr_gladiator-0.5.0.dist-info/METADATA,sha256=Pikc_vdgYG1KFYss8y4pLTsflM7W6d5wgZ5TvDIckC0,1912
|
|
7
|
-
lr_gladiator-0.5.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
8
|
-
lr_gladiator-0.5.0.dist-info/entry_points.txt,sha256=SLka4w7iGS2B8HrbeZyNk5mxaIC6QKcv93us1OaWNwQ,48
|
|
9
|
-
lr_gladiator-0.5.0.dist-info/top_level.txt,sha256=tfrcAmK7_7Lf63w7kWy0wv_Qg9RrcFWGoins1-jGUF4,10
|
|
10
|
-
lr_gladiator-0.5.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|