lr-gladiator 0.7.0__tar.gz → 0.9.0__tar.gz

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.

@@ -0,0 +1,183 @@
1
+ Metadata-Version: 2.4
2
+ Name: lr-gladiator
3
+ Version: 0.9.0
4
+ Summary: CLI and Python client for Arena PLM (app.bom.com): login, get revisions, list/download attachments, and upload to working revisions.
5
+ Author-email: Jonas Estberger <jonas.estberger@lumenradio.com>
6
+ License: MIT
7
+ Keywords: Arena,PLM,BOM,attachments,CLI
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Operating System :: OS Independent
11
+ Requires-Python: >=3.9
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Requires-Dist: requests>=2.32
15
+ Requires-Dist: typer>=0.12
16
+ Requires-Dist: rich>=13.7
17
+ Requires-Dist: pydantic>=2.8
18
+ Provides-Extra: dev
19
+ Requires-Dist: build>=1.2.1; extra == "dev"
20
+ Requires-Dist: twine>=5.1.1; extra == "dev"
21
+ Requires-Dist: wheel; extra == "dev"
22
+ Dynamic: license-file
23
+
24
+ # gladiator-arena
25
+
26
+ CLI + Python client for interacting with the Arena PLM.
27
+
28
+ ## Install
29
+
30
+ ```bash
31
+ pip install lr-gladiator
32
+ ```
33
+
34
+ ## Quick start
35
+
36
+ ### 1) Create `login.json`
37
+
38
+ Interactive login (prompts for username/password):
39
+
40
+ ```bash
41
+ gladiator login
42
+ ```
43
+
44
+ Non-interactive (for CI/CD):
45
+
46
+ ```bash
47
+ gladiator login --username "$ARENA_USERNAME" --password "$ARENA_PASSWORD" --ci
48
+ ```
49
+
50
+ By default, this stores session details at:
51
+
52
+ ```
53
+ ~/.config/gladiator/login.json
54
+ ```
55
+
56
+ ### 2) Common commands
57
+
58
+ Get the latest approved revision for an item:
59
+
60
+ ```bash
61
+ gladiator latest-approved 890-1001
62
+ ```
63
+
64
+ List all files on an item (defaults to the latest approved revision):
65
+
66
+ ```bash
67
+ gladiator list-files 890-1001
68
+ ```
69
+
70
+ Output JSON instead of a table:
71
+
72
+ ```bash
73
+ gladiator list-files 890-1001 --format json
74
+ ```
75
+
76
+ List the Bill of Materials (BOM) for an item:
77
+
78
+ ```bash
79
+ gladiator bom 890-1001
80
+ ```
81
+
82
+ Recursively expand subassemblies up to two levels deep:
83
+
84
+ ```bash
85
+ gladiator bom 890-1001 --recursive --max-depth 2
86
+ ```
87
+
88
+ Download attached files to a directory named after the article:
89
+
90
+ ```bash
91
+ gladiator get-files 890-1001
92
+ ```
93
+
94
+ Specify a different output directory:
95
+
96
+ ```bash
97
+ gladiator get-files 890-1001 --out downloads/
98
+ ```
99
+
100
+ Recursively download all files in the full BOM tree:
101
+
102
+ ```bash
103
+ gladiator get-files 890-1001 --recursive
104
+ ```
105
+
106
+ Upload or update a file on the working revision:
107
+
108
+ ```bash
109
+ gladiator upload-file 890-1001 ./datasheet.pdf --category "CAD Data" --title "Datasheet"
110
+ ```
111
+
112
+ ### 3) Output control
113
+
114
+ Most commands support a JSON output mode.
115
+ Example:
116
+
117
+ ```bash
118
+ gladiator bom 890-1001 --output json
119
+ ```
120
+
121
+ ### Example sessions
122
+
123
+ #### Human-readable
124
+
125
+ ```bash
126
+ $ gladiator list-files 101-1031
127
+ Files for 101-1031 rev (latest approved)
128
+ ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━┓
129
+ ┃ Name ┃ Size ┃ Checksum ┃
130
+ ┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━┩
131
+ │ Drawing.pdf │ 12345 │ d41d8cd98f00b204e… │
132
+ └──────────────────────────────────────┴───────┴─────────────────────┘
133
+ ```
134
+
135
+ #### JSON output
136
+
137
+ ```bash
138
+ $ gladiator list-files 101-1031 --format json
139
+ {
140
+ "article": "101-1031",
141
+ "revision": "EFFECTIVE",
142
+ "files": [
143
+ {
144
+ "filename": "Drawing.pdf",
145
+ "size": 12345,
146
+ "checksum": "d41d8cd98f00b204e9800998ecf8427e"
147
+ }
148
+ ]
149
+ }
150
+ ```
151
+
152
+ ## Programmatic use
153
+
154
+ ```python
155
+ from gladiator import ArenaClient, load_config
156
+
157
+ client = ArenaClient(load_config())
158
+ rev = client.get_latest_approved_revision("890-1001")
159
+ files = client.list_files("890-1001", rev)
160
+ ```
161
+
162
+ ## Development
163
+
164
+ ```bash
165
+ python -m pip install -e .[dev]
166
+ python -m build
167
+ ```
168
+
169
+ ## FAQ
170
+
171
+ - **Where is the config kept?**
172
+ `~/.config/gladiator/login.json` (override with `GLADIATOR_CONFIG`)
173
+
174
+ - **How do I run non-interactively?**
175
+ Pass `--ci` together with `--username` and `--password` (or use environment variables).
176
+
177
+ - **What does `--recursive` do?**
178
+ Expands subassemblies and downloads or lists all contained items up to the given `--max-depth`.
179
+
180
+ - **How does Gladiator handle authentication?**
181
+ It performs a `/login` call and stores the resulting `arenaSessionId` for reuse. If it expires, re-run `gladiator login`.
182
+
183
+
@@ -0,0 +1,160 @@
1
+ # gladiator-arena
2
+
3
+ CLI + Python client for interacting with the Arena PLM.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install lr-gladiator
9
+ ```
10
+
11
+ ## Quick start
12
+
13
+ ### 1) Create `login.json`
14
+
15
+ Interactive login (prompts for username/password):
16
+
17
+ ```bash
18
+ gladiator login
19
+ ```
20
+
21
+ Non-interactive (for CI/CD):
22
+
23
+ ```bash
24
+ gladiator login --username "$ARENA_USERNAME" --password "$ARENA_PASSWORD" --ci
25
+ ```
26
+
27
+ By default, this stores session details at:
28
+
29
+ ```
30
+ ~/.config/gladiator/login.json
31
+ ```
32
+
33
+ ### 2) Common commands
34
+
35
+ Get the latest approved revision for an item:
36
+
37
+ ```bash
38
+ gladiator latest-approved 890-1001
39
+ ```
40
+
41
+ List all files on an item (defaults to the latest approved revision):
42
+
43
+ ```bash
44
+ gladiator list-files 890-1001
45
+ ```
46
+
47
+ Output JSON instead of a table:
48
+
49
+ ```bash
50
+ gladiator list-files 890-1001 --format json
51
+ ```
52
+
53
+ List the Bill of Materials (BOM) for an item:
54
+
55
+ ```bash
56
+ gladiator bom 890-1001
57
+ ```
58
+
59
+ Recursively expand subassemblies up to two levels deep:
60
+
61
+ ```bash
62
+ gladiator bom 890-1001 --recursive --max-depth 2
63
+ ```
64
+
65
+ Download attached files to a directory named after the article:
66
+
67
+ ```bash
68
+ gladiator get-files 890-1001
69
+ ```
70
+
71
+ Specify a different output directory:
72
+
73
+ ```bash
74
+ gladiator get-files 890-1001 --out downloads/
75
+ ```
76
+
77
+ Recursively download all files in the full BOM tree:
78
+
79
+ ```bash
80
+ gladiator get-files 890-1001 --recursive
81
+ ```
82
+
83
+ Upload or update a file on the working revision:
84
+
85
+ ```bash
86
+ gladiator upload-file 890-1001 ./datasheet.pdf --category "CAD Data" --title "Datasheet"
87
+ ```
88
+
89
+ ### 3) Output control
90
+
91
+ Most commands support a JSON output mode.
92
+ Example:
93
+
94
+ ```bash
95
+ gladiator bom 890-1001 --output json
96
+ ```
97
+
98
+ ### Example sessions
99
+
100
+ #### Human-readable
101
+
102
+ ```bash
103
+ $ gladiator list-files 101-1031
104
+ Files for 101-1031 rev (latest approved)
105
+ ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━┓
106
+ ┃ Name ┃ Size ┃ Checksum ┃
107
+ ┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━┩
108
+ │ Drawing.pdf │ 12345 │ d41d8cd98f00b204e… │
109
+ └──────────────────────────────────────┴───────┴─────────────────────┘
110
+ ```
111
+
112
+ #### JSON output
113
+
114
+ ```bash
115
+ $ gladiator list-files 101-1031 --format json
116
+ {
117
+ "article": "101-1031",
118
+ "revision": "EFFECTIVE",
119
+ "files": [
120
+ {
121
+ "filename": "Drawing.pdf",
122
+ "size": 12345,
123
+ "checksum": "d41d8cd98f00b204e9800998ecf8427e"
124
+ }
125
+ ]
126
+ }
127
+ ```
128
+
129
+ ## Programmatic use
130
+
131
+ ```python
132
+ from gladiator import ArenaClient, load_config
133
+
134
+ client = ArenaClient(load_config())
135
+ rev = client.get_latest_approved_revision("890-1001")
136
+ files = client.list_files("890-1001", rev)
137
+ ```
138
+
139
+ ## Development
140
+
141
+ ```bash
142
+ python -m pip install -e .[dev]
143
+ python -m build
144
+ ```
145
+
146
+ ## FAQ
147
+
148
+ - **Where is the config kept?**
149
+ `~/.config/gladiator/login.json` (override with `GLADIATOR_CONFIG`)
150
+
151
+ - **How do I run non-interactively?**
152
+ Pass `--ci` together with `--username` and `--password` (or use environment variables).
153
+
154
+ - **What does `--recursive` do?**
155
+ Expands subassemblies and downloads or lists all contained items up to the given `--max-depth`.
156
+
157
+ - **How does Gladiator handle authentication?**
158
+ It performs a `/login` call and stores the resulting `arenaSessionId` for reuse. If it expires, re-run `gladiator login`.
159
+
160
+
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "lr-gladiator"
7
- version = "0.7.0"
7
+ version = "0.9.0"
8
8
  description = "CLI and Python client for Arena PLM (app.bom.com): login, get revisions, list/download attachments, and upload to working revisions."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -9,6 +9,7 @@ from pathlib import Path
9
9
  from typing import Dict, List, Optional, Tuple
10
10
  import requests
11
11
  from .config import LoginConfig
12
+ from .checksums import sha256_file
12
13
 
13
14
 
14
15
  class ArenaError(RuntimeError):
@@ -252,7 +253,7 @@ class ArenaClient:
252
253
  description: Optional[str] = None,
253
254
  primary: bool = True,
254
255
  latest_edition_association: bool = True,
255
- edition: str = "1",
256
+ edition: str = None,
256
257
  ) -> Dict:
257
258
  """
258
259
  Update-if-exists-else-create semantics, matching the bash script:
@@ -476,16 +477,16 @@ class ArenaClient:
476
477
  "id": row.get("guid") or row.get("id"),
477
478
  "fileGuid": file_guid,
478
479
  "name": f.get("name") or f.get("title"),
480
+ "title": f.get("title"),
479
481
  "filename": f.get("name") or f.get("title"),
480
482
  "size": f.get("size"),
481
- "checksum": f.get("checksum") or f.get("md5"),
482
483
  "haveContent": f.get("haveContent", True),
483
484
  "downloadUrl": (
484
485
  f"{self._api_base()}/files/{file_guid}/content"
485
486
  if file_guid
486
487
  else None
487
488
  ),
488
- "version": f.get("version") or f.get("edition"),
489
+ "edition": f.get("edition"),
489
490
  "updatedAt": f.get("lastModifiedDateTime")
490
491
  or f.get("lastModifiedDate")
491
492
  or f.get("creationDateTime"),
@@ -571,14 +572,13 @@ class ArenaClient:
571
572
  "name": f.get("name") or f.get("title"),
572
573
  "filename": f.get("name") or f.get("title"),
573
574
  "size": f.get("size"),
574
- "checksum": f.get("checksum") or f.get("md5"),
575
575
  "haveContent": f.get("haveContent", True),
576
576
  "downloadUrl": (
577
577
  f"{self._api_base()}/files/{file_guid}/content"
578
578
  if file_guid
579
579
  else None
580
580
  ),
581
- "version": f.get("version") or f.get("edition"),
581
+ "edition": f.get("edition"),
582
582
  "updatedAt": f.get("lastModifiedDateTime")
583
583
  or f.get("lastModifiedDate")
584
584
  or f.get("creationDateTime"),
@@ -604,6 +604,13 @@ class ArenaClient:
604
604
  if not file_path.exists() or not file_path.is_file():
605
605
  raise ArenaError(f"File not found: {file_path}")
606
606
 
607
+ filename = file_path.name # Use truncated SHA256 hash if no edition is provided
608
+ if not edition:
609
+ # Arena seems to only accept 16 characters of edition information.
610
+ # The hex digest gives 16 hex × 4 bits = 64 bits of entropy.
611
+ # Less than a million files, collision risk is practically zero (~1 / 10^8).
612
+ edition = sha256_file(file_path)[:16]
613
+
607
614
  # 0) Resolve EFFECTIVE revision guid from item number
608
615
  effective_guid = self._api_resolve_item_guid(item_number)
609
616
 
@@ -674,6 +681,17 @@ class ArenaClient:
674
681
  if existing_ct is not None:
675
682
  self.session.headers["Content-Type"] = existing_ct
676
683
  ur.raise_for_status()
684
+
685
+ # Update the edition label on the File itself
686
+ try:
687
+ put_url = f"{self._api_base()}/files/{file_guid}"
688
+ self._log(f"PUT {put_url} (set edition={edition})")
689
+ pr = self.session.put(put_url, json={"edition": str(edition)})
690
+ pr.raise_for_status()
691
+ except requests.HTTPError as e:
692
+ # Don't fail the whole operation if the label update is rejected
693
+ self._log(f"Edition update failed for {file_guid}: {e}")
694
+
677
695
  # Many tenants return 201 with no JSON for content updates. Be flexible.
678
696
  data = self._try_json(ur)
679
697
  if data is None:
@@ -683,6 +701,7 @@ class ArenaClient:
683
701
  "status": ur.status_code,
684
702
  "fileGuid": file_guid,
685
703
  "location": ur.headers.get("Location"),
704
+ "edition": str(edition),
686
705
  }
687
706
  return data
688
707
 
@@ -706,7 +725,7 @@ class ArenaClient:
706
725
  )
707
726
 
708
727
  # 3) Prepare multipart (create association)
709
- title = title or file_path.stem
728
+ title = title or file_path.name
710
729
  file_format = file_format or (
711
730
  file_path.suffix[1:].lower() if file_path.suffix else "bin"
712
731
  )
@@ -754,6 +773,22 @@ class ArenaClient:
754
773
  # Normalize common fields we use elsewhere
755
774
  row = resp if isinstance(resp, dict) else {}
756
775
  f = row.get("file", {})
776
+
777
+ # Ensure the edition label is exactly what we asked for (some tenants ignore form edition)
778
+ try:
779
+ file_guid_created = (f or {}).get("guid")
780
+ if file_guid_created and str(edition):
781
+ put_url = f"{self._api_base()}/files/{file_guid_created}"
782
+ self._log(f"PUT {put_url} (set edition={edition})")
783
+ pr = self.session.put(put_url, json={"edition": str(edition)})
784
+ pr.raise_for_status()
785
+ # Update local 'f' edition if the PUT succeeded
786
+ f["edition"] = str(edition)
787
+ except requests.HTTPError as e:
788
+ self._log(
789
+ f"Edition update after create failed for {file_guid_created}: {e}"
790
+ )
791
+
757
792
  return {
758
793
  "associationGuid": row.get("guid"),
759
794
  "primary": row.get("primary"),
@@ -765,7 +800,7 @@ class ArenaClient:
765
800
  "size": f.get("size"),
766
801
  "format": f.get("format"),
767
802
  "category": (f.get("category") or {}).get("name"),
768
- "edition": f.get("edition"),
803
+ "edition": f.get("edition") or str(edition),
769
804
  "lastModifiedDateTime": f.get("lastModifiedDateTime"),
770
805
  },
771
806
  "downloadUrl": (
@@ -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")
@@ -8,13 +8,33 @@ 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
14
17
  from .config import LoginConfig, save_config, load_config, save_config_raw, CONFIG_PATH
15
18
  from .arena import ArenaClient, ArenaError
16
19
 
17
20
  app = typer.Typer(add_completion=False, help="Arena PLM command-line utility")
21
+ console = Console()
22
+
23
+ # --- tiny helper to show a spinner when appropriate ---
24
+ from contextlib import contextmanager
25
+
26
+
27
+ @contextmanager
28
+ def spinner(message: str, enabled: bool = True):
29
+ """
30
+ Show a Rich spinner while the body executes.
31
+ Auto-disables if stdout is not a TTY (e.g., CI) or enabled=False.
32
+ """
33
+ if enabled and sys.stdout.isatty():
34
+ with console.status(message, spinner="dots"):
35
+ yield
36
+ else:
37
+ yield
18
38
 
19
39
 
20
40
  @app.command()
@@ -36,10 +56,7 @@ def login(
36
56
  "CI/CD integration", help="Arena-Usage-Reason header"
37
57
  ),
38
58
  ):
39
- """Create or update ~/.config/gladiator/login.json for subsequent commands.
40
-
41
- This performs a `/login` call against Arena and stores the JSON (including arenaSessionId) in login.json.
42
- """
59
+ """Create or update ~/.config/gladiator/login.json for subsequent commands."""
43
60
  if not username and not non_interactive:
44
61
  username = typer.prompt("Email/username")
45
62
  if not password and not non_interactive:
@@ -59,27 +76,22 @@ def login(
59
76
  "User-Agent": "gladiator-arena/0.1",
60
77
  }
61
78
  url = f"{(base_url or '').rstrip('/')}/login"
62
- resp = sess.post(
63
- url, headers=headers, json={"email": username, "password": password}
64
- )
65
79
  try:
66
- resp.raise_for_status()
80
+ with spinner("Logging in…", enabled=sys.stdout.isatty()):
81
+ resp = sess.post(
82
+ url, headers=headers, json={"email": username, "password": password}
83
+ )
84
+ resp.raise_for_status()
67
85
  except Exception as e:
68
86
  typer.secho(
69
- f"Login failed: {e} Body: {resp.text[:400]}", fg=typer.colors.RED, err=True
87
+ f"Login failed: {e} Body: {getattr(resp, 'text', '')[:400]}",
88
+ fg=typer.colors.RED,
89
+ err=True,
70
90
  )
71
91
  raise typer.Exit(2)
72
92
 
73
93
  data = resp.json()
74
-
75
- # Merge our client settings alongside the session info into the same file (compatible with your bash scripts)
76
- data.update(
77
- {
78
- "base_url": base_url,
79
- "verify_tls": verify_tls,
80
- "reason": reason,
81
- }
82
- )
94
+ data.update({"base_url": base_url, "verify_tls": verify_tls, "reason": reason})
83
95
  save_config_raw(data)
84
96
  print(f"[green]Saved session to {CONFIG_PATH}[/green]")
85
97
 
@@ -97,9 +109,13 @@ def latest_approved(
97
109
  ),
98
110
  ):
99
111
  """Print latest approved revision for the given item number."""
112
+ json_mode = (format or "").lower() == "json"
100
113
  try:
101
- rev = _client().get_latest_approved_revision(item)
102
- if format == "json":
114
+ with spinner(
115
+ f"Resolving latest approved revision for {item}…", enabled=not json_mode
116
+ ):
117
+ rev = _client().get_latest_approved_revision(item)
118
+ if json_mode:
103
119
  json.dump({"article": item, "revision": rev}, sys.stdout, indent=2)
104
120
  sys.stdout.write("\n")
105
121
  else:
@@ -124,9 +140,15 @@ def list_files(
124
140
  None, "--format", "-f", help="Output format: human (default) or json"
125
141
  ),
126
142
  ):
143
+ json_mode = (format or "").lower() == "json"
127
144
  try:
128
- files = _client().list_files(item, revision)
129
- if format == "json":
145
+ with spinner(
146
+ f"Listing files for {item} ({revision or 'EFFECTIVE'})…",
147
+ enabled=not json_mode,
148
+ ):
149
+ files = _client().list_files(item, revision)
150
+
151
+ if json_mode:
130
152
  json.dump(
131
153
  {"article": item, "revision": revision, "files": files},
132
154
  sys.stdout,
@@ -138,11 +160,9 @@ def list_files(
138
160
  table = Table(title=f"Files for {item} rev {revision or '(latest approved)'}")
139
161
  table.add_column("Name")
140
162
  table.add_column("Size", justify="right")
141
- table.add_column("Checksum")
163
+ table.add_column("Edition")
142
164
  for f in files:
143
- table.add_row(
144
- str(f.get("filename")), str(f.get("size")), str(f.get("checksum"))
145
- )
165
+ table.add_row(str(f.get("name")), str(f.get("size")), str(f.get("edition")))
146
166
  print(table)
147
167
  except requests.HTTPError as e:
148
168
  typer.secho(f"Arena request failed: {e}", fg=typer.colors.RED, err=True)
@@ -174,12 +194,19 @@ def bom(
174
194
  ),
175
195
  ):
176
196
  """List the BOM lines for an item revision."""
197
+ json_mode = output.lower() == "json"
177
198
  try:
178
- lines = _client().get_bom(
179
- item, revision, recursive=recursive, max_depth=max_depth
180
- )
199
+ with spinner(
200
+ f"Fetching BOM for {item} ({revision or 'EFFECTIVE'})"
201
+ + (" [recursive]" if recursive else "")
202
+ + "…",
203
+ enabled=not json_mode,
204
+ ):
205
+ lines = _client().get_bom(
206
+ item, revision, recursive=recursive, max_depth=max_depth
207
+ )
181
208
 
182
- if output.lower() == "json":
209
+ if json_mode:
183
210
  print(json.dumps({"count": len(lines), "results": lines}, indent=2))
184
211
  return
185
212
 
@@ -193,7 +220,7 @@ def bom(
193
220
 
194
221
  for ln in lines:
195
222
  lvl = int(ln.get("level", 0) or 0)
196
- indent = " " * lvl # 2 spaces per level
223
+ indent = " " * lvl
197
224
  table.add_row(
198
225
  str(ln.get("lineNumber") or ""),
199
226
  str(ln.get("quantity") or ""),
@@ -233,14 +260,21 @@ def get_files(
233
260
  help="Maximum recursion depth for --recursive (1 = only direct children).",
234
261
  ),
235
262
  ):
263
+ json_mode = False # this command prints file paths line-by-line (no JSON mode here)
236
264
  try:
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)
265
+ out_dir = out or Path(item)
266
+ with spinner(
267
+ f"Downloading files for {item} ({revision or 'EFFECTIVE'})"
268
+ + (" [recursive]" if recursive else "")
269
+ + f" → {out_dir}…",
270
+ enabled=not json_mode,
271
+ ):
272
+ if recursive:
273
+ paths = _client().download_files_recursive(
274
+ item, revision, out_dir=out_dir, max_depth=max_depth
275
+ )
276
+ else:
277
+ paths = _client().download_files(item, revision, out_dir=out_dir)
244
278
 
245
279
  for p in paths:
246
280
  print(str(p))
@@ -277,25 +311,26 @@ def upload_file(
277
311
  False, "--primary/--no-primary", help="Mark association as primary"
278
312
  ),
279
313
  edition: str = typer.Option(
280
- "1",
314
+ None,
281
315
  "--edition",
282
- help="Edition number when creating a new association (default: 1)",
316
+ help="Edition number when creating a new association (default: SHA256 checksum of file)",
283
317
  ),
284
318
  ):
285
319
  """If a file with the same filename exists: update its content (new edition).
286
320
  Otherwise: create a new association on the WORKING revision (requires --edition)."""
287
321
  try:
288
- result = _client().upload_file_to_working(
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,
298
- )
322
+ with spinner(f"Uploading {file.name} to {item}…", enabled=sys.stdout.isatty()):
323
+ result = _client().upload_file_to_working(
324
+ item,
325
+ file,
326
+ reference,
327
+ title=title,
328
+ category_name=category,
329
+ file_format=file_format,
330
+ description=description,
331
+ primary=primary,
332
+ edition=edition,
333
+ )
299
334
  print(json.dumps(result, indent=2))
300
335
  except requests.HTTPError as e:
301
336
  typer.secho(f"Arena request failed: {e}", fg=typer.colors.RED, err=True)
@@ -0,0 +1,183 @@
1
+ Metadata-Version: 2.4
2
+ Name: lr-gladiator
3
+ Version: 0.9.0
4
+ Summary: CLI and Python client for Arena PLM (app.bom.com): login, get revisions, list/download attachments, and upload to working revisions.
5
+ Author-email: Jonas Estberger <jonas.estberger@lumenradio.com>
6
+ License: MIT
7
+ Keywords: Arena,PLM,BOM,attachments,CLI
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Operating System :: OS Independent
11
+ Requires-Python: >=3.9
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Requires-Dist: requests>=2.32
15
+ Requires-Dist: typer>=0.12
16
+ Requires-Dist: rich>=13.7
17
+ Requires-Dist: pydantic>=2.8
18
+ Provides-Extra: dev
19
+ Requires-Dist: build>=1.2.1; extra == "dev"
20
+ Requires-Dist: twine>=5.1.1; extra == "dev"
21
+ Requires-Dist: wheel; extra == "dev"
22
+ Dynamic: license-file
23
+
24
+ # gladiator-arena
25
+
26
+ CLI + Python client for interacting with the Arena PLM.
27
+
28
+ ## Install
29
+
30
+ ```bash
31
+ pip install lr-gladiator
32
+ ```
33
+
34
+ ## Quick start
35
+
36
+ ### 1) Create `login.json`
37
+
38
+ Interactive login (prompts for username/password):
39
+
40
+ ```bash
41
+ gladiator login
42
+ ```
43
+
44
+ Non-interactive (for CI/CD):
45
+
46
+ ```bash
47
+ gladiator login --username "$ARENA_USERNAME" --password "$ARENA_PASSWORD" --ci
48
+ ```
49
+
50
+ By default, this stores session details at:
51
+
52
+ ```
53
+ ~/.config/gladiator/login.json
54
+ ```
55
+
56
+ ### 2) Common commands
57
+
58
+ Get the latest approved revision for an item:
59
+
60
+ ```bash
61
+ gladiator latest-approved 890-1001
62
+ ```
63
+
64
+ List all files on an item (defaults to the latest approved revision):
65
+
66
+ ```bash
67
+ gladiator list-files 890-1001
68
+ ```
69
+
70
+ Output JSON instead of a table:
71
+
72
+ ```bash
73
+ gladiator list-files 890-1001 --format json
74
+ ```
75
+
76
+ List the Bill of Materials (BOM) for an item:
77
+
78
+ ```bash
79
+ gladiator bom 890-1001
80
+ ```
81
+
82
+ Recursively expand subassemblies up to two levels deep:
83
+
84
+ ```bash
85
+ gladiator bom 890-1001 --recursive --max-depth 2
86
+ ```
87
+
88
+ Download attached files to a directory named after the article:
89
+
90
+ ```bash
91
+ gladiator get-files 890-1001
92
+ ```
93
+
94
+ Specify a different output directory:
95
+
96
+ ```bash
97
+ gladiator get-files 890-1001 --out downloads/
98
+ ```
99
+
100
+ Recursively download all files in the full BOM tree:
101
+
102
+ ```bash
103
+ gladiator get-files 890-1001 --recursive
104
+ ```
105
+
106
+ Upload or update a file on the working revision:
107
+
108
+ ```bash
109
+ gladiator upload-file 890-1001 ./datasheet.pdf --category "CAD Data" --title "Datasheet"
110
+ ```
111
+
112
+ ### 3) Output control
113
+
114
+ Most commands support a JSON output mode.
115
+ Example:
116
+
117
+ ```bash
118
+ gladiator bom 890-1001 --output json
119
+ ```
120
+
121
+ ### Example sessions
122
+
123
+ #### Human-readable
124
+
125
+ ```bash
126
+ $ gladiator list-files 101-1031
127
+ Files for 101-1031 rev (latest approved)
128
+ ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━┓
129
+ ┃ Name ┃ Size ┃ Checksum ┃
130
+ ┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━┩
131
+ │ Drawing.pdf │ 12345 │ d41d8cd98f00b204e… │
132
+ └──────────────────────────────────────┴───────┴─────────────────────┘
133
+ ```
134
+
135
+ #### JSON output
136
+
137
+ ```bash
138
+ $ gladiator list-files 101-1031 --format json
139
+ {
140
+ "article": "101-1031",
141
+ "revision": "EFFECTIVE",
142
+ "files": [
143
+ {
144
+ "filename": "Drawing.pdf",
145
+ "size": 12345,
146
+ "checksum": "d41d8cd98f00b204e9800998ecf8427e"
147
+ }
148
+ ]
149
+ }
150
+ ```
151
+
152
+ ## Programmatic use
153
+
154
+ ```python
155
+ from gladiator import ArenaClient, load_config
156
+
157
+ client = ArenaClient(load_config())
158
+ rev = client.get_latest_approved_revision("890-1001")
159
+ files = client.list_files("890-1001", rev)
160
+ ```
161
+
162
+ ## Development
163
+
164
+ ```bash
165
+ python -m pip install -e .[dev]
166
+ python -m build
167
+ ```
168
+
169
+ ## FAQ
170
+
171
+ - **Where is the config kept?**
172
+ `~/.config/gladiator/login.json` (override with `GLADIATOR_CONFIG`)
173
+
174
+ - **How do I run non-interactively?**
175
+ Pass `--ci` together with `--username` and `--password` (or use environment variables).
176
+
177
+ - **What does `--recursive` do?**
178
+ Expands subassemblies and downloads or lists all contained items up to the given `--max-depth`.
179
+
180
+ - **How does Gladiator handle authentication?**
181
+ It performs a `/login` call and stores the resulting `arenaSessionId` for reuse. If it expires, re-run `gladiator login`.
182
+
183
+
@@ -3,6 +3,7 @@ README.md
3
3
  pyproject.toml
4
4
  src/gladiator/__init__.py
5
5
  src/gladiator/arena.py
6
+ src/gladiator/checksums.py
6
7
  src/gladiator/cli.py
7
8
  src/gladiator/config.py
8
9
  src/lr_gladiator.egg-info/PKG-INFO
@@ -11,4 +12,5 @@ src/lr_gladiator.egg-info/dependency_links.txt
11
12
  src/lr_gladiator.egg-info/entry_points.txt
12
13
  src/lr_gladiator.egg-info/requires.txt
13
14
  src/lr_gladiator.egg-info/top_level.txt
15
+ tests/test_checksums.py
14
16
  tests/test_smoke.py
@@ -0,0 +1,16 @@
1
+ # tests/test_md5_content_header.py
2
+ from pathlib import Path
3
+ from gladiator.checksums import md5_base64_file
4
+ import hashlib, base64, os, tempfile
5
+
6
+
7
+ def test_md5_base64_file_matches_python():
8
+ payload = b"hello world"
9
+ with tempfile.NamedTemporaryFile(delete=False) as tf:
10
+ tf.write(payload)
11
+ p = Path(tf.name)
12
+ try:
13
+ expected = base64.b64encode(hashlib.md5(payload).digest()).decode("ascii")
14
+ assert md5_base64_file(p) == expected
15
+ finally:
16
+ os.unlink(p)
@@ -1,90 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: lr-gladiator
3
- Version: 0.7.0
4
- Summary: CLI and Python client for Arena PLM (app.bom.com): login, get revisions, list/download attachments, and upload to working revisions.
5
- Author-email: Jonas Estberger <jonas.estberger@lumenradio.com>
6
- License: MIT
7
- Keywords: Arena,PLM,BOM,attachments,CLI
8
- Classifier: Programming Language :: Python :: 3
9
- Classifier: License :: OSI Approved :: MIT License
10
- Classifier: Operating System :: OS Independent
11
- Requires-Python: >=3.9
12
- Description-Content-Type: text/markdown
13
- License-File: LICENSE
14
- Requires-Dist: requests>=2.32
15
- Requires-Dist: typer>=0.12
16
- Requires-Dist: rich>=13.7
17
- Requires-Dist: pydantic>=2.8
18
- Provides-Extra: dev
19
- Requires-Dist: build>=1.2.1; extra == "dev"
20
- Requires-Dist: twine>=5.1.1; extra == "dev"
21
- Requires-Dist: wheel; extra == "dev"
22
- Dynamic: license-file
23
-
24
- # gladiator-arena
25
-
26
- CLI + Python client for interacting with the Arena PLM.
27
-
28
- ## Install
29
-
30
- ```bash
31
- pip install lr-gladiator
32
- ```
33
-
34
- ## Quick start
35
-
36
- ### 1) Create `login.json`
37
-
38
- Interactive:
39
-
40
- ```bash
41
- gladiator login
42
- ```
43
-
44
- `login.json` is stored at `~/.config/gladiator/login.json` by default.
45
-
46
- ### 2) Queries
47
-
48
- Get latest approved revision for an item:
49
-
50
- ```bash
51
- gladiator latest-approved ABC-1234
52
- ```
53
-
54
- List files on an item (defaults to latest approved):
55
-
56
- ```bash
57
- gladiator list-files ABC-1234
58
- ```
59
-
60
- Download files:
61
-
62
- ```bash
63
- gladiator get-files ABC-1234 --out downloads/
64
- ```
65
-
66
- Upload a file to the working revision
67
-
68
- ```bash
69
- gladiator upload-file ABC-1234 ./datasheet.pdf --reference datasheet
70
- ```
71
-
72
- ## Programmatic use
73
-
74
- ```python
75
- from gladiator import ArenaClient, load_config
76
- client = ArenaClient(load_config())
77
- rev = client.get_latest_approved_revision("ABC-1234")
78
- files = client.list_files("ABC-1234", rev)
79
- ```
80
-
81
- ## Development
82
-
83
- ```bash
84
- python -m pip install -e .[dev]
85
- python -m build
86
- ```
87
-
88
- ## FAQ
89
-
90
- - **Where is the config kept?** `~/.config/gladiator/login.json` (override via `GLADIATOR_CONFIG`).
@@ -1,67 +0,0 @@
1
- # gladiator-arena
2
-
3
- CLI + Python client for interacting with the Arena PLM.
4
-
5
- ## Install
6
-
7
- ```bash
8
- pip install lr-gladiator
9
- ```
10
-
11
- ## Quick start
12
-
13
- ### 1) Create `login.json`
14
-
15
- Interactive:
16
-
17
- ```bash
18
- gladiator login
19
- ```
20
-
21
- `login.json` is stored at `~/.config/gladiator/login.json` by default.
22
-
23
- ### 2) Queries
24
-
25
- Get latest approved revision for an item:
26
-
27
- ```bash
28
- gladiator latest-approved ABC-1234
29
- ```
30
-
31
- List files on an item (defaults to latest approved):
32
-
33
- ```bash
34
- gladiator list-files ABC-1234
35
- ```
36
-
37
- Download files:
38
-
39
- ```bash
40
- gladiator get-files ABC-1234 --out downloads/
41
- ```
42
-
43
- Upload a file to the working revision
44
-
45
- ```bash
46
- gladiator upload-file ABC-1234 ./datasheet.pdf --reference datasheet
47
- ```
48
-
49
- ## Programmatic use
50
-
51
- ```python
52
- from gladiator import ArenaClient, load_config
53
- client = ArenaClient(load_config())
54
- rev = client.get_latest_approved_revision("ABC-1234")
55
- files = client.list_files("ABC-1234", rev)
56
- ```
57
-
58
- ## Development
59
-
60
- ```bash
61
- python -m pip install -e .[dev]
62
- python -m build
63
- ```
64
-
65
- ## FAQ
66
-
67
- - **Where is the config kept?** `~/.config/gladiator/login.json` (override via `GLADIATOR_CONFIG`).
@@ -1,90 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: lr-gladiator
3
- Version: 0.7.0
4
- Summary: CLI and Python client for Arena PLM (app.bom.com): login, get revisions, list/download attachments, and upload to working revisions.
5
- Author-email: Jonas Estberger <jonas.estberger@lumenradio.com>
6
- License: MIT
7
- Keywords: Arena,PLM,BOM,attachments,CLI
8
- Classifier: Programming Language :: Python :: 3
9
- Classifier: License :: OSI Approved :: MIT License
10
- Classifier: Operating System :: OS Independent
11
- Requires-Python: >=3.9
12
- Description-Content-Type: text/markdown
13
- License-File: LICENSE
14
- Requires-Dist: requests>=2.32
15
- Requires-Dist: typer>=0.12
16
- Requires-Dist: rich>=13.7
17
- Requires-Dist: pydantic>=2.8
18
- Provides-Extra: dev
19
- Requires-Dist: build>=1.2.1; extra == "dev"
20
- Requires-Dist: twine>=5.1.1; extra == "dev"
21
- Requires-Dist: wheel; extra == "dev"
22
- Dynamic: license-file
23
-
24
- # gladiator-arena
25
-
26
- CLI + Python client for interacting with the Arena PLM.
27
-
28
- ## Install
29
-
30
- ```bash
31
- pip install lr-gladiator
32
- ```
33
-
34
- ## Quick start
35
-
36
- ### 1) Create `login.json`
37
-
38
- Interactive:
39
-
40
- ```bash
41
- gladiator login
42
- ```
43
-
44
- `login.json` is stored at `~/.config/gladiator/login.json` by default.
45
-
46
- ### 2) Queries
47
-
48
- Get latest approved revision for an item:
49
-
50
- ```bash
51
- gladiator latest-approved ABC-1234
52
- ```
53
-
54
- List files on an item (defaults to latest approved):
55
-
56
- ```bash
57
- gladiator list-files ABC-1234
58
- ```
59
-
60
- Download files:
61
-
62
- ```bash
63
- gladiator get-files ABC-1234 --out downloads/
64
- ```
65
-
66
- Upload a file to the working revision
67
-
68
- ```bash
69
- gladiator upload-file ABC-1234 ./datasheet.pdf --reference datasheet
70
- ```
71
-
72
- ## Programmatic use
73
-
74
- ```python
75
- from gladiator import ArenaClient, load_config
76
- client = ArenaClient(load_config())
77
- rev = client.get_latest_approved_revision("ABC-1234")
78
- files = client.list_files("ABC-1234", rev)
79
- ```
80
-
81
- ## Development
82
-
83
- ```bash
84
- python -m pip install -e .[dev]
85
- python -m build
86
- ```
87
-
88
- ## FAQ
89
-
90
- - **Where is the config kept?** `~/.config/gladiator/login.json` (override via `GLADIATOR_CONFIG`).
File without changes
File without changes