lr-gladiator 0.1.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.
- lr_gladiator-0.1.0/LICENSE +25 -0
- lr_gladiator-0.1.0/PKG-INFO +104 -0
- lr_gladiator-0.1.0/README.md +81 -0
- lr_gladiator-0.1.0/pyproject.toml +35 -0
- lr_gladiator-0.1.0/setup.cfg +4 -0
- lr_gladiator-0.1.0/src/gladiator/__init__.py +7 -0
- lr_gladiator-0.1.0/src/gladiator/arena.py +505 -0
- lr_gladiator-0.1.0/src/gladiator/cli.py +158 -0
- lr_gladiator-0.1.0/src/gladiator/config.py +52 -0
- lr_gladiator-0.1.0/src/lr_gladiator.egg-info/PKG-INFO +104 -0
- lr_gladiator-0.1.0/src/lr_gladiator.egg-info/SOURCES.txt +14 -0
- lr_gladiator-0.1.0/src/lr_gladiator.egg-info/dependency_links.txt +1 -0
- lr_gladiator-0.1.0/src/lr_gladiator.egg-info/entry_points.txt +2 -0
- lr_gladiator-0.1.0/src/lr_gladiator.egg-info/requires.txt +9 -0
- lr_gladiator-0.1.0/src/lr_gladiator.egg-info/top_level.txt +1 -0
- lr_gladiator-0.1.0/tests/test_smoke.py +6 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
Copyright (c) 2025 Your Name
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
8
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
9
|
+
in the Software without restriction, including without limitation the rights
|
|
10
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
11
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
12
|
+
furnished to do so, subject to the following conditions:
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
The above copyright notice and this permission notice shall be included in all
|
|
16
|
+
copies or substantial portions of the Software.
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
20
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
21
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
22
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
23
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
24
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
25
|
+
SOFTWARE.
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: lr-gladiator
|
|
3
|
+
Version: 0.1.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 gladiator-arena
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Quick start
|
|
35
|
+
|
|
36
|
+
### 1) Create `login.json`
|
|
37
|
+
|
|
38
|
+
Interactive:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
gladiator login --subdomain acme
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
CI/CD friendly:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
gladiator login --subdomain acme --api-key "$ARENA_API_KEY" --ci
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
To use your existing scripts while prototyping:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
gladiator login --subdomain acme --mode bash --scripts-root /path/to/scripts
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
`login.json` is stored at `~/.config/gladiator/login.json` by default.
|
|
57
|
+
|
|
58
|
+
### 2) Queries
|
|
59
|
+
|
|
60
|
+
Get latest approved revision for an item:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
gladiator latest-approved ABC-1234
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
List files on an item (defaults to latest approved):
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
gladiator list-files ABC-1234
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Download files:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
gladiator get-files ABC-1234 --out downloads/
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Upload a file to the working revision (will refuse if the revision is approved/released):
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
gladiator upload-file ABC-1234 ./datasheet.pdf --reference datasheet
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Programmatic use
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
from gladiator import ArenaClient, load_config
|
|
88
|
+
client = ArenaClient(load_config())
|
|
89
|
+
rev = client.get_latest_approved_revision("ABC-1234")
|
|
90
|
+
files = client.list_files("ABC-1234", rev)
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Development
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
python -m pip install -e .[dev]
|
|
97
|
+
python -m build
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## FAQ
|
|
101
|
+
|
|
102
|
+
- **Where is the config kept?** `~/.config/gladiator/login.json` (override via `GLADIATOR_CONFIG`).
|
|
103
|
+
- **Auth method?** Prefer API key. Username/password is supported to be CI-friendly but avoid storing passwords when possible.
|
|
104
|
+
- **Approved states?** By default any state labelled `Approved` or `Released` is treated as approved. Adjust the code if your tenant uses custom labels.
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# gladiator-arena
|
|
2
|
+
|
|
3
|
+
CLI + Python client for interacting with the Arena PLM.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install gladiator-arena
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick start
|
|
12
|
+
|
|
13
|
+
### 1) Create `login.json`
|
|
14
|
+
|
|
15
|
+
Interactive:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
gladiator login --subdomain acme
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
CI/CD friendly:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
gladiator login --subdomain acme --api-key "$ARENA_API_KEY" --ci
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
To use your existing scripts while prototyping:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
gladiator login --subdomain acme --mode bash --scripts-root /path/to/scripts
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
`login.json` is stored at `~/.config/gladiator/login.json` by default.
|
|
34
|
+
|
|
35
|
+
### 2) Queries
|
|
36
|
+
|
|
37
|
+
Get latest approved revision for an item:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
gladiator latest-approved ABC-1234
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
List files on an item (defaults to latest approved):
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
gladiator list-files ABC-1234
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Download files:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
gladiator get-files ABC-1234 --out downloads/
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Upload a file to the working revision (will refuse if the revision is approved/released):
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
gladiator upload-file ABC-1234 ./datasheet.pdf --reference datasheet
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Programmatic use
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
from gladiator import ArenaClient, load_config
|
|
65
|
+
client = ArenaClient(load_config())
|
|
66
|
+
rev = client.get_latest_approved_revision("ABC-1234")
|
|
67
|
+
files = client.list_files("ABC-1234", rev)
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Development
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
python -m pip install -e .[dev]
|
|
74
|
+
python -m build
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## FAQ
|
|
78
|
+
|
|
79
|
+
- **Where is the config kept?** `~/.config/gladiator/login.json` (override via `GLADIATOR_CONFIG`).
|
|
80
|
+
- **Auth method?** Prefer API key. Username/password is supported to be CI-friendly but avoid storing passwords when possible.
|
|
81
|
+
- **Approved states?** By default any state labelled `Approved` or `Released` is treated as approved. Adjust the code if your tenant uses custom labels.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "lr-gladiator"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "CLI and Python client for Arena PLM (app.bom.com): login, get revisions, list/download attachments, and upload to working revisions."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = {text = "MIT"}
|
|
12
|
+
authors = [{name="Jonas Estberger", email="jonas.estberger@lumenradio.com"}]
|
|
13
|
+
dependencies = [
|
|
14
|
+
"requests>=2.32",
|
|
15
|
+
"typer>=0.12",
|
|
16
|
+
"rich>=13.7",
|
|
17
|
+
"pydantic>=2.8",
|
|
18
|
+
]
|
|
19
|
+
keywords = ["Arena", "PLM", "BOM", "attachments", "CLI"]
|
|
20
|
+
classifiers = [
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"License :: OSI Approved :: MIT License",
|
|
23
|
+
"Operating System :: OS Independent",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[project.scripts]
|
|
27
|
+
# installs the `gladiator` executable
|
|
28
|
+
gladiator = "gladiator.cli:app"
|
|
29
|
+
|
|
30
|
+
[tool.setuptools]
|
|
31
|
+
package-dir = {"" = "src"}
|
|
32
|
+
packages = ["gladiator"]
|
|
33
|
+
|
|
34
|
+
[project.optional-dependencies]
|
|
35
|
+
dev = ["build>=1.2.1", "twine>=5.1.1", "wheel"]
|
|
@@ -0,0 +1,505 @@
|
|
|
1
|
+
#! /usr/bin/env python
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
# src/gladiator/arena.py
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
import subprocess
|
|
6
|
+
import shlex
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Dict, List, Optional, Tuple
|
|
10
|
+
import requests
|
|
11
|
+
from .config import LoginConfig
|
|
12
|
+
|
|
13
|
+
class ArenaError(RuntimeError):
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
class ArenaClient:
|
|
17
|
+
def __init__(self, cfg: LoginConfig):
|
|
18
|
+
self.cfg = cfg
|
|
19
|
+
self.session = requests.Session()
|
|
20
|
+
self.session.verify = cfg.verify_tls
|
|
21
|
+
# Default headers: explicitly request/submit JSON
|
|
22
|
+
self.session.headers.update({
|
|
23
|
+
"Accept": "application/json",
|
|
24
|
+
"Content-Type": "application/json",
|
|
25
|
+
"User-Agent": "gladiator-arena/0.1",
|
|
26
|
+
"Arena-Usage-Reason": cfg.reason or "gladiator/cli",
|
|
27
|
+
})
|
|
28
|
+
if cfg.arena_session_id:
|
|
29
|
+
self.session.headers.update({"arena_session_id": cfg.arena_session_id})
|
|
30
|
+
|
|
31
|
+
self._debug = bool(int(os.environ.get("GLADIATOR_DEBUG", "0")))
|
|
32
|
+
|
|
33
|
+
# ---------- Utilities ----------
|
|
34
|
+
def _ensure_json(self, resp: requests.Response):
|
|
35
|
+
ctype = resp.headers.get("Content-Type", "").lower()
|
|
36
|
+
if "application/json" not in ctype:
|
|
37
|
+
snippet = resp.text[:400].replace("", " ")
|
|
38
|
+
raise ArenaError(
|
|
39
|
+
f"Expected JSON but got '{ctype or 'unknown'}' from {resp.url}. "
|
|
40
|
+
f"Status {resp.status_code}. Body starts with: {snippet}"
|
|
41
|
+
)
|
|
42
|
+
try:
|
|
43
|
+
return resp.json()
|
|
44
|
+
except Exception as e:
|
|
45
|
+
raise ArenaError(f"Failed to parse JSON from {resp.url}: {e}") from e
|
|
46
|
+
|
|
47
|
+
def _log(self, msg: str):
|
|
48
|
+
if self._debug:
|
|
49
|
+
print(f"[gladiator debug] {msg}")
|
|
50
|
+
|
|
51
|
+
def _try_json(self, resp: requests.Response) -> Optional[dict]:
|
|
52
|
+
"""Best-effort JSON parse. Returns None if not JSON or parse fails."""
|
|
53
|
+
ctype = resp.headers.get("Content-Type", "").lower()
|
|
54
|
+
if "application/json" not in ctype:
|
|
55
|
+
return None
|
|
56
|
+
try:
|
|
57
|
+
data = resp.json()
|
|
58
|
+
return data if isinstance(data, dict) else {"data": data}
|
|
59
|
+
except Exception:
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
# --- version picking helpers ---
|
|
63
|
+
@staticmethod
|
|
64
|
+
def _logical_key(f: Dict) -> str:
|
|
65
|
+
# Prefer any group-level id; fall back to normalized filename
|
|
66
|
+
return (
|
|
67
|
+
f.get("attachmentGroupGuid")
|
|
68
|
+
or f.get("attachmentGroupId")
|
|
69
|
+
or f.get("attachmentGuid")
|
|
70
|
+
or (f.get("name") or f.get("filename") or "").lower()
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
@staticmethod
|
|
74
|
+
def _version_of(f: Dict) -> int:
|
|
75
|
+
for k in ("version", "fileVersion", "versionNumber", "rev", "revision"):
|
|
76
|
+
v = f.get(k)
|
|
77
|
+
if v is None:
|
|
78
|
+
continue
|
|
79
|
+
try:
|
|
80
|
+
return int(v)
|
|
81
|
+
except Exception:
|
|
82
|
+
if isinstance(v, str) and len(v) == 1 and v.isalpha():
|
|
83
|
+
return ord(v.upper()) - 64 # A->1
|
|
84
|
+
return -1
|
|
85
|
+
|
|
86
|
+
@staticmethod
|
|
87
|
+
def _timestamp_of(f: Dict):
|
|
88
|
+
from datetime import datetime
|
|
89
|
+
from email.utils import parsedate_to_datetime
|
|
90
|
+
for k in ("modifiedAt", "updatedAt", "lastModified", "lastModifiedDate", "effectiveDate", "createdAt"):
|
|
91
|
+
s = f.get(k)
|
|
92
|
+
if not s:
|
|
93
|
+
continue
|
|
94
|
+
try:
|
|
95
|
+
return datetime.fromisoformat(s.replace("Z", "+00:00"))
|
|
96
|
+
except Exception:
|
|
97
|
+
try:
|
|
98
|
+
return parsedate_to_datetime(s)
|
|
99
|
+
except Exception:
|
|
100
|
+
continue
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
def _latest_files(self, files: List[Dict]) -> List[Dict]:
|
|
104
|
+
best: Dict[str, Dict] = {}
|
|
105
|
+
for f in files:
|
|
106
|
+
key = self._logical_key(f)
|
|
107
|
+
if not key:
|
|
108
|
+
continue
|
|
109
|
+
score = (self._version_of(f), self._timestamp_of(f) or 0)
|
|
110
|
+
prev = best.get(key)
|
|
111
|
+
if not prev:
|
|
112
|
+
f["_score"] = score
|
|
113
|
+
best[key] = f
|
|
114
|
+
continue
|
|
115
|
+
if score > prev.get("_score", (-1, 0)):
|
|
116
|
+
f["_score"] = score
|
|
117
|
+
best[key] = f
|
|
118
|
+
out = []
|
|
119
|
+
for v in best.values():
|
|
120
|
+
v.pop("_score", None)
|
|
121
|
+
out.append(v)
|
|
122
|
+
return out
|
|
123
|
+
|
|
124
|
+
# ---------- Public high-level methods ----------
|
|
125
|
+
def get_latest_approved_revision(self, item_number: str) -> str:
|
|
126
|
+
return self._api_get_latest_approved(item_number)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def list_files(self, item_number: str, revision: Optional[str] = None) -> List[Dict]:
|
|
130
|
+
target_guid = self._api_resolve_revision_guid(item_number, revision or "EFFECTIVE")
|
|
131
|
+
raw = self._api_list_files_by_item_guid(target_guid)
|
|
132
|
+
return self._latest_files(raw)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def download_files(self, item_number: str, revision: Optional[str] = None, out_dir: Path = Path(".")) -> List[Path]:
|
|
136
|
+
files = self.list_files(item_number, revision)
|
|
137
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
138
|
+
downloaded: List[Path] = []
|
|
139
|
+
for f in files:
|
|
140
|
+
url = f.get("downloadUrl") or f.get("url")
|
|
141
|
+
filename = f.get("filename") or f.get("name")
|
|
142
|
+
if not url or not filename:
|
|
143
|
+
continue
|
|
144
|
+
p = out_dir / filename
|
|
145
|
+
with self.session.get(url, stream=True, headers={"arena_session_id": self.cfg.arena_session_id or ""}) as r:
|
|
146
|
+
r.raise_for_status()
|
|
147
|
+
with open(p, "wb") as fh:
|
|
148
|
+
for chunk in r.iter_content(128 * 1024):
|
|
149
|
+
fh.write(chunk)
|
|
150
|
+
downloaded.append(p)
|
|
151
|
+
return downloaded
|
|
152
|
+
|
|
153
|
+
def upload_file_to_working(
|
|
154
|
+
self,
|
|
155
|
+
item_number: str,
|
|
156
|
+
file_path: Path,
|
|
157
|
+
reference: Optional[str] = None,
|
|
158
|
+
*,
|
|
159
|
+
title: Optional[str] = None,
|
|
160
|
+
category_name: str = "CAD Data",
|
|
161
|
+
file_format: Optional[str] = None,
|
|
162
|
+
description: Optional[str] = None,
|
|
163
|
+
primary: bool = True,
|
|
164
|
+
latest_edition_association: bool = True,
|
|
165
|
+
edition: str = "1",
|
|
166
|
+
) -> Dict:
|
|
167
|
+
"""
|
|
168
|
+
Update-if-exists-else-create semantics, matching the bash script:
|
|
169
|
+
1) Resolve EFFECTIVE GUID from item number
|
|
170
|
+
2) Resolve WORKING revision GUID (fail if none)
|
|
171
|
+
3) Find existing file by exact filename (WORKING first, then EFFECTIVE)
|
|
172
|
+
- If found: POST /files/{fileGuid}/content (multipart)
|
|
173
|
+
- Else: POST /items/{workingGuid}/files (multipart) with file.edition
|
|
174
|
+
"""
|
|
175
|
+
return self._api_upload_or_update_file(
|
|
176
|
+
item_number=item_number,
|
|
177
|
+
file_path=file_path,
|
|
178
|
+
reference=reference,
|
|
179
|
+
title=title,
|
|
180
|
+
category_name=category_name,
|
|
181
|
+
file_format=file_format,
|
|
182
|
+
description=description,
|
|
183
|
+
primary=primary,
|
|
184
|
+
latest_edition_association=latest_edition_association,
|
|
185
|
+
edition=edition,
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
# ---------- API-mode (HTTP) ----------
|
|
189
|
+
def _api_base(self) -> str:
|
|
190
|
+
return self.cfg.base_url.rstrip("/")
|
|
191
|
+
|
|
192
|
+
def _api_get_latest_approved(self, item_number: str) -> str:
|
|
193
|
+
item_guid = self._api_resolve_item_guid(item_number)
|
|
194
|
+
url = f"{self._api_base()}/items/{item_guid}/revisions"
|
|
195
|
+
self._log(f"GET {url}")
|
|
196
|
+
r = self.session.get(url)
|
|
197
|
+
if r.status_code == 404:
|
|
198
|
+
raise ArenaError(f"Item {item_number} not found")
|
|
199
|
+
r.raise_for_status()
|
|
200
|
+
data = self._ensure_json(r)
|
|
201
|
+
revs = data.get("results", data if isinstance(data, list) else [])
|
|
202
|
+
if not isinstance(revs, list):
|
|
203
|
+
raise ArenaError(f"Unexpected revisions payload for item {item_number}")
|
|
204
|
+
|
|
205
|
+
# Arena marks the currently effective (approved) revision as:
|
|
206
|
+
# - revisionStatus == "EFFECTIVE" (string)
|
|
207
|
+
# - OR status == 1 (numeric)
|
|
208
|
+
effective = [
|
|
209
|
+
rv for rv in revs
|
|
210
|
+
if (str(rv.get("revisionStatus") or "").upper() == "EFFECTIVE") or (rv.get("status") == 1)
|
|
211
|
+
]
|
|
212
|
+
if not effective:
|
|
213
|
+
raise ArenaError(f"No approved/released revisions for item {item_number}")
|
|
214
|
+
|
|
215
|
+
# Prefer the one that is not superseded; otherwise fall back to the most recently superseded.
|
|
216
|
+
current = next((rv for rv in effective if not rv.get("supersededDateTime")), None)
|
|
217
|
+
if not current:
|
|
218
|
+
# sort by supersededDateTime (None last) then by number/name as a stable tie-breaker
|
|
219
|
+
def _sd(rv):
|
|
220
|
+
dt = rv.get("supersededDateTime")
|
|
221
|
+
return dt or "0000-00-00T00:00:00Z"
|
|
222
|
+
effective.sort(key=_sd)
|
|
223
|
+
current = effective[-1]
|
|
224
|
+
|
|
225
|
+
# The human-visible revision is under "number" (e.g., "B3"); fall back defensively.
|
|
226
|
+
rev_label = current.get("number") or current.get("name") or current.get("revision")
|
|
227
|
+
if not rev_label:
|
|
228
|
+
raise ArenaError(f"Could not determine revision label for item {item_number}")
|
|
229
|
+
return rev_label
|
|
230
|
+
|
|
231
|
+
def _api_list_files(self, item_number: str) -> List[Dict]:
|
|
232
|
+
item_guid = self._api_resolve_item_guid(item_number)
|
|
233
|
+
url = f"{self._api_base()}/items/{item_guid}/files"
|
|
234
|
+
self._log(f"GET {url}")
|
|
235
|
+
r = self.session.get(url)
|
|
236
|
+
r.raise_for_status()
|
|
237
|
+
data = self._ensure_json(r)
|
|
238
|
+
rows = data.get("results", data if isinstance(data, list) else [])
|
|
239
|
+
norm: List[Dict] = []
|
|
240
|
+
for row in rows:
|
|
241
|
+
f = row.get("file", {}) if isinstance(row, dict) else {}
|
|
242
|
+
file_guid = f.get("guid") or f.get("id")
|
|
243
|
+
norm.append({
|
|
244
|
+
"id": row.get("guid") or row.get("id"), # association id
|
|
245
|
+
"fileGuid": file_guid, # actual file id
|
|
246
|
+
"name": f.get("name") or f.get("title"),
|
|
247
|
+
"filename": f.get("name") or f.get("title"),
|
|
248
|
+
"size": f.get("size"),
|
|
249
|
+
"checksum": f.get("checksum") or f.get("md5"),
|
|
250
|
+
"downloadUrl": f"{self._api_base()}/files/{file_guid}/content" if file_guid else None,
|
|
251
|
+
# for “pick latest” helper:
|
|
252
|
+
"version": f.get("version") or f.get("edition"),
|
|
253
|
+
"updatedAt": f.get("lastModifiedDateTime") or f.get("lastModifiedDate") or f.get("creationDateTime"),
|
|
254
|
+
"attachmentGroupGuid": row.get("guid"),
|
|
255
|
+
})
|
|
256
|
+
return norm
|
|
257
|
+
|
|
258
|
+
def _api_resolve_revision_guid(self, item_number: str, selector: str | None) -> str:
|
|
259
|
+
"""Return the item GUID for the requested revision selector."""
|
|
260
|
+
# Resolve base item (effective) guid from number
|
|
261
|
+
effective_guid = self._api_resolve_item_guid(item_number)
|
|
262
|
+
|
|
263
|
+
# If no selector, we default to EFFECTIVE
|
|
264
|
+
sel = (selector or "EFFECTIVE").strip().upper()
|
|
265
|
+
|
|
266
|
+
# Fetch revisions
|
|
267
|
+
url = f"{self._api_base()}/items/{effective_guid}/revisions"
|
|
268
|
+
self._log(f"GET {url}")
|
|
269
|
+
r = self.session.get(url); r.raise_for_status()
|
|
270
|
+
data = self._ensure_json(r)
|
|
271
|
+
revs = data.get("results", data if isinstance(data, list) else [])
|
|
272
|
+
|
|
273
|
+
def pick(pred):
|
|
274
|
+
for rv in revs:
|
|
275
|
+
if pred(rv):
|
|
276
|
+
return rv.get("guid")
|
|
277
|
+
return None
|
|
278
|
+
|
|
279
|
+
# Named selectors
|
|
280
|
+
if sel in {"WORKING"}:
|
|
281
|
+
guid = pick(lambda rv: (rv.get("revisionStatus") or "").upper() == "WORKING" or rv.get("status") == 0)
|
|
282
|
+
if not guid:
|
|
283
|
+
raise ArenaError("No WORKING revision exists for this item.")
|
|
284
|
+
return guid
|
|
285
|
+
|
|
286
|
+
if sel in {"EFFECTIVE", "APPROVED", "RELEASED"}:
|
|
287
|
+
# Prefer the one not superseded
|
|
288
|
+
eff = [rv for rv in revs if (rv.get("revisionStatus") or "").upper() == "EFFECTIVE" or rv.get("status") == 1]
|
|
289
|
+
if not eff:
|
|
290
|
+
raise ArenaError("No approved/effective revision exists for this item.")
|
|
291
|
+
current = next((rv for rv in eff if not rv.get("supersededDateTime")), eff[-1])
|
|
292
|
+
return current.get("guid")
|
|
293
|
+
|
|
294
|
+
# Specific label (e.g., "A", "B2")
|
|
295
|
+
guid = pick(lambda rv: (rv.get("number") or rv.get("name")) and str(rv.get("number") or rv.get("name")).upper() == sel)
|
|
296
|
+
if not guid:
|
|
297
|
+
raise ArenaError(f'Revision "{selector}" not found for item {item_number}.')
|
|
298
|
+
return guid
|
|
299
|
+
|
|
300
|
+
def _api_list_files_by_item_guid(self, item_guid: str) -> list[dict]:
|
|
301
|
+
url = f"{self._api_base()}/items/{item_guid}/files"
|
|
302
|
+
self._log(f"GET {url}")
|
|
303
|
+
r = self.session.get(url); r.raise_for_status()
|
|
304
|
+
data = self._ensure_json(r)
|
|
305
|
+
rows = data.get("results", data if isinstance(data, list) else [])
|
|
306
|
+
# … keep existing normalization from _api_list_files() …
|
|
307
|
+
norm = []
|
|
308
|
+
for row in rows:
|
|
309
|
+
f = row.get("file", {}) if isinstance(row, dict) else {}
|
|
310
|
+
file_guid = f.get("guid") or f.get("id")
|
|
311
|
+
norm.append({
|
|
312
|
+
"id": row.get("guid") or row.get("id"),
|
|
313
|
+
"fileGuid": file_guid,
|
|
314
|
+
"name": f.get("name") or f.get("title"),
|
|
315
|
+
"filename": f.get("name") or f.get("title"),
|
|
316
|
+
"size": f.get("size"),
|
|
317
|
+
"checksum": f.get("checksum") or f.get("md5"),
|
|
318
|
+
"downloadUrl": f"{self._api_base()}/files/{file_guid}/content" if file_guid else None,
|
|
319
|
+
"version": f.get("version") or f.get("edition"),
|
|
320
|
+
"updatedAt": f.get("lastModifiedDateTime") or f.get("lastModifiedDate") or f.get("creationDateTime"),
|
|
321
|
+
"attachmentGroupGuid": row.get("guid"),
|
|
322
|
+
})
|
|
323
|
+
return norm
|
|
324
|
+
|
|
325
|
+
def _api_upload_or_update_file(
|
|
326
|
+
self,
|
|
327
|
+
*,
|
|
328
|
+
item_number: str,
|
|
329
|
+
file_path: Path,
|
|
330
|
+
reference: Optional[str],
|
|
331
|
+
title: Optional[str],
|
|
332
|
+
category_name: str,
|
|
333
|
+
file_format: Optional[str],
|
|
334
|
+
description: Optional[str],
|
|
335
|
+
primary: bool,
|
|
336
|
+
latest_edition_association: bool,
|
|
337
|
+
edition: str,
|
|
338
|
+
) -> Dict:
|
|
339
|
+
if not file_path.exists() or not file_path.is_file():
|
|
340
|
+
raise ArenaError(f"File not found: {file_path}")
|
|
341
|
+
|
|
342
|
+
# 0) Resolve EFFECTIVE revision guid from item number
|
|
343
|
+
effective_guid = self._api_resolve_item_guid(item_number)
|
|
344
|
+
|
|
345
|
+
# 1) Resolve WORKING revision guid
|
|
346
|
+
revs_url = f"{self._api_base()}/items/{effective_guid}/revisions"
|
|
347
|
+
self._log(f"GET {revs_url}")
|
|
348
|
+
r = self.session.get(revs_url)
|
|
349
|
+
r.raise_for_status()
|
|
350
|
+
data = self._ensure_json(r)
|
|
351
|
+
rows = data.get("results", data if isinstance(data, list) else [])
|
|
352
|
+
working_guid = None
|
|
353
|
+
for rv in rows:
|
|
354
|
+
if (str(rv.get("revisionStatus") or "").upper() == "WORKING") or (rv.get("status") == 0):
|
|
355
|
+
working_guid = rv.get("guid")
|
|
356
|
+
break
|
|
357
|
+
if not working_guid:
|
|
358
|
+
raise ArenaError(
|
|
359
|
+
"No WORKING revision exists for this item. Create a working revision in Arena, then retry."
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
# Helper to list associations for a given item/revision guid
|
|
363
|
+
def _list_assocs(item_guid: str) -> list:
|
|
364
|
+
url = f"{self._api_base()}/items/{item_guid}/files"
|
|
365
|
+
self._log(f"GET {url}")
|
|
366
|
+
lr = self.session.get(url)
|
|
367
|
+
lr.raise_for_status()
|
|
368
|
+
payload = self._ensure_json(lr)
|
|
369
|
+
return payload.get("results", payload if isinstance(payload, list) else [])
|
|
370
|
+
|
|
371
|
+
# Try to find existing association by exact filename (WORKING first, then EFFECTIVE)
|
|
372
|
+
filename = file_path.name
|
|
373
|
+
assoc = None
|
|
374
|
+
for guid in (working_guid, effective_guid):
|
|
375
|
+
assocs = _list_assocs(guid)
|
|
376
|
+
# prefer primary && latestEditionAssociation, then any by name
|
|
377
|
+
prim_latest = [a for a in assocs if a.get("primary") and a.get("latestEditionAssociation")
|
|
378
|
+
and ((a.get("file") or {}).get("name") == filename)]
|
|
379
|
+
if prim_latest:
|
|
380
|
+
assoc = prim_latest[0]
|
|
381
|
+
break
|
|
382
|
+
any_by_name = [a for a in assocs if (a.get("file") or {}).get("name") == filename]
|
|
383
|
+
if any_by_name:
|
|
384
|
+
assoc = any_by_name[0]
|
|
385
|
+
break
|
|
386
|
+
|
|
387
|
+
# If an existing file is found: update its content (new edition)
|
|
388
|
+
if assoc:
|
|
389
|
+
file_guid = (assoc.get("file") or {}).get("guid")
|
|
390
|
+
if not file_guid:
|
|
391
|
+
raise ArenaError("Existing association found but no file.guid present.")
|
|
392
|
+
post_url = f"{self._api_base()}/files/{file_guid}/content"
|
|
393
|
+
self._log(f"POST {post_url} (multipart content update)")
|
|
394
|
+
with open(file_path, "rb") as fp:
|
|
395
|
+
files = {"content": (filename, fp, "application/octet-stream")}
|
|
396
|
+
existing_ct = self.session.headers.pop("Content-Type", None)
|
|
397
|
+
try:
|
|
398
|
+
ur = self.session.post(post_url, files=files)
|
|
399
|
+
finally:
|
|
400
|
+
if existing_ct is not None:
|
|
401
|
+
self.session.headers["Content-Type"] = existing_ct
|
|
402
|
+
ur.raise_for_status()
|
|
403
|
+
# Many tenants return 201 with no JSON for content updates. Be flexible.
|
|
404
|
+
data = self._try_json(ur)
|
|
405
|
+
if data is None:
|
|
406
|
+
# Synthesize a small success payload with whatever we can glean.
|
|
407
|
+
return {
|
|
408
|
+
"ok": True,
|
|
409
|
+
"status": ur.status_code,
|
|
410
|
+
"fileGuid": file_guid,
|
|
411
|
+
"location": ur.headers.get("Location"),
|
|
412
|
+
}
|
|
413
|
+
return data
|
|
414
|
+
|
|
415
|
+
# Else: create a new association on WORKING
|
|
416
|
+
# 2) Resolve file category guid by name (default: CAD Data) cats_url = f"{self._api_base()}/settings/files/categories"
|
|
417
|
+
self._log(f"GET {cats_url}")
|
|
418
|
+
r = self.session.get(cats_url)
|
|
419
|
+
r.raise_for_status()
|
|
420
|
+
cats = self._ensure_json(r).get("results", [])
|
|
421
|
+
cat_guid = None
|
|
422
|
+
for c in cats:
|
|
423
|
+
if c.get("name") == category_name and (c.get("parentCategory") or {}).get("name") in {"Internal File", None}:
|
|
424
|
+
cat_guid = c.get("guid")
|
|
425
|
+
break
|
|
426
|
+
if not cat_guid:
|
|
427
|
+
raise ArenaError(f'File category "{category_name}" not found or not allowed.')
|
|
428
|
+
|
|
429
|
+
# 3) Prepare multipart (create association)
|
|
430
|
+
title = title or file_path.stem
|
|
431
|
+
file_format = file_format or (file_path.suffix[1:].lower() if file_path.suffix else "bin")
|
|
432
|
+
description = description or "Uploaded via gladiator"
|
|
433
|
+
|
|
434
|
+
files = {
|
|
435
|
+
"content": (file_path.name, open(file_path, "rb"), "application/octet-stream"),
|
|
436
|
+
}
|
|
437
|
+
# NOTE: nested field names are sent in `data`, not `files`
|
|
438
|
+
data_form = {
|
|
439
|
+
"file.title": title,
|
|
440
|
+
"file.name": filename,
|
|
441
|
+
"file.description": description,
|
|
442
|
+
"file.category.guid": cat_guid,
|
|
443
|
+
"file.format": file_format,
|
|
444
|
+
"file.edition": str(edition),
|
|
445
|
+
"file.storageMethodName": "FILE",
|
|
446
|
+
"file.private": "false",
|
|
447
|
+
"primary": "true" if primary else "false",
|
|
448
|
+
"latestEditionAssociation": "true" if latest_edition_association else "false",
|
|
449
|
+
}
|
|
450
|
+
if reference:
|
|
451
|
+
data_form["reference"] = reference
|
|
452
|
+
|
|
453
|
+
# 4) POST to /items/{workingGuid}/files (multipart). Ensure Content-Type not pinned.
|
|
454
|
+
post_url = f"{self._api_base()}/items/{working_guid}/files"
|
|
455
|
+
self._log(f"POST {post_url} (multipart)")
|
|
456
|
+
|
|
457
|
+
with open(file_path, "rb") as fp:
|
|
458
|
+
files = {"content": (filename, fp, "application/octet-stream")}
|
|
459
|
+
existing_ct = self.session.headers.pop("Content-Type", None)
|
|
460
|
+
try:
|
|
461
|
+
cr = self.session.post(post_url, data=data_form, files=files)
|
|
462
|
+
finally:
|
|
463
|
+
if existing_ct is not None:
|
|
464
|
+
self.session.headers["Content-Type"] = existing_ct
|
|
465
|
+
cr.raise_for_status()
|
|
466
|
+
resp = self._ensure_json(cr)
|
|
467
|
+
|
|
468
|
+
# Normalize common fields we use elsewhere
|
|
469
|
+
row = resp if isinstance(resp, dict) else {}
|
|
470
|
+
f = row.get("file", {})
|
|
471
|
+
return {
|
|
472
|
+
"associationGuid": row.get("guid"),
|
|
473
|
+
"primary": row.get("primary"),
|
|
474
|
+
"latestEditionAssociation": row.get("latestEditionAssociation"),
|
|
475
|
+
"file": {
|
|
476
|
+
"guid": f.get("guid"),
|
|
477
|
+
"title": f.get("title"),
|
|
478
|
+
"name": f.get("name"),
|
|
479
|
+
"size": f.get("size"),
|
|
480
|
+
"format": f.get("format"),
|
|
481
|
+
"category": (f.get("category") or {}).get("name"),
|
|
482
|
+
"edition": f.get("edition"),
|
|
483
|
+
"lastModifiedDateTime": f.get("lastModifiedDateTime"),
|
|
484
|
+
},
|
|
485
|
+
"downloadUrl": f"{self._api_base()}/files/{(f or {}).get('guid')}/content" if f.get("guid") else None,
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
def _api_resolve_item_guid(self, item_number: str) -> str:
|
|
489
|
+
url = f"{self._api_base()}/items/"
|
|
490
|
+
params = {"number": item_number, "limit": 1, "responseview": "minimal"}
|
|
491
|
+
self._log(f"GET {url} params={params}")
|
|
492
|
+
r = self.session.get(url, params=params)
|
|
493
|
+
r.raise_for_status()
|
|
494
|
+
data = self._ensure_json(r)
|
|
495
|
+
results = data.get("results") if isinstance(data, dict) else data
|
|
496
|
+
if not results:
|
|
497
|
+
raise ArenaError(f"Item number {item_number} not found")
|
|
498
|
+
guid = (results[0].get("guid") or results[0].get("id") or results[0].get("itemId"))
|
|
499
|
+
if not guid:
|
|
500
|
+
raise ArenaError("API response missing item GUID")
|
|
501
|
+
return guid
|
|
502
|
+
|
|
503
|
+
def _run(self, cmd: str) -> Tuple[int, str, str]:
|
|
504
|
+
proc = subprocess.run(cmd, shell=True, check=False, capture_output=True, text=True)
|
|
505
|
+
return proc.returncode, proc.stdout.strip(), proc.stderr.strip()
|
|
@@ -0,0 +1,158 @@
|
|
|
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 getpass import getpass
|
|
12
|
+
import requests
|
|
13
|
+
import sys
|
|
14
|
+
from .config import LoginConfig, save_config, load_config, save_config_raw, CONFIG_PATH
|
|
15
|
+
from .arena import ArenaClient, ArenaError
|
|
16
|
+
|
|
17
|
+
app = typer.Typer(add_completion=False, help="Arena PLM command-line utility")
|
|
18
|
+
|
|
19
|
+
@app.command()
|
|
20
|
+
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"),
|
|
24
|
+
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"),
|
|
27
|
+
):
|
|
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
|
+
"""
|
|
32
|
+
if not username and not non_interactive:
|
|
33
|
+
username = typer.prompt("Email/username")
|
|
34
|
+
if not password and not non_interactive:
|
|
35
|
+
password = getpass("Password: ")
|
|
36
|
+
if non_interactive and (not username or not password):
|
|
37
|
+
raise typer.BadParameter("Provide --username and --password (or set env vars) for --ci mode")
|
|
38
|
+
|
|
39
|
+
# Perform login
|
|
40
|
+
sess = requests.Session()
|
|
41
|
+
sess.verify = verify_tls
|
|
42
|
+
headers = {
|
|
43
|
+
"Content-Type": "application/json",
|
|
44
|
+
"Accept": "application/json",
|
|
45
|
+
"Arena-Usage-Reason": reason or "gladiator/cli",
|
|
46
|
+
"User-Agent": "gladiator-arena/0.1",
|
|
47
|
+
}
|
|
48
|
+
url = f"{(base_url or '').rstrip('/')}/login"
|
|
49
|
+
resp = sess.post(url, headers=headers, json={"email": username, "password": password})
|
|
50
|
+
try:
|
|
51
|
+
resp.raise_for_status()
|
|
52
|
+
except Exception as e:
|
|
53
|
+
typer.secho(f"Login failed: {e} Body: {resp.text[:400]}", fg=typer.colors.RED, err=True)
|
|
54
|
+
raise typer.Exit(2)
|
|
55
|
+
|
|
56
|
+
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
|
+
})
|
|
64
|
+
save_config_raw(data)
|
|
65
|
+
print(f"[green]Saved session to {CONFIG_PATH}[/green]")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _client() -> ArenaClient:
|
|
69
|
+
cfg = load_config()
|
|
70
|
+
return ArenaClient(cfg)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@app.command("latest-approved")
|
|
74
|
+
def latest_approved(
|
|
75
|
+
item: str = typer.Argument(..., help="Item/article number"),
|
|
76
|
+
format: Optional[str] = typer.Option(None, "--format", "-f", help="Output format: human (default) or json"),
|
|
77
|
+
):
|
|
78
|
+
"""Print latest approved revision for the given item number."""
|
|
79
|
+
try:
|
|
80
|
+
rev = _client().get_latest_approved_revision(item)
|
|
81
|
+
if format == "json":
|
|
82
|
+
json.dump({"article": item, "revision": rev}, sys.stdout, indent=2)
|
|
83
|
+
sys.stdout.write("\n")
|
|
84
|
+
else:
|
|
85
|
+
print(rev)
|
|
86
|
+
except ArenaError as e:
|
|
87
|
+
typer.secho(str(e), fg=typer.colors.RED, err=True)
|
|
88
|
+
raise typer.Exit(2)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@app.command("list-files")
|
|
92
|
+
def list_files(
|
|
93
|
+
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"),
|
|
96
|
+
):
|
|
97
|
+
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)
|
|
101
|
+
sys.stdout.write("\n")
|
|
102
|
+
return
|
|
103
|
+
|
|
104
|
+
table = Table(title=f"Files for {item} rev {revision or '(latest approved)'}")
|
|
105
|
+
table.add_column("Name")
|
|
106
|
+
table.add_column("Size", justify="right")
|
|
107
|
+
table.add_column("Checksum")
|
|
108
|
+
for f in files:
|
|
109
|
+
table.add_row(str(f.get("filename")), str(f.get("size")), str(f.get("checksum")))
|
|
110
|
+
print(table)
|
|
111
|
+
except ArenaError as e:
|
|
112
|
+
typer.secho(str(e), fg=typer.colors.RED, err=True)
|
|
113
|
+
raise typer.Exit(2)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@app.command("get-files")
|
|
117
|
+
def get_files(
|
|
118
|
+
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"),
|
|
121
|
+
format: Optional[str] = typer.Option(None, "--format", "-f", help="Output format: human (default) or json"),
|
|
122
|
+
):
|
|
123
|
+
try:
|
|
124
|
+
paths = _client().download_files(item, revision, out_dir=out)
|
|
125
|
+
for p in paths:
|
|
126
|
+
print(str(p))
|
|
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("upload-file")
|
|
133
|
+
def upload_file(
|
|
134
|
+
item: str = typer.Argument(...),
|
|
135
|
+
file: Path = typer.Argument(...),
|
|
136
|
+
reference: Optional[str] = typer.Option(None, "--reference", help="Optional reference string"),
|
|
137
|
+
title: Optional[str] = typer.Option(None, "--title", help="Override file title (default: filename without extension)"),
|
|
138
|
+
category: str = typer.Option("CAD Data", "--category", help='File category name (default: "CAD Data")'),
|
|
139
|
+
file_format: Optional[str] = typer.Option(None, "--format", help="File format (default: file extension)"),
|
|
140
|
+
description: Optional[str] = typer.Option(None, "--desc", help="Optional description"),
|
|
141
|
+
primary: bool = typer.Option(True, "--primary/--no-primary", help="Mark association as primary"),
|
|
142
|
+
edition: str = typer.Option("1", "--edition", help="Edition number when creating a new association (default: 1)"),
|
|
143
|
+
):
|
|
144
|
+
"""If a file with the same filename exists: update its content (new edition).
|
|
145
|
+
Otherwise: create a new association on the WORKING revision (requires --edition)."""
|
|
146
|
+
try:
|
|
147
|
+
result = _client().upload_file_to_working(
|
|
148
|
+
item, file, reference,
|
|
149
|
+
title=title, category_name=category, file_format=file_format,
|
|
150
|
+
description=description, primary=primary, edition=edition
|
|
151
|
+
)
|
|
152
|
+
print(json.dumps(result, indent=2))
|
|
153
|
+
except ArenaError as e:
|
|
154
|
+
typer.secho(str(e), fg=typer.colors.RED, err=True)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
if __name__ == "__main__":
|
|
158
|
+
app()
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
#! /usr/bin/env python
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
# src/gladiator/config.py
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Optional, Any, Dict
|
|
9
|
+
from pydantic import BaseModel, Field
|
|
10
|
+
|
|
11
|
+
CONFIG_HOME = Path(os.environ.get("GLADIATOR_CONFIG_HOME", Path.home() / ".config" / "gladiator"))
|
|
12
|
+
CONFIG_PATH = Path(os.environ.get("GLADIATOR_CONFIG", CONFIG_HOME / "login.json"))
|
|
13
|
+
|
|
14
|
+
class LoginConfig(BaseModel):
|
|
15
|
+
# Primary connection settings
|
|
16
|
+
base_url: str = Field("https://api.arenasolutions.com/v1", description="Arena REST API base URL")
|
|
17
|
+
verify_tls: bool = True
|
|
18
|
+
|
|
19
|
+
# Auth options
|
|
20
|
+
api_key: Optional[str] = None # not used by Arena v1 but kept for future
|
|
21
|
+
username: Optional[str] = None
|
|
22
|
+
password: Optional[str] = None
|
|
23
|
+
|
|
24
|
+
# Session from `/login`
|
|
25
|
+
arena_session_id: Optional[str] = Field(None, alias="arenaSessionId")
|
|
26
|
+
workspace_id: Optional[int] = Field(None, alias="workspaceId")
|
|
27
|
+
workspace_name: Optional[str] = Field(None, alias="workspaceName")
|
|
28
|
+
workspace_request_limit: Optional[int] = Field(None, alias="workspaceRequestLimit")
|
|
29
|
+
reason: Optional[str] = None
|
|
30
|
+
|
|
31
|
+
class Config:
|
|
32
|
+
populate_by_name = True
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def save_config_raw(data: Dict[str, Any], path: Path = CONFIG_PATH) -> None:
|
|
36
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
37
|
+
with open(path, "w") as f:
|
|
38
|
+
json.dump(data, f, indent=2)
|
|
39
|
+
try:
|
|
40
|
+
os.chmod(path, 0o600)
|
|
41
|
+
except PermissionError:
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def save_config(cfg: LoginConfig, path: Path = CONFIG_PATH) -> None:
|
|
46
|
+
save_config_raw(cfg.model_dump(by_alias=True))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def load_config(path: Path = CONFIG_PATH) -> LoginConfig:
|
|
50
|
+
with open(path, "r") as f:
|
|
51
|
+
raw = json.load(f)
|
|
52
|
+
return LoginConfig(**raw)
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: lr-gladiator
|
|
3
|
+
Version: 0.1.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 gladiator-arena
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Quick start
|
|
35
|
+
|
|
36
|
+
### 1) Create `login.json`
|
|
37
|
+
|
|
38
|
+
Interactive:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
gladiator login --subdomain acme
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
CI/CD friendly:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
gladiator login --subdomain acme --api-key "$ARENA_API_KEY" --ci
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
To use your existing scripts while prototyping:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
gladiator login --subdomain acme --mode bash --scripts-root /path/to/scripts
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
`login.json` is stored at `~/.config/gladiator/login.json` by default.
|
|
57
|
+
|
|
58
|
+
### 2) Queries
|
|
59
|
+
|
|
60
|
+
Get latest approved revision for an item:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
gladiator latest-approved ABC-1234
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
List files on an item (defaults to latest approved):
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
gladiator list-files ABC-1234
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Download files:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
gladiator get-files ABC-1234 --out downloads/
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Upload a file to the working revision (will refuse if the revision is approved/released):
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
gladiator upload-file ABC-1234 ./datasheet.pdf --reference datasheet
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Programmatic use
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
from gladiator import ArenaClient, load_config
|
|
88
|
+
client = ArenaClient(load_config())
|
|
89
|
+
rev = client.get_latest_approved_revision("ABC-1234")
|
|
90
|
+
files = client.list_files("ABC-1234", rev)
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Development
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
python -m pip install -e .[dev]
|
|
97
|
+
python -m build
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## FAQ
|
|
101
|
+
|
|
102
|
+
- **Where is the config kept?** `~/.config/gladiator/login.json` (override via `GLADIATOR_CONFIG`).
|
|
103
|
+
- **Auth method?** Prefer API key. Username/password is supported to be CI-friendly but avoid storing passwords when possible.
|
|
104
|
+
- **Approved states?** By default any state labelled `Approved` or `Released` is treated as approved. Adjust the code if your tenant uses custom labels.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/gladiator/__init__.py
|
|
5
|
+
src/gladiator/arena.py
|
|
6
|
+
src/gladiator/cli.py
|
|
7
|
+
src/gladiator/config.py
|
|
8
|
+
src/lr_gladiator.egg-info/PKG-INFO
|
|
9
|
+
src/lr_gladiator.egg-info/SOURCES.txt
|
|
10
|
+
src/lr_gladiator.egg-info/dependency_links.txt
|
|
11
|
+
src/lr_gladiator.egg-info/entry_points.txt
|
|
12
|
+
src/lr_gladiator.egg-info/requires.txt
|
|
13
|
+
src/lr_gladiator.egg-info/top_level.txt
|
|
14
|
+
tests/test_smoke.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
gladiator
|