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 ADDED
@@ -0,0 +1,3 @@
1
+ __all__ = ["__version__"]
2
+
3
+ __version__ = "0.1.0"
disk_cli/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ from .cli import main
2
+
3
+
4
+ if __name__ == "__main__":
5
+ raise SystemExit(main())
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.")