htx-cli 0.1.0__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.0/PKG-INFO ADDED
@@ -0,0 +1,157 @@
1
+ Metadata-Version: 2.4
2
+ Name: htx-cli
3
+ Version: 0.1.0
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
+ # htx-cli
11
+
12
+ `htx-cli` is a command-line client for the Disk backend API. It supports:
13
+
14
+ - SSO-based login by exchanging an authorization `code` for the backend JWT token
15
+ - Listing remote Disk directories
16
+ - Uploading a local file into a remote Disk directory
17
+ - Uploading a local directory recursively while recreating its folder structure remotely
18
+
19
+ ## Install
20
+
21
+ ```bash
22
+ source /Users/jyonn/Projects/venv/django/bin/activate
23
+ pip install -e .
24
+ ```
25
+
26
+ ## Commands
27
+
28
+ Print the SSO authorization URL:
29
+
30
+ ```bash
31
+ htx login --print-url
32
+ ```
33
+
34
+ Interactive login:
35
+
36
+ ```bash
37
+ htx login
38
+ ```
39
+
40
+ You can also paste the full callback URL directly:
41
+
42
+ ```bash
43
+ htx login --code 'https://d.6-79.cn/oauth/qtb/callback?code=...'
44
+ ```
45
+
46
+ Show the authenticated user:
47
+
48
+ ```bash
49
+ htx whoami
50
+ ```
51
+
52
+ List the root directory:
53
+
54
+ ```bash
55
+ htx ls
56
+ ```
57
+
58
+ List a nested remote directory by path:
59
+
60
+ ```bash
61
+ htx ls /Photos/2024
62
+ ```
63
+
64
+ List a remote resource directly by resource ID:
65
+
66
+ ```bash
67
+ htx ls id:Ab12Cd
68
+ ```
69
+
70
+ The shorter `@资源ID` form works too:
71
+
72
+ ```bash
73
+ htx ls @Ab12Cd
74
+ ```
75
+
76
+ Upload a local file into a remote directory:
77
+
78
+ ```bash
79
+ htx upload ./report.pdf /Docs
80
+ ```
81
+
82
+ Upload a local directory recursively. The local directory name is created or reused under the target remote directory:
83
+
84
+ ```bash
85
+ htx upload ./albums /Photos
86
+ ```
87
+
88
+ Create a directory under the remote root:
89
+
90
+ ```bash
91
+ htx mkdir Projects
92
+ ```
93
+
94
+ Create a directory under a specific remote folder:
95
+
96
+ ```bash
97
+ htx mkdir 2026 /Photos
98
+ htx mkdir Archive @Ab12Cd
99
+ ```
100
+
101
+ Rename a remote resource:
102
+
103
+ ```bash
104
+ htx rename /Photos/old-name.jpg new-name.jpg
105
+ ```
106
+
107
+ Move a remote resource into another remote directory:
108
+
109
+ ```bash
110
+ htx mv /Photos/new-name.jpg /Archive
111
+ ```
112
+
113
+ Delete a remote file:
114
+
115
+ ```bash
116
+ htx rm /Archive/new-name.jpg
117
+ ```
118
+
119
+ Delete a non-empty remote directory recursively:
120
+
121
+ ```bash
122
+ htx rm --recursive /Archive
123
+ ```
124
+
125
+ Download a remote file to the current directory:
126
+
127
+ ```bash
128
+ htx download /Docs/report.pdf
129
+ ```
130
+
131
+ Download a remote file into a chosen local path:
132
+
133
+ ```bash
134
+ htx download @Ab12Cd ./downloads/report.pdf
135
+ ```
136
+
137
+ Download a remote directory recursively:
138
+
139
+ ```bash
140
+ htx download @Ab12Cd ./downloads/Archive
141
+ ```
142
+
143
+ Create a remote link resource:
144
+
145
+ ```bash
146
+ htx link Search https://www.google.com /Bookmarks
147
+ ```
148
+
149
+ ## State File
150
+
151
+ The CLI stores the backend JWT token and the last fetched user payload in:
152
+
153
+ ```text
154
+ ~/.config/htx-cli/state.json
155
+ ```
156
+
157
+ Use `--config` to point to a different state file.
@@ -0,0 +1,148 @@
1
+ # htx-cli
2
+
3
+ `htx-cli` is a command-line client for the Disk backend API. It supports:
4
+
5
+ - SSO-based login by exchanging an authorization `code` for the backend JWT token
6
+ - Listing remote Disk directories
7
+ - Uploading a local file into a remote Disk directory
8
+ - Uploading a local directory recursively while recreating its folder structure remotely
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ source /Users/jyonn/Projects/venv/django/bin/activate
14
+ pip install -e .
15
+ ```
16
+
17
+ ## Commands
18
+
19
+ Print the SSO authorization URL:
20
+
21
+ ```bash
22
+ htx login --print-url
23
+ ```
24
+
25
+ Interactive login:
26
+
27
+ ```bash
28
+ htx login
29
+ ```
30
+
31
+ You can also paste the full callback URL directly:
32
+
33
+ ```bash
34
+ htx login --code 'https://d.6-79.cn/oauth/qtb/callback?code=...'
35
+ ```
36
+
37
+ Show the authenticated user:
38
+
39
+ ```bash
40
+ htx whoami
41
+ ```
42
+
43
+ List the root directory:
44
+
45
+ ```bash
46
+ htx ls
47
+ ```
48
+
49
+ List a nested remote directory by path:
50
+
51
+ ```bash
52
+ htx ls /Photos/2024
53
+ ```
54
+
55
+ List a remote resource directly by resource ID:
56
+
57
+ ```bash
58
+ htx ls id:Ab12Cd
59
+ ```
60
+
61
+ The shorter `@资源ID` form works too:
62
+
63
+ ```bash
64
+ htx ls @Ab12Cd
65
+ ```
66
+
67
+ Upload a local file into a remote directory:
68
+
69
+ ```bash
70
+ htx upload ./report.pdf /Docs
71
+ ```
72
+
73
+ Upload a local directory recursively. The local directory name is created or reused under the target remote directory:
74
+
75
+ ```bash
76
+ htx upload ./albums /Photos
77
+ ```
78
+
79
+ Create a directory under the remote root:
80
+
81
+ ```bash
82
+ htx mkdir Projects
83
+ ```
84
+
85
+ Create a directory under a specific remote folder:
86
+
87
+ ```bash
88
+ htx mkdir 2026 /Photos
89
+ htx mkdir Archive @Ab12Cd
90
+ ```
91
+
92
+ Rename a remote resource:
93
+
94
+ ```bash
95
+ htx rename /Photos/old-name.jpg new-name.jpg
96
+ ```
97
+
98
+ Move a remote resource into another remote directory:
99
+
100
+ ```bash
101
+ htx mv /Photos/new-name.jpg /Archive
102
+ ```
103
+
104
+ Delete a remote file:
105
+
106
+ ```bash
107
+ htx rm /Archive/new-name.jpg
108
+ ```
109
+
110
+ Delete a non-empty remote directory recursively:
111
+
112
+ ```bash
113
+ htx rm --recursive /Archive
114
+ ```
115
+
116
+ Download a remote file to the current directory:
117
+
118
+ ```bash
119
+ htx download /Docs/report.pdf
120
+ ```
121
+
122
+ Download a remote file into a chosen local path:
123
+
124
+ ```bash
125
+ htx download @Ab12Cd ./downloads/report.pdf
126
+ ```
127
+
128
+ Download a remote directory recursively:
129
+
130
+ ```bash
131
+ htx download @Ab12Cd ./downloads/Archive
132
+ ```
133
+
134
+ Create a remote link resource:
135
+
136
+ ```bash
137
+ htx link Search https://www.google.com /Bookmarks
138
+ ```
139
+
140
+ ## State File
141
+
142
+ The CLI stores the backend JWT token and the last fetched user payload in:
143
+
144
+ ```text
145
+ ~/.config/htx-cli/state.json
146
+ ```
147
+
148
+ Use `--config` to point to a different state file.
@@ -0,0 +1,23 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "htx-cli"
7
+ version = "0.1.0"
8
+ description = "htx-cli command-line client for the Disk backend API"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ dependencies = [
12
+ "rich>=13.9,<14",
13
+ "requests>=2.32,<3",
14
+ ]
15
+
16
+ [project.scripts]
17
+ htx = "disk_cli.cli:main"
18
+
19
+ [tool.setuptools]
20
+ package-dir = {"" = "src"}
21
+
22
+ [tool.setuptools.packages.find]
23
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ __all__ = ["__version__"]
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ from .cli import main
2
+
3
+
4
+ if __name__ == "__main__":
5
+ raise SystemExit(main())
@@ -0,0 +1,368 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ import time
6
+ from typing import Any, Callable
7
+ from urllib.parse import urljoin
8
+
9
+ import requests
10
+
11
+ from .config import DiskSettings
12
+
13
+
14
+ class DiskAPIError(RuntimeError):
15
+ def __init__(
16
+ self,
17
+ message: str,
18
+ *,
19
+ status_code: int | None = None,
20
+ identifier: str | None = None,
21
+ debug_msg: str | None = None,
22
+ ) -> None:
23
+ super().__init__(message)
24
+ self.status_code = status_code
25
+ self.identifier = identifier
26
+ self.debug_msg = debug_msg
27
+
28
+
29
+ @dataclass
30
+ class UploadToken:
31
+ key: str
32
+ upload_token: str
33
+
34
+
35
+ ProgressCallback = Callable[[int, int | None], None]
36
+ MAX_RETRIES = 5
37
+ RETRYABLE_STATUS_CODES = {429, 500, 502, 503, 504}
38
+
39
+
40
+ class UploadMonitor:
41
+ def __init__(self, local_path: Path, callback: ProgressCallback | None = 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
+ class DiskClient:
72
+ def __init__(self, settings: DiskSettings, *, timeout: float = 30.0) -> None:
73
+ self.settings = settings
74
+ self.timeout = timeout
75
+ self.session = requests.Session()
76
+
77
+ def _headers(self, *, include_auth: bool = True) -> dict[str, str]:
78
+ headers: dict[str, str] = {}
79
+ if include_auth and self.settings.token:
80
+ headers["Token"] = self.settings.token
81
+ return headers
82
+
83
+ def _unwrap_response(self, response: requests.Response) -> Any:
84
+ try:
85
+ payload = response.json()
86
+ except ValueError as exc:
87
+ raise DiskAPIError(
88
+ f"Server returned a non-JSON response (HTTP {response.status_code})."
89
+ ) from exc
90
+
91
+ if not isinstance(payload, dict) or "code" not in payload:
92
+ return payload
93
+
94
+ if payload.get("code") != 0:
95
+ raise DiskAPIError(
96
+ payload.get("msg") or "The Disk API request failed.",
97
+ status_code=response.status_code,
98
+ identifier=payload.get("identifier"),
99
+ debug_msg=payload.get("debug_msg"),
100
+ )
101
+
102
+ return payload.get("body")
103
+
104
+ @staticmethod
105
+ def _retry_delay(attempt: int) -> float:
106
+ return min(0.5 * (2 ** (attempt - 1)), 4.0)
107
+
108
+ @staticmethod
109
+ def _should_retry_api_error(error: DiskAPIError) -> bool:
110
+ return error.status_code in RETRYABLE_STATUS_CODES
111
+
112
+ def _request_once(
113
+ self,
114
+ method: str,
115
+ url: str,
116
+ *,
117
+ params: dict[str, Any] | None = None,
118
+ json: dict[str, Any] | None = None,
119
+ data: dict[str, Any] | None = None,
120
+ files: dict[str, Any] | None = None,
121
+ include_auth: bool = True,
122
+ ) -> Any:
123
+ response = self.session.request(
124
+ method=method.upper(),
125
+ url=url,
126
+ params=params,
127
+ json=json,
128
+ data=data,
129
+ files=files,
130
+ headers=self._headers(include_auth=include_auth),
131
+ timeout=self.timeout,
132
+ )
133
+ return self._unwrap_response(response)
134
+
135
+ def request(
136
+ self,
137
+ method: str,
138
+ path: str,
139
+ *,
140
+ params: dict[str, Any] | None = None,
141
+ json: dict[str, Any] | None = None,
142
+ absolute_url: str | None = None,
143
+ include_auth: bool = True,
144
+ data: dict[str, Any] | None = None,
145
+ files: dict[str, Any] | None = None,
146
+ ) -> Any:
147
+ url = absolute_url or urljoin(f"{self.settings.backend_url}/", path.lstrip("/"))
148
+ last_error: Exception | None = None
149
+ for attempt in range(1, MAX_RETRIES + 1):
150
+ try:
151
+ return self._request_once(
152
+ method,
153
+ url,
154
+ params=params,
155
+ json=json,
156
+ data=data,
157
+ files=files,
158
+ include_auth=include_auth,
159
+ )
160
+ except DiskAPIError as exc:
161
+ last_error = exc
162
+ if attempt >= MAX_RETRIES or not self._should_retry_api_error(exc):
163
+ raise
164
+ except requests.RequestException as exc:
165
+ last_error = exc
166
+ if attempt >= MAX_RETRIES:
167
+ raise DiskAPIError(
168
+ f"Request failed after {MAX_RETRIES} attempts: {exc}"
169
+ ) from exc
170
+ time.sleep(self._retry_delay(attempt))
171
+
172
+ if isinstance(last_error, DiskAPIError):
173
+ raise last_error
174
+ raise DiskAPIError(f"Request failed after {MAX_RETRIES} attempts.")
175
+
176
+ def exchange_code(self, code: str) -> dict[str, Any]:
177
+ body = self.request(
178
+ "GET",
179
+ "/api/oauth/qtb/callback",
180
+ params={"code": code},
181
+ include_auth=False,
182
+ )
183
+ if not isinstance(body, dict) or "token" not in body:
184
+ raise DiskAPIError("OAuth callback did not return a login token.")
185
+ self.settings.token = body["token"]
186
+ return body
187
+
188
+ def get_current_user(self) -> dict[str, Any]:
189
+ body = self.request("GET", "/api/user/")
190
+ if not isinstance(body, dict):
191
+ raise DiskAPIError("User endpoint returned an unexpected payload.")
192
+ return body
193
+
194
+ def get_resource(self, res_str_id: str, visit_key: str | None = None) -> dict[str, Any]:
195
+ params = {"visit_key": visit_key} if visit_key else None
196
+ body = self.request("GET", f"/api/res/{res_str_id}", params=params)
197
+ if not isinstance(body, dict):
198
+ raise DiskAPIError("Resource endpoint returned an unexpected payload.")
199
+ return body
200
+
201
+ def get_selector(self, res_str_id: str) -> dict[str, Any]:
202
+ body = self.request("GET", f"/api/res/{res_str_id}/selector")
203
+ if not isinstance(body, dict):
204
+ raise DiskAPIError("Selector endpoint returned an unexpected payload.")
205
+ return body
206
+
207
+ def create_folder(self, parent_id: str, folder_name: str) -> dict[str, Any]:
208
+ body = self.request(
209
+ "POST",
210
+ f"/api/res/{parent_id}/folder",
211
+ json={"folder_name": folder_name},
212
+ )
213
+ if not isinstance(body, dict):
214
+ raise DiskAPIError("Create-folder endpoint returned an unexpected payload.")
215
+ return body
216
+
217
+ def create_link(self, parent_id: str, link_name: str, link: str) -> dict[str, Any]:
218
+ body = self.request(
219
+ "POST",
220
+ f"/api/res/{parent_id}/link",
221
+ json={"link_name": link_name, "link": link},
222
+ )
223
+ if not isinstance(body, dict):
224
+ raise DiskAPIError("Create-link endpoint returned an unexpected payload.")
225
+ return body
226
+
227
+ def update_resource(
228
+ self,
229
+ res_str_id: str,
230
+ *,
231
+ rname: str | None = None,
232
+ status: int | None = None,
233
+ description: str | None = None,
234
+ visit_key: str | None = None,
235
+ right_bubble: bool | None = None,
236
+ parent_str_id: str | None = None,
237
+ ) -> dict[str, Any]:
238
+ body = self.request(
239
+ "PUT",
240
+ f"/api/res/{res_str_id}",
241
+ json={
242
+ "rname": rname,
243
+ "status": status,
244
+ "description": description,
245
+ "visit_key": visit_key,
246
+ "right_bubble": right_bubble,
247
+ "parent_str_id": parent_str_id,
248
+ },
249
+ )
250
+ if not isinstance(body, dict):
251
+ raise DiskAPIError("Update-resource endpoint returned an unexpected payload.")
252
+ return body
253
+
254
+ def delete_resource(self, res_str_id: str) -> None:
255
+ self.request("DELETE", f"/api/res/{res_str_id}")
256
+
257
+ def download_resource(
258
+ self,
259
+ res_str_id: str,
260
+ destination: Path,
261
+ *,
262
+ progress_callback: ProgressCallback | None = None,
263
+ ) -> str:
264
+ url = urljoin(f"{self.settings.backend_url}/", f"/api/res/{res_str_id}/dl".lstrip("/"))
265
+ last_error: Exception | None = None
266
+ for attempt in range(1, MAX_RETRIES + 1):
267
+ response: requests.Response | None = None
268
+ try:
269
+ response = self.session.get(
270
+ url,
271
+ params={"token": self.settings.token or ""},
272
+ headers={},
273
+ timeout=self.timeout,
274
+ stream=True,
275
+ allow_redirects=True,
276
+ )
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
+ try:
282
+ response.raise_for_status()
283
+ except requests.HTTPError as exc:
284
+ raise DiskAPIError(
285
+ f"Download failed with HTTP {response.status_code}.",
286
+ status_code=response.status_code,
287
+ ) from exc
288
+
289
+ total_header = response.headers.get("content-length")
290
+ total = int(total_header) if total_header and total_header.isdigit() else None
291
+ if progress_callback:
292
+ progress_callback(0, total)
293
+
294
+ destination.parent.mkdir(parents=True, exist_ok=True)
295
+ transferred = 0
296
+ with destination.open("wb") as handle:
297
+ for chunk in response.iter_content(chunk_size=1024 * 128):
298
+ if chunk:
299
+ handle.write(chunk)
300
+ transferred += len(chunk)
301
+ if progress_callback:
302
+ progress_callback(transferred, total)
303
+ return response.url
304
+ except DiskAPIError as exc:
305
+ last_error = exc
306
+ if attempt >= MAX_RETRIES or not self._should_retry_api_error(exc):
307
+ raise
308
+ except requests.RequestException as exc:
309
+ last_error = exc
310
+ if attempt >= MAX_RETRIES:
311
+ raise DiskAPIError(
312
+ f"Download failed after {MAX_RETRIES} attempts: {exc}"
313
+ ) from exc
314
+ finally:
315
+ if response is not None:
316
+ response.close()
317
+ time.sleep(self._retry_delay(attempt))
318
+
319
+ if isinstance(last_error, DiskAPIError):
320
+ raise last_error
321
+ raise DiskAPIError(f"Download failed after {MAX_RETRIES} attempts.")
322
+
323
+ def get_upload_token(self, parent_id: str, filename: str) -> UploadToken:
324
+ body = self.request(
325
+ "GET",
326
+ f"/api/res/{parent_id}/token",
327
+ params={"filename": filename},
328
+ )
329
+ if not isinstance(body, dict):
330
+ raise DiskAPIError("Upload-token endpoint returned an unexpected payload.")
331
+ return UploadToken(key=body["key"], upload_token=body["upload_token"])
332
+
333
+ def upload_file(
334
+ self,
335
+ upload: UploadToken,
336
+ local_path: Path,
337
+ *,
338
+ progress_callback: ProgressCallback | None = None,
339
+ ) -> dict[str, Any]:
340
+ last_error: Exception | None = None
341
+ for attempt in range(1, MAX_RETRIES + 1):
342
+ 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)},
350
+ )
351
+ if not isinstance(body, dict):
352
+ raise DiskAPIError("Upload endpoint returned an unexpected payload.")
353
+ return body
354
+ except DiskAPIError as exc:
355
+ last_error = exc
356
+ if attempt >= MAX_RETRIES or not self._should_retry_api_error(exc):
357
+ raise
358
+ except requests.RequestException as exc:
359
+ last_error = exc
360
+ if attempt >= MAX_RETRIES:
361
+ raise DiskAPIError(
362
+ f"Upload failed after {MAX_RETRIES} attempts: {exc}"
363
+ ) from exc
364
+ time.sleep(self._retry_delay(attempt))
365
+
366
+ if isinstance(last_error, DiskAPIError):
367
+ raise last_error
368
+ raise DiskAPIError(f"Upload failed after {MAX_RETRIES} attempts.")