pocklo 0.2.0__py3-none-any.whl
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.
- pocklo-0.2.0.dist-info/METADATA +210 -0
- pocklo-0.2.0.dist-info/RECORD +10 -0
- pocklo-0.2.0.dist-info/WHEEL +5 -0
- pocklo-0.2.0.dist-info/entry_points.txt +2 -0
- pocklo-0.2.0.dist-info/top_level.txt +1 -0
- pocklo_cli/__init__.py +3 -0
- pocklo_cli/__main__.py +5 -0
- pocklo_cli/api.py +426 -0
- pocklo_cli/cli.py +1008 -0
- pocklo_cli/config.py +144 -0
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pocklo
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Command-line client for the Pocklo file workspace
|
|
5
|
+
Project-URL: Homepage, https://pocklo.jyonn.space/
|
|
6
|
+
Project-URL: Documentation, https://pocklo.jyonn.space/cli
|
|
7
|
+
Project-URL: Source, https://github.com/pocklo-app/pocklo-cli
|
|
8
|
+
Requires-Python: >=3.9
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
Requires-Dist: qiniu<8,>=7.13
|
|
11
|
+
Requires-Dist: rich<14,>=13.9
|
|
12
|
+
Requires-Dist: requests<3,>=2.32
|
|
13
|
+
|
|
14
|
+
# Pocklo CLI · 小兜
|
|
15
|
+
|
|
16
|
+
`pocklo` is the command-line client for the Pocklo file workspace. It supports:
|
|
17
|
+
|
|
18
|
+
- SSO-based login by exchanging an authorization `code` for the backend JWT token
|
|
19
|
+
- Listing remote directories
|
|
20
|
+
- Switching into a remote working directory with `cd`
|
|
21
|
+
- Uploading a local file into a remote directory
|
|
22
|
+
- Uploading a local directory recursively while recreating its folder structure remotely
|
|
23
|
+
- Resumable multipart uploads with real remote-part progress
|
|
24
|
+
|
|
25
|
+
## Install
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
python3 -m pip install pocklo
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
For local development:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install -e .
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Commands
|
|
38
|
+
|
|
39
|
+
Print the SSO authorization URL:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pocklo login --print-url
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Interactive login:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
pocklo login
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
You can also paste the full callback URL directly:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
pocklo login --code 'https://pocklo.jyonn.space/oauth/qtb/callback?code=...'
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Show the authenticated user:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
pocklo whoami
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Change into a remote directory:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
pocklo cd @Ab12Cd
|
|
67
|
+
pocklo cd Projects
|
|
68
|
+
pocklo cd ../Archive
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Print the current remote directory:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
pocklo pwd
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
List the current remote directory:
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
pocklo ls
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
List the remote root directory explicitly:
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
pocklo ls /
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
List a nested remote directory by relative path:
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
pocklo ls Photos/2024
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
List a remote resource directly by resource ID:
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
pocklo ls id:Ab12Cd
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
The shorter `@资源ID` form works too:
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
pocklo ls @Ab12Cd
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Upload a local file into a remote directory:
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
pocklo upload ./report.pdf
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Upload a local directory recursively. The local directory name is created or reused under the target remote directory:
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
pocklo upload ./albums
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Only upload selected extensions:
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
pocklo upload ./albums /Photos --include-ext jpg --include-ext png
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Exclude specific extensions:
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
pocklo upload ./albums /Photos --exclude-ext tmp --exclude-ext log
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
You can combine both flags. `--exclude-ext` takes precedence when both match the same file.
|
|
132
|
+
|
|
133
|
+
Create a directory under the remote root:
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
pocklo mkdir Projects
|
|
137
|
+
pocklo cd Projects
|
|
138
|
+
pocklo mkdir 2026
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Create a directory under a specific remote folder:
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
pocklo mkdir 2026 /Photos
|
|
145
|
+
pocklo mkdir Archive @Ab12Cd
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Rename a remote resource:
|
|
149
|
+
|
|
150
|
+
```bash
|
|
151
|
+
pocklo rename /Photos/old-name.jpg new-name.jpg
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Move a remote resource into another remote directory:
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
pocklo mv new-name.jpg ../Archive
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Delete a remote file:
|
|
161
|
+
|
|
162
|
+
```bash
|
|
163
|
+
pocklo rm new-name.jpg
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
Delete a non-empty remote directory recursively:
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
pocklo rm --recursive Archive
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Download a remote file to the current directory:
|
|
173
|
+
|
|
174
|
+
```bash
|
|
175
|
+
pocklo download report.pdf
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Download a remote file into a chosen local path:
|
|
179
|
+
|
|
180
|
+
```bash
|
|
181
|
+
pocklo download @Ab12Cd ./downloads/report.pdf
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Download a remote directory recursively:
|
|
185
|
+
|
|
186
|
+
```bash
|
|
187
|
+
pocklo download @Ab12Cd ./downloads/Archive
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
Create a remote link resource:
|
|
191
|
+
|
|
192
|
+
```bash
|
|
193
|
+
pocklo link Search https://www.google.com
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
## State File
|
|
197
|
+
|
|
198
|
+
The CLI stores the backend JWT token and the last fetched user payload in:
|
|
199
|
+
|
|
200
|
+
```text
|
|
201
|
+
~/.config/pocklo-cli/state.json
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
On first use, Pocklo imports existing login state and the remote working directory from `~/.config/htx-cli` or `~/.config/disk-cli` when present. Existing upload resume records remain in their configured directory. `DISK_*` environment variables are still read as fallbacks. Use `--config` to point to a different state file.
|
|
205
|
+
|
|
206
|
+
Multipart upload resume records are stored under:
|
|
207
|
+
|
|
208
|
+
```text
|
|
209
|
+
~/.config/pocklo-cli/upload-records/
|
|
210
|
+
```
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
pocklo_cli/__init__.py,sha256=QwLGZDZhVE7dBKbOwCLRt3tRMXTtg8fYYHHDXi3sZ1c,49
|
|
2
|
+
pocklo_cli/__main__.py,sha256=PSQ4rpL0dG6f-qH4N7H-gD9igQkdHzH4yVZDcW8lfZo,80
|
|
3
|
+
pocklo_cli/api.py,sha256=JDYwS8iHUA5ujxIQYVMLmwfN7sYsyLLWWyeoEuGiOJk,16288
|
|
4
|
+
pocklo_cli/cli.py,sha256=VFYqwlLYOAbBL8BwqZUDRHtntCcC1wCKzwija-LD5BI,36101
|
|
5
|
+
pocklo_cli/config.py,sha256=ylhWbh-rWmo9RkPm_yLQfaqx83deUgIPtl4FDCDpvh4,5735
|
|
6
|
+
pocklo-0.2.0.dist-info/METADATA,sha256=NRv9eoigXtEuo-9ufFJqjrCx4Ex0KjHnklmELDVcfHM,3964
|
|
7
|
+
pocklo-0.2.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
8
|
+
pocklo-0.2.0.dist-info/entry_points.txt,sha256=ikMkStZExnopZOYi5YYHPqTuCGC2hC2hjoAJUba_OtU,47
|
|
9
|
+
pocklo-0.2.0.dist-info/top_level.txt,sha256=JeKwepBYiVu3Marrp0WJFby7-pazJVpy2nxldJnzvkc,11
|
|
10
|
+
pocklo-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pocklo_cli
|
pocklo_cli/__init__.py
ADDED
pocklo_cli/__main__.py
ADDED
pocklo_cli/api.py
ADDED
|
@@ -0,0 +1,426 @@
|
|
|
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, Dict, Optional
|
|
7
|
+
from urllib.parse import urljoin, urlparse
|
|
8
|
+
|
|
9
|
+
import requests
|
|
10
|
+
from qiniu import Auth
|
|
11
|
+
from qiniu.services.storage.uploader import ResumeUploaderV2, UploadProgressRecorder
|
|
12
|
+
|
|
13
|
+
from .config import PockloSettings
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class PockloAPIError(RuntimeError):
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
message: str,
|
|
20
|
+
*,
|
|
21
|
+
status_code: Optional[int] = None,
|
|
22
|
+
identifier: Optional[str] = None,
|
|
23
|
+
debug_msg: Optional[str] = None,
|
|
24
|
+
) -> None:
|
|
25
|
+
super().__init__(message)
|
|
26
|
+
self.status_code = status_code
|
|
27
|
+
self.identifier = identifier
|
|
28
|
+
self.debug_msg = debug_msg
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class UploadToken:
|
|
33
|
+
key: str
|
|
34
|
+
upload_token: str
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
ProgressCallback = Callable[[int, Optional[int]], None]
|
|
38
|
+
UploadPhaseCallback = Callable[[str], None]
|
|
39
|
+
MAX_RETRIES = 5
|
|
40
|
+
RETRYABLE_STATUS_CODES = {429, 500, 502, 503, 504}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class PockloClient:
|
|
44
|
+
def __init__(self, settings: PockloSettings, *, timeout: float = 30.0) -> None:
|
|
45
|
+
self.settings = settings
|
|
46
|
+
self.timeout = timeout
|
|
47
|
+
self.session = requests.Session()
|
|
48
|
+
|
|
49
|
+
def _headers(self, *, include_auth: bool = True) -> Dict[str, str]:
|
|
50
|
+
headers: Dict[str, str] = {}
|
|
51
|
+
if include_auth and self.settings.token:
|
|
52
|
+
headers["Token"] = self.settings.token
|
|
53
|
+
return headers
|
|
54
|
+
|
|
55
|
+
def _unwrap_response(self, response: requests.Response) -> Any:
|
|
56
|
+
try:
|
|
57
|
+
payload = response.json()
|
|
58
|
+
except ValueError as exc:
|
|
59
|
+
raise PockloAPIError(
|
|
60
|
+
f"Server returned a non-JSON response (HTTP {response.status_code})."
|
|
61
|
+
) from exc
|
|
62
|
+
|
|
63
|
+
return self._unwrap_payload(payload, status_code=response.status_code)
|
|
64
|
+
|
|
65
|
+
@staticmethod
|
|
66
|
+
def _unwrap_payload(payload: Any, *, status_code: Optional[int] = None) -> Any:
|
|
67
|
+
if not isinstance(payload, dict) or "code" not in payload:
|
|
68
|
+
return payload
|
|
69
|
+
|
|
70
|
+
if payload.get("code") != 0:
|
|
71
|
+
raise PockloAPIError(
|
|
72
|
+
payload.get("msg") or "The Pocklo API request failed.",
|
|
73
|
+
status_code=status_code,
|
|
74
|
+
identifier=payload.get("identifier"),
|
|
75
|
+
debug_msg=payload.get("debug_msg"),
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
return payload.get("body")
|
|
79
|
+
|
|
80
|
+
@staticmethod
|
|
81
|
+
def _retry_delay(attempt: int) -> float:
|
|
82
|
+
return min(0.5 * (2 ** (attempt - 1)), 4.0)
|
|
83
|
+
|
|
84
|
+
@staticmethod
|
|
85
|
+
def _should_retry_api_error(error: PockloAPIError) -> bool:
|
|
86
|
+
return error.status_code in RETRYABLE_STATUS_CODES
|
|
87
|
+
|
|
88
|
+
def _request_once(
|
|
89
|
+
self,
|
|
90
|
+
method: str,
|
|
91
|
+
url: str,
|
|
92
|
+
*,
|
|
93
|
+
params: Optional[Dict[str, Any]] = None,
|
|
94
|
+
json: Optional[Dict[str, Any]] = None,
|
|
95
|
+
data: Optional[Dict[str, Any]] = None,
|
|
96
|
+
files: Optional[Dict[str, Any]] = None,
|
|
97
|
+
include_auth: bool = True,
|
|
98
|
+
) -> Any:
|
|
99
|
+
response = self.session.request(
|
|
100
|
+
method=method.upper(),
|
|
101
|
+
url=url,
|
|
102
|
+
params=params,
|
|
103
|
+
json=json,
|
|
104
|
+
data=data,
|
|
105
|
+
files=files,
|
|
106
|
+
headers=self._headers(include_auth=include_auth),
|
|
107
|
+
timeout=self.timeout,
|
|
108
|
+
)
|
|
109
|
+
return self._unwrap_response(response)
|
|
110
|
+
|
|
111
|
+
def request(
|
|
112
|
+
self,
|
|
113
|
+
method: str,
|
|
114
|
+
path: str,
|
|
115
|
+
*,
|
|
116
|
+
params: Optional[Dict[str, Any]] = None,
|
|
117
|
+
json: Optional[Dict[str, Any]] = None,
|
|
118
|
+
absolute_url: Optional[str] = None,
|
|
119
|
+
include_auth: bool = True,
|
|
120
|
+
data: Optional[Dict[str, Any]] = None,
|
|
121
|
+
files: Optional[Dict[str, Any]] = None,
|
|
122
|
+
) -> Any:
|
|
123
|
+
url = absolute_url or urljoin(f"{self.settings.backend_url}/", path.lstrip("/"))
|
|
124
|
+
last_error: Optional[Exception] = None
|
|
125
|
+
for attempt in range(1, MAX_RETRIES + 1):
|
|
126
|
+
try:
|
|
127
|
+
return self._request_once(
|
|
128
|
+
method,
|
|
129
|
+
url,
|
|
130
|
+
params=params,
|
|
131
|
+
json=json,
|
|
132
|
+
data=data,
|
|
133
|
+
files=files,
|
|
134
|
+
include_auth=include_auth,
|
|
135
|
+
)
|
|
136
|
+
except PockloAPIError as exc:
|
|
137
|
+
last_error = exc
|
|
138
|
+
if attempt >= MAX_RETRIES or not self._should_retry_api_error(exc):
|
|
139
|
+
raise
|
|
140
|
+
except requests.RequestException as exc:
|
|
141
|
+
last_error = exc
|
|
142
|
+
if attempt >= MAX_RETRIES:
|
|
143
|
+
raise PockloAPIError(
|
|
144
|
+
f"Request failed after {MAX_RETRIES} attempts: {exc}"
|
|
145
|
+
) from exc
|
|
146
|
+
time.sleep(self._retry_delay(attempt))
|
|
147
|
+
|
|
148
|
+
if isinstance(last_error, PockloAPIError):
|
|
149
|
+
raise last_error
|
|
150
|
+
raise PockloAPIError(f"Request failed after {MAX_RETRIES} attempts.")
|
|
151
|
+
|
|
152
|
+
def exchange_code(self, code: str) -> Dict[str, Any]:
|
|
153
|
+
body = self.request(
|
|
154
|
+
"GET",
|
|
155
|
+
"/api/oauth/qtb/callback",
|
|
156
|
+
params={"code": code},
|
|
157
|
+
include_auth=False,
|
|
158
|
+
)
|
|
159
|
+
if not isinstance(body, dict) or "token" not in body:
|
|
160
|
+
raise PockloAPIError("OAuth callback did not return a login token.")
|
|
161
|
+
self.settings.token = body["token"]
|
|
162
|
+
return body
|
|
163
|
+
|
|
164
|
+
def get_current_user(self) -> Dict[str, Any]:
|
|
165
|
+
body = self.request("GET", "/api/user/")
|
|
166
|
+
if not isinstance(body, dict):
|
|
167
|
+
raise PockloAPIError("User endpoint returned an unexpected payload.")
|
|
168
|
+
return body
|
|
169
|
+
|
|
170
|
+
def get_resource(self, res_str_id: str, visit_key: Optional[str] = None) -> Dict[str, Any]:
|
|
171
|
+
params = {"visit_key": visit_key} if visit_key else None
|
|
172
|
+
body = self.request("GET", f"/api/res/{res_str_id}", params=params)
|
|
173
|
+
if not isinstance(body, dict):
|
|
174
|
+
raise PockloAPIError("Resource endpoint returned an unexpected payload.")
|
|
175
|
+
return body
|
|
176
|
+
|
|
177
|
+
def get_selector(self, res_str_id: str) -> Dict[str, Any]:
|
|
178
|
+
body = self.request("GET", f"/api/res/{res_str_id}/selector")
|
|
179
|
+
if not isinstance(body, dict):
|
|
180
|
+
raise PockloAPIError("Selector endpoint returned an unexpected payload.")
|
|
181
|
+
return body
|
|
182
|
+
|
|
183
|
+
def get_path(self, res_str_id: str) -> list[str]:
|
|
184
|
+
body = self.request("GET", f"/api/res/{res_str_id}/path")
|
|
185
|
+
if not isinstance(body, list):
|
|
186
|
+
raise PockloAPIError("Path endpoint returned an unexpected payload.")
|
|
187
|
+
return [str(item) for item in body]
|
|
188
|
+
|
|
189
|
+
def create_folder(self, parent_id: str, folder_name: str) -> Dict[str, Any]:
|
|
190
|
+
body = self.request(
|
|
191
|
+
"POST",
|
|
192
|
+
f"/api/res/{parent_id}/folder",
|
|
193
|
+
json={"folder_name": folder_name},
|
|
194
|
+
)
|
|
195
|
+
if not isinstance(body, dict):
|
|
196
|
+
raise PockloAPIError("Create-folder endpoint returned an unexpected payload.")
|
|
197
|
+
return body
|
|
198
|
+
|
|
199
|
+
def create_link(self, parent_id: str, link_name: str, link: str) -> Dict[str, Any]:
|
|
200
|
+
body = self.request(
|
|
201
|
+
"POST",
|
|
202
|
+
f"/api/res/{parent_id}/link",
|
|
203
|
+
json={"link_name": link_name, "link": link},
|
|
204
|
+
)
|
|
205
|
+
if not isinstance(body, dict):
|
|
206
|
+
raise PockloAPIError("Create-link endpoint returned an unexpected payload.")
|
|
207
|
+
return body
|
|
208
|
+
|
|
209
|
+
def update_resource(
|
|
210
|
+
self,
|
|
211
|
+
res_str_id: str,
|
|
212
|
+
*,
|
|
213
|
+
rname: Optional[str] = None,
|
|
214
|
+
status: Optional[int] = None,
|
|
215
|
+
description: Optional[str] = None,
|
|
216
|
+
visit_key: Optional[str] = None,
|
|
217
|
+
right_bubble: Optional[bool] = None,
|
|
218
|
+
parent_str_id: Optional[str] = None,
|
|
219
|
+
) -> Dict[str, Any]:
|
|
220
|
+
body = self.request(
|
|
221
|
+
"PUT",
|
|
222
|
+
f"/api/res/{res_str_id}",
|
|
223
|
+
json={
|
|
224
|
+
"rname": rname,
|
|
225
|
+
"status": status,
|
|
226
|
+
"description": description,
|
|
227
|
+
"visit_key": visit_key,
|
|
228
|
+
"right_bubble": right_bubble,
|
|
229
|
+
"parent_str_id": parent_str_id,
|
|
230
|
+
},
|
|
231
|
+
)
|
|
232
|
+
if not isinstance(body, dict):
|
|
233
|
+
raise PockloAPIError("Update-resource endpoint returned an unexpected payload.")
|
|
234
|
+
return body
|
|
235
|
+
|
|
236
|
+
def delete_resource(self, res_str_id: str) -> None:
|
|
237
|
+
self.request("DELETE", f"/api/res/{res_str_id}")
|
|
238
|
+
|
|
239
|
+
def _is_backend_json_api_response(
|
|
240
|
+
self,
|
|
241
|
+
response: requests.Response,
|
|
242
|
+
*,
|
|
243
|
+
requested_url: str,
|
|
244
|
+
) -> bool:
|
|
245
|
+
content_type = response.headers.get("content-type", "").lower()
|
|
246
|
+
if "application/json" not in content_type:
|
|
247
|
+
return False
|
|
248
|
+
|
|
249
|
+
content_disposition = response.headers.get("content-disposition", "").lower()
|
|
250
|
+
if "attachment" in content_disposition or "filename=" in content_disposition:
|
|
251
|
+
return False
|
|
252
|
+
|
|
253
|
+
final_url = urlparse(response.url)
|
|
254
|
+
backend_url = urlparse(self.settings.backend_url)
|
|
255
|
+
requested = urlparse(requested_url)
|
|
256
|
+
if (final_url.scheme, final_url.netloc) != (backend_url.scheme, backend_url.netloc):
|
|
257
|
+
return False
|
|
258
|
+
return final_url.path.rstrip("/") == requested.path.rstrip("/")
|
|
259
|
+
|
|
260
|
+
def download_resource(
|
|
261
|
+
self,
|
|
262
|
+
res_str_id: str,
|
|
263
|
+
destination: Path,
|
|
264
|
+
*,
|
|
265
|
+
progress_callback: Optional[ProgressCallback] = None,
|
|
266
|
+
) -> str:
|
|
267
|
+
url = urljoin(f"{self.settings.backend_url}/", f"/api/res/{res_str_id}/dl".lstrip("/"))
|
|
268
|
+
last_error: Optional[Exception] = None
|
|
269
|
+
for attempt in range(1, MAX_RETRIES + 1):
|
|
270
|
+
response: Optional[requests.Response] = None
|
|
271
|
+
try:
|
|
272
|
+
response = self.session.get(
|
|
273
|
+
url,
|
|
274
|
+
params={"token": self.settings.token or ""},
|
|
275
|
+
headers={},
|
|
276
|
+
timeout=self.timeout,
|
|
277
|
+
stream=True,
|
|
278
|
+
allow_redirects=True,
|
|
279
|
+
)
|
|
280
|
+
try:
|
|
281
|
+
response.raise_for_status()
|
|
282
|
+
except requests.HTTPError as exc:
|
|
283
|
+
raise PockloAPIError(
|
|
284
|
+
f"Download failed with HTTP {response.status_code}.",
|
|
285
|
+
status_code=response.status_code,
|
|
286
|
+
) from exc
|
|
287
|
+
|
|
288
|
+
if self._is_backend_json_api_response(response, requested_url=url):
|
|
289
|
+
self._unwrap_response(response)
|
|
290
|
+
raise PockloAPIError(
|
|
291
|
+
"Download endpoint returned JSON metadata instead of file content."
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
total_header = response.headers.get("content-length")
|
|
295
|
+
total = int(total_header) if total_header and total_header.isdigit() else None
|
|
296
|
+
if progress_callback:
|
|
297
|
+
progress_callback(0, total)
|
|
298
|
+
|
|
299
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
300
|
+
transferred = 0
|
|
301
|
+
with destination.open("wb") as handle:
|
|
302
|
+
for chunk in response.iter_content(chunk_size=1024 * 128):
|
|
303
|
+
if chunk:
|
|
304
|
+
handle.write(chunk)
|
|
305
|
+
transferred += len(chunk)
|
|
306
|
+
if progress_callback:
|
|
307
|
+
progress_callback(transferred, total)
|
|
308
|
+
return response.url
|
|
309
|
+
except PockloAPIError as exc:
|
|
310
|
+
last_error = exc
|
|
311
|
+
if attempt >= MAX_RETRIES or not self._should_retry_api_error(exc):
|
|
312
|
+
raise
|
|
313
|
+
except requests.RequestException as exc:
|
|
314
|
+
last_error = exc
|
|
315
|
+
if attempt >= MAX_RETRIES:
|
|
316
|
+
raise PockloAPIError(
|
|
317
|
+
f"Download failed after {MAX_RETRIES} attempts: {exc}"
|
|
318
|
+
) from exc
|
|
319
|
+
finally:
|
|
320
|
+
if response is not None:
|
|
321
|
+
response.close()
|
|
322
|
+
time.sleep(self._retry_delay(attempt))
|
|
323
|
+
|
|
324
|
+
if isinstance(last_error, PockloAPIError):
|
|
325
|
+
raise last_error
|
|
326
|
+
raise PockloAPIError(f"Download failed after {MAX_RETRIES} attempts.")
|
|
327
|
+
|
|
328
|
+
def get_upload_token(self, parent_id: str, filename: str) -> UploadToken:
|
|
329
|
+
body = self.request(
|
|
330
|
+
"GET",
|
|
331
|
+
f"/api/res/{parent_id}/token",
|
|
332
|
+
params={"filename": filename},
|
|
333
|
+
)
|
|
334
|
+
if not isinstance(body, dict):
|
|
335
|
+
raise PockloAPIError("Upload-token endpoint returned an unexpected payload.")
|
|
336
|
+
return UploadToken(key=body["key"], upload_token=body["upload_token"])
|
|
337
|
+
|
|
338
|
+
def upload_file(
|
|
339
|
+
self,
|
|
340
|
+
upload: UploadToken,
|
|
341
|
+
local_path: Path,
|
|
342
|
+
*,
|
|
343
|
+
progress_callback: Optional[ProgressCallback] = None,
|
|
344
|
+
phase_callback: Optional[UploadPhaseCallback] = None,
|
|
345
|
+
) -> Dict[str, Any]:
|
|
346
|
+
last_error: Optional[Exception] = None
|
|
347
|
+
file_size = local_path.stat().st_size
|
|
348
|
+
bucket_name = Auth.get_bucket_name(upload.upload_token)
|
|
349
|
+
if not bucket_name:
|
|
350
|
+
raise PockloAPIError("Upload token does not contain a valid bucket name.")
|
|
351
|
+
|
|
352
|
+
for attempt in range(1, MAX_RETRIES + 1):
|
|
353
|
+
try:
|
|
354
|
+
self.settings.upload_record_dir.mkdir(parents=True, exist_ok=True)
|
|
355
|
+
recorder = UploadProgressRecorder(str(self.settings.upload_record_dir))
|
|
356
|
+
uploader = ResumeUploaderV2(
|
|
357
|
+
bucket_name,
|
|
358
|
+
part_size=4 * 1024 * 1024,
|
|
359
|
+
progress_handler=progress_callback,
|
|
360
|
+
upload_progress_recorder=recorder,
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
context, resp = uploader.initial_parts(
|
|
364
|
+
up_token=upload.upload_token,
|
|
365
|
+
key=upload.key,
|
|
366
|
+
file_path=str(local_path),
|
|
367
|
+
file_name=local_path.name,
|
|
368
|
+
)
|
|
369
|
+
if resp is not None and not resp.ok():
|
|
370
|
+
raise PockloAPIError(
|
|
371
|
+
f"Failed to initialize multipart upload: {resp.error}",
|
|
372
|
+
status_code=resp.status_code,
|
|
373
|
+
debug_msg=resp.text_body,
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
with local_path.open("rb") as file_handle:
|
|
377
|
+
_, resp = uploader.upload_parts(
|
|
378
|
+
up_token=upload.upload_token,
|
|
379
|
+
data=file_handle,
|
|
380
|
+
data_size=file_size,
|
|
381
|
+
context=context,
|
|
382
|
+
key=upload.key,
|
|
383
|
+
file_name=local_path.name,
|
|
384
|
+
)
|
|
385
|
+
if resp is not None and not resp.ok():
|
|
386
|
+
raise PockloAPIError(
|
|
387
|
+
f"Failed while uploading file parts: {resp.error}",
|
|
388
|
+
status_code=resp.status_code,
|
|
389
|
+
debug_msg=resp.text_body,
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
if phase_callback:
|
|
393
|
+
phase_callback("finalizing")
|
|
394
|
+
|
|
395
|
+
body, resp = uploader.complete_parts(
|
|
396
|
+
up_token=upload.upload_token,
|
|
397
|
+
data_size=file_size,
|
|
398
|
+
context=context,
|
|
399
|
+
key=upload.key,
|
|
400
|
+
file_name=local_path.name,
|
|
401
|
+
)
|
|
402
|
+
if resp is not None and not resp.ok():
|
|
403
|
+
raise PockloAPIError(
|
|
404
|
+
f"Failed to finalize multipart upload: {resp.error}",
|
|
405
|
+
status_code=resp.status_code,
|
|
406
|
+
debug_msg=resp.text_body,
|
|
407
|
+
)
|
|
408
|
+
body = self._unwrap_payload(body, status_code=resp.status_code if resp is not None else None)
|
|
409
|
+
if not isinstance(body, dict):
|
|
410
|
+
raise PockloAPIError("Upload endpoint returned an unexpected payload.")
|
|
411
|
+
return body
|
|
412
|
+
except PockloAPIError as exc:
|
|
413
|
+
last_error = exc
|
|
414
|
+
if attempt >= MAX_RETRIES or not self._should_retry_api_error(exc):
|
|
415
|
+
raise
|
|
416
|
+
except requests.RequestException as exc:
|
|
417
|
+
last_error = exc
|
|
418
|
+
if attempt >= MAX_RETRIES:
|
|
419
|
+
raise PockloAPIError(
|
|
420
|
+
f"Upload failed after {MAX_RETRIES} attempts: {exc}"
|
|
421
|
+
) from exc
|
|
422
|
+
time.sleep(self._retry_delay(attempt))
|
|
423
|
+
|
|
424
|
+
if isinstance(last_error, PockloAPIError):
|
|
425
|
+
raise last_error
|
|
426
|
+
raise PockloAPIError(f"Upload failed after {MAX_RETRIES} attempts.")
|