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 +157 -0
- htx_cli-0.1.0/README.md +148 -0
- htx_cli-0.1.0/pyproject.toml +23 -0
- htx_cli-0.1.0/setup.cfg +4 -0
- htx_cli-0.1.0/src/disk_cli/__init__.py +3 -0
- htx_cli-0.1.0/src/disk_cli/__main__.py +5 -0
- htx_cli-0.1.0/src/disk_cli/api.py +368 -0
- htx_cli-0.1.0/src/disk_cli/cli.py +775 -0
- htx_cli-0.1.0/src/disk_cli/config.py +67 -0
- htx_cli-0.1.0/src/htx_cli.egg-info/PKG-INFO +157 -0
- htx_cli-0.1.0/src/htx_cli.egg-info/SOURCES.txt +13 -0
- htx_cli-0.1.0/src/htx_cli.egg-info/dependency_links.txt +1 -0
- htx_cli-0.1.0/src/htx_cli.egg-info/entry_points.txt +2 -0
- htx_cli-0.1.0/src/htx_cli.egg-info/requires.txt +2 -0
- htx_cli-0.1.0/src/htx_cli.egg-info/top_level.txt +1 -0
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.
|
htx_cli-0.1.0/README.md
ADDED
|
@@ -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"]
|
htx_cli-0.1.0/setup.cfg
ADDED
|
@@ -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.")
|