lr-gladiator 0.12.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/checksums.py ADDED
@@ -0,0 +1,31 @@
1
+ #! /usr/bin/env python
2
+ # -*- coding: utf-8 -*-
3
+ # src/gladiator/checksums.py
4
+ from __future__ import annotations
5
+ from pathlib import Path
6
+ import hashlib
7
+ import base64
8
+
9
+
10
+ def sha256_file(path: Path, chunk_size: int = 128 * 1024) -> str:
11
+ """
12
+ Return the lowercase hex SHA-256 of the file at `path`.
13
+ Streams the file in chunks to support large files.
14
+ """
15
+ h = hashlib.sha256()
16
+ with open(path, "rb") as f:
17
+ for chunk in iter(lambda: f.read(chunk_size), b""):
18
+ h.update(chunk)
19
+ return h.hexdigest()
20
+
21
+
22
+ def md5_base64_file(path: Path, chunk_size: int = 128 * 1024) -> str:
23
+ """
24
+ Return base64-encoded MD5 digest of the file at `path`,
25
+ suitable for the Content-MD5 header per RFC 1864.
26
+ """
27
+ h = hashlib.md5()
28
+ with open(path, "rb") as f:
29
+ for chunk in iter(lambda: f.read(chunk_size), b""):
30
+ h.update(chunk)
31
+ return base64.b64encode(h.digest()).decode("ascii")
gladiator/cli.py ADDED
@@ -0,0 +1,433 @@
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 rich.console import Console
12
+ from rich.status import Status
13
+ from getpass import getpass
14
+ import requests
15
+ import sys
16
+ import os
17
+ from urllib.parse import urlparse
18
+ from .config import LoginConfig, save_config, load_config, save_config_raw, CONFIG_PATH
19
+ from .arena import ArenaClient, ArenaError
20
+
21
+ app = typer.Typer(add_completion=False, help="Arena PLM command-line utility")
22
+ console = Console()
23
+
24
+ # --- tiny helper to show a spinner when appropriate ---
25
+ from contextlib import contextmanager
26
+
27
+
28
+ @contextmanager
29
+ def spinner(message: str, enabled: bool = True):
30
+ """
31
+ Show a Rich spinner while the body executes.
32
+ Auto-disables if stdout is not a TTY (e.g., CI) or enabled=False.
33
+ """
34
+ if enabled and sys.stdout.isatty():
35
+ with console.status(message, spinner="dots"):
36
+ yield
37
+ else:
38
+ yield
39
+
40
+
41
+ @app.command()
42
+ def login(
43
+ username: Optional[str] = typer.Option(
44
+ None, "--username", envvar="GLADIATOR_USERNAME"
45
+ ),
46
+ password: Optional[str] = typer.Option(
47
+ None, "--password", envvar="GLADIATOR_PASSWORD"
48
+ ),
49
+ base_url: Optional[str] = typer.Option(
50
+ "https://api.arenasolutions.com/v1", help="Arena API base URL"
51
+ ),
52
+ verify_tls: bool = typer.Option(True, help="Verify TLS certificates"),
53
+ non_interactive: bool = typer.Option(
54
+ False, "--ci", help="Fail instead of prompting for missing values"
55
+ ),
56
+ reason: Optional[str] = typer.Option(
57
+ "CI/CD integration", help="Arena-Usage-Reason header"
58
+ ),
59
+ ):
60
+ """Create or update ~/.config/gladiator/login.json for subsequent commands."""
61
+ if not username and not non_interactive:
62
+ username = typer.prompt("Email/username")
63
+ if not password and not non_interactive:
64
+ password = getpass("Password: ")
65
+ if non_interactive and (not username or not password):
66
+ raise typer.BadParameter(
67
+ "Provide --username and --password (or set env vars) for --ci mode"
68
+ )
69
+
70
+ # Perform login
71
+ sess = requests.Session()
72
+ sess.verify = verify_tls
73
+ headers = {
74
+ "Content-Type": "application/json",
75
+ "Accept": "application/json",
76
+ "Arena-Usage-Reason": reason or "gladiator/cli",
77
+ "User-Agent": "gladiator-arena/0.1",
78
+ }
79
+ url = f"{(base_url or '').rstrip('/')}/login"
80
+ try:
81
+ with spinner("Logging in…", enabled=sys.stdout.isatty()):
82
+ resp = sess.post(
83
+ url, headers=headers, json={"email": username, "password": password}
84
+ )
85
+ resp.raise_for_status()
86
+ except Exception as e:
87
+ typer.secho(
88
+ f"Login failed: {e} Body: {getattr(resp, 'text', '')[:400]}",
89
+ fg=typer.colors.RED,
90
+ err=True,
91
+ )
92
+ raise typer.Exit(2)
93
+
94
+ data = resp.json()
95
+ data.update({"base_url": base_url, "verify_tls": verify_tls, "reason": reason})
96
+ save_config_raw(data)
97
+ print(f"[green]Saved session to {CONFIG_PATH}[/green]")
98
+
99
+
100
+ def _client() -> ArenaClient:
101
+ cfg = load_config()
102
+ return ArenaClient(cfg)
103
+
104
+
105
+ @app.command("latest-approved")
106
+ def latest_approved(
107
+ item: str = typer.Argument(..., help="Item/article number"),
108
+ format: Optional[str] = typer.Option(
109
+ None, "--format", "-f", help="Output format: human (default) or json"
110
+ ),
111
+ ):
112
+ """Print latest approved revision for the given item number."""
113
+ json_mode = (format or "").lower() == "json"
114
+ try:
115
+ with spinner(
116
+ f"Resolving latest approved revision for {item}…", enabled=not json_mode
117
+ ):
118
+ rev = _client().get_latest_approved_revision(item)
119
+ if json_mode:
120
+ json.dump({"article": item, "revision": rev}, sys.stdout, indent=2)
121
+ sys.stdout.write("\n")
122
+ else:
123
+ print(rev)
124
+ except requests.HTTPError as e:
125
+ typer.secho(f"Arena request failed: {e}", fg=typer.colors.RED, err=True)
126
+ raise typer.Exit(2)
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("list-files")
133
+ def list_files(
134
+ item: str = typer.Argument(..., help="Item/article number"),
135
+ revision: Optional[str] = typer.Option(
136
+ None,
137
+ "--rev",
138
+ help="Revision selector: WORKING | EFFECTIVE | <label> (default: EFFECTIVE)",
139
+ ),
140
+ format: Optional[str] = typer.Option(
141
+ None, "--format", "-f", help="Output format: human (default) or json"
142
+ ),
143
+ ):
144
+ json_mode = (format or "").lower() == "json"
145
+ try:
146
+ with spinner(
147
+ f"Listing files for {item} ({revision or 'EFFECTIVE'})…",
148
+ enabled=not json_mode,
149
+ ):
150
+ files = _client().list_files(item, revision)
151
+
152
+ if json_mode:
153
+ json.dump(
154
+ {"article": item, "revision": revision, "files": files},
155
+ sys.stdout,
156
+ indent=2,
157
+ )
158
+ sys.stdout.write("\n")
159
+ return
160
+
161
+ table = Table(title=f"Files for {item} rev {revision or '(latest approved)'}")
162
+ table.add_column("Title")
163
+ table.add_column("Filename")
164
+ table.add_column("Size", justify="right")
165
+ table.add_column("Edition")
166
+ table.add_column("Type")
167
+ table.add_column("Location")
168
+ for f in files:
169
+ table.add_row(
170
+ str(f.get("title")),
171
+ str(f.get("name")),
172
+ str(f.get("size")),
173
+ str(f.get("edition")),
174
+ str(f.get("storageMethodName") or ""),
175
+ str(f.get("location") or ""),
176
+ )
177
+ print(table)
178
+ except requests.HTTPError as e:
179
+ typer.secho(f"Arena request failed: {e}", fg=typer.colors.RED, err=True)
180
+ raise typer.Exit(2)
181
+ except ArenaError as e:
182
+ typer.secho(str(e), fg=typer.colors.RED, err=True)
183
+ raise typer.Exit(2)
184
+
185
+
186
+ @app.command("bom")
187
+ def bom(
188
+ item: str = typer.Argument(..., help="Item/article number (e.g., 890-1001)"),
189
+ revision: Optional[str] = typer.Option(
190
+ None,
191
+ "--rev",
192
+ help='Revision selector: WORKING, EFFECTIVE (default), or label (e.g., "B2")',
193
+ ),
194
+ output: str = typer.Option(
195
+ "table", "--output", help='Output format: "table" (default) or "json"'
196
+ ),
197
+ recursive: bool = typer.Option(
198
+ False, "--recursive/--no-recursive", help="Recursively expand subassemblies"
199
+ ),
200
+ max_depth: Optional[int] = typer.Option(
201
+ None,
202
+ "--max-depth",
203
+ min=1,
204
+ help="Maximum recursion depth (1 = only children). Omit for unlimited.",
205
+ ),
206
+ ):
207
+ """List the BOM lines for an item revision."""
208
+ json_mode = output.lower() == "json"
209
+ try:
210
+ with spinner(
211
+ f"Fetching BOM for {item} ({revision or 'EFFECTIVE'})"
212
+ + (" [recursive]" if recursive else "")
213
+ + "…",
214
+ enabled=not json_mode,
215
+ ):
216
+ lines = _client().get_bom(
217
+ item, revision, recursive=recursive, max_depth=max_depth
218
+ )
219
+
220
+ if json_mode:
221
+ print(json.dumps({"count": len(lines), "results": lines}, indent=2))
222
+ return
223
+
224
+ title_rev = revision or "(latest approved)"
225
+ table = Table(title=f"BOM for {item} rev {title_rev}")
226
+ table.add_column("Line", justify="right")
227
+ table.add_column("Qty", justify="right")
228
+ table.add_column("Number")
229
+ table.add_column("Name")
230
+ table.add_column("RefDes")
231
+
232
+ for ln in lines:
233
+ lvl = int(ln.get("level", 0) or 0)
234
+ indent = " " * lvl
235
+ table.add_row(
236
+ str(ln.get("lineNumber") or ""),
237
+ str(ln.get("quantity") or ""),
238
+ str(ln.get("itemNumber") or ""),
239
+ f"{indent}{str(ln.get('itemName') or '')}",
240
+ str(ln.get("refDes") or ""),
241
+ )
242
+ print(table)
243
+ except requests.HTTPError as e:
244
+ typer.secho(f"Arena request failed: {e}", fg=typer.colors.RED, err=True)
245
+ raise typer.Exit(2)
246
+ except ArenaError as e:
247
+ typer.secho(str(e), fg=typer.colors.RED, err=True)
248
+ raise typer.Exit(2)
249
+
250
+
251
+ @app.command("get-files")
252
+ def get_files(
253
+ item: str = typer.Argument(..., help="Item/article number"),
254
+ revision: Optional[str] = typer.Option(
255
+ None, "--rev", help="Revision (default: latest approved)"
256
+ ),
257
+ out: Optional[Path] = typer.Option(
258
+ None,
259
+ "--out",
260
+ help="Output directory (default: a folder named after the item number)",
261
+ ),
262
+ recursive: bool = typer.Option(
263
+ False,
264
+ "--recursive/--no-recursive",
265
+ help="Recursively download files from subassemblies",
266
+ ),
267
+ max_depth: Optional[int] = typer.Option(
268
+ None,
269
+ "--max-depth",
270
+ min=1,
271
+ help="Maximum recursion depth for --recursive (1 = only direct children).",
272
+ ),
273
+ ):
274
+ json_mode = False # this command prints file paths line-by-line (no JSON mode here)
275
+ try:
276
+ out_dir = out or Path(item)
277
+ with spinner(
278
+ f"Downloading files for {item} ({revision or 'EFFECTIVE'})"
279
+ + (" [recursive]" if recursive else "")
280
+ + f" → {out_dir}…",
281
+ enabled=not json_mode,
282
+ ):
283
+ if recursive:
284
+ paths = _client().download_files_recursive(
285
+ item, revision, out_dir=out_dir, max_depth=max_depth
286
+ )
287
+ else:
288
+ paths = _client().download_files(item, revision, out_dir=out_dir)
289
+
290
+ for p in paths:
291
+ print(str(p))
292
+ except requests.HTTPError as e:
293
+ typer.secho(f"Arena request failed: {e}", fg=typer.colors.RED, err=True)
294
+ raise typer.Exit(2)
295
+ except ArenaError as e:
296
+ typer.secho(str(e), fg=typer.colors.RED, err=True)
297
+ raise typer.Exit(2)
298
+
299
+
300
+ @app.command("upload-file")
301
+ def upload_file(
302
+ item: str = typer.Argument(...),
303
+ file: Path = typer.Argument(...),
304
+ reference: Optional[str] = typer.Option(
305
+ None, "--reference", help="Optional reference string"
306
+ ),
307
+ title: Optional[str] = typer.Option(
308
+ None,
309
+ "--title",
310
+ help="Override file title (default: filename without extension)",
311
+ ),
312
+ category: str = typer.Option(
313
+ "Firmware", "--category", help='File category name (default: "Firmware")'
314
+ ),
315
+ file_format: Optional[str] = typer.Option(
316
+ None, "--format", help="File format (default: file extension)"
317
+ ),
318
+ description: Optional[str] = typer.Option(
319
+ None, "--desc", help="Optional description"
320
+ ),
321
+ primary: bool = typer.Option(
322
+ False, "--primary/--no-primary", help="Mark association as primary"
323
+ ),
324
+ edition: str = typer.Option(
325
+ None,
326
+ "--edition",
327
+ help="Edition number when creating a new association (default: SHA256 checksum of file)",
328
+ ),
329
+ ):
330
+ """
331
+ Create or update a file.
332
+ If a file with the same filename exists: update its content (new edition).
333
+ Otherwise: create a new association on the WORKING revision (requires --edition)."""
334
+ try:
335
+ with spinner(f"Uploading {file.name} to {item}…", enabled=sys.stdout.isatty()):
336
+ result = _client().upload_file_to_working(
337
+ item,
338
+ file,
339
+ reference,
340
+ title=title,
341
+ category_name=category,
342
+ file_format=file_format,
343
+ description=description,
344
+ primary=primary,
345
+ edition=edition,
346
+ )
347
+ print(json.dumps(result, indent=2))
348
+ except requests.HTTPError as e:
349
+ typer.secho(f"Arena request failed: {e}", fg=typer.colors.RED, err=True)
350
+ raise typer.Exit(2)
351
+ except ArenaError as e:
352
+ typer.secho(str(e), fg=typer.colors.RED, err=True)
353
+ raise typer.Exit(2)
354
+
355
+
356
+ @app.command("upload-weblink")
357
+ def upload_weblink(
358
+ item: str = typer.Argument(..., help="Item/article number"),
359
+ url: str = typer.Argument(..., help="HTTP(S) URL to associate as a web link"),
360
+ reference: Optional[str] = typer.Option(
361
+ None, "--reference", help="Optional reference string on the association"
362
+ ),
363
+ title: Optional[str] = typer.Option(
364
+ None,
365
+ "--title",
366
+ help="File title (default: derived from URL hostname/path)",
367
+ ),
368
+ category: str = typer.Option(
369
+ "Source Code", "--category", help='File category name (default: "Source Code")'
370
+ ),
371
+ file_format: Optional[str] = typer.Option(
372
+ "url",
373
+ "--format",
374
+ help='File format/extension label (default: "url")',
375
+ ),
376
+ description: Optional[str] = typer.Option(
377
+ "None", "--description", help="Optional description"
378
+ ),
379
+ primary: bool = typer.Option(
380
+ False,
381
+ "--primary/--no-primary",
382
+ help="Mark association as primary (default: false)",
383
+ ),
384
+ edition: Optional[str] = typer.Option(
385
+ None,
386
+ "--edition",
387
+ help="Edition label (default: SHA256(url)[:16])",
388
+ ),
389
+ latest_edition_association: bool = typer.Option(
390
+ True,
391
+ "--latest/--no-latest",
392
+ help="Keep association pointed to the latest edition (default: true)",
393
+ ),
394
+ ):
395
+ """
396
+ Create or update a 'web link' file on the WORKING revision.
397
+ If a matching link (by URL or title) exists, its File is updated in-place.
398
+ Otherwise a new File is created and associated.
399
+ """
400
+ # Best-effort default title from URL if not provided
401
+ if not title:
402
+ try:
403
+ u = urlparse(url)
404
+ base = (u.netloc + u.path).rstrip("/") or u.netloc or url
405
+ title = base.split("/")[-1] or base
406
+ except Exception:
407
+ title = url
408
+
409
+ try:
410
+ with spinner(f"Uploading web-link to {item}…", enabled=sys.stdout.isatty()):
411
+ result = _client().upload_weblink_to_working(
412
+ item_number=item,
413
+ url=url,
414
+ reference=reference,
415
+ title=title,
416
+ category_name=category,
417
+ file_format=file_format,
418
+ description=description,
419
+ primary=primary,
420
+ latest_edition_association=latest_edition_association,
421
+ edition=edition,
422
+ )
423
+ print(json.dumps(result, indent=2))
424
+ except requests.HTTPError as e:
425
+ typer.secho(f"Arena request failed: {e}", fg=typer.colors.RED, err=True)
426
+ raise typer.Exit(2)
427
+ except ArenaError as e:
428
+ typer.secho(str(e), fg=typer.colors.RED, err=True)
429
+ raise typer.Exit(2)
430
+
431
+
432
+ if __name__ == "__main__":
433
+ app()
gladiator/config.py ADDED
@@ -0,0 +1,60 @@
1
+ #! /usr/bin/env python
2
+ # -*- coding: utf-8 -*-
3
+ # src/gladiator/config.py
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import os
8
+ from pathlib import Path
9
+ from typing import Optional, Any, Dict
10
+
11
+ from pydantic import BaseModel, Field, ConfigDict
12
+
13
+ CONFIG_HOME = Path(
14
+ os.environ.get("GLADIATOR_CONFIG_HOME", Path.home() / ".config" / "gladiator")
15
+ )
16
+ CONFIG_PATH = Path(os.environ.get("GLADIATOR_CONFIG", CONFIG_HOME / "login.json"))
17
+
18
+
19
+ class LoginConfig(BaseModel):
20
+ model_config = ConfigDict(populate_by_name=True)
21
+
22
+ # Primary connection settings
23
+ base_url: str = Field(
24
+ "https://api.arenasolutions.com/v1", description="Arena REST API base URL"
25
+ )
26
+ verify_tls: bool = True
27
+
28
+ # Auth options
29
+ api_key: Optional[str] = None # not used by Arena v1 but kept for future
30
+ username: Optional[str] = None
31
+ password: Optional[str] = None
32
+
33
+ # Session from `/login`
34
+ arena_session_id: Optional[str] = Field(None, alias="arenaSessionId")
35
+ workspace_id: Optional[int] = Field(None, alias="workspaceId")
36
+ workspace_name: Optional[str] = Field(None, alias="workspaceName")
37
+ workspace_request_limit: Optional[int] = Field(None, alias="workspaceRequestLimit")
38
+ reason: Optional[str] = None
39
+
40
+
41
+ def save_config_raw(data: Dict[str, Any], path: Path = CONFIG_PATH) -> None:
42
+ path.parent.mkdir(parents=True, exist_ok=True)
43
+ with open(path, "w") as f:
44
+ json.dump(data, f, indent=2)
45
+ try:
46
+ os.chmod(path, 0o600)
47
+ except PermissionError:
48
+ # Best-effort on non-POSIX filesystems
49
+ pass
50
+
51
+
52
+ def save_config(cfg: LoginConfig, path: Path = CONFIG_PATH) -> None:
53
+ # Respect the provided path (bug fix); keep aliases for compatibility with bash scripts.
54
+ save_config_raw(cfg.model_dump(by_alias=True), path=path)
55
+
56
+
57
+ def load_config(path: Path = CONFIG_PATH) -> LoginConfig:
58
+ with open(path, "r") as f:
59
+ raw = json.load(f)
60
+ return LoginConfig.model_validate(raw)