htx-cli 0.1.3__tar.gz → 0.1.4__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.4}/PKG-INFO +9 -1
- {htx_cli-0.1.3 → htx_cli-0.1.4}/README.md +7 -0
- {htx_cli-0.1.3 → htx_cli-0.1.4}/pyproject.toml +2 -1
- {htx_cli-0.1.3 → htx_cli-0.1.4}/src/disk_cli/__init__.py +1 -1
- {htx_cli-0.1.3 → htx_cli-0.1.4}/src/disk_cli/api.py +68 -39
- {htx_cli-0.1.3 → htx_cli-0.1.4}/src/disk_cli/cli.py +10 -1
- {htx_cli-0.1.3 → htx_cli-0.1.4}/src/disk_cli/config.py +13 -6
- {htx_cli-0.1.3 → htx_cli-0.1.4}/src/htx_cli.egg-info/PKG-INFO +9 -1
- {htx_cli-0.1.3 → htx_cli-0.1.4}/src/htx_cli.egg-info/requires.txt +1 -0
- {htx_cli-0.1.3 → htx_cli-0.1.4}/setup.cfg +0 -0
- {htx_cli-0.1.3 → htx_cli-0.1.4}/src/disk_cli/__main__.py +0 -0
- {htx_cli-0.1.3 → htx_cli-0.1.4}/src/htx_cli.egg-info/SOURCES.txt +0 -0
- {htx_cli-0.1.3 → htx_cli-0.1.4}/src/htx_cli.egg-info/dependency_links.txt +0 -0
- {htx_cli-0.1.3 → htx_cli-0.1.4}/src/htx_cli.egg-info/entry_points.txt +0 -0
- {htx_cli-0.1.3 → htx_cli-0.1.4}/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.4
|
|
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.4"
|
|
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
|
]
|
|
@@ -7,6 +7,8 @@ from typing import Any, Callable, Dict, Optional
|
|
|
7
7
|
from urllib.parse import urljoin
|
|
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
|
)
|
|
@@ -336,18 +312,71 @@ class DiskClient:
|
|
|
336
312
|
local_path: Path,
|
|
337
313
|
*,
|
|
338
314
|
progress_callback: Optional[ProgressCallback] = None,
|
|
315
|
+
phase_callback: Optional[UploadPhaseCallback] = None,
|
|
339
316
|
) -> Dict[str, Any]:
|
|
340
317
|
last_error: Optional[Exception] = None
|
|
318
|
+
file_size = local_path.stat().st_size
|
|
319
|
+
bucket_name = Auth.get_bucket_name(upload.upload_token)
|
|
320
|
+
if not bucket_name:
|
|
321
|
+
raise DiskAPIError("Upload token does not contain a valid bucket name.")
|
|
322
|
+
|
|
341
323
|
for attempt in range(1, MAX_RETRIES + 1):
|
|
342
324
|
try:
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
325
|
+
self.settings.upload_record_dir.mkdir(parents=True, exist_ok=True)
|
|
326
|
+
recorder = UploadProgressRecorder(str(self.settings.upload_record_dir))
|
|
327
|
+
uploader = ResumeUploaderV2(
|
|
328
|
+
bucket_name,
|
|
329
|
+
part_size=4 * 1024 * 1024,
|
|
330
|
+
progress_handler=progress_callback,
|
|
331
|
+
upload_progress_recorder=recorder,
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
context, resp = uploader.initial_parts(
|
|
335
|
+
up_token=upload.upload_token,
|
|
336
|
+
key=upload.key,
|
|
337
|
+
file_path=str(local_path),
|
|
338
|
+
file_name=local_path.name,
|
|
339
|
+
)
|
|
340
|
+
if resp is not None and not resp.ok():
|
|
341
|
+
raise DiskAPIError(
|
|
342
|
+
f"Failed to initialize multipart upload: {resp.error}",
|
|
343
|
+
status_code=resp.status_code,
|
|
344
|
+
debug_msg=resp.text_body,
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
with local_path.open("rb") as file_handle:
|
|
348
|
+
_, resp = uploader.upload_parts(
|
|
349
|
+
up_token=upload.upload_token,
|
|
350
|
+
data=file_handle,
|
|
351
|
+
data_size=file_size,
|
|
352
|
+
context=context,
|
|
353
|
+
key=upload.key,
|
|
354
|
+
file_name=local_path.name,
|
|
355
|
+
)
|
|
356
|
+
if resp is not None and not resp.ok():
|
|
357
|
+
raise DiskAPIError(
|
|
358
|
+
f"Failed while uploading file parts: {resp.error}",
|
|
359
|
+
status_code=resp.status_code,
|
|
360
|
+
debug_msg=resp.text_body,
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
if phase_callback:
|
|
364
|
+
phase_callback("finalizing")
|
|
365
|
+
|
|
366
|
+
body, resp = uploader.complete_parts(
|
|
367
|
+
up_token=upload.upload_token,
|
|
368
|
+
data_size=file_size,
|
|
369
|
+
context=context,
|
|
370
|
+
key=upload.key,
|
|
371
|
+
file_name=local_path.name,
|
|
372
|
+
)
|
|
373
|
+
if resp is not None and not resp.ok():
|
|
374
|
+
raise DiskAPIError(
|
|
375
|
+
f"Failed to finalize multipart upload: {resp.error}",
|
|
376
|
+
status_code=resp.status_code,
|
|
377
|
+
debug_msg=resp.text_body,
|
|
350
378
|
)
|
|
379
|
+
body = self._unwrap_payload(body, status_code=resp.status_code if resp is not None else None)
|
|
351
380
|
if not isinstance(body, dict):
|
|
352
381
|
raise DiskAPIError("Upload endpoint returned an unexpected payload.")
|
|
353
382
|
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.4
|
|
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
|