dexverse-cli 0.0.2__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.
- dexverse/__init__.py +54 -0
- dexverse/cli.py +422 -0
- dexverse/client.py +128 -0
- dexverse/config.py +85 -0
- dexverse/convert/__init__.py +38 -0
- dexverse/convert/hdf5_to_raw.py +498 -0
- dexverse/convert/image_video_encode.py +449 -0
- dexverse/downloader/__init__.py +26 -0
- dexverse/downloader/base.py +101 -0
- dexverse/downloader/http_backend.py +508 -0
- dexverse/downloader/manager.py +365 -0
- dexverse/downloader/python_s3_backend.py +137 -0
- dexverse/downloader/s5cmd_backend.py +170 -0
- dexverse/downloader/state.py +263 -0
- dexverse/downloader/verify.py +114 -0
- dexverse/manifest.py +284 -0
- dexverse/mock/__init__.py +2 -0
- dexverse/mock/mock_tasks.json +22 -0
- dexverse/mock/server.py +211 -0
- dexverse_cli-0.0.2.dist-info/METADATA +198 -0
- dexverse_cli-0.0.2.dist-info/RECORD +23 -0
- dexverse_cli-0.0.2.dist-info/WHEEL +4 -0
- dexverse_cli-0.0.2.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
"""HTTP / presigned-URL backend with retry and single-file resume support."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Dict, List, Optional, Tuple
|
|
12
|
+
from urllib.parse import urlparse
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
from ..manifest import FileSpec, Manifest, resolve_dest
|
|
17
|
+
from .base import FileResult, ProgressReporter
|
|
18
|
+
|
|
19
|
+
_HTTP_BACKEND_NAMES = ("http", "https", "presigned")
|
|
20
|
+
_ALLOWED_SCHEMES = ("http", "https")
|
|
21
|
+
_RETRYABLE_STATUS_CODES = {408, 425, 429, 500, 502, 503, 504}
|
|
22
|
+
_CONTENT_RANGE_RE = re.compile(r"^bytes (\d+)-(\d+)/(\d+|\*)$")
|
|
23
|
+
_UNSATISFIED_RANGE_RE = re.compile(r"^bytes \*/(\d+)$")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class _DownloadError(Exception):
|
|
27
|
+
"""A permanent response/protocol error for one file."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class _RetryableDownloadError(Exception):
|
|
31
|
+
"""A response/protocol error worth retrying."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, message: str, retry_after: Optional[float] = None):
|
|
34
|
+
super().__init__(message)
|
|
35
|
+
self.retry_after = retry_after
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class _RestartDownload(Exception):
|
|
39
|
+
"""The partial file cannot be appended safely and must be restarted."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _validate_presigned_url(url: str) -> None:
|
|
43
|
+
"""Reject non-http(s) schemes and optionally enforce a host allowlist."""
|
|
44
|
+
parsed = urlparse(url)
|
|
45
|
+
if parsed.scheme not in _ALLOWED_SCHEMES:
|
|
46
|
+
raise ValueError(
|
|
47
|
+
f"unsupported presignedUrl scheme {parsed.scheme!r}; "
|
|
48
|
+
f"expected one of {_ALLOWED_SCHEMES}"
|
|
49
|
+
)
|
|
50
|
+
allow = os.getenv("DEXVERSE_ALLOWED_HOSTS")
|
|
51
|
+
if allow:
|
|
52
|
+
allowed = {h.strip().lower() for h in allow.split(",") if h.strip()}
|
|
53
|
+
host = (parsed.hostname or "").lower()
|
|
54
|
+
if host and host not in allowed:
|
|
55
|
+
raise ValueError(
|
|
56
|
+
f"presignedUrl host {host!r} not in DEXVERSE_ALLOWED_HOSTS"
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _part_paths(dest: Path) -> Tuple[Path, Path]:
|
|
61
|
+
part = dest.with_name(dest.name + ".part")
|
|
62
|
+
return part, part.with_name(part.name + ".meta")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _load_part_meta(path: Path) -> Dict[str, object]:
|
|
66
|
+
try:
|
|
67
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
68
|
+
except (OSError, ValueError, TypeError):
|
|
69
|
+
return {}
|
|
70
|
+
return data if isinstance(data, dict) else {}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _save_part_meta(
|
|
74
|
+
path: Path,
|
|
75
|
+
*,
|
|
76
|
+
etag: Optional[str],
|
|
77
|
+
expected_size: Optional[int],
|
|
78
|
+
) -> None:
|
|
79
|
+
data = {"etag": etag, "expectedSize": expected_size}
|
|
80
|
+
staging = path.with_name(path.name + ".tmp")
|
|
81
|
+
staging.write_text(json.dumps(data, separators=(",", ":")), encoding="utf-8")
|
|
82
|
+
staging.replace(path)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _parse_int_header(value: Optional[str]) -> Optional[int]:
|
|
86
|
+
if value is None or not value.isdigit():
|
|
87
|
+
return None
|
|
88
|
+
return int(value)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _parse_content_range(value: Optional[str]) -> Optional[Tuple[int, int, Optional[int]]]:
|
|
92
|
+
if not value:
|
|
93
|
+
return None
|
|
94
|
+
match = _CONTENT_RANGE_RE.match(value.strip())
|
|
95
|
+
if not match:
|
|
96
|
+
return None
|
|
97
|
+
start, end, total = match.groups()
|
|
98
|
+
return int(start), int(end), None if total == "*" else int(total)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _retry_after_seconds(value: Optional[str]) -> Optional[float]:
|
|
102
|
+
if value is None:
|
|
103
|
+
return None
|
|
104
|
+
try:
|
|
105
|
+
return max(0.0, min(float(value), 60.0))
|
|
106
|
+
except ValueError:
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _file_size(path: Path) -> int:
|
|
111
|
+
try:
|
|
112
|
+
return path.stat().st_size
|
|
113
|
+
except OSError:
|
|
114
|
+
return 0
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class HttpBackend:
|
|
118
|
+
name = "http"
|
|
119
|
+
|
|
120
|
+
def __init__(
|
|
121
|
+
self,
|
|
122
|
+
*,
|
|
123
|
+
verify: bool = True,
|
|
124
|
+
timeout: float = 120.0,
|
|
125
|
+
connect_timeout: float = 30.0,
|
|
126
|
+
max_retries: int = 5,
|
|
127
|
+
retry_backoff: float = 1.0,
|
|
128
|
+
client_factory=None,
|
|
129
|
+
):
|
|
130
|
+
if timeout <= 0 or connect_timeout <= 0:
|
|
131
|
+
raise ValueError("HTTP timeouts must be greater than zero")
|
|
132
|
+
if max_retries < 0:
|
|
133
|
+
raise ValueError("max_retries must be zero or greater")
|
|
134
|
+
if retry_backoff < 0:
|
|
135
|
+
raise ValueError("retry_backoff must be zero or greater")
|
|
136
|
+
self.verify = verify
|
|
137
|
+
self.timeout = timeout
|
|
138
|
+
self.connect_timeout = connect_timeout
|
|
139
|
+
self.max_retries = max_retries
|
|
140
|
+
self.retry_backoff = retry_backoff
|
|
141
|
+
self._client_factory = client_factory
|
|
142
|
+
self._tls = threading.local()
|
|
143
|
+
self._clients: List[httpx.Client] = []
|
|
144
|
+
self._clients_lock = threading.Lock()
|
|
145
|
+
self._log_lock = threading.Lock()
|
|
146
|
+
|
|
147
|
+
def _client(self) -> httpx.Client:
|
|
148
|
+
cached = getattr(self._tls, "client", None)
|
|
149
|
+
if cached is None:
|
|
150
|
+
if self._client_factory:
|
|
151
|
+
cached = self._client_factory()
|
|
152
|
+
else:
|
|
153
|
+
cached = httpx.Client(
|
|
154
|
+
timeout=httpx.Timeout(
|
|
155
|
+
self.timeout,
|
|
156
|
+
connect=self.connect_timeout,
|
|
157
|
+
),
|
|
158
|
+
verify=self.verify,
|
|
159
|
+
follow_redirects=True,
|
|
160
|
+
)
|
|
161
|
+
self._tls.client = cached
|
|
162
|
+
with self._clients_lock:
|
|
163
|
+
self._clients.append(cached)
|
|
164
|
+
return cached
|
|
165
|
+
|
|
166
|
+
def download(
|
|
167
|
+
self,
|
|
168
|
+
manifest: Manifest,
|
|
169
|
+
files: List[FileSpec],
|
|
170
|
+
out: Path,
|
|
171
|
+
*,
|
|
172
|
+
parallel: int,
|
|
173
|
+
log_path: Optional[Path] = None,
|
|
174
|
+
progress: Optional[ProgressReporter] = None,
|
|
175
|
+
) -> Dict[str, FileResult]:
|
|
176
|
+
if not files:
|
|
177
|
+
return {}
|
|
178
|
+
workers = max(1, min(parallel or 4, 32))
|
|
179
|
+
results: Dict[str, FileResult] = {}
|
|
180
|
+
|
|
181
|
+
def _one(file_spec: FileSpec) -> FileResult:
|
|
182
|
+
url = file_spec.presignedUrl
|
|
183
|
+
key = file_spec.effective_key()
|
|
184
|
+
if not url:
|
|
185
|
+
return FileResult(key, False, error="no presignedUrl")
|
|
186
|
+
try:
|
|
187
|
+
_validate_presigned_url(url)
|
|
188
|
+
except ValueError as exc:
|
|
189
|
+
return FileResult(key, False, error=str(exc))
|
|
190
|
+
|
|
191
|
+
dest = resolve_dest(out, file_spec.relpath())
|
|
192
|
+
part, meta_path = _part_paths(dest)
|
|
193
|
+
part.parent.mkdir(parents=True, exist_ok=True)
|
|
194
|
+
if not part.exists():
|
|
195
|
+
meta_path.unlink(missing_ok=True)
|
|
196
|
+
elif file_spec.size is not None and _file_size(part) > file_spec.size:
|
|
197
|
+
part.unlink(missing_ok=True)
|
|
198
|
+
meta_path.unlink(missing_ok=True)
|
|
199
|
+
|
|
200
|
+
last_error = ""
|
|
201
|
+
for attempt in range(self.max_retries + 1):
|
|
202
|
+
try:
|
|
203
|
+
expected_size = self._download_once(
|
|
204
|
+
file_spec,
|
|
205
|
+
url,
|
|
206
|
+
part,
|
|
207
|
+
meta_path,
|
|
208
|
+
progress,
|
|
209
|
+
)
|
|
210
|
+
local_size = _file_size(part)
|
|
211
|
+
if expected_size is not None and local_size < expected_size:
|
|
212
|
+
raise _RetryableDownloadError(
|
|
213
|
+
f"incomplete response: got {local_size}, "
|
|
214
|
+
f"expected {expected_size}"
|
|
215
|
+
)
|
|
216
|
+
if expected_size is not None and local_size > expected_size:
|
|
217
|
+
part.unlink(missing_ok=True)
|
|
218
|
+
meta_path.unlink(missing_ok=True)
|
|
219
|
+
raise _DownloadError(
|
|
220
|
+
f"size mismatch: got {local_size}, "
|
|
221
|
+
f"expected {expected_size}"
|
|
222
|
+
)
|
|
223
|
+
part.replace(dest)
|
|
224
|
+
meta_path.unlink(missing_ok=True)
|
|
225
|
+
return FileResult(
|
|
226
|
+
key,
|
|
227
|
+
True,
|
|
228
|
+
localSize=local_size,
|
|
229
|
+
expectedSize=expected_size,
|
|
230
|
+
)
|
|
231
|
+
except _RestartDownload as exc:
|
|
232
|
+
last_error = str(exc)
|
|
233
|
+
part.unlink(missing_ok=True)
|
|
234
|
+
meta_path.unlink(missing_ok=True)
|
|
235
|
+
retryable = True
|
|
236
|
+
retry_after = 0.0
|
|
237
|
+
except _RetryableDownloadError as exc:
|
|
238
|
+
last_error = str(exc)
|
|
239
|
+
retryable = True
|
|
240
|
+
retry_after = exc.retry_after
|
|
241
|
+
except (httpx.TransportError, httpx.StreamError) as exc:
|
|
242
|
+
last_error = str(exc) or type(exc).__name__
|
|
243
|
+
retryable = True
|
|
244
|
+
retry_after = None
|
|
245
|
+
except _DownloadError as exc:
|
|
246
|
+
return self._failed_result(
|
|
247
|
+
key, str(exc), file_spec, part, meta_path
|
|
248
|
+
)
|
|
249
|
+
except Exception as exc: # noqa: BLE001 - per-file failure
|
|
250
|
+
return self._failed_result(
|
|
251
|
+
key,
|
|
252
|
+
str(exc) or type(exc).__name__,
|
|
253
|
+
file_spec,
|
|
254
|
+
part,
|
|
255
|
+
meta_path,
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
if not retryable or attempt >= self.max_retries:
|
|
259
|
+
break
|
|
260
|
+
delay = (
|
|
261
|
+
retry_after
|
|
262
|
+
if retry_after is not None
|
|
263
|
+
else min(self.retry_backoff * (2 ** attempt), 30.0)
|
|
264
|
+
)
|
|
265
|
+
self._append_log(
|
|
266
|
+
log_path,
|
|
267
|
+
f" RETRY {key}: attempt={attempt + 2}/"
|
|
268
|
+
f"{self.max_retries + 1} offset={_file_size(part)} "
|
|
269
|
+
f"delay={delay:g}s reason={last_error}\n",
|
|
270
|
+
)
|
|
271
|
+
if delay:
|
|
272
|
+
time.sleep(delay)
|
|
273
|
+
|
|
274
|
+
return self._failed_result(
|
|
275
|
+
key,
|
|
276
|
+
f"{last_error}; exhausted {self.max_retries + 1} attempts",
|
|
277
|
+
file_spec,
|
|
278
|
+
part,
|
|
279
|
+
meta_path,
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
try:
|
|
283
|
+
with ThreadPoolExecutor(max_workers=workers) as pool:
|
|
284
|
+
futures = [pool.submit(_one, f) for f in files]
|
|
285
|
+
for future in as_completed(futures):
|
|
286
|
+
result = future.result()
|
|
287
|
+
results[result.objectKey] = result
|
|
288
|
+
if progress is not None:
|
|
289
|
+
progress.complete_file()
|
|
290
|
+
finally:
|
|
291
|
+
self._close_thread_clients()
|
|
292
|
+
|
|
293
|
+
self._log(log_path, files, results)
|
|
294
|
+
return results
|
|
295
|
+
|
|
296
|
+
def _download_once(
|
|
297
|
+
self,
|
|
298
|
+
file_spec: FileSpec,
|
|
299
|
+
url: str,
|
|
300
|
+
part: Path,
|
|
301
|
+
meta_path: Path,
|
|
302
|
+
progress: Optional[ProgressReporter],
|
|
303
|
+
) -> Optional[int]:
|
|
304
|
+
offset = _file_size(part)
|
|
305
|
+
saved_meta = _load_part_meta(meta_path) if offset else {}
|
|
306
|
+
saved_etag = saved_meta.get("etag")
|
|
307
|
+
saved_expected = saved_meta.get("expectedSize")
|
|
308
|
+
if not isinstance(saved_etag, str):
|
|
309
|
+
saved_etag = None
|
|
310
|
+
if not isinstance(saved_expected, int):
|
|
311
|
+
saved_expected = None
|
|
312
|
+
|
|
313
|
+
headers = {"Accept-Encoding": "identity"}
|
|
314
|
+
if offset:
|
|
315
|
+
headers["Range"] = f"bytes={offset}-"
|
|
316
|
+
if saved_etag:
|
|
317
|
+
headers["If-Range"] = saved_etag
|
|
318
|
+
|
|
319
|
+
with self._client().stream("GET", url, headers=headers) as response:
|
|
320
|
+
status = response.status_code
|
|
321
|
+
if status == 416 and offset:
|
|
322
|
+
value = response.headers.get("content-range", "").strip()
|
|
323
|
+
match = _UNSATISFIED_RANGE_RE.match(value)
|
|
324
|
+
total = int(match.group(1)) if match else None
|
|
325
|
+
if file_spec.size is not None:
|
|
326
|
+
expected = file_spec.size
|
|
327
|
+
elif saved_expected is not None:
|
|
328
|
+
expected = saved_expected
|
|
329
|
+
else:
|
|
330
|
+
expected = total
|
|
331
|
+
if total == offset and (expected is None or expected == total):
|
|
332
|
+
return expected
|
|
333
|
+
if total is not None and offset > total:
|
|
334
|
+
raise _RestartDownload(
|
|
335
|
+
f"partial file is larger than remote object ({offset} > {total})"
|
|
336
|
+
)
|
|
337
|
+
raise _DownloadError(
|
|
338
|
+
f"HTTP 416 for {file_spec.effective_key()} at offset {offset}"
|
|
339
|
+
)
|
|
340
|
+
if status in _RETRYABLE_STATUS_CODES:
|
|
341
|
+
raise _RetryableDownloadError(
|
|
342
|
+
f"HTTP {status} for {file_spec.effective_key()}",
|
|
343
|
+
_retry_after_seconds(response.headers.get("retry-after")),
|
|
344
|
+
)
|
|
345
|
+
if status >= 400:
|
|
346
|
+
raise _DownloadError(
|
|
347
|
+
f"HTTP {status} for {file_spec.effective_key()}"
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
content_length = _parse_int_header(
|
|
351
|
+
response.headers.get("content-length")
|
|
352
|
+
)
|
|
353
|
+
response_etag = response.headers.get("etag")
|
|
354
|
+
content_range = _parse_content_range(
|
|
355
|
+
response.headers.get("content-range")
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
if status == 206:
|
|
359
|
+
if content_range is None:
|
|
360
|
+
raise _DownloadError(
|
|
361
|
+
"HTTP 206 response is missing a valid Content-Range"
|
|
362
|
+
)
|
|
363
|
+
range_start, range_end, range_total = content_range
|
|
364
|
+
if range_start != offset:
|
|
365
|
+
raise _RestartDownload(
|
|
366
|
+
f"server resumed at offset {range_start}, requested {offset}"
|
|
367
|
+
)
|
|
368
|
+
if range_end < range_start:
|
|
369
|
+
raise _DownloadError("invalid Content-Range end offset")
|
|
370
|
+
if range_total is not None and range_end >= range_total:
|
|
371
|
+
raise _DownloadError("Content-Range exceeds remote object size")
|
|
372
|
+
if (
|
|
373
|
+
content_length is not None
|
|
374
|
+
and range_end - range_start + 1 != content_length
|
|
375
|
+
):
|
|
376
|
+
raise _DownloadError(
|
|
377
|
+
"Content-Length does not match Content-Range"
|
|
378
|
+
)
|
|
379
|
+
if saved_etag and response_etag and saved_etag != response_etag:
|
|
380
|
+
raise _RestartDownload("remote object changed while resuming")
|
|
381
|
+
response_expected = range_total
|
|
382
|
+
if response_expected is None and content_length is not None:
|
|
383
|
+
response_expected = offset + content_length
|
|
384
|
+
mode = "ab"
|
|
385
|
+
base_size = offset
|
|
386
|
+
else:
|
|
387
|
+
# A server may ignore Range or reject If-Range by returning 200.
|
|
388
|
+
# Overwrite instead of appending so the final file cannot contain
|
|
389
|
+
# a duplicated prefix.
|
|
390
|
+
response_expected = content_length
|
|
391
|
+
mode = "wb"
|
|
392
|
+
base_size = 0
|
|
393
|
+
|
|
394
|
+
if (
|
|
395
|
+
file_spec.size is not None
|
|
396
|
+
and response_expected is not None
|
|
397
|
+
and file_spec.size != response_expected
|
|
398
|
+
):
|
|
399
|
+
raise _DownloadError(
|
|
400
|
+
f"remote size {response_expected} does not match manifest "
|
|
401
|
+
f"size {file_spec.size}"
|
|
402
|
+
)
|
|
403
|
+
if (
|
|
404
|
+
status == 206
|
|
405
|
+
and saved_expected is not None
|
|
406
|
+
and response_expected is not None
|
|
407
|
+
and saved_expected != response_expected
|
|
408
|
+
):
|
|
409
|
+
raise _RestartDownload("remote object size changed while resuming")
|
|
410
|
+
|
|
411
|
+
if file_spec.size is not None:
|
|
412
|
+
expected_size = file_spec.size
|
|
413
|
+
elif response_expected is not None:
|
|
414
|
+
expected_size = response_expected
|
|
415
|
+
else:
|
|
416
|
+
expected_size = saved_expected
|
|
417
|
+
_save_part_meta(
|
|
418
|
+
meta_path,
|
|
419
|
+
etag=response_etag or saved_etag,
|
|
420
|
+
expected_size=expected_size,
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
size = base_size
|
|
424
|
+
with part.open(mode) as file_handle:
|
|
425
|
+
# Use transport-native chunks. Supplying a large chunk_size makes
|
|
426
|
+
# httpx buffer a short tail in memory, which is then lost if the
|
|
427
|
+
# connection times out before that buffer fills.
|
|
428
|
+
for chunk in response.iter_bytes():
|
|
429
|
+
if not chunk:
|
|
430
|
+
continue
|
|
431
|
+
file_handle.write(chunk)
|
|
432
|
+
size += len(chunk)
|
|
433
|
+
if progress is not None:
|
|
434
|
+
progress.advance(len(chunk))
|
|
435
|
+
|
|
436
|
+
if expected_size is not None and size < expected_size:
|
|
437
|
+
raise _RetryableDownloadError(
|
|
438
|
+
f"incomplete response: got {size}, expected {expected_size}"
|
|
439
|
+
)
|
|
440
|
+
return expected_size
|
|
441
|
+
|
|
442
|
+
def _failed_result(
|
|
443
|
+
self,
|
|
444
|
+
key: str,
|
|
445
|
+
error: str,
|
|
446
|
+
file_spec: FileSpec,
|
|
447
|
+
part: Path,
|
|
448
|
+
meta_path: Path,
|
|
449
|
+
) -> FileResult:
|
|
450
|
+
local_size = _file_size(part) if part.exists() else None
|
|
451
|
+
meta = _load_part_meta(meta_path)
|
|
452
|
+
meta_expected = meta.get("expectedSize")
|
|
453
|
+
expected_size = (
|
|
454
|
+
file_spec.size
|
|
455
|
+
if file_spec.size is not None
|
|
456
|
+
else meta_expected if isinstance(meta_expected, int) else None
|
|
457
|
+
)
|
|
458
|
+
if local_size:
|
|
459
|
+
error = (
|
|
460
|
+
f"{error}; partial download retained at {part.name} "
|
|
461
|
+
f"({local_size} bytes)"
|
|
462
|
+
)
|
|
463
|
+
return FileResult(
|
|
464
|
+
key,
|
|
465
|
+
False,
|
|
466
|
+
error=error,
|
|
467
|
+
localSize=local_size,
|
|
468
|
+
expectedSize=expected_size,
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
def _close_thread_clients(self) -> None:
|
|
472
|
+
with self._clients_lock:
|
|
473
|
+
clients, self._clients = self._clients, []
|
|
474
|
+
for client in clients:
|
|
475
|
+
try:
|
|
476
|
+
client.close()
|
|
477
|
+
except Exception:
|
|
478
|
+
pass
|
|
479
|
+
|
|
480
|
+
def _append_log(self, log_path: Optional[Path], message: str) -> None:
|
|
481
|
+
if not log_path:
|
|
482
|
+
return
|
|
483
|
+
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
484
|
+
with self._log_lock:
|
|
485
|
+
with log_path.open("a", encoding="utf-8") as log_file:
|
|
486
|
+
log_file.write(message)
|
|
487
|
+
|
|
488
|
+
def _log(
|
|
489
|
+
self,
|
|
490
|
+
log_path: Optional[Path],
|
|
491
|
+
files: List[FileSpec],
|
|
492
|
+
results: Dict[str, FileResult],
|
|
493
|
+
) -> None:
|
|
494
|
+
if not log_path:
|
|
495
|
+
return
|
|
496
|
+
ok = sum(1 for result in results.values() if result.ok)
|
|
497
|
+
self._append_log(
|
|
498
|
+
log_path,
|
|
499
|
+
f"[http backend] files={len(files)} ok={ok} "
|
|
500
|
+
f"verify_ssl={self.verify} read_timeout={self.timeout:g}s "
|
|
501
|
+
f"max_retries={self.max_retries}\n",
|
|
502
|
+
)
|
|
503
|
+
for result in results.values():
|
|
504
|
+
if not result.ok:
|
|
505
|
+
self._append_log(
|
|
506
|
+
log_path,
|
|
507
|
+
f" FAIL {result.objectKey}: {result.error}\n",
|
|
508
|
+
)
|