htx-cli 0.1.2__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.2
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
 
@@ -90,6 +92,20 @@ Upload a local directory recursively. The local directory name is created or reu
90
92
  htx upload ./albums /Photos
91
93
  ```
92
94
 
95
+ Only upload selected extensions:
96
+
97
+ ```bash
98
+ htx upload ./albums /Photos --include-ext jpg --include-ext png
99
+ ```
100
+
101
+ Exclude specific extensions:
102
+
103
+ ```bash
104
+ htx upload ./albums /Photos --exclude-ext tmp --exclude-ext log
105
+ ```
106
+
107
+ You can combine both flags. `--exclude-ext` takes precedence when both match the same file.
108
+
93
109
  Create a directory under the remote root:
94
110
 
95
111
  ```bash
@@ -160,3 +176,9 @@ The CLI stores the backend JWT token and the last fetched user payload in:
160
176
  ```
161
177
 
162
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,12 +1,3 @@
1
- Metadata-Version: 2.4
2
- Name: htx-cli
3
- Version: 0.1.2
4
- Summary: htx-cli command-line client for the Disk backend API
5
- Requires-Python: >=3.9
6
- Description-Content-Type: text/markdown
7
- Requires-Dist: rich<14,>=13.9
8
- Requires-Dist: requests<3,>=2.32
9
-
10
1
  # htx-cli
11
2
 
12
3
  `htx-cli` is a command-line client for the Disk backend API. It supports:
@@ -15,6 +6,7 @@ Requires-Dist: requests<3,>=2.32
15
6
  - Listing remote Disk directories
16
7
  - Uploading a local file into a remote Disk directory
17
8
  - Uploading a local directory recursively while recreating its folder structure remotely
9
+ - Resumable multipart uploads with real remote-part progress
18
10
 
19
11
  ## Install
20
12
 
@@ -90,6 +82,20 @@ Upload a local directory recursively. The local directory name is created or reu
90
82
  htx upload ./albums /Photos
91
83
  ```
92
84
 
85
+ Only upload selected extensions:
86
+
87
+ ```bash
88
+ htx upload ./albums /Photos --include-ext jpg --include-ext png
89
+ ```
90
+
91
+ Exclude specific extensions:
92
+
93
+ ```bash
94
+ htx upload ./albums /Photos --exclude-ext tmp --exclude-ext log
95
+ ```
96
+
97
+ You can combine both flags. `--exclude-ext` takes precedence when both match the same file.
98
+
93
99
  Create a directory under the remote root:
94
100
 
95
101
  ```bash
@@ -160,3 +166,9 @@ The CLI stores the backend JWT token and the last fetched user payload in:
160
166
  ```
161
167
 
162
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.2"
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.2"
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
@@ -4,6 +4,7 @@ import argparse
4
4
  import datetime as dt
5
5
  import json
6
6
  import os
7
+ import subprocess
7
8
  import sys
8
9
  import webbrowser
9
10
  from dataclasses import dataclass
@@ -94,6 +95,20 @@ class CommandContext:
94
95
  return root_res
95
96
 
96
97
 
98
+ @dataclass(frozen=True)
99
+ class UploadFilters:
100
+ include_exts: Optional[set[str]] = None
101
+ exclude_exts: Optional[set[str]] = None
102
+
103
+ def matches(self, local_path: Path) -> bool:
104
+ extension = normalize_extension(local_path.suffix)
105
+ if self.include_exts is not None and extension not in self.include_exts:
106
+ return False
107
+ if self.exclude_exts is not None and extension in self.exclude_exts:
108
+ return False
109
+ return True
110
+
111
+
97
112
  def build_parser() -> argparse.ArgumentParser:
98
113
  parser = argparse.ArgumentParser(
99
114
  prog="htx",
@@ -121,6 +136,11 @@ def build_parser() -> argparse.ArgumentParser:
121
136
  action="store_true",
122
137
  help="Do not try to open the authorization URL in a browser.",
123
138
  )
