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.
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ pocklo = pocklo_cli.cli:main
@@ -0,0 +1 @@
1
+ pocklo_cli
pocklo_cli/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ __all__ = ["__version__"]
2
+
3
+ __version__ = "0.2.0"
pocklo_cli/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ from .cli import main
2
+
3
+
4
+ if __name__ == "__main__":
5
+ raise SystemExit(main())
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.")