htx-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- disk_cli/__init__.py +3 -0
- disk_cli/__main__.py +5 -0
- disk_cli/api.py +368 -0
- disk_cli/cli.py +775 -0
- disk_cli/config.py +67 -0
- htx_cli-0.1.0.dist-info/METADATA +157 -0
- htx_cli-0.1.0.dist-info/RECORD +10 -0
- htx_cli-0.1.0.dist-info/WHEEL +5 -0
- htx_cli-0.1.0.dist-info/entry_points.txt +2 -0
- htx_cli-0.1.0.dist-info/top_level.txt +1 -0
disk_cli/__init__.py
ADDED
disk_cli/__main__.py
ADDED
disk_cli/api.py
ADDED
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import time
|
|
6
|
+
from typing import Any, Callable
|
|
7
|
+
from urllib.parse import urljoin
|
|
8
|
+
|
|
9
|
+
import requests
|
|
10
|
+
|
|
11
|
+
from .config import DiskSettings
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class DiskAPIError(RuntimeError):
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
message: str,
|
|
18
|
+
*,
|
|
19
|
+
status_code: int | None = None,
|
|
20
|
+
identifier: str | None = None,
|
|
21
|
+
debug_msg: str | None = None,
|
|
22
|
+
) -> None:
|
|
23
|
+
super().__init__(message)
|
|
24
|
+
self.status_code = status_code
|
|
25
|
+
self.identifier = identifier
|
|
26
|
+
self.debug_msg = debug_msg
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class UploadToken:
|
|
31
|
+
key: str
|
|
32
|
+
upload_token: str
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
ProgressCallback = Callable[[int, int | None], None]
|
|
36
|
+
MAX_RETRIES = 5
|
|
37
|
+
RETRYABLE_STATUS_CODES = {429, 500, 502, 503, 504}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class UploadMonitor:
|
|
41
|
+
def __init__(self, local_path: Path, callback: ProgressCallback | None = None) -> None:
|
|
42
|
+
self.local_path = local_path
|
|
43
|
+
self.callback = callback
|
|
44
|
+
self.handle = local_path.open("rb")
|
|
45
|
+
self.total = local_path.stat().st_size
|
|
46
|
+
self.transferred = 0
|
|
47
|
+
if self.callback:
|
|
48
|
+
self.callback(0, self.total)
|
|
49
|
+
|
|
50
|
+
def read(self, size: int = -1) -> bytes:
|
|
51
|
+
chunk = self.handle.read(size)
|
|
52
|
+
if chunk:
|
|
53
|
+
self.transferred += len(chunk)
|
|
54
|
+
if self.callback:
|
|
55
|
+
self.callback(self.transferred, self.total)
|
|
56
|
+
return chunk
|
|
57
|
+
|
|
58
|
+
def close(self) -> None:
|
|
59
|
+
self.handle.close()
|
|
60
|
+
|
|
61
|
+
def __getattr__(self, item: str) -> Any:
|
|
62
|
+
return getattr(self.handle, item)
|
|
63
|
+
|
|
64
|
+
def __enter__(self) -> "UploadMonitor":
|
|
65
|
+
return self
|
|
66
|
+
|
|
67
|
+
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
68
|
+
self.close()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class DiskClient:
|
|
72
|
+
def __init__(self, settings: DiskSettings, *, timeout: float = 30.0) -> None:
|
|
73
|
+
self.settings = settings
|
|
74
|
+
self.timeout = timeout
|
|
75
|
+
self.session = requests.Session()
|
|
76
|
+
|
|
77
|
+
def _headers(self, *, include_auth: bool = True) -> dict[str, str]:
|
|
78
|
+
headers: dict[str, str] = {}
|
|
79
|
+
if include_auth and self.settings.token:
|
|
80
|
+
headers["Token"] = self.settings.token
|
|
81
|
+
return headers
|
|
82
|
+
|
|
83
|
+
def _unwrap_response(self, response: requests.Response) -> Any:
|
|
84
|
+
try:
|
|
85
|
+
payload = response.json()
|
|
86
|
+
except ValueError as exc:
|
|
87
|
+
raise DiskAPIError(
|
|
88
|
+
f"Server returned a non-JSON response (HTTP {response.status_code})."
|
|
89
|
+
) from exc
|
|
90
|
+
|
|
91
|
+
if not isinstance(payload, dict) or "code" not in payload:
|
|
92
|
+
return payload
|
|
93
|
+
|
|
94
|
+
if payload.get("code") != 0:
|
|
95
|
+
raise DiskAPIError(
|
|
96
|
+
payload.get("msg") or "The Disk API request failed.",
|
|
97
|
+
status_code=response.status_code,
|
|
98
|
+
identifier=payload.get("identifier"),
|
|
99
|
+
debug_msg=payload.get("debug_msg"),
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
return payload.get("body")
|
|
103
|
+
|
|
104
|
+
@staticmethod
|
|
105
|
+
def _retry_delay(attempt: int) -> float:
|
|
106
|
+
return min(0.5 * (2 ** (attempt - 1)), 4.0)
|
|
107
|
+
|
|
108
|
+
@staticmethod
|
|
109
|
+
def _should_retry_api_error(error: DiskAPIError) -> bool:
|
|
110
|
+
return error.status_code in RETRYABLE_STATUS_CODES
|
|
111
|
+
|
|
112
|
+
def _request_once(
|
|
113
|
+
self,
|
|
114
|
+
method: str,
|
|
115
|
+
url: str,
|
|
116
|
+
*,
|
|
117
|
+
params: dict[str, Any] | None = None,
|
|
118
|
+
json: dict[str, Any] | None = None,
|
|
119
|
+
data: dict[str, Any] | None = None,
|
|
120
|
+
files: dict[str, Any] | None = None,
|
|
121
|
+
include_auth: bool = True,
|
|
122
|
+
) -> Any:
|
|
123
|
+
response = self.session.request(
|
|
124
|
+
method=method.upper(),
|
|
125
|
+
url=url,
|
|
126
|
+
params=params,
|
|
127
|
+
json=json,
|
|
128
|
+
data=data,
|
|
129
|
+
files=files,
|
|
130
|
+
headers=self._headers(include_auth=include_auth),
|
|
131
|
+
timeout=self.timeout,
|
|
132
|
+
)
|
|
133
|
+
return self._unwrap_response(response)
|
|
134
|
+
|
|
135
|
+
def request(
|
|
136
|
+
self,
|
|
137
|
+
method: str,
|
|
138
|
+
path: str,
|
|
139
|
+
*,
|
|
140
|
+
params: dict[str, Any] | None = None,
|
|
141
|
+
json: dict[str, Any] | None = None,
|
|
142
|
+
absolute_url: str | None = None,
|
|
143
|
+
include_auth: bool = True,
|
|
144
|
+
data: dict[str, Any] | None = None,
|
|
145
|
+
files: dict[str, Any] | None = None,
|
|
146
|
+
) -> Any:
|
|
147
|
+
url = absolute_url or urljoin(f"{self.settings.backend_url}/", path.lstrip("/"))
|
|
148
|
+
last_error: Exception | None = None
|
|
149
|
+
for attempt in range(1, MAX_RETRIES + 1):
|
|
150
|
+
try:
|
|
151
|
+
return self._request_once(
|
|
152
|
+
method,
|
|
153
|
+
url,
|
|
154
|
+
params=params,
|
|
155
|
+
json=json,
|
|
156
|
+
data=data,
|
|
157
|
+
files=files,
|
|
158
|
+
include_auth=include_auth,
|
|
159
|
+
)
|
|
160
|
+
except DiskAPIError as exc:
|
|
161
|
+
last_error = exc
|
|
162
|
+
if attempt >= MAX_RETRIES or not self._should_retry_api_error(exc):
|
|
163
|
+
raise
|
|
164
|
+
except requests.RequestException as exc:
|
|
165
|
+
last_error = exc
|
|
166
|
+
if attempt >= MAX_RETRIES:
|
|
167
|
+
raise DiskAPIError(
|
|
168
|
+
f"Request failed after {MAX_RETRIES} attempts: {exc}"
|
|
169
|
+
) from exc
|
|
170
|
+
time.sleep(self._retry_delay(attempt))
|
|
171
|
+
|
|
172
|
+
if isinstance(last_error, DiskAPIError):
|
|
173
|
+
raise last_error
|
|
174
|
+
raise DiskAPIError(f"Request failed after {MAX_RETRIES} attempts.")
|
|
175
|
+
|
|
176
|
+
def exchange_code(self, code: str) -> dict[str, Any]:
|
|
177
|
+
body = self.request(
|
|
178
|
+
"GET",
|
|
179
|
+
"/api/oauth/qtb/callback",
|
|
180
|
+
params={"code": code},
|
|
181
|
+
include_auth=False,
|
|
182
|
+
)
|
|
183
|
+
if not isinstance(body, dict) or "token" not in body:
|
|
184
|
+
raise DiskAPIError("OAuth callback did not return a login token.")
|
|
185
|
+
self.settings.token = body["token"]
|
|
186
|
+
return body
|
|
187
|
+
|
|
188
|
+
def get_current_user(self) -> dict[str, Any]:
|
|
189
|
+
body = self.request("GET", "/api/user/")
|
|
190
|
+
if not isinstance(body, dict):
|
|
191
|
+
raise DiskAPIError("User endpoint returned an unexpected payload.")
|
|
192
|
+
return body
|
|
193
|
+
|
|
194
|
+
def get_resource(self, res_str_id: str, visit_key: str | None = None) -> dict[str, Any]:
|
|
195
|
+
params = {"visit_key": visit_key} if visit_key else None
|
|
196
|
+
body = self.request("GET", f"/api/res/{res_str_id}", params=params)
|
|
197
|
+
if not isinstance(body, dict):
|
|
198
|
+
raise DiskAPIError("Resource endpoint returned an unexpected payload.")
|
|
199
|
+
return body
|
|
200
|
+
|
|
201
|
+
def get_selector(self, res_str_id: str) -> dict[str, Any]:
|
|
202
|
+
body = self.request("GET", f"/api/res/{res_str_id}/selector")
|
|
203
|
+
if not isinstance(body, dict):
|
|
204
|
+
raise DiskAPIError("Selector endpoint returned an unexpected payload.")
|
|
205
|
+
return body
|
|
206
|
+
|
|
207
|
+
def create_folder(self, parent_id: str, folder_name: str) -> dict[str, Any]:
|
|
208
|
+
body = self.request(
|
|
209
|
+
"POST",
|
|
210
|
+
f"/api/res/{parent_id}/folder",
|
|
211
|
+
json={"folder_name": folder_name},
|
|
212
|
+
)
|
|
213
|
+
if not isinstance(body, dict):
|
|
214
|
+
raise DiskAPIError("Create-folder endpoint returned an unexpected payload.")
|
|
215
|
+
return body
|
|
216
|
+
|
|
217
|
+
def create_link(self, parent_id: str, link_name: str, link: str) -> dict[str, Any]:
|
|
218
|
+
body = self.request(
|
|
219
|
+
"POST",
|
|
220
|
+
f"/api/res/{parent_id}/link",
|
|
221
|
+
json={"link_name": link_name, "link": link},
|
|
222
|
+
)
|
|
223
|
+
if not isinstance(body, dict):
|
|
224
|
+
raise DiskAPIError("Create-link endpoint returned an unexpected payload.")
|
|
225
|
+
return body
|
|
226
|
+
|
|
227
|
+
def update_resource(
|
|
228
|
+
self,
|
|
229
|
+
res_str_id: str,
|
|
230
|
+
*,
|
|
231
|
+
rname: str | None = None,
|
|
232
|
+
status: int | None = None,
|
|
233
|
+
description: str | None = None,
|
|
234
|
+
visit_key: str | None = None,
|
|
235
|
+
right_bubble: bool | None = None,
|
|
236
|
+
parent_str_id: str | None = None,
|
|
237
|
+
) -> dict[str, Any]:
|
|
238
|
+
body = self.request(
|
|
239
|
+
"PUT",
|
|
240
|
+
f"/api/res/{res_str_id}",
|
|
241
|
+
json={
|
|
242
|
+
"rname": rname,
|
|
243
|
+
"status": status,
|
|
244
|
+
"description": description,
|
|
245
|
+
"visit_key": visit_key,
|
|
246
|
+
"right_bubble": right_bubble,
|
|
247
|
+
"parent_str_id": parent_str_id,
|
|
248
|
+
},
|
|
249
|
+
)
|
|
250
|
+
if not isinstance(body, dict):
|
|
251
|
+
raise DiskAPIError("Update-resource endpoint returned an unexpected payload.")
|
|
252
|
+
return body
|
|
253
|
+
|
|
254
|
+
def delete_resource(self, res_str_id: str) -> None:
|
|
255
|
+
self.request("DELETE", f"/api/res/{res_str_id}")
|
|
256
|
+
|
|
257
|
+
def download_resource(
|
|
258
|
+
self,
|
|
259
|
+
res_str_id: str,
|
|
260
|
+
destination: Path,
|
|
261
|
+
*,
|
|
262
|
+
progress_callback: ProgressCallback | None = None,
|
|
263
|
+
) -> str:
|
|
264
|
+
url = urljoin(f"{self.settings.backend_url}/", f"/api/res/{res_str_id}/dl".lstrip("/"))
|
|
265
|
+
last_error: Exception | None = None
|
|
266
|
+
for attempt in range(1, MAX_RETRIES + 1):
|
|
267
|
+
response: requests.Response | None = None
|
|
268
|
+
try:
|
|
269
|
+
response = self.session.get(
|
|
270
|
+
url,
|
|
271
|
+
params={"token": self.settings.token or ""},
|
|
272
|
+
headers={},
|
|
273
|
+
timeout=self.timeout,
|
|
274
|
+
stream=True,
|
|
275
|
+
allow_redirects=True,
|
|
276
|
+
)
|
|
277
|
+
content_type = response.headers.get("content-type", "")
|
|
278
|
+
if "application/json" in content_type:
|
|
279
|
+
self._unwrap_response(response)
|
|
280
|
+
raise DiskAPIError("Download endpoint returned JSON instead of file content.")
|
|
281
|
+
try:
|
|
282
|
+
response.raise_for_status()
|
|
283
|
+
except requests.HTTPError as exc:
|
|
284
|
+
raise DiskAPIError(
|
|
285
|
+
f"Download failed with HTTP {response.status_code}.",
|
|
286
|
+
status_code=response.status_code,
|
|
287
|
+
) from exc
|
|
288
|
+
|
|
289
|
+
total_header = response.headers.get("content-length")
|
|
290
|
+
total = int(total_header) if total_header and total_header.isdigit() else None
|
|
291
|
+
if progress_callback:
|
|
292
|
+
progress_callback(0, total)
|
|
293
|
+
|
|
294
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
295
|
+
transferred = 0
|
|
296
|
+
with destination.open("wb") as handle:
|
|
297
|
+
for chunk in response.iter_content(chunk_size=1024 * 128):
|
|
298
|
+
if chunk:
|
|
299
|
+
handle.write(chunk)
|
|
300
|
+
transferred += len(chunk)
|
|
301
|
+
if progress_callback:
|
|
302
|
+
progress_callback(transferred, total)
|
|
303
|
+
return response.url
|
|
304
|
+
except DiskAPIError as exc:
|
|
305
|
+
last_error = exc
|
|
306
|
+
if attempt >= MAX_RETRIES or not self._should_retry_api_error(exc):
|
|
307
|
+
raise
|
|
308
|
+
except requests.RequestException as exc:
|
|
309
|
+
last_error = exc
|
|
310
|
+
if attempt >= MAX_RETRIES:
|
|
311
|
+
raise DiskAPIError(
|
|
312
|
+
f"Download failed after {MAX_RETRIES} attempts: {exc}"
|
|
313
|
+
) from exc
|
|
314
|
+
finally:
|
|
315
|
+
if response is not None:
|
|
316
|
+
response.close()
|
|
317
|
+
time.sleep(self._retry_delay(attempt))
|
|
318
|
+
|
|
319
|
+
if isinstance(last_error, DiskAPIError):
|
|
320
|
+
raise last_error
|
|
321
|
+
raise DiskAPIError(f"Download failed after {MAX_RETRIES} attempts.")
|
|
322
|
+
|
|
323
|
+
def get_upload_token(self, parent_id: str, filename: str) -> UploadToken:
|
|
324
|
+
body = self.request(
|
|
325
|
+
"GET",
|
|
326
|
+
f"/api/res/{parent_id}/token",
|
|
327
|
+
params={"filename": filename},
|
|
328
|
+
)
|
|
329
|
+
if not isinstance(body, dict):
|
|
330
|
+
raise DiskAPIError("Upload-token endpoint returned an unexpected payload.")
|
|
331
|
+
return UploadToken(key=body["key"], upload_token=body["upload_token"])
|
|
332
|
+
|
|
333
|
+
def upload_file(
|
|
334
|
+
self,
|
|
335
|
+
upload: UploadToken,
|
|
336
|
+
local_path: Path,
|
|
337
|
+
*,
|
|
338
|
+
progress_callback: ProgressCallback | None = None,
|
|
339
|
+
) -> dict[str, Any]:
|
|
340
|
+
last_error: Exception | None = None
|
|
341
|
+
for attempt in range(1, MAX_RETRIES + 1):
|
|
342
|
+
try:
|
|
343
|
+
with UploadMonitor(local_path, progress_callback) as handle:
|
|
344
|
+
body = self._request_once(
|
|
345
|
+
"POST",
|
|
346
|
+
self.settings.qiniu_upload_url,
|
|
347
|
+
include_auth=False,
|
|
348
|
+
data={"key": upload.key, "token": upload.upload_token},
|
|
349
|
+
files={"file": (local_path.name, handle)},
|
|
350
|
+
)
|
|
351
|
+
if not isinstance(body, dict):
|
|
352
|
+
raise DiskAPIError("Upload endpoint returned an unexpected payload.")
|
|
353
|
+
return body
|
|
354
|
+
except DiskAPIError as exc:
|
|
355
|
+
last_error = exc
|
|
356
|
+
if attempt >= MAX_RETRIES or not self._should_retry_api_error(exc):
|
|
357
|
+
raise
|
|
358
|
+
except requests.RequestException as exc:
|
|
359
|
+
last_error = exc
|
|
360
|
+
if attempt >= MAX_RETRIES:
|
|
361
|
+
raise DiskAPIError(
|
|
362
|
+
f"Upload failed after {MAX_RETRIES} attempts: {exc}"
|
|
363
|
+
) from exc
|
|
364
|
+
time.sleep(self._retry_delay(attempt))
|
|
365
|
+
|
|
366
|
+
if isinstance(last_error, DiskAPIError):
|
|
367
|
+
raise last_error
|
|
368
|
+
raise DiskAPIError(f"Upload failed after {MAX_RETRIES} attempts.")
|