139
+ login.add_argument(
140
+ "--browser",
141
+ action="store_true",
142
+ help="Force the browser-based web OAuth flow instead of using `qt app oauth`.",
143
+ )
124
144
 
125
145
  whoami = subparsers.add_parser("whoami", help="Show the authenticated Disk user.")
126
146
  whoami.add_argument("--json", action="store_true", help="Print the raw API payload.")
@@ -143,6 +163,20 @@ def build_parser() -> argparse.ArgumentParser:
143
163
  default="/",
144
164
  help="Target remote directory path, @<res_str_id>, or id:<res_str_id>.",
145
165
  )
166
+ upload.add_argument(
167
+ "--include-ext",
168
+ action="append",
169
+ default=[],
170
+ metavar="EXT",
171
+ help="Only upload files with this extension. Repeatable; accepts `jpg` or `.jpg`.",
172
+ )
173
+ upload.add_argument(
174
+ "--exclude-ext",
175
+ action="append",
176
+ default=[],
177
+ metavar="EXT",
178
+ help="Skip files with this extension. Repeatable; accepts `log` or `.log`.",
179
+ )
146
180
 
147
181
  mkdir = subparsers.add_parser("mkdir", help="Create a remote directory.")
148
182
  mkdir.add_argument("name", help="Directory name to create.")
@@ -217,6 +251,26 @@ def format_resource_ref(res_str_id: str) -> str:
217
251
  return f"@{res_str_id}"
218
252
 
219
253
 
254
+ def normalize_extension(value: str) -> str:
255
+ normalized = value.strip().lower()
256
+ if not normalized:
257
+ raise CLIUsageError("Extension values cannot be empty.")
258
+ if normalized == ".":
259
+ raise CLIUsageError("Extension values must include at least one character.")
260
+ if not normalized.startswith("."):
261
+ normalized = f".{normalized}"
262
+ return normalized
263
+
264
+
265
+ def build_upload_filters(include_exts: List[str], exclude_exts: List[str]) -> UploadFilters:
266
+ normalized_include = {normalize_extension(ext) for ext in include_exts}
267
+ normalized_exclude = {normalize_extension(ext) for ext in exclude_exts}
268
+ return UploadFilters(
269
+ include_exts=normalized_include or None,
270
+ exclude_exts=normalized_exclude or None,
271
+ )
272
+
273
+
220
274
  def format_timestamp(timestamp: Any) -> str:
221
275
  if timestamp in (None, ""):
222
276
  return "-"
@@ -385,30 +439,72 @@ def upload_file_to_remote(ctx: CommandContext, local_path: Path, parent_id: str)
385
439
  def on_progress(transferred: int, callback_total: Optional[int]) -> None:
386
440
  progress.update(task_id, completed=transferred, total=callback_total or total)
387
441
 
388
- 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
+ )
389
452
  print_success(
390
453
  f"uploaded {local_path.name} -> {uploaded['rname']} ({format_resource_ref(uploaded['res_str_id'])})"
391
454
  )
392
455
  return uploaded
393
456
 
394
457
 
395
- def upload_directory_to_remote(ctx: CommandContext, local_dir: Path, parent_id: str) -> None:
458
+ def ensure_remote_directory_path(
459
+ ctx: CommandContext,
460
+ remote_folder_map: Dict[Path, str],
461
+ root_remote_id: str,
462
+ relative_parent: Path,
463
+ ) -> str:
464
+ if relative_parent in remote_folder_map:
465
+ return remote_folder_map[relative_parent]
466
+
467
+ parts = relative_parent.parts
468
+ current_relative = Path(".")
469
+ current_remote_id = root_remote_id
470
+ for part in parts:
471
+ current_relative = current_relative / part
472
+ existing = remote_folder_map.get(current_relative)
473
+ if existing is not None:
474
+ current_remote_id = existing
475
+ continue
476
+ current_remote_id = ensure_remote_folder(ctx, current_remote_id, part)
477
+ remote_folder_map[current_relative] = current_remote_id
478
+ return current_remote_id
479
+
480
+
481
+ def upload_directory_to_remote(
482
+ ctx: CommandContext,
483
+ local_dir: Path,
484
+ parent_id: str,
485
+ upload_filters: UploadFilters,
486
+ ) -> Tuple[int, int]:
396
487
  root_remote_id = ensure_remote_folder(ctx, parent_id, local_dir.name)
