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.
@@ -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
+ )