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.
- {htx_cli-0.1.2/src/htx_cli.egg-info → htx_cli-0.1.4}/PKG-INFO +23 -1
- htx_cli-0.1.2/PKG-INFO → htx_cli-0.1.4/README.md +21 -9
- {htx_cli-0.1.2 → htx_cli-0.1.4}/pyproject.toml +2 -1
- {htx_cli-0.1.2 → htx_cli-0.1.4}/src/disk_cli/__init__.py +1 -1
- {htx_cli-0.1.2 → htx_cli-0.1.4}/src/disk_cli/api.py +68 -39
- {htx_cli-0.1.2 → htx_cli-0.1.4}/src/disk_cli/cli.py +152 -9
- {htx_cli-0.1.2 → htx_cli-0.1.4}/src/disk_cli/config.py +22 -8
- htx_cli-0.1.2/README.md → htx_cli-0.1.4/src/htx_cli.egg-info/PKG-INFO +31 -0
- {htx_cli-0.1.2 → htx_cli-0.1.4}/src/htx_cli.egg-info/requires.txt +1 -0
- {htx_cli-0.1.2 → htx_cli-0.1.4}/setup.cfg +0 -0
- {htx_cli-0.1.2 → htx_cli-0.1.4}/src/disk_cli/__main__.py +0 -0
- {htx_cli-0.1.2 → htx_cli-0.1.4}/src/htx_cli.egg-info/SOURCES.txt +0 -0
- {htx_cli-0.1.2 → htx_cli-0.1.4}/src/htx_cli.egg-info/dependency_links.txt +0 -0
- {htx_cli-0.1.2 → htx_cli-0.1.4}/src/htx_cli.egg-info/entry_points.txt +0 -0
- {htx_cli-0.1.2 → 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
|
|
|
@@ -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.
|
|
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
|
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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 = "
|
|
11
|
+
DEFAULT_APP_ID = "R41nXZEw"
|
|
11
12
|
DEFAULT_BACKEND_URL = "https://disk.6-79.cn"
|
|
12
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
```
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|