397
488
  remote_folder_map: Dict[Path, str] = {Path("."): root_remote_id}
489
+ uploaded_count = 0
490
+ skipped_count = 0
398
491
 
399
492
  for current_root, dirnames, filenames in os.walk(local_dir):
400
493
  dirnames.sort()
401
494
  filenames.sort()
402
495
  current_root_path = Path(current_root)
403
496
  relative_root = current_root_path.relative_to(local_dir)
404
- remote_parent_id = remote_folder_map[relative_root]
405
-
406
- for dirname in dirnames:
407
- child_relative = relative_root / dirname
408
- remote_folder_map[child_relative] = ensure_remote_folder(ctx, remote_parent_id, dirname)
409
497
 
410
498
  for filename in filenames:
411
- upload_file_to_remote(ctx, current_root_path / filename, remote_parent_id)
499
+ local_file = current_root_path / filename
500
+ if not upload_filters.matches(local_file):
501
+ skipped_count += 1
502
+ continue
503
+ remote_parent_id = ensure_remote_directory_path(ctx, remote_folder_map, root_remote_id, relative_root)
504
+ upload_file_to_remote(ctx, local_file, remote_parent_id)
505
+ uploaded_count += 1
506
+
507
+ return uploaded_count, skipped_count
412
508
 
413
509
 
414
510
  def resolve_download_destination(resource: Dict[str, Any], local_path: Optional[Path]) -> Path:
@@ -544,6 +640,13 @@ def print_user_summary(user: Dict[str, Any]) -> None:
544
640
 
545
641
 
546
642
  def handle_login(ctx: CommandContext, args: argparse.Namespace) -> int:
643
+ if not args.code and not args.browser:
644
+ qt_command = ctx.settings.resolve_qt_command()
645
+ if qt_command:
646
+ qt_code = request_qt_oauth_code(ctx, qt_command)
647
+ if qt_code:
648
+ args.code = qt_code
649
+
547
650
  auth_url = ctx.settings.oauth_authorize_url()
548
651
  if args.print_url:
549
652
  console.print(auth_url)
@@ -578,6 +681,41 @@ def handle_login(ctx: CommandContext, args: argparse.Namespace) -> int:
578
681
  return 0
579
682
 
580
683
 
684
+ def request_qt_oauth_code(ctx: CommandContext, qt_command: str) -> Optional[str]:
685
+ try:
686
+ result = subprocess.run(
687
+ [
688
+ qt_command,
689
+ "app",
690
+ "oauth",
691
+ "--app-id",
692
+ ctx.settings.sso_app_id,
693
+ "--client-name",
694
+ "htx-cli",
695
+ ],
696
+ check=True,
697
+ capture_output=True,
698
+ text=True,
699
+ )
700
+ except FileNotFoundError:
701
+ return None
702
+ except subprocess.CalledProcessError as exc:
703
+ stderr = exc.stderr.strip() if exc.stderr else ""
704
+ stdout = exc.stdout.strip() if exc.stdout else ""
705
+ message = stderr or stdout
706
+ if message:
707
+ print_note(f"`qt app oauth` unavailable, falling back to browser login: {message}")
708
+ return None
709
+
710
+ code = result.stdout.strip()
711
+ if not code:
712
+ print_note("`qt app oauth` returned an empty authorization code, falling back to browser login.")
713
+ return None
714
+
715
+ print_note("received OAuth code from `qt app oauth`; exchanging directly with Disk backend")
716
+ return code
717
+
718
+
581
719
  def handle_whoami(ctx: CommandContext, _: argparse.Namespace) -> int:
