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.
@@ -1,8 +1,8 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: htx-cli
3
- Version: 0.1.1
3
+ Version: 0.1.3
4
4
  Summary: htx-cli command-line client for the Disk backend API
5
- Requires-Python: >=3.11
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.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.11"
10
+ requires-python = ">=3.9"
11
11
  dependencies = [
12
12
  "rich>=13.9,<14",
13
13
  "requests>=2.32,<3",
@@ -1,3 +1,3 @@
1
1
  __all__ = ["__version__"]
2
2
 
3
- __version__ = "0.1.1"
3
+ __version__ = "0.1.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 | None = None,
20
- identifier: str | None = None,
21
- debug_msg: str | None = None,
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 | None], None]
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 | None = None) -> None:
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) -> dict[str, str]:
78
- headers: dict[str, str] = {}
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: 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,
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: dict[str, Any] | None = None,
141
- json: dict[str, Any] | None = None,
142
- absolute_url: str | None = None,
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: dict[str, Any] | None = None,
145
- files: dict[str, Any] | None = None,
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 | None = None
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) -> dict[str, Any]:
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) -> dict[str, Any]:
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 | None = None) -> dict[str, Any]:
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) -> dict[str, Any]:
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) -> dict[str, Any]:
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) -> dict[str, Any]:
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 | 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]:
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 | None = None,
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 | None = None
265
+ last_error: Optional[Exception] = None
266
266
  for attempt in range(1, MAX_RETRIES + 1):
267
- response: requests.Response | None = None
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 | None = None,
339
- ) -> dict[str, Any]:
340
- last_error: Exception | None = None
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) -> dict[str, Any]:
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: dict[str, Any]) -> Text:
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: dict[str, Any]) -> str:
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() -> list[Any]:
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: list[dict[str, Any]]) -> list[dict[str, Any]]:
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: dict[str, Any], *, label: str) -> None:
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) -> tuple[str | None, 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) -> dict[str, Any]:
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) -> dict[str, Any] | None:
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) -> dict[str, Any] | None:
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) -> dict[str, Any]:
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 | None) -> None:
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 upload_directory_to_remote(ctx: CommandContext, local_dir: Path, parent_id: str) -> None:
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: dict[Path, str] = {Path("."): root_remote_id}
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
- upload_file_to_remote(ctx, current_root_path / filename, remote_parent_id)
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: dict[str, Any], local_path: Path | None) -> Path:
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: dict[str, Any], destination: Path) -> str:
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 | None) -> None:
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: dict[str, Any],
528
+ resource: Dict[str, Any],
442
529
  destination: Path,
443
- ) -> tuple[int, int]:
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: dict[str, Any]) -> None:
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: dict[str, Any]) -> None:
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: dict[str, Any]) -> None:
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: list[str] | None = None) -> int:
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 typing import Any
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 = "R41nXZEwpfl6tw6KYyoEZ4FYST4BXPlF"
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://sso.6-79.cn/oauth/"
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
- token: str | None = None
25
- user: dict[str, Any] | None = None
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 | None = None) -> "DiskSettings":
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 | None = "htx-cli") -> 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