htx-cli 0.1.3__tar.gz → 0.1.5__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.
- {htx_cli-0.1.3 → htx_cli-0.1.5}/PKG-INFO +9 -1
- {htx_cli-0.1.3 → htx_cli-0.1.5}/README.md +7 -0
- {htx_cli-0.1.3 → htx_cli-0.1.5}/pyproject.toml +2 -1
- {htx_cli-0.1.3 → htx_cli-0.1.5}/src/disk_cli/__init__.py +1 -1
- {htx_cli-0.1.3 → htx_cli-0.1.5}/src/disk_cli/api.py +96 -44
- {htx_cli-0.1.3 → htx_cli-0.1.5}/src/disk_cli/cli.py +10 -1
- {htx_cli-0.1.3 → htx_cli-0.1.5}/src/disk_cli/config.py +13 -6
- {htx_cli-0.1.3 → htx_cli-0.1.5}/src/htx_cli.egg-info/PKG-INFO +9 -1
- {htx_cli-0.1.3 → htx_cli-0.1.5}/src/htx_cli.egg-info/requires.txt +1 -0
- {htx_cli-0.1.3 → htx_cli-0.1.5}/setup.cfg +0 -0
- {htx_cli-0.1.3 → htx_cli-0.1.5}/src/disk_cli/__main__.py +0 -0
- {htx_cli-0.1.3 → htx_cli-0.1.5}/src/htx_cli.egg-info/SOURCES.txt +0 -0
- {htx_cli-0.1.3 → htx_cli-0.1.5}/src/htx_cli.egg-info/dependency_links.txt +0 -0
- {htx_cli-0.1.3 → htx_cli-0.1.5}/src/htx_cli.egg-info/entry_points.txt +0 -0
- {htx_cli-0.1.3 → htx_cli-0.1.5}/src/htx_cli.egg-info/top_level.txt +0 -0
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: htx-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.5
|
|
4
4
|
Summary: htx-cli command-line client for the Disk backend API
|
|
5
5
|
Requires-Python: >=3.9
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: qiniu<8,>=7.13
|
|
7
8
|
Requires-Dist: rich<14,>=13.9
|
|
8
9
|
Requires-Dist: requests<3,>=2.32
|
|
9
10
|
|
|
@@ -15,6 +16,7 @@ Requires-Dist: requests<3,>=2.32
|
|
|
15
16
|
- Listing remote Disk directories
|
|
16
17
|
- Uploading a local file into a remote Disk directory
|
|
17
18
|
- Uploading a local directory recursively while recreating its folder structure remotely
|
|
19
|
+
- Resumable multipart uploads with real remote-part progress
|
|
18
20
|
|
|
19
21
|
## Install
|
|
20
22
|
|
|
@@ -174,3 +176,9 @@ The CLI stores the backend JWT token and the last fetched user payload in:
|
|
|
174
176
|
```
|
|
175
177
|
|
|
176
178
|
Use `--config` to point to a different state file.
|
|
179
|
+
|
|
180
|
+
Multipart upload resume records are stored under:
|
|
181
|
+
|
|
182
|
+
```text
|
|
183
|
+
~/.config/htx-cli/upload-records/
|
|
184
|
+
```
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
- Listing remote Disk directories
|
|
7
7
|
- Uploading a local file into a remote Disk directory
|
|
8
8
|
- Uploading a local directory recursively while recreating its folder structure remotely
|
|
9
|
+
- Resumable multipart uploads with real remote-part progress
|
|
9
10
|
|
|
10
11
|
## Install
|
|
11
12
|
|
|
@@ -165,3 +166,9 @@ The CLI stores the backend JWT token and the last fetched user payload in:
|
|
|
165
166
|
```
|
|
166
167
|
|
|
167
168
|
Use `--config` to point to a different state file.
|
|
169
|
+
|
|
170
|
+
Multipart upload resume records are stored under:
|
|
171
|
+
|
|
172
|
+
```text
|
|
173
|
+
~/.config/htx-cli/upload-records/
|
|
174
|
+
```
|
|
@@ -4,11 +4,12 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "htx-cli"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.5"
|
|
8
8
|
description = "htx-cli command-line client for the Disk backend API"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
11
11
|
dependencies = [
|
|
12
|
+
"qiniu>=7.13,<8",
|
|
12
13
|
"rich>=13.9,<14",
|
|
13
14
|
"requests>=2.32,<3",
|
|
14
15
|
]
|
|
@@ -4,9 +4,11 @@ from dataclasses import dataclass
|
|
|
4
4
|
from pathlib import Path
|
|
5
5
|
import time
|
|
6
6
|
from typing import Any, Callable, Dict, Optional
|
|
7
|
-
from urllib.parse import urljoin
|
|
7
|
+
from urllib.parse import urljoin, urlparse
|
|
8
8
|
|
|
9
9
|
import requests
|
|
10
|
+
from qiniu import Auth
|
|
11
|
+
from qiniu.services.storage.uploader import ResumeUploaderV2, UploadProgressRecorder
|
|
10
12
|
|
|
11
13
|
from .config import DiskSettings
|
|
12
14
|
|
|
@@ -33,41 +35,11 @@ class UploadToken:
|
|
|
33
35
|
|
|
34
36
|
|
|
35
37
|
ProgressCallback = Callable[[int, Optional[int]], None]
|
|
38
|
+
UploadPhaseCallback = Callable[[str], None]
|
|
36
39
|
MAX_RETRIES = 5
|
|
37
40
|
RETRYABLE_STATUS_CODES = {429, 500, 502, 503, 504}
|
|
38
41
|
|
|
39
42
|
|
|
40
|
-
class UploadMonitor:
|
|
41
|
-
def __init__(self, local_path: Path, callback: Optional[ProgressCallback] = 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
43
|
class DiskClient:
|
|
72
44
|
def __init__(self, settings: DiskSettings, *, timeout: float = 30.0) -> None:
|
|
73
45
|
self.settings = settings
|
|
@@ -88,13 +60,17 @@ class DiskClient:
|
|
|
88
60
|
f"Server returned a non-JSON response (HTTP {response.status_code})."
|
|
89
61
|
) from exc
|
|
90
62
|
|
|
63
|
+
return self._unwrap_payload(payload, status_code=response.status_code)
|
|
64
|
+
|
|
65
|
+
@staticmethod
|
|
66
|
+
def _unwrap_payload(payload: Any, *, status_code: Optional[int] = None) -> Any:
|
|
91
67
|
if not isinstance(payload, dict) or "code" not in payload:
|
|
92
68
|
return payload
|
|
93
69
|
|
|
94
70
|
if payload.get("code") != 0:
|
|
95
71
|
raise DiskAPIError(
|
|
96
72
|
payload.get("msg") or "The Disk API request failed.",
|
|
97
|
-
status_code=
|
|
73
|
+
status_code=status_code,
|
|
98
74
|
identifier=payload.get("identifier"),
|
|
99
75
|
debug_msg=payload.get("debug_msg"),
|
|
100
76
|
)
|
|
@@ -254,6 +230,27 @@ class DiskClient:
|
|
|
254
230
|
def delete_resource(self, res_str_id: str) -> None:
|
|
255
231
|
self.request("DELETE", f"/api/res/{res_str_id}")
|
|
256
232
|
|
|
233
|
+
def _is_backend_json_api_response(
|
|
234
|
+
self,
|
|
235
|
+
response: requests.Response,
|
|
236
|
+
*,
|
|
237
|
+
requested_url: str,
|
|
238
|
+
) -> bool:
|
|
239
|
+
content_type = response.headers.get("content-type", "").lower()
|
|
240
|
+
if "application/json" not in content_type:
|
|
241
|
+
return False
|
|
242
|
+
|
|
243
|
+
content_disposition = response.headers.get("content-disposition", "").lower()
|
|
244
|
+
if "attachment" in content_disposition or "filename=" in content_disposition:
|
|
245
|
+
return False
|
|
246
|
+
|
|
247
|
+
final_url = urlparse(response.url)
|
|
248
|
+
backend_url = urlparse(self.settings.backend_url)
|
|
249
|
+
requested = urlparse(requested_url)
|
|
250
|
+
if (final_url.scheme, final_url.netloc) != (backend_url.scheme, backend_url.netloc):
|
|
251
|
+
return False
|
|
252
|
+
return final_url.path.rstrip("/") == requested.path.rstrip("/")
|
|
253
|
+
|
|
257
254
|
def download_resource(
|
|
258
255
|
self,
|
|
259
256
|
res_str_id: str,
|
|
@@ -274,10 +271,6 @@ class DiskClient:
|
|
|
274
271
|
stream=True,
|
|
275
272
|
allow_redirects=True,
|
|
276
273
|
)
|
|
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
274
|
try:
|
|
282
275
|
response.raise_for_status()
|
|
283
276
|
except requests.HTTPError as exc:
|
|
@@ -286,6 +279,12 @@ class DiskClient:
|
|
|
286
279
|
status_code=response.status_code,
|
|
287
280
|
) from exc
|
|
288
281
|
|
|
282
|
+
if self._is_backend_json_api_response(response, requested_url=url):
|
|
283
|
+
self._unwrap_response(response)
|
|
284
|
+
raise DiskAPIError(
|
|
285
|
+
"Download endpoint returned JSON metadata instead of file content."
|
|
286
|
+
)
|
|
287
|
+
|
|
289
288
|
total_header = response.headers.get("content-length")
|
|
290
289
|
total = int(total_header) if total_header and total_header.isdigit() else None
|
|
291
290
|
if progress_callback:
|
|
@@ -336,18 +335,71 @@ class DiskClient:
|
|
|
336
335
|
local_path: Path,
|
|
337
336
|
*,
|
|
338
337
|
progress_callback: Optional[ProgressCallback] = None,
|
|
338
|
+
phase_callback: Optional[UploadPhaseCallback] = None,
|
|
339
339
|
) -> Dict[str, Any]:
|
|
340
340
|
last_error: Optional[Exception] = None
|
|
341
|
+
file_size = local_path.stat().st_size
|
|
342
|
+
bucket_name = Auth.get_bucket_name(upload.upload_token)
|
|
343
|
+
if not bucket_name:
|
|
344
|
+
raise DiskAPIError("Upload token does not contain a valid bucket name.")
|
|
345
|
+
|
|
341
346
|
for attempt in range(1, MAX_RETRIES + 1):
|
|
342
347
|
try:
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
348
|
+
self.settings.upload_record_dir.mkdir(parents=True, exist_ok=True)
|
|
349
|
+
recorder = UploadProgressRecorder(str(self.settings.upload_record_dir))
|
|
350
|
+
uploader = ResumeUploaderV2(
|
|
351
|
+
bucket_name,
|
|
352
|
+
part_size=4 * 1024 * 1024,
|
|
353
|
+
progress_handler=progress_callback,
|
|
354
|
+
upload_progress_recorder=recorder,
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
context, resp = uploader.initial_parts(
|
|
358
|
+
up_token=upload.upload_token,
|
|
359
|
+
key=upload.key,
|
|
360
|
+
file_path=str(local_path),
|
|
361
|
+
file_name=local_path.name,
|
|
362
|
+
)
|
|
363
|
+
if resp is not None and not resp.ok():
|
|
364
|
+
raise DiskAPIError(
|
|
365
|
+
f"Failed to initialize multipart upload: {resp.error}",
|
|
366
|
+
status_code=resp.status_code,
|
|
367
|
+
debug_msg=resp.text_body,
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
with local_path.open("rb") as file_handle:
|
|
371
|
+
_, resp = uploader.upload_parts(
|
|
372
|
+
up_token=upload.upload_token,
|
|
373
|
+
data=file_handle,
|
|
374
|
+
data_size=file_size,
|
|
375
|
+
context=context,
|
|
376
|
+
key=upload.key,
|
|
377
|
+
file_name=local_path.name,
|
|
378
|
+
)
|
|
379
|
+
if resp is not None and not resp.ok():
|
|
380
|
+
raise DiskAPIError(
|
|
381
|
+
f"Failed while uploading file parts: {resp.error}",
|
|
382
|
+
status_code=resp.status_code,
|
|
383
|
+
debug_msg=resp.text_body,
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
if phase_callback:
|
|
387
|
+
phase_callback("finalizing")
|
|
388
|
+
|
|
389
|
+
body, resp = uploader.complete_parts(
|
|
390
|
+
up_token=upload.upload_token,
|
|
391
|
+
data_size=file_size,
|
|
392
|
+
context=context,
|
|
393
|
+
key=upload.key,
|
|
394
|
+
file_name=local_path.name,
|
|
395
|
+
)
|
|
396
|
+
if resp is not None and not resp.ok():
|
|
397
|
+
raise DiskAPIError(
|
|
398
|
+
f"Failed to finalize multipart upload: {resp.error}",
|
|
399
|
+
status_code=resp.status_code,
|
|
400
|
+
debug_msg=resp.text_body,
|
|
350
401
|
)
|
|
402
|
+
body = self._unwrap_payload(body, status_code=resp.status_code if resp is not None else None)
|
|
351
403
|
if not isinstance(body, dict):
|
|
352
404
|
raise DiskAPIError("Upload endpoint returned an unexpected payload.")
|
|
353
405
|
return body
|
|
@@ -439,7 +439,16 @@ def upload_file_to_remote(ctx: CommandContext, local_path: Path, parent_id: str)
|
|
|
439
439
|
def on_progress(transferred: int, callback_total: Optional[int]) -> None:
|
|
440
440
|
progress.update(task_id, completed=transferred, total=callback_total or total)
|
|
441
441
|
|
|
442
|
-
|
|
442
|
+
def on_phase(phase: str) -> None:
|
|
443
|
+
if phase == "finalizing":
|
|
444
|
+
progress.update(task_id, completed=total, total=total, description=f"Finalizing {local_path.name}")
|
|
445
|
+
|
|
446
|
+
uploaded = ctx.client.upload_file(
|
|
447
|
+
upload_token,
|
|
448
|
+
local_path,
|
|
449
|
+
progress_callback=on_progress,
|
|
450
|
+
phase_callback=on_phase,
|
|
451
|
+
)
|
|
443
452
|
print_success(
|
|
444
453
|
f"uploaded {local_path.name} -> {uploaded['rname']} ({format_resource_ref(uploaded['res_str_id'])})"
|
|
445
454
|
)
|
|
@@ -10,9 +10,9 @@ from urllib.parse import urlencode
|
|
|
10
10
|
|
|
11
11
|
DEFAULT_APP_ID = "R41nXZEw"
|
|
12
12
|
DEFAULT_BACKEND_URL = "https://disk.6-79.cn"
|
|
13
|
-
DEFAULT_QINIU_UPLOAD_URL = "https://up.qiniup.com"
|
|
14
13
|
DEFAULT_SSO_URL = "https://qt.6-79.cn/oauth/"
|
|
15
14
|
DEFAULT_STATE_PATH = Path.home() / ".config" / "htx-cli" / "state.json"
|
|
15
|
+
DEFAULT_UPLOAD_RECORD_DIR = Path.home() / ".config" / "htx-cli" / "upload-records"
|
|
16
16
|
LEGACY_STATE_PATH = Path.home() / ".config" / "disk-cli" / "state.json"
|
|
17
17
|
DEFAULT_QT_COMMAND = "qt"
|
|
18
18
|
|
|
@@ -22,11 +22,15 @@ class DiskSettings:
|
|
|
22
22
|
backend_url: str = field(default_factory=lambda: os.getenv("DISK_BACKEND_URL", DEFAULT_BACKEND_URL))
|
|
23
23
|
sso_url: str = field(default_factory=lambda: os.getenv("DISK_SSO_URL", DEFAULT_SSO_URL))
|
|
24
24
|
sso_app_id: str = field(default_factory=lambda: os.getenv("DISK_SSO_APP_ID", DEFAULT_APP_ID))
|
|
25
|
-
qiniu_upload_url: str = field(default_factory=lambda: os.getenv("DISK_QINIU_UPLOAD_URL", DEFAULT_QINIU_UPLOAD_URL))
|
|
26
25
|
qt_command: str = field(default_factory=lambda: os.getenv("DISK_QT_COMMAND", DEFAULT_QT_COMMAND))
|
|
27
26
|
token: Optional[str] = None
|
|
28
27
|
user: Optional[Dict[str, Any]] = None
|
|
29
28
|
state_path: Path = DEFAULT_STATE_PATH
|
|
29
|
+
upload_record_dir: Path = field(
|
|
30
|
+
default_factory=lambda: Path(
|
|
31
|
+
os.getenv("DISK_UPLOAD_RECORD_DIR", str(DEFAULT_UPLOAD_RECORD_DIR))
|
|
32
|
+
).expanduser()
|
|
33
|
+
)
|
|
30
34
|
|
|
31
35
|
@classmethod
|
|
32
36
|
def load(cls, state_path: Optional[Path] = None) -> "DiskSettings":
|
|
@@ -41,20 +45,23 @@ class DiskSettings:
|
|
|
41
45
|
backend_url=data.get("backend_url", os.getenv("DISK_BACKEND_URL", DEFAULT_BACKEND_URL)),
|
|
42
46
|
sso_url=data.get("sso_url", os.getenv("DISK_SSO_URL", DEFAULT_SSO_URL)),
|
|
43
47
|
sso_app_id=data.get("sso_app_id", os.getenv("DISK_SSO_APP_ID", DEFAULT_APP_ID)),
|
|
44
|
-
qiniu_upload_url=data.get(
|
|
45
|
-
"qiniu_upload_url",
|
|
46
|
-
os.getenv("DISK_QINIU_UPLOAD_URL", DEFAULT_QINIU_UPLOAD_URL),
|
|
47
|
-
),
|
|
48
48
|
qt_command=data.get("qt_command", os.getenv("DISK_QT_COMMAND", DEFAULT_QT_COMMAND)),
|
|
49
49
|
token=data.get("token"),
|
|
50
50
|
user=data.get("user"),
|
|
51
51
|
state_path=resolved_path,
|
|
52
|
+
upload_record_dir=Path(
|
|
53
|
+
data.get(
|
|
54
|
+
"upload_record_dir",
|
|
55
|
+
os.getenv("DISK_UPLOAD_RECORD_DIR", str(DEFAULT_UPLOAD_RECORD_DIR)),
|
|
56
|
+
)
|
|
57
|
+
).expanduser(),
|
|
52
58
|
)
|
|
53
59
|
|
|
54
60
|
def save(self) -> None:
|
|
55
61
|
self.state_path.parent.mkdir(parents=True, exist_ok=True)
|
|
56
62
|
payload = asdict(self)
|
|
57
63
|
payload["state_path"] = str(self.state_path)
|
|
64
|
+
payload["upload_record_dir"] = str(self.upload_record_dir)
|
|
58
65
|
self.state_path.write_text(
|
|
59
66
|
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
|
|
60
67
|
encoding="utf-8",
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: htx-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.5
|
|
4
4
|
Summary: htx-cli command-line client for the Disk backend API
|
|
5
5
|
Requires-Python: >=3.9
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: qiniu<8,>=7.13
|
|
7
8
|
Requires-Dist: rich<14,>=13.9
|
|
8
9
|
Requires-Dist: requests<3,>=2.32
|
|
9
10
|
|
|
@@ -15,6 +16,7 @@ Requires-Dist: requests<3,>=2.32
|
|
|
15
16
|
- Listing remote Disk directories
|
|
16
17
|
- Uploading a local file into a remote Disk directory
|
|
17
18
|
- Uploading a local directory recursively while recreating its folder structure remotely
|
|
19
|
+
- Resumable multipart uploads with real remote-part progress
|
|
18
20
|
|
|
19
21
|
## Install
|
|
20
22
|
|
|
@@ -174,3 +176,9 @@ The CLI stores the backend JWT token and the last fetched user payload in:
|
|
|
174
176
|
```
|
|
175
177
|
|
|
176
178
|
Use `--config` to point to a different state file.
|
|
179
|
+
|
|
180
|
+
Multipart upload resume records are stored under:
|
|
181
|
+
|
|
182
|
+
```text
|
|
183
|
+
~/.config/htx-cli/upload-records/
|
|
184
|
+
```
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|