582
720
  with console.status("[bold cyan]Fetching user profile[/bold cyan]", spinner="dots"):
583
721
  user = ctx.sync_user()
@@ -609,6 +747,7 @@ def handle_upload(ctx: CommandContext, args: argparse.Namespace) -> int:
609
747
  local_path = args.local_path.expanduser().resolve()
610
748
  if not local_path.exists():
611
749
  raise CLIUsageError(f"Local path does not exist: {local_path}")
750
+ upload_filters = build_upload_filters(args.include_ext, args.exclude_ext)
612
751
 
613
752
  with console.status("[bold cyan]Resolving target directory[/bold cyan]", spinner="dots"):
614
753
  target_resource = resolve_remote_reference(ctx, args.remote_path)
@@ -616,10 +755,14 @@ def handle_upload(ctx: CommandContext, args: argparse.Namespace) -> int:
616
755
  target_id = target_resource["info"]["res_str_id"]
617
756
 
618
757
  if local_path.is_file():
758
+ if not upload_filters.matches(local_path):
759
+ print_note(f"skipped {local_path.name} because it does not match the upload extension filters")
760
+ return 0
619
761
  upload_file_to_remote(ctx, local_path, target_id)
620
762
  return 0
621
763
 
622
- upload_directory_to_remote(ctx, local_path, target_id)
764
+ uploaded_count, skipped_count = upload_directory_to_remote(ctx, local_path, target_id, upload_filters)
765
+ print_note(f"directory upload summary: {uploaded_count} uploaded, {skipped_count} skipped")
623
766
  return 0
624
767
 
625
768
 
@@ -4,15 +4,17 @@ import json
4
4
  import os
5
5
  from dataclasses import asdict, dataclass, field
6
6
  from pathlib import Path
7
+ from shutil import which
7
8
  from typing import Any, Dict, Optional
8
9
  from urllib.parse import urlencode
9
10
 
10
- DEFAULT_APP_ID = "R41nXZEwpfl6tw6KYyoEZ4FYST4BXPlF"
11
+ DEFAULT_APP_ID = "R41nXZEw"
11
12
  DEFAULT_BACKEND_URL = "https://disk.6-79.cn"
12
- DEFAULT_QINIU_UPLOAD_URL = "https://up.qiniup.com"
13
- DEFAULT_SSO_URL = "https://sso.6-79.cn/oauth/"
13
+ DEFAULT_SSO_URL = "https://qt.6-79.cn/oauth/"
14
14
  DEFAULT_STATE_PATH = Path.home() / ".config" / "htx-cli" / "state.json"
15
+ DEFAULT_UPLOAD_RECORD_DIR = Path.home() / ".config" / "htx-cli" / "upload-records"
15
16
  LEGACY_STATE_PATH = Path.home() / ".config" / "disk-cli" / "state.json"
17
+ DEFAULT_QT_COMMAND = "qt"
16
18
 
17
19
 
18
20
  @dataclass
@@ -20,10 +22,15 @@ class DiskSettings:
20
22
  backend_url: str = field(default_factory=lambda: os.getenv("DISK_BACKEND_URL", DEFAULT_BACKEND_URL))
21
23
  sso_url: str = field(default_factory=lambda: os.getenv("DISK_SSO_URL", DEFAULT_SSO_URL))
22
24
  sso_app_id: str = field(default_factory=lambda: os.getenv("DISK_SSO_APP_ID", DEFAULT_APP_ID))
