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.
@@ -1,9 +1,10 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: htx-cli
3
- Version: 0.1.3
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.3"
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
  ]
@@ -1,3 +1,3 @@
1
1
  __all__ = ["__version__"]
2
2
 
3
- __version__ = "0.1.3"
3
+ __version__ = "0.1.4"
@@ -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=response.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
- 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)},
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
- uploaded = ctx.client.upload_file(upload_token, local_path, progress_callback=on_progress)
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
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
+ ```
@@ -1,2 +1,3 @@
1
+ qiniu<8,>=7.13
1
2
  rich<14,>=13.9
2
3
  requests<3,>=2.32
File without changes