packspliter-cli 1.0.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.
- packspliter_cli-1.0.0/PKG-INFO +51 -0
- packspliter_cli-1.0.0/README.md +23 -0
- packspliter_cli-1.0.0/pyproject.toml +47 -0
- packspliter_cli-1.0.0/setup.cfg +4 -0
- packspliter_cli-1.0.0/src/packspliter/api.py +315 -0
- packspliter_cli-1.0.0/src/packspliter/commands/configure.py +168 -0
- packspliter_cli-1.0.0/src/packspliter/commands/convert.py +371 -0
- packspliter_cli-1.0.0/src/packspliter/config.py +241 -0
- packspliter_cli-1.0.0/src/packspliter/main.py +409 -0
- packspliter_cli-1.0.0/src/packspliter/output.py +263 -0
- packspliter_cli-1.0.0/src/packspliter_cli.egg-info/PKG-INFO +51 -0
- packspliter_cli-1.0.0/src/packspliter_cli.egg-info/SOURCES.txt +14 -0
- packspliter_cli-1.0.0/src/packspliter_cli.egg-info/dependency_links.txt +1 -0
- packspliter_cli-1.0.0/src/packspliter_cli.egg-info/entry_points.txt +2 -0
- packspliter_cli-1.0.0/src/packspliter_cli.egg-info/requires.txt +9 -0
- packspliter_cli-1.0.0/src/packspliter_cli.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: packspliter-cli
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Convert Modrinth/CurseForge modpacks to server files from the command line
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Homepage, https://packspliter.qzz.io
|
|
7
|
+
Project-URL: Repository, https://github.com/packspliter/cli
|
|
8
|
+
Project-URL: Issues, https://github.com/packspliter/cli/issues
|
|
9
|
+
Keywords: minecraft,modpack,server,modrinth,curseforge
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Topic :: Games/Entertainment
|
|
19
|
+
Requires-Python: >=3.9
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
Requires-Dist: httpx>=0.27.0
|
|
22
|
+
Requires-Dist: rich>=13.7.0
|
|
23
|
+
Requires-Dist: typer>=0.12.0
|
|
24
|
+
Requires-Dist: platformdirs>=4.2.0
|
|
25
|
+
Requires-Dist: tomli>=2.0.1; python_version < "3.11"
|
|
26
|
+
Requires-Dist: tomli-w>=1.0.0
|
|
27
|
+
Requires-Dist: packaging>=24.0
|
|
28
|
+
|
|
29
|
+
# packspliter-cli
|
|
30
|
+
|
|
31
|
+
Command-line interface for PackSpliter — convert client-side Minecraft modpacks (.mrpack) into server-ready ZIP packages by automatically stripping client-only mods.
|
|
32
|
+
|
|
33
|
+
## Installation
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install packspliter-cli
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Quickstart
|
|
40
|
+
|
|
41
|
+
Configure the API key and server URL:
|
|
42
|
+
```bash
|
|
43
|
+
packspliter configure
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Convert a modpack:
|
|
47
|
+
```bash
|
|
48
|
+
packspliter convert ATM10.mrpack
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
For full documentation, visit [https://packspliter.qzz.io/docs](https://packspliter.qzz.io/docs).
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# packspliter-cli
|
|
2
|
+
|
|
3
|
+
Command-line interface for PackSpliter — convert client-side Minecraft modpacks (.mrpack) into server-ready ZIP packages by automatically stripping client-only mods.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install packspliter-cli
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quickstart
|
|
12
|
+
|
|
13
|
+
Configure the API key and server URL:
|
|
14
|
+
```bash
|
|
15
|
+
packspliter configure
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Convert a modpack:
|
|
19
|
+
```bash
|
|
20
|
+
packspliter convert ATM10.mrpack
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
For full documentation, visit [https://packspliter.qzz.io/docs](https://packspliter.qzz.io/docs).
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "packspliter-cli"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Convert Modrinth/CurseForge modpacks to server files from the command line"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
keywords = ["minecraft", "modpack", "server", "modrinth", "curseforge"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 4 - Beta",
|
|
15
|
+
"Environment :: Console",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3.9",
|
|
19
|
+
"Programming Language :: Python :: 3.10",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Topic :: Games/Entertainment",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
dependencies = [
|
|
26
|
+
"httpx>=0.27.0", # async-capable HTTP client with streaming
|
|
27
|
+
"rich>=13.7.0", # beautiful cross-platform terminal output
|
|
28
|
+
"typer>=0.12.0", # type-annotated CLI framework
|
|
29
|
+
"platformdirs>=4.2.0", # OS-correct config/data directories
|
|
30
|
+
"tomli>=2.0.1; python_version<'3.11'", # TOML parser (stdlib in 3.11+)
|
|
31
|
+
"tomli-w>=1.0.0", # TOML writer (no stdlib equivalent)
|
|
32
|
+
"packaging>=24.0", # version comparison for update check
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
[project.scripts]
|
|
36
|
+
packspliter = "packspliter.main:app"
|
|
37
|
+
|
|
38
|
+
[project.urls]
|
|
39
|
+
Homepage = "https://packspliter.qzz.io"
|
|
40
|
+
Repository = "https://github.com/packspliter/cli"
|
|
41
|
+
Issues = "https://github.com/packspliter/cli/issues"
|
|
42
|
+
|
|
43
|
+
[tool.setuptools.packages.find]
|
|
44
|
+
where = ["src"]
|
|
45
|
+
|
|
46
|
+
[tool.setuptools.package-dir]
|
|
47
|
+
"" = "src"
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
"""
|
|
2
|
+
packspliter.api
|
|
3
|
+
===============
|
|
4
|
+
HTTP client for the PackSpliter REST API v1.
|
|
5
|
+
Wraps httpx with retry logic, streaming upload/download, and structured error handling.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import time
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Dict, Generator, Iterator, Optional
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
from packspliter.config import Config
|
|
18
|
+
|
|
19
|
+
# Timeout config: connect fast, reads long (generation takes up to 60 min)
|
|
20
|
+
_CONNECT_TIMEOUT = 10.0
|
|
21
|
+
_READ_TIMEOUT = 120.0 # for status polls
|
|
22
|
+
_UPLOAD_TIMEOUT = None # no timeout for large file uploads (streaming)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class APIError(Exception):
|
|
26
|
+
"""Raised when the server returns an error response."""
|
|
27
|
+
def __init__(self, message: str, status_code: int = 0, body: Any = None):
|
|
28
|
+
super().__init__(message)
|
|
29
|
+
self.status_code = status_code
|
|
30
|
+
self.body = body
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class PackSpliterClient:
|
|
34
|
+
"""
|
|
35
|
+
Stateless HTTP client for the PackSpliter API v1.
|
|
36
|
+
Create one per command invocation; not thread-safe.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(self, cfg: Config):
|
|
40
|
+
self._base = cfg.effective_server
|
|
41
|
+
self._key = cfg.effective_key
|
|
42
|
+
self._timeout = httpx.Timeout(
|
|
43
|
+
connect=_CONNECT_TIMEOUT,
|
|
44
|
+
read=_READ_TIMEOUT,
|
|
45
|
+
write=None,
|
|
46
|
+
pool=_CONNECT_TIMEOUT,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
def _headers(self, extra: Optional[Dict[str, str]] = None) -> Dict[str, str]:
|
|
50
|
+
h: Dict[str, str] = {}
|
|
51
|
+
if self._key:
|
|
52
|
+
h["X-Api-Key"] = self._key
|
|
53
|
+
if extra:
|
|
54
|
+
h.update(extra)
|
|
55
|
+
return h
|
|
56
|
+
|
|
57
|
+
def _url(self, path: str) -> str:
|
|
58
|
+
return f"{self._base}/api/v1/{path.lstrip('/')}"
|
|
59
|
+
|
|
60
|
+
def _raise_for_error(self, resp: httpx.Response) -> None:
|
|
61
|
+
if resp.status_code >= 400:
|
|
62
|
+
try:
|
|
63
|
+
body = resp.json()
|
|
64
|
+
msg = body.get("error", resp.text[:200])
|
|
65
|
+
except Exception:
|
|
66
|
+
msg = resp.text[:200]
|
|
67
|
+
raise APIError(msg, status_code=resp.status_code, body=body if "body" in dir() else None)
|
|
68
|
+
|
|
69
|
+
# -----------------------------------------------------------------------
|
|
70
|
+
# Key management
|
|
71
|
+
# -----------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
def key_info(self) -> Dict[str, Any]:
|
|
74
|
+
"""GET /api/v1/keys/me"""
|
|
75
|
+
with httpx.Client(timeout=self._timeout) as client:
|
|
76
|
+
resp = client.get(self._url("keys/me"), headers=self._headers())
|
|
77
|
+
self._raise_for_error(resp)
|
|
78
|
+
return resp.json()
|
|
79
|
+
|
|
80
|
+
def create_key(self, label: Optional[str] = None, daily_limit: Optional[int] = None) -> Dict[str, Any]:
|
|
81
|
+
"""POST /api/v1/keys (admin)"""
|
|
82
|
+
with httpx.Client(timeout=self._timeout) as client:
|
|
83
|
+
resp = client.post(
|
|
84
|
+
self._url("keys"),
|
|
85
|
+
json={"label": label, "daily_limit": daily_limit},
|
|
86
|
+
headers=self._headers({"X-Admin-Secret": os.environ.get("PACKSPLITER_ADMIN_SECRET", "")}),
|
|
87
|
+
)
|
|
88
|
+
self._raise_for_error(resp)
|
|
89
|
+
return resp.json()
|
|
90
|
+
|
|
91
|
+
# -----------------------------------------------------------------------
|
|
92
|
+
# Job submission
|
|
93
|
+
# -----------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
def submit_job_file(
|
|
96
|
+
self,
|
|
97
|
+
file_path: Path,
|
|
98
|
+
*,
|
|
99
|
+
generation_mode: str = "server",
|
|
100
|
+
os_type: str = "linux",
|
|
101
|
+
eula: bool = False,
|
|
102
|
+
exclude_client_mods: bool = True,
|
|
103
|
+
) -> Dict[str, Any]:
|
|
104
|
+
"""
|
|
105
|
+
POST /api/v1/jobs (multipart file upload, streamed from disk).
|
|
106
|
+
Uses a long-lived httpx.Client so the upload isn't bounded by _READ_TIMEOUT.
|
|
107
|
+
"""
|
|
108
|
+
with open(file_path, "rb") as fh:
|
|
109
|
+
files = {"file": (file_path.name, fh, "application/octet-stream")}
|
|
110
|
+
data = {
|
|
111
|
+
"generation_mode": generation_mode,
|
|
112
|
+
"os_type": os_type,
|
|
113
|
+
"eula": "true" if eula else "false",
|
|
114
|
+
"exclude_client_mods": "true" if exclude_client_mods else "false",
|
|
115
|
+
}
|
|
116
|
+
# No read timeout during upload — can be very large files
|
|
117
|
+
upload_timeout = httpx.Timeout(connect=_CONNECT_TIMEOUT, read=None, write=None, pool=_CONNECT_TIMEOUT)
|
|
118
|
+
with httpx.Client(timeout=upload_timeout) as client:
|
|
119
|
+
resp = client.post(
|
|
120
|
+
self._url("jobs"),
|
|
121
|
+
headers=self._headers(),
|
|
122
|
+
files=files,
|
|
123
|
+
data=data,
|
|
124
|
+
)
|
|
125
|
+
self._raise_for_error(resp)
|
|
126
|
+
return resp.json()
|
|
127
|
+
|
|
128
|
+
def submit_job_url(
|
|
129
|
+
self,
|
|
130
|
+
url: str,
|
|
131
|
+
*,
|
|
132
|
+
generation_mode: str = "server",
|
|
133
|
+
os_type: str = "linux",
|
|
134
|
+
eula: bool = False,
|
|
135
|
+
exclude_client_mods: bool = True,
|
|
136
|
+
) -> Dict[str, Any]:
|
|
137
|
+
"""POST /api/v1/jobs (JSON body with URL)."""
|
|
138
|
+
with httpx.Client(timeout=self._timeout) as client:
|
|
139
|
+
resp = client.post(
|
|
140
|
+
self._url("jobs"),
|
|
141
|
+
headers=self._headers(),
|
|
142
|
+
json={
|
|
143
|
+
"url": url,
|
|
144
|
+
"generation_mode": generation_mode,
|
|
145
|
+
"os_type": os_type,
|
|
146
|
+
"eula": eula,
|
|
147
|
+
"exclude_client_mods": exclude_client_mods,
|
|
148
|
+
},
|
|
149
|
+
)
|
|
150
|
+
self._raise_for_error(resp)
|
|
151
|
+
return resp.json()
|
|
152
|
+
|
|
153
|
+
# -----------------------------------------------------------------------
|
|
154
|
+
# Job lifecycle
|
|
155
|
+
# -----------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
def job_status(self, job_id: str) -> Dict[str, Any]:
|
|
158
|
+
"""GET /api/v1/jobs/<id>"""
|
|
159
|
+
with httpx.Client(timeout=self._timeout) as client:
|
|
160
|
+
resp = client.get(self._url(f"jobs/{job_id}"), headers=self._headers())
|
|
161
|
+
self._raise_for_error(resp)
|
|
162
|
+
return resp.json()
|
|
163
|
+
|
|
164
|
+
def list_jobs(self, limit: int = 10, status: Optional[str] = None) -> Dict[str, Any]:
|
|
165
|
+
"""GET /api/v1/jobs"""
|
|
166
|
+
params: Dict[str, Any] = {"limit": limit}
|
|
167
|
+
if status:
|
|
168
|
+
params["status"] = status
|
|
169
|
+
with httpx.Client(timeout=self._timeout) as client:
|
|
170
|
+
resp = client.get(self._url("jobs"), params=params, headers=self._headers())
|
|
171
|
+
self._raise_for_error(resp)
|
|
172
|
+
return resp.json()
|
|
173
|
+
|
|
174
|
+
def cancel_job(self, job_id: str) -> Dict[str, Any]:
|
|
175
|
+
"""DELETE /api/v1/jobs/<id>"""
|
|
176
|
+
with httpx.Client(timeout=self._timeout) as client:
|
|
177
|
+
resp = client.delete(self._url(f"jobs/{job_id}"), headers=self._headers())
|
|
178
|
+
self._raise_for_error(resp)
|
|
179
|
+
return resp.json()
|
|
180
|
+
|
|
181
|
+
def renew_job(self, job_id: str) -> Dict[str, Any]:
|
|
182
|
+
"""POST /api/v1/jobs/<id>/renew"""
|
|
183
|
+
with httpx.Client(timeout=self._timeout) as client:
|
|
184
|
+
resp = client.post(self._url(f"jobs/{job_id}/renew"), headers=self._headers())
|
|
185
|
+
self._raise_for_error(resp)
|
|
186
|
+
return resp.json()
|
|
187
|
+
|
|
188
|
+
# -----------------------------------------------------------------------
|
|
189
|
+
# Log history (JSON, non-streaming)
|
|
190
|
+
# -----------------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
def job_log_history(self, job_id: str) -> list[str]:
|
|
193
|
+
"""GET /api/v1/jobs/<id>/logs?history=true → list of log lines."""
|
|
194
|
+
try:
|
|
195
|
+
with httpx.Client(timeout=self._timeout) as client:
|
|
196
|
+
resp = client.get(
|
|
197
|
+
self._url(f"jobs/{job_id}/logs"),
|
|
198
|
+
params={"history": "true"},
|
|
199
|
+
headers=self._headers(),
|
|
200
|
+
)
|
|
201
|
+
if resp.status_code == 200:
|
|
202
|
+
return resp.json().get("logs", [])
|
|
203
|
+
except Exception:
|
|
204
|
+
pass
|
|
205
|
+
return []
|
|
206
|
+
|
|
207
|
+
# -----------------------------------------------------------------------
|
|
208
|
+
# Download (streaming, supports resume via Range header)
|
|
209
|
+
# -----------------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
def download_job(
|
|
212
|
+
self,
|
|
213
|
+
job_id: str,
|
|
214
|
+
dest: Path,
|
|
215
|
+
*,
|
|
216
|
+
resume: bool = False,
|
|
217
|
+
on_progress: Optional[callable] = None,
|
|
218
|
+
) -> Path:
|
|
219
|
+
"""
|
|
220
|
+
Stream the completed job ZIP to `dest`.
|
|
221
|
+
|
|
222
|
+
If `resume=True` and `dest` (or `dest.with_suffix('.part')`) already
|
|
223
|
+
exists, sends a Range header to resume from that byte offset.
|
|
224
|
+
|
|
225
|
+
`on_progress(downloaded_bytes, total_bytes)` is called periodically.
|
|
226
|
+
|
|
227
|
+
Returns the final path on success.
|
|
228
|
+
"""
|
|
229
|
+
part_path = dest.with_suffix(dest.suffix + ".part") if dest.suffix else dest.parent / (dest.name + ".part")
|
|
230
|
+
|
|
231
|
+
resume_from = 0
|
|
232
|
+
if resume and part_path.exists():
|
|
233
|
+
resume_from = part_path.stat().st_size
|
|
234
|
+
|
|
235
|
+
headers = self._headers()
|
|
236
|
+
if resume_from:
|
|
237
|
+
headers["Range"] = f"bytes={resume_from}-"
|
|
238
|
+
|
|
239
|
+
dl_timeout = httpx.Timeout(connect=_CONNECT_TIMEOUT, read=300.0, write=None, pool=_CONNECT_TIMEOUT)
|
|
240
|
+
|
|
241
|
+
with httpx.Client(timeout=dl_timeout, follow_redirects=True) as client:
|
|
242
|
+
with client.stream(
|
|
243
|
+
"GET",
|
|
244
|
+
self._url(f"jobs/{job_id}/download"),
|
|
245
|
+
headers=headers,
|
|
246
|
+
) as resp:
|
|
247
|
+
if resp.status_code == 404:
|
|
248
|
+
raise APIError("File not found — it may have expired.", 404)
|
|
249
|
+
if resp.status_code == 410:
|
|
250
|
+
raise APIError("File has been deleted from the server.", 410)
|
|
251
|
+
if resp.status_code == 409:
|
|
252
|
+
raise APIError("Job is not yet complete.", 409)
|
|
253
|
+
if resp.status_code not in (200, 206):
|
|
254
|
+
raise APIError(f"Download failed (HTTP {resp.status_code})", resp.status_code)
|
|
255
|
+
|
|
256
|
+
total = int(resp.headers.get("Content-Length", 0))
|
|
257
|
+
if resume_from:
|
|
258
|
+
# Content-Range: bytes START-END/TOTAL
|
|
259
|
+
cr = resp.headers.get("Content-Range", "")
|
|
260
|
+
if cr and "/" in cr:
|
|
261
|
+
try:
|
|
262
|
+
total = int(cr.split("/")[-1])
|
|
263
|
+
except ValueError:
|
|
264
|
+
pass
|
|
265
|
+
|
|
266
|
+
downloaded = resume_from
|
|
267
|
+
mode = "ab" if (resume_from and resp.status_code == 206) else "wb"
|
|
268
|
+
|
|
269
|
+
with open(part_path, mode) as fh:
|
|
270
|
+
for chunk in resp.iter_bytes(chunk_size=512 * 1024): # 512 KB chunks
|
|
271
|
+
fh.write(chunk)
|
|
272
|
+
downloaded += len(chunk)
|
|
273
|
+
if on_progress:
|
|
274
|
+
on_progress(downloaded, total)
|
|
275
|
+
|
|
276
|
+
# Rename .part → final filename
|
|
277
|
+
part_path.replace(dest)
|
|
278
|
+
return dest
|
|
279
|
+
|
|
280
|
+
# -----------------------------------------------------------------------
|
|
281
|
+
# Preview
|
|
282
|
+
# -----------------------------------------------------------------------
|
|
283
|
+
|
|
284
|
+
def remote_preview(self, file_path: Path) -> Dict[str, Any]:
|
|
285
|
+
"""POST /api/v1/preview — server-side pack parsing."""
|
|
286
|
+
with open(file_path, "rb") as fh:
|
|
287
|
+
files = {"file": (file_path.name, fh, "application/octet-stream")}
|
|
288
|
+
with httpx.Client(timeout=self._timeout) as client:
|
|
289
|
+
resp = client.post(
|
|
290
|
+
self._url("preview"),
|
|
291
|
+
headers=self._headers(),
|
|
292
|
+
files=files,
|
|
293
|
+
)
|
|
294
|
+
self._raise_for_error(resp)
|
|
295
|
+
return resp.json()
|
|
296
|
+
|
|
297
|
+
# -----------------------------------------------------------------------
|
|
298
|
+
# Server health check (no auth required)
|
|
299
|
+
# -----------------------------------------------------------------------
|
|
300
|
+
|
|
301
|
+
def health_check(self) -> Optional[str]:
|
|
302
|
+
"""
|
|
303
|
+
GET /health — returns server version string if reachable, None otherwise.
|
|
304
|
+
Tries up to 1 retry.
|
|
305
|
+
"""
|
|
306
|
+
for _ in range(2):
|
|
307
|
+
try:
|
|
308
|
+
with httpx.Client(timeout=httpx.Timeout(5.0)) as client:
|
|
309
|
+
resp = client.get(f"{self._base}/health")
|
|
310
|
+
if resp.status_code == 200:
|
|
311
|
+
data = resp.json() if resp.headers.get("content-type", "").startswith("application/json") else {}
|
|
312
|
+
return data.get("version", "unknown")
|
|
313
|
+
except Exception:
|
|
314
|
+
time.sleep(0.5)
|
|
315
|
+
return None
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""
|
|
2
|
+
packspliter.commands.configure
|
|
3
|
+
================================
|
|
4
|
+
packspliter configure
|
|
5
|
+
--key TEXT Set API key
|
|
6
|
+
--server TEXT Set server URL
|
|
7
|
+
--check Verify key against server and exit
|
|
8
|
+
--show Print current config and exit
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
import typer
|
|
16
|
+
|
|
17
|
+
from packspliter.config import Config, config_file_path, load_config, save_config
|
|
18
|
+
from packspliter import output as out
|
|
19
|
+
|
|
20
|
+
app = typer.Typer(help="Configure API key and server settings.")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@app.callback(invoke_without_command=True)
|
|
24
|
+
def configure(
|
|
25
|
+
ctx: typer.Context,
|
|
26
|
+
key: Optional[str] = typer.Option(None, "--key", "-k", help="API key to save"),
|
|
27
|
+
server: Optional[str] = typer.Option(None, "--server", "-s", help="Server URL"),
|
|
28
|
+
check: bool = typer.Option(False, "--check", help="Verify key against server and exit"),
|
|
29
|
+
show: bool = typer.Option(False, "--show", help="Print current config and exit"),
|
|
30
|
+
):
|
|
31
|
+
cfg = ctx.obj if (ctx and ctx.obj) else load_config()
|
|
32
|
+
json_mode = cfg.is_json_mode
|
|
33
|
+
|
|
34
|
+
# --show: print current config
|
|
35
|
+
if show:
|
|
36
|
+
if json_mode:
|
|
37
|
+
out.emit_json("config", {
|
|
38
|
+
"server_url": cfg.effective_server,
|
|
39
|
+
"api_key_prefix": (cfg.effective_key or "")[:12] or None,
|
|
40
|
+
"output_dir": cfg.output_dir,
|
|
41
|
+
"format": cfg.format,
|
|
42
|
+
"poll_interval": cfg.poll_interval,
|
|
43
|
+
"config_file": str(config_file_path()),
|
|
44
|
+
})
|
|
45
|
+
else:
|
|
46
|
+
out.print_separator()
|
|
47
|
+
typer.echo(f" Server: {cfg.effective_server}")
|
|
48
|
+
typer.echo(f" API key: {mask_key(cfg.effective_key)}")
|
|
49
|
+
typer.echo(f" Output dir: {cfg.output_dir}")
|
|
50
|
+
typer.echo(f" Poll interval:{cfg.poll_interval}s")
|
|
51
|
+
typer.echo(f" Format: {cfg.format}")
|
|
52
|
+
typer.echo(f" Config file: {config_file_path()}")
|
|
53
|
+
out.print_separator()
|
|
54
|
+
return
|
|
55
|
+
|
|
56
|
+
# --check: verify key against server
|
|
57
|
+
if check:
|
|
58
|
+
_do_check(cfg, json_mode)
|
|
59
|
+
return
|
|
60
|
+
|
|
61
|
+
# Interactive mode: prompt for what's missing if not supplied via flags
|
|
62
|
+
if not key and not server:
|
|
63
|
+
_interactive(cfg, json_mode)
|
|
64
|
+
return
|
|
65
|
+
|
|
66
|
+
# Non-interactive: apply provided flags
|
|
67
|
+
changed = False
|
|
68
|
+
if server:
|
|
69
|
+
cfg.server_url = server.rstrip("/")
|
|
70
|
+
changed = True
|
|
71
|
+
if key:
|
|
72
|
+
cfg.api_key = key
|
|
73
|
+
changed = True
|
|
74
|
+
|
|
75
|
+
if changed:
|
|
76
|
+
if save_config(cfg):
|
|
77
|
+
if json_mode:
|
|
78
|
+
out.emit_json("configure_ok", {
|
|
79
|
+
"server_url": cfg.effective_server,
|
|
80
|
+
"key_prefix": (cfg.effective_key or "")[:12],
|
|
81
|
+
"config_file": str(config_file_path()),
|
|
82
|
+
})
|
|
83
|
+
else:
|
|
84
|
+
out.print_ok(f"Saved to {config_file_path()}")
|
|
85
|
+
out.print_ok(f"Server: {cfg.effective_server}")
|
|
86
|
+
out.print_ok(f"Key: {mask_key(cfg.effective_key)}")
|
|
87
|
+
else:
|
|
88
|
+
out.print_err("Failed to save config (check permissions)")
|
|
89
|
+
raise typer.Exit(1)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _interactive(cfg: Config, json_mode: bool) -> None:
|
|
93
|
+
"""Guided wizard — prompts for each field."""
|
|
94
|
+
if json_mode:
|
|
95
|
+
out.print_err("Use --key and --server flags in non-interactive (piped) mode.")
|
|
96
|
+
raise typer.Exit(1)
|
|
97
|
+
|
|
98
|
+
typer.echo()
|
|
99
|
+
typer.echo(" PackSpliter CLI Setup")
|
|
100
|
+
out.print_separator()
|
|
101
|
+
|
|
102
|
+
new_server = typer.prompt(
|
|
103
|
+
" Server URL",
|
|
104
|
+
default=cfg.effective_server,
|
|
105
|
+
).strip().rstrip("/")
|
|
106
|
+
|
|
107
|
+
new_key = typer.prompt(
|
|
108
|
+
" API key (paste here — input hidden)",
|
|
109
|
+
default=cfg.api_key or "",
|
|
110
|
+
hide_input=True,
|
|
111
|
+
).strip()
|
|
112
|
+
|
|
113
|
+
new_output = typer.prompt(
|
|
114
|
+
" Default output directory",
|
|
115
|
+
default=cfg.output_dir,
|
|
116
|
+
).strip() or "."
|
|
117
|
+
|
|
118
|
+
cfg.server_url = new_server
|
|
119
|
+
cfg.api_key = new_key or cfg.api_key
|
|
120
|
+
cfg.output_dir = new_output
|
|
121
|
+
|
|
122
|
+
if save_config(cfg):
|
|
123
|
+
out.print_separator()
|
|
124
|
+
out.print_ok(f"Saved to {config_file_path()}")
|
|
125
|
+
out.print_ok(f"Server: {cfg.effective_server}")
|
|
126
|
+
out.print_ok(f"Key: {mask_key(cfg.effective_key)}")
|
|
127
|
+
out.print_separator()
|
|
128
|
+
# Immediately verify
|
|
129
|
+
_do_check(cfg, json_mode)
|
|
130
|
+
else:
|
|
131
|
+
out.print_err("Could not write config file. Check permissions.")
|
|
132
|
+
raise typer.Exit(1)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _do_check(cfg: Config, json_mode: bool) -> None:
|
|
136
|
+
"""Hit /api/v1/keys/me and report back."""
|
|
137
|
+
from packspliter.api import PackSpliterClient, APIError
|
|
138
|
+
|
|
139
|
+
if not cfg.effective_key:
|
|
140
|
+
if json_mode:
|
|
141
|
+
out.emit_json("config_error", {"error": "No API key configured."})
|
|
142
|
+
else:
|
|
143
|
+
out.print_err("No API key configured. Run: packspliter configure --key <key>")
|
|
144
|
+
raise typer.Exit(1)
|
|
145
|
+
|
|
146
|
+
client = PackSpliterClient(cfg)
|
|
147
|
+
try:
|
|
148
|
+
info = client.key_info()
|
|
149
|
+
if json_mode:
|
|
150
|
+
out.emit_json("key_ok", info)
|
|
151
|
+
else:
|
|
152
|
+
out.print_ok(f"Connected to {cfg.effective_server}")
|
|
153
|
+
out.print_ok(f"Key prefix: {info.get('key_prefix', '?')}")
|
|
154
|
+
out.print_ok(f"Label: {info.get('label') or '(no label)'}")
|
|
155
|
+
out.print_ok(f"Requests today:{info.get('requests_today', 0)} / {info.get('daily_limit') or '∞'}")
|
|
156
|
+
out.print_ok(f"Total requests:{info.get('total_requests', 0)}")
|
|
157
|
+
except APIError as e:
|
|
158
|
+
if json_mode:
|
|
159
|
+
out.emit_json("key_error", {"error": str(e), "status": e.status_code})
|
|
160
|
+
else:
|
|
161
|
+
out.print_err(f"Key check failed: {e}")
|
|
162
|
+
raise typer.Exit(1)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def mask_key(key: Optional[str]) -> str:
|
|
166
|
+
if not key:
|
|
167
|
+
return "(not set)"
|
|
168
|
+
return key[:16] + "..." + key[-4:]
|