23
- qiniu_upload_url: str = field(default_factory=lambda: os.getenv("DISK_QINIU_UPLOAD_URL", DEFAULT_QINIU_UPLOAD_URL))
25
+ qt_command: str = field(default_factory=lambda: os.getenv("DISK_QT_COMMAND", DEFAULT_QT_COMMAND))
24
26
  token: Optional[str] = None
25
27
  user: Optional[Dict[str, Any]] = None
26
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
+ )
27
34
 
28
35
  @classmethod
29
36
  def load(cls, state_path: Optional[Path] = None) -> "DiskSettings":
@@ -38,19 +45,23 @@ class DiskSettings:
38
45
  backend_url=data.get("backend_url", os.getenv("DISK_BACKEND_URL", DEFAULT_BACKEND_URL)),
39
46
  sso_url=data.get("sso_url", os.getenv("DISK_SSO_URL", DEFAULT_SSO_URL)),
40
47
  sso_app_id=data.get("sso_app_id", os.getenv("DISK_SSO_APP_ID", DEFAULT_APP_ID)),
41
- qiniu_upload_url=data.get(
42
- "qiniu_upload_url",
43
- os.getenv("DISK_QINIU_UPLOAD_URL", DEFAULT_QINIU_UPLOAD_URL),
44
- ),
48
+ qt_command=data.get("qt_command", os.getenv("DISK_QT_COMMAND", DEFAULT_QT_COMMAND)),
45
49
  token=data.get("token"),
46
50
  user=data.get("user"),
47
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(),
48
58
  )
49
59
 
50
60
  def save(self) -> None:
51
61
  self.state_path.parent.mkdir(parents=True, exist_ok=True)
52
62
  payload = asdict(self)
53
63
  payload["state_path"] = str(self.state_path)
64
+ payload["upload_record_dir"] = str(self.upload_record_dir)
54
65
  self.state_path.write_text(
55
66
  json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
56
67
  encoding="utf-8",
@@ -65,3 +76,6 @@ class DiskSettings:
65
76
  if state:
66
77
  params["state"] = state
67
78
  return f"{self.sso_url}?{urlencode(params)}"
79
+
80
+ def resolve_qt_command(self) -> Optional[str]:
81
+ return which(self.qt_command) or (self.qt_command if Path(self.qt_command).exists() else None)
@@ -1,3 +1,13 @@
1
+ Metadata-Version: 2.4
2
+ Name: htx-cli
3
+ Version: 0.1.4
4
+ Summary: htx-cli command-line client for the Disk backend API
5
+ Requires-Python: >=3.9
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: qiniu<8,>=7.13
8
+ Requires-Dist: rich<14,>=13.9
9
+ Requires-Dist: requests<3,>=2.32
10
+
1
11
  # htx-cli
2
12
 
3
13
  `htx-cli` is a command-line client for the Disk backend API. It supports:
@@ -6,6 +16,7 @@
6
16
  - Listing remote Disk directories
7
17
  - Uploading a local file into a remote Disk directory
8
18
  - Uploading a local directory recursively while recreating its folder structure remotely
19
+ - Resumable multipart uploads with real remote-part progress
9
20
 
10
21
  ## Install
11
22
 
@@ -81,6 +92,20 @@ Upload a local directory recursively. The local directory name is created or reu
81
92
  htx upload ./albums /Photos
82
93
  ```
83
94
 
95
+ Only upload selected extensions:
96
+
97
+ ```bash
98
+ htx upload ./albums /Photos --include-ext jpg --include-ext png
99
+ ```
100
+
101
+ Exclude specific extensions:
102
+
103
+ ```bash
104
+ htx upload ./albums /Photos --exclude-ext tmp --exclude-ext log
105
+ ```
106
+
107
+ You can combine both flags. `--exclude-ext` takes precedence when both match the same file.
108
+
84
109
  Create a directory under the remote root:
85
110
 
86
111
  ```bash
@@ -151,3 +176,9 @@ The CLI stores the backend JWT token and the last fetched user payload in:
151
176
  ```
152
177
 
153
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