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