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