htx-cli 0.1.1__tar.gz → 0.1.3__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.1/src/htx_cli.egg-info → htx_cli-0.1.3}/PKG-INFO +16 -2
- htx_cli-0.1.1/PKG-INFO → htx_cli-0.1.3/README.md +14 -9
- {htx_cli-0.1.1 → htx_cli-0.1.3}/pyproject.toml +2 -2
- {htx_cli-0.1.1 → htx_cli-0.1.3}/src/disk_cli/__init__.py +1 -1
- {htx_cli-0.1.1 → htx_cli-0.1.3}/src/disk_cli/api.py +37 -37
- {htx_cli-0.1.1 → htx_cli-0.1.3}/src/disk_cli/cli.py +165 -31
- {htx_cli-0.1.1 → htx_cli-0.1.3}/src/disk_cli/config.py +14 -7
- htx_cli-0.1.1/README.md → htx_cli-0.1.3/src/htx_cli.egg-info/PKG-INFO +23 -0
- {htx_cli-0.1.1 → htx_cli-0.1.3}/setup.cfg +0 -0
- {htx_cli-0.1.1 → htx_cli-0.1.3}/src/disk_cli/__main__.py +0 -0
- {htx_cli-0.1.1 → htx_cli-0.1.3}/src/htx_cli.egg-info/SOURCES.txt +0 -0
- {htx_cli-0.1.1 → htx_cli-0.1.3}/src/htx_cli.egg-info/dependency_links.txt +0 -0
- {htx_cli-0.1.1 → htx_cli-0.1.3}/src/htx_cli.egg-info/entry_points.txt +0 -0
- {htx_cli-0.1.1 → htx_cli-0.1.3}/src/htx_cli.egg-info/requires.txt +0 -0
- {htx_cli-0.1.1 → htx_cli-0.1.3}/src/htx_cli.egg-info/top_level.txt +0 -0
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: htx-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.3
|
|
4
4
|
Summary: htx-cli command-line client for the Disk backend API
|
|
5
|
-
Requires-Python: >=3.
|
|
5
|
+
Requires-Python: >=3.9
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
7
7
|
Requires-Dist: rich<14,>=13.9
|
|
8
8
|
Requires-Dist: requests<3,>=2.32
|
|
@@ -90,6 +90,20 @@ Upload a local directory recursively. The local directory name is created or reu
|
|
|
90
90
|
htx upload ./albums /Photos
|
|
91
91
|
```
|
|
92
92
|
|
|
93
|
+
Only upload selected extensions:
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
htx upload ./albums /Photos --include-ext jpg --include-ext png
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Exclude specific extensions:
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
htx upload ./albums /Photos --exclude-ext tmp --exclude-ext log
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
You can combine both flags. `--exclude-ext` takes precedence when both match the same file.
|
|
106
|
+
|
|
93
107
|
Create a directory under the remote root:
|
|
94
108
|
|
|
95
109
|
```bash
|
|
@@ -1,12 +1,3 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: htx-cli
|
|
3
|
-
Version: 0.1.1
|
|
4
|
-
Summary: htx-cli command-line client for the Disk backend API
|
|
5
|
-
Requires-Python: >=3.11
|
|
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:
|
|
@@ -90,6 +81,20 @@ Upload a local directory recursively. The local directory name is created or reu
|
|
|
90
81
|
htx upload ./albums /Photos
|
|
91
82
|
```
|
|
92
83
|
|
|
84
|
+
Only upload selected extensions:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
htx upload ./albums /Photos --include-ext jpg --include-ext png
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Exclude specific extensions:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
htx upload ./albums /Photos --exclude-ext tmp --exclude-ext log
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
You can combine both flags. `--exclude-ext` takes precedence when both match the same file.
|
|
97
|
+
|
|
93
98
|
Create a directory under the remote root:
|
|
94
99
|
|
|
95
100
|
```bash
|
|
@@ -4,10 +4,10 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "htx-cli"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.3"
|
|
8
8
|
description = "htx-cli command-line client for the Disk backend API"
|
|
9
9
|
readme = "README.md"
|
|
10
|
-
requires-python = ">=3.
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
11
|
dependencies = [
|
|
12
12
|
"rich>=13.9,<14",
|
|
13
13
|
"requests>=2.32,<3",
|
|
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
from dataclasses import dataclass
|
|
4
4
|
from pathlib import Path
|
|
5
5
|
import time
|
|
6
|
-
from typing import Any, Callable
|
|
6
|
+
from typing import Any, Callable, Dict, Optional
|
|
7
7
|
from urllib.parse import urljoin
|
|
8
8
|
|
|
9
9
|
import requests
|
|
@@ -16,9 +16,9 @@ class DiskAPIError(RuntimeError):
|
|
|
16
16
|
self,
|
|
17
17
|
message: str,
|
|
18
18
|
*,
|
|
19
|
-
status_code: int
|
|
20
|
-
identifier: str
|
|
21
|
-
debug_msg: str
|
|
19
|
+
status_code: Optional[int] = None,
|
|
20
|
+
identifier: Optional[str] = None,
|
|
21
|
+
debug_msg: Optional[str] = None,
|
|
22
22
|
) -> None:
|
|
23
23
|
super().__init__(message)
|
|
24
24
|
self.status_code = status_code
|
|
@@ -32,13 +32,13 @@ class UploadToken:
|
|
|
32
32
|
upload_token: str
|
|
33
33
|
|
|
34
34
|
|
|
35
|
-
ProgressCallback = Callable[[int, int
|
|
35
|
+
ProgressCallback = Callable[[int, Optional[int]], None]
|
|
36
36
|
MAX_RETRIES = 5
|
|
37
37
|
RETRYABLE_STATUS_CODES = {429, 500, 502, 503, 504}
|
|
38
38
|
|
|
39
39
|
|
|
40
40
|
class UploadMonitor:
|
|
41
|
-
def __init__(self, local_path: Path, callback: ProgressCallback
|
|
41
|
+
def __init__(self, local_path: Path, callback: Optional[ProgressCallback] = None) -> None:
|
|
42
42
|
self.local_path = local_path
|
|
43
43
|
self.callback = callback
|
|
44
44
|
self.handle = local_path.open("rb")
|
|
@@ -74,8 +74,8 @@ class DiskClient:
|
|
|
74
74
|
self.timeout = timeout
|
|
75
75
|
self.session = requests.Session()
|
|
76
76
|
|
|
77
|
-
def _headers(self, *, include_auth: bool = True) ->
|
|
78
|
-
headers:
|
|
77
|
+
def _headers(self, *, include_auth: bool = True) -> Dict[str, str]:
|
|
78
|
+
headers: Dict[str, str] = {}
|
|
79
79
|
if include_auth and self.settings.token:
|
|
80
80
|
headers["Token"] = self.settings.token
|
|
81
81
|
return headers
|
|
@@ -114,10 +114,10 @@ class DiskClient:
|
|
|
114
114
|
method: str,
|
|
115
115
|
url: str,
|
|
116
116
|
*,
|
|
117
|
-
params:
|
|
118
|
-
json:
|
|
119
|
-
data:
|
|
120
|
-
files:
|
|
117
|
+
params: Optional[Dict[str, Any]] = None,
|
|
118
|
+
json: Optional[Dict[str, Any]] = None,
|
|
119
|
+
data: Optional[Dict[str, Any]] = None,
|
|
120
|
+
files: Optional[Dict[str, Any]] = None,
|
|
121
121
|
include_auth: bool = True,
|
|
122
122
|
) -> Any:
|
|
123
123
|
response = self.session.request(
|
|
@@ -137,15 +137,15 @@ class DiskClient:
|
|
|
137
137
|
method: str,
|
|
138
138
|
path: str,
|
|
139
139
|
*,
|
|
140
|
-
params:
|
|
141
|
-
json:
|
|
142
|
-
absolute_url: str
|
|
140
|
+
params: Optional[Dict[str, Any]] = None,
|
|
141
|
+
json: Optional[Dict[str, Any]] = None,
|
|
142
|
+
absolute_url: Optional[str] = None,
|
|
143
143
|
include_auth: bool = True,
|
|
144
|
-
data:
|
|
145
|
-
files:
|
|
144
|
+
data: Optional[Dict[str, Any]] = None,
|
|
145
|
+
files: Optional[Dict[str, Any]] = None,
|
|
146
146
|
) -> Any:
|
|
147
147
|
url = absolute_url or urljoin(f"{self.settings.backend_url}/", path.lstrip("/"))
|
|
148
|
-
last_error: Exception
|
|
148
|
+
last_error: Optional[Exception] = None
|
|
149
149
|
for attempt in range(1, MAX_RETRIES + 1):
|
|
150
150
|
try:
|
|
151
151
|
return self._request_once(
|
|
@@ -173,7 +173,7 @@ class DiskClient:
|
|
|
173
173
|
raise last_error
|
|
174
174
|
raise DiskAPIError(f"Request failed after {MAX_RETRIES} attempts.")
|
|
175
175
|
|
|
176
|
-
def exchange_code(self, code: str) ->
|
|
176
|
+
def exchange_code(self, code: str) -> Dict[str, Any]:
|
|
177
177
|
body = self.request(
|
|
178
178
|
"GET",
|
|
179
179
|
"/api/oauth/qtb/callback",
|
|
@@ -185,26 +185,26 @@ class DiskClient:
|
|
|
185
185
|
self.settings.token = body["token"]
|
|
186
186
|
return body
|
|
187
187
|
|
|
188
|
-
def get_current_user(self) ->
|
|
188
|
+
def get_current_user(self) -> Dict[str, Any]:
|
|
189
189
|
body = self.request("GET", "/api/user/")
|
|
190
190
|
if not isinstance(body, dict):
|
|
191
191
|
raise DiskAPIError("User endpoint returned an unexpected payload.")
|
|
192
192
|
return body
|
|
193
193
|
|
|
194
|
-
def get_resource(self, res_str_id: str, visit_key: str
|
|
194
|
+
def get_resource(self, res_str_id: str, visit_key: Optional[str] = None) -> Dict[str, Any]:
|
|
195
195
|
params = {"visit_key": visit_key} if visit_key else None
|
|
196
196
|
body = self.request("GET", f"/api/res/{res_str_id}", params=params)
|
|
197
197
|
if not isinstance(body, dict):
|
|
198
198
|
raise DiskAPIError("Resource endpoint returned an unexpected payload.")
|
|
199
199
|
return body
|
|
200
200
|
|
|
201
|
-
def get_selector(self, res_str_id: str) ->
|
|
201
|
+
def get_selector(self, res_str_id: str) -> Dict[str, Any]:
|
|
202
202
|
body = self.request("GET", f"/api/res/{res_str_id}/selector")
|
|
203
203
|
if not isinstance(body, dict):
|
|
204
204
|
raise DiskAPIError("Selector endpoint returned an unexpected payload.")
|
|
205
205
|
return body
|
|
206
206
|
|
|
207
|
-
def create_folder(self, parent_id: str, folder_name: str) ->
|
|
207
|
+
def create_folder(self, parent_id: str, folder_name: str) -> Dict[str, Any]:
|
|
208
208
|
body = self.request(
|
|
209
209
|
"POST",
|
|
210
210
|
f"/api/res/{parent_id}/folder",
|
|
@@ -214,7 +214,7 @@ class DiskClient:
|
|
|
214
214
|
raise DiskAPIError("Create-folder endpoint returned an unexpected payload.")
|
|
215
215
|
return body
|
|
216
216
|
|
|
217
|
-
def create_link(self, parent_id: str, link_name: str, link: str) ->
|
|
217
|
+
def create_link(self, parent_id: str, link_name: str, link: str) -> Dict[str, Any]:
|
|
218
218
|
body = self.request(
|
|
219
219
|
"POST",
|
|
220
220
|
f"/api/res/{parent_id}/link",
|
|
@@ -228,13 +228,13 @@ class DiskClient:
|
|
|
228
228
|
self,
|
|
229
229
|
res_str_id: str,
|
|
230
230
|
*,
|
|
231
|
-
rname: str
|
|
232
|
-
status: int
|
|
233
|
-
description: str
|
|
234
|
-
visit_key: str
|
|
235
|
-
right_bubble: bool
|
|
236
|
-
parent_str_id: str
|
|
237
|
-
) ->
|
|
231
|
+
rname: Optional[str] = None,
|
|
232
|
+
status: Optional[int] = None,
|
|
233
|
+
description: Optional[str] = None,
|
|
234
|
+
visit_key: Optional[str] = None,
|
|
235
|
+
right_bubble: Optional[bool] = None,
|
|
236
|
+
parent_str_id: Optional[str] = None,
|
|
237
|
+
) -> Dict[str, Any]:
|
|
238
238
|
body = self.request(
|
|
239
239
|
"PUT",
|
|
240
240
|
f"/api/res/{res_str_id}",
|
|
@@ -259,12 +259,12 @@ class DiskClient:
|
|
|
259
259
|
res_str_id: str,
|
|
260
260
|
destination: Path,
|
|
261
261
|
*,
|
|
262
|
-
progress_callback: ProgressCallback
|
|
262
|
+
progress_callback: Optional[ProgressCallback] = None,
|
|
263
263
|
) -> str:
|
|
264
264
|
url = urljoin(f"{self.settings.backend_url}/", f"/api/res/{res_str_id}/dl".lstrip("/"))
|
|
265
|
-
last_error: Exception
|
|
265
|
+
last_error: Optional[Exception] = None
|
|
266
266
|
for attempt in range(1, MAX_RETRIES + 1):
|
|
267
|
-
response: requests.Response
|
|
267
|
+
response: Optional[requests.Response] = None
|
|
268
268
|
try:
|
|
269
269
|
response = self.session.get(
|
|
270
270
|
url,
|
|
@@ -335,9 +335,9 @@ class DiskClient:
|
|
|
335
335
|
upload: UploadToken,
|
|
336
336
|
local_path: Path,
|
|
337
337
|
*,
|
|
338
|
-
progress_callback: ProgressCallback
|
|
339
|
-
) ->
|
|
340
|
-
last_error: Exception
|
|
338
|
+
progress_callback: Optional[ProgressCallback] = None,
|
|
339
|
+
) -> Dict[str, Any]:
|
|
340
|
+
last_error: Optional[Exception] = None
|
|
341
341
|
for attempt in range(1, MAX_RETRIES + 1):
|
|
342
342
|
try:
|
|
343
343
|
with UploadMonitor(local_path, progress_callback) as handle:
|
|
@@ -4,11 +4,12 @@ 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
|
|
10
11
|
from pathlib import Path
|
|
11
|
-
from typing import Any
|
|
12
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
12
13
|
from urllib.parse import parse_qs, urlparse
|
|
13
14
|
|
|
14
15
|
from rich import box
|
|
@@ -76,7 +77,7 @@ class CommandContext:
|
|
|
76
77
|
if not self.settings.token:
|
|
77
78
|
raise CLIUsageError("Please run `htx login` first.")
|
|
78
79
|
|
|
79
|
-
def sync_user(self) ->
|
|
80
|
+
def sync_user(self) -> Dict[str, Any]:
|
|
80
81
|
self.require_login()
|
|
81
82
|
user = self.client.get_current_user()
|
|
82
83
|
self.settings.user = user
|
|
@@ -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 "-"
|
|
@@ -256,7 +310,7 @@ def status_text(status: int) -> Text:
|
|
|
256
310
|
)
|
|
257
311
|
|
|
258
312
|
|
|
259
|
-
def resource_name_text(resource:
|
|
313
|
+
def resource_name_text(resource: Dict[str, Any]) -> Text:
|
|
260
314
|
text = Text()
|
|
261
315
|
text.append(format_resource_ref(resource["res_str_id"]), style="bold yellow")
|
|
262
316
|
text.append(" ")
|
|
@@ -264,13 +318,13 @@ def resource_name_text(resource: dict[str, Any]) -> Text:
|
|
|
264
318
|
return text
|
|
265
319
|
|
|
266
320
|
|
|
267
|
-
def display_size(resource:
|
|
321
|
+
def display_size(resource: Dict[str, Any]) -> str:
|
|
268
322
|
if resource.get("rtype") != RTYPE_FILE:
|
|
269
323
|
return "-"
|
|
270
324
|
return format_size(resource.get("rsize"))
|
|
271
325
|
|
|
272
326
|
|
|
273
|
-
def progress_columns() ->
|
|
327
|
+
def progress_columns() -> List[Any]:
|
|
274
328
|
return [
|
|
275
329
|
SpinnerColumn(style="bold cyan"),
|
|
276
330
|
TextColumn("[progress.description]{task.description}"),
|
|
@@ -294,7 +348,7 @@ def print_warning(message: str) -> None:
|
|
|
294
348
|
error_console.print(f"[bold yellow]WARN[/bold yellow] {message}")
|
|
295
349
|
|
|
296
350
|
|
|
297
|
-
def sort_children(children:
|
|
351
|
+
def sort_children(children: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
298
352
|
return sorted(
|
|
299
353
|
children,
|
|
300
354
|
key=lambda item: (
|
|
@@ -304,13 +358,13 @@ def sort_children(children: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
|
304
358
|
)
|
|
305
359
|
|
|
306
360
|
|
|
307
|
-
def ensure_folder_resource(resource:
|
|
361
|
+
def ensure_folder_resource(resource: Dict[str, Any], *, label: str) -> None:
|
|
308
362
|
info = resource.get("info", resource)
|
|
309
363
|
if info.get("rtype") != RTYPE_FOLDER:
|
|
310
364
|
raise CLIUsageError(f"{label} must refer to a remote directory.")
|
|
311
365
|
|
|
312
366
|
|
|
313
|
-
def parse_resource_reference(reference: str) ->
|
|
367
|
+
def parse_resource_reference(reference: str) -> Tuple[Optional[str], str]:
|
|
314
368
|
if reference.startswith("@"):
|
|
315
369
|
return reference[1:], "resource id"
|
|
316
370
|
if reference.startswith("id:"):
|
|
@@ -318,7 +372,7 @@ def parse_resource_reference(reference: str) -> tuple[str | None, str]:
|
|
|
318
372
|
return None, "path"
|
|
319
373
|
|
|
320
374
|
|
|
321
|
-
def resolve_remote_reference(ctx: CommandContext, reference: str) ->
|
|
375
|
+
def resolve_remote_reference(ctx: CommandContext, reference: str) -> Dict[str, Any]:
|
|
322
376
|
ctx.require_login()
|
|
323
377
|
resource_id, _ = parse_resource_reference(reference)
|
|
324
378
|
if resource_id:
|
|
@@ -345,7 +399,7 @@ def resolve_remote_reference(ctx: CommandContext, reference: str) -> dict[str, A
|
|
|
345
399
|
return current_resource
|
|
346
400
|
|
|
347
401
|
|
|
348
|
-
def find_child_folder(ctx: CommandContext, parent_id: str, folder_name: str) ->
|
|
402
|
+
def find_child_folder(ctx: CommandContext, parent_id: str, folder_name: str) -> Optional[Dict[str, Any]]:
|
|
349
403
|
selector = ctx.client.get_selector(parent_id)
|
|
350
404
|
for child in selector.get("child_list", []):
|
|
351
405
|
if child.get("rname") == folder_name:
|
|
@@ -357,7 +411,7 @@ def find_child_folder(ctx: CommandContext, parent_id: str, folder_name: str) ->
|
|
|
357
411
|
return None
|
|
358
412
|
|
|
359
413
|
|
|
360
|
-
def find_child_resource(ctx: CommandContext, parent_id: str, resource_name: str) ->
|
|
414
|
+
def find_child_resource(ctx: CommandContext, parent_id: str, resource_name: str) -> Optional[Dict[str, Any]]:
|
|
361
415
|
selector = ctx.client.get_selector(parent_id)
|
|
362
416
|
for child in selector.get("child_list", []):
|
|
363
417
|
if child.get("rname") == resource_name:
|
|
@@ -374,7 +428,7 @@ def ensure_remote_folder(ctx: CommandContext, parent_id: str, folder_name: str)
|
|
|
374
428
|
return created["res_str_id"]
|
|
375
429
|
|
|
376
430
|
|
|
377
|
-
def upload_file_to_remote(ctx: CommandContext, local_path: Path, parent_id: str) ->
|
|
431
|
+
def upload_file_to_remote(ctx: CommandContext, local_path: Path, parent_id: str) -> Dict[str, Any]:
|
|
378
432
|
with console.status(f"[bold cyan]Preparing upload for[/bold cyan] {local_path.name}", spinner="dots"):
|
|
379
433
|
upload_token = ctx.client.get_upload_token(parent_id, local_path.name)
|
|
380
434
|
|
|
@@ -382,7 +436,7 @@ def upload_file_to_remote(ctx: CommandContext, local_path: Path, parent_id: str)
|
|
|
382
436
|
with Progress(*progress_columns(), console=console, transient=True) as progress:
|
|
383
437
|
task_id = progress.add_task(f"Uploading {local_path.name}", total=total)
|
|
384
438
|
|
|
385
|
-
def on_progress(transferred: int, callback_total: int
|
|
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
|
uploaded = ctx.client.upload_file(upload_token, local_path, progress_callback=on_progress)
|
|
@@ -392,26 +446,59 @@ def upload_file_to_remote(ctx: CommandContext, local_path: Path, parent_id: str)
|
|
|
392
446
|
return uploaded
|
|
393
447
|
|
|
394
448
|
|
|
395
|
-
def
|
|
449
|
+
def ensure_remote_directory_path(
|
|
450
|
+
ctx: CommandContext,
|
|
451
|
+
remote_folder_map: Dict[Path, str],
|
|
452
|
+
root_remote_id: str,
|
|
453
|
+
relative_parent: Path,
|
|
454
|
+
) -> str:
|
|
455
|
+
if relative_parent in remote_folder_map:
|
|
456
|
+
return remote_folder_map[relative_parent]
|
|
457
|
+
|
|
458
|
+
parts = relative_parent.parts
|
|
459
|
+
current_relative = Path(".")
|
|
460
|
+
current_remote_id = root_remote_id
|
|
461
|
+
for part in parts:
|
|
462
|
+
current_relative = current_relative / part
|
|
463
|
+
existing = remote_folder_map.get(current_relative)
|
|
464
|
+
if existing is not None:
|
|
465
|
+
current_remote_id = existing
|
|
466
|
+
continue
|
|
467
|
+
current_remote_id = ensure_remote_folder(ctx, current_remote_id, part)
|
|
468
|
+
remote_folder_map[current_relative] = current_remote_id
|
|
469
|
+
return current_remote_id
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def upload_directory_to_remote(
|
|
473
|
+
ctx: CommandContext,
|
|
474
|
+
local_dir: Path,
|
|
475
|
+
parent_id: str,
|
|
476
|
+
upload_filters: UploadFilters,
|
|
477
|
+
) -> Tuple[int, int]:
|
|
396
478
|
root_remote_id = ensure_remote_folder(ctx, parent_id, local_dir.name)
|
|
397
|
-
remote_folder_map:
|
|
479
|
+
remote_folder_map: Dict[Path, str] = {Path("."): root_remote_id}
|
|
480
|
+
uploaded_count = 0
|
|
481
|
+
skipped_count = 0
|
|
398
482
|
|
|
399
483
|
for current_root, dirnames, filenames in os.walk(local_dir):
|
|
400
484
|
dirnames.sort()
|
|
401
485
|
filenames.sort()
|
|
402
486
|
current_root_path = Path(current_root)
|
|
403
487
|
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
488
|
|
|
410
489
|
for filename in filenames:
|
|
411
|
-
|
|
490
|
+
local_file = current_root_path / filename
|
|
491
|
+
if not upload_filters.matches(local_file):
|
|
492
|
+
skipped_count += 1
|
|
493
|
+
continue
|
|
494
|
+
remote_parent_id = ensure_remote_directory_path(ctx, remote_folder_map, root_remote_id, relative_root)
|
|
495
|
+
upload_file_to_remote(ctx, local_file, remote_parent_id)
|
|
496
|
+
uploaded_count += 1
|
|
497
|
+
|
|
498
|
+
return uploaded_count, skipped_count
|
|
412
499
|
|
|
413
500
|
|
|
414
|
-
def resolve_download_destination(resource:
|
|
501
|
+
def resolve_download_destination(resource: Dict[str, Any], local_path: Optional[Path]) -> Path:
|
|
415
502
|
resource_name = resource["info"]["rname"]
|
|
416
503
|
if local_path is None:
|
|
417
504
|
return Path.cwd() / resource_name
|
|
@@ -422,11 +509,11 @@ def resolve_download_destination(resource: dict[str, Any], local_path: Path | No
|
|
|
422
509
|
return resolved
|
|
423
510
|
|
|
424
511
|
|
|
425
|
-
def download_file_to_local(ctx: CommandContext, resource:
|
|
512
|
+
def download_file_to_local(ctx: CommandContext, resource: Dict[str, Any], destination: Path) -> str:
|
|
426
513
|
with Progress(*progress_columns(), console=console, transient=True) as progress:
|
|
427
514
|
task_id = progress.add_task(f"Downloading {resource['info']['rname']}", total=None)
|
|
428
515
|
|
|
429
|
-
def on_progress(transferred: int, total: int
|
|
516
|
+
def on_progress(transferred: int, total: Optional[int]) -> None:
|
|
430
517
|
progress.update(task_id, completed=transferred, total=total)
|
|
431
518
|
|
|
432
519
|
return ctx.client.download_resource(
|
|
@@ -438,9 +525,9 @@ def download_file_to_local(ctx: CommandContext, resource: dict[str, Any], destin
|
|
|
438
525
|
|
|
439
526
|
def download_directory_to_local(
|
|
440
527
|
ctx: CommandContext,
|
|
441
|
-
resource:
|
|
528
|
+
resource: Dict[str, Any],
|
|
442
529
|
destination: Path,
|
|
443
|
-
) ->
|
|
530
|
+
) -> Tuple[int, int]:
|
|
444
531
|
destination.mkdir(parents=True, exist_ok=True)
|
|
445
532
|
file_count = 0
|
|
446
533
|
directory_count = 1
|
|
@@ -466,7 +553,7 @@ def download_directory_to_local(
|
|
|
466
553
|
return file_count, directory_count
|
|
467
554
|
|
|
468
555
|
|
|
469
|
-
def delete_resource_recursive(ctx: CommandContext, resource:
|
|
556
|
+
def delete_resource_recursive(ctx: CommandContext, resource: Dict[str, Any]) -> None:
|
|
470
557
|
info = resource["info"]
|
|
471
558
|
if info["rtype"] == RTYPE_FOLDER:
|
|
472
559
|
for child in sort_children(resource.get("child_list", [])):
|
|
@@ -476,7 +563,7 @@ def delete_resource_recursive(ctx: CommandContext, resource: dict[str, Any]) ->
|
|
|
476
563
|
print_success(f"deleted {info['rname']} ({format_resource_ref(info['res_str_id'])})")
|
|
477
564
|
|
|
478
565
|
|
|
479
|
-
def print_resource_listing(resource:
|
|
566
|
+
def print_resource_listing(resource: Dict[str, Any]) -> None:
|
|
480
567
|
info = resource["info"]
|
|
481
568
|
child_list = sort_children(resource.get("child_list", []))
|
|
482
569
|
|
|
@@ -529,7 +616,7 @@ def print_resource_listing(resource: dict[str, Any]) -> None:
|
|
|
529
616
|
console.print(table)
|
|
530
617
|
|
|
531
618
|
|
|
532
|
-
def print_user_summary(user:
|
|
619
|
+
def print_user_summary(user: Dict[str, Any]) -> None:
|
|
533
620
|
summary = Table.grid(padding=(0, 2))
|
|
534
621
|
summary.add_column(style="bold cyan", justify="right")
|
|
535
622
|
summary.add_column()
|
|
@@ -544,6 +631,13 @@ def print_user_summary(user: dict[str, Any]) -> None:
|
|
|
544
631
|
|
|
545
632
|
|
|
546
633
|
def handle_login(ctx: CommandContext, args: argparse.Namespace) -> int:
|
|
634
|
+
if not args.code and not args.browser:
|
|
635
|
+
qt_command = ctx.settings.resolve_qt_command()
|
|
636
|
+
if qt_command:
|
|
637
|
+
qt_code = request_qt_oauth_code(ctx, qt_command)
|
|
638
|
+
if qt_code:
|
|
639
|
+
args.code = qt_code
|
|
640
|
+
|
|
547
641
|
auth_url = ctx.settings.oauth_authorize_url()
|
|
548
642
|
if args.print_url:
|
|
549
643
|
console.print(auth_url)
|
|
@@ -578,6 +672,41 @@ def handle_login(ctx: CommandContext, args: argparse.Namespace) -> int:
|
|
|
578
672
|
return 0
|
|
579
673
|
|
|
580
674
|
|
|
675
|
+
def request_qt_oauth_code(ctx: CommandContext, qt_command: str) -> Optional[str]:
|
|
676
|
+
try:
|
|
677
|
+
result = subprocess.run(
|
|
678
|
+
[
|
|
679
|
+
qt_command,
|
|
680
|
+
"app",
|
|
681
|
+
"oauth",
|
|
682
|
+
"--app-id",
|
|
683
|
+
ctx.settings.sso_app_id,
|
|
684
|
+
"--client-name",
|
|
685
|
+
"htx-cli",
|
|
686
|
+
],
|
|
687
|
+
check=True,
|
|
688
|
+
capture_output=True,
|
|
689
|
+
text=True,
|
|
690
|
+
)
|
|
691
|
+
except FileNotFoundError:
|
|
692
|
+
return None
|
|
693
|
+
except subprocess.CalledProcessError as exc:
|
|
694
|
+
stderr = exc.stderr.strip() if exc.stderr else ""
|
|
695
|
+
stdout = exc.stdout.strip() if exc.stdout else ""
|
|
696
|
+
message = stderr or stdout
|
|
697
|
+
if message:
|
|
698
|
+
print_note(f"`qt app oauth` unavailable, falling back to browser login: {message}")
|
|
699
|
+
return None
|
|
700
|
+
|
|
701
|
+
code = result.stdout.strip()
|
|
702
|
+
if not code:
|
|
703
|
+
print_note("`qt app oauth` returned an empty authorization code, falling back to browser login.")
|
|
704
|
+
return None
|
|
705
|
+
|
|
706
|
+
print_note("received OAuth code from `qt app oauth`; exchanging directly with Disk backend")
|
|
707
|
+
return code
|
|
708
|
+
|
|
709
|
+
|
|
581
710
|
def handle_whoami(ctx: CommandContext, _: argparse.Namespace) -> int:
|
|
582
711
|
with console.status("[bold cyan]Fetching user profile[/bold cyan]", spinner="dots"):
|
|
583
712
|
user = ctx.sync_user()
|
|
@@ -609,6 +738,7 @@ def handle_upload(ctx: CommandContext, args: argparse.Namespace) -> int:
|
|
|
609
738
|
local_path = args.local_path.expanduser().resolve()
|
|
610
739
|
if not local_path.exists():
|
|
611
740
|
raise CLIUsageError(f"Local path does not exist: {local_path}")
|
|
741
|
+
upload_filters = build_upload_filters(args.include_ext, args.exclude_ext)
|
|
612
742
|
|
|
613
743
|
with console.status("[bold cyan]Resolving target directory[/bold cyan]", spinner="dots"):
|
|
614
744
|
target_resource = resolve_remote_reference(ctx, args.remote_path)
|
|
@@ -616,10 +746,14 @@ def handle_upload(ctx: CommandContext, args: argparse.Namespace) -> int:
|
|
|
616
746
|
target_id = target_resource["info"]["res_str_id"]
|
|
617
747
|
|
|
618
748
|
if local_path.is_file():
|
|
749
|
+
if not upload_filters.matches(local_path):
|
|
750
|
+
print_note(f"skipped {local_path.name} because it does not match the upload extension filters")
|
|
751
|
+
return 0
|
|
619
752
|
upload_file_to_remote(ctx, local_path, target_id)
|
|
620
753
|
return 0
|
|
621
754
|
|
|
622
|
-
upload_directory_to_remote(ctx, local_path, target_id)
|
|
755
|
+
uploaded_count, skipped_count = upload_directory_to_remote(ctx, local_path, target_id, upload_filters)
|
|
756
|
+
print_note(f"directory upload summary: {uploaded_count} uploaded, {skipped_count} skipped")
|
|
623
757
|
return 0
|
|
624
758
|
|
|
625
759
|
|
|
@@ -753,7 +887,7 @@ def dispatch(args: argparse.Namespace) -> int:
|
|
|
753
887
|
return handlers[args.command](ctx, args)
|
|
754
888
|
|
|
755
889
|
|
|
756
|
-
def main(argv:
|
|
890
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
757
891
|
parser = build_parser()
|
|
758
892
|
args = parser.parse_args(argv)
|
|
759
893
|
try:
|
|
@@ -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
|
|
7
|
+
from shutil import which
|
|
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_QINIU_UPLOAD_URL = "https://up.qiniup.com"
|
|
13
|
-
DEFAULT_SSO_URL = "https://
|
|
14
|
+
DEFAULT_SSO_URL = "https://qt.6-79.cn/oauth/"
|
|
14
15
|
DEFAULT_STATE_PATH = Path.home() / ".config" / "htx-cli" / "state.json"
|
|
15
16
|
LEGACY_STATE_PATH = Path.home() / ".config" / "disk-cli" / "state.json"
|
|
17
|
+
DEFAULT_QT_COMMAND = "qt"
|
|
16
18
|
|
|
17
19
|
|
|
18
20
|
@dataclass
|
|
@@ -21,12 +23,13 @@ class DiskSettings:
|
|
|
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
|
qiniu_upload_url: str = field(default_factory=lambda: os.getenv("DISK_QINIU_UPLOAD_URL", DEFAULT_QINIU_UPLOAD_URL))
|
|
24
|
-
|
|
25
|
-
|
|
26
|
+
qt_command: str = field(default_factory=lambda: os.getenv("DISK_QT_COMMAND", DEFAULT_QT_COMMAND))
|
|
27
|
+
token: Optional[str] = None
|
|
28
|
+
user: Optional[Dict[str, Any]] = None
|
|
26
29
|
state_path: Path = DEFAULT_STATE_PATH
|
|
27
30
|
|
|
28
31
|
@classmethod
|
|
29
|
-
def load(cls, state_path: Path
|
|
32
|
+
def load(cls, state_path: Optional[Path] = None) -> "DiskSettings":
|
|
30
33
|
resolved_path = (state_path or DEFAULT_STATE_PATH).expanduser()
|
|
31
34
|
if state_path is None and not resolved_path.exists() and LEGACY_STATE_PATH.exists():
|
|
32
35
|
resolved_path = LEGACY_STATE_PATH
|
|
@@ -42,6 +45,7 @@ class DiskSettings:
|
|
|
42
45
|
"qiniu_upload_url",
|
|
43
46
|
os.getenv("DISK_QINIU_UPLOAD_URL", DEFAULT_QINIU_UPLOAD_URL),
|
|
44
47
|
),
|
|
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,
|
|
@@ -60,8 +64,11 @@ class DiskSettings:
|
|
|
60
64
|
self.token = None
|
|
61
65
|
self.user = None
|
|
62
66
|
|
|
63
|
-
def oauth_authorize_url(self, *, state: str
|
|
67
|
+
def oauth_authorize_url(self, *, state: Optional[str] = "htx-cli") -> str:
|
|
64
68
|
params = {"app_id": self.sso_app_id}
|
|
65
69
|
if state:
|
|
66
70
|
params["state"] = state
|
|
67
71
|
return f"{self.sso_url}?{urlencode(params)}"
|
|
72
|
+
|
|
73
|
+
def resolve_qt_command(self) -> Optional[str]:
|
|
74
|
+
return which(self.qt_command) or (self.qt_command if Path(self.qt_command).exists() else None)
|
|
@@ -1,3 +1,12 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: htx-cli
|
|
3
|
+
Version: 0.1.3
|
|
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
|
+
|
|
1
10
|
# htx-cli
|
|
2
11
|
|
|
3
12
|
`htx-cli` is a command-line client for the Disk backend API. It supports:
|
|
@@ -81,6 +90,20 @@ Upload a local directory recursively. The local directory name is created or reu
|
|
|
81
90
|
htx upload ./albums /Photos
|
|
82
91
|
```
|
|
83
92
|
|
|
93
|
+
Only upload selected extensions:
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
htx upload ./albums /Photos --include-ext jpg --include-ext png
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Exclude specific extensions:
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
htx upload ./albums /Photos --exclude-ext tmp --exclude-ext log
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
You can combine both flags. `--exclude-ext` takes precedence when both match the same file.
|
|
106
|
+
|
|
84
107
|
Create a directory under the remote root:
|
|
85
108
|
|
|
86
109
|
```bash
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|