pywget 2.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.
pywget/core.py ADDED
@@ -0,0 +1,702 @@
1
+ #!/usr/bin/env python3
2
+
3
+ # File: pywget/core.py
4
+ # Author: Hadi Cahyadi <cumulus13@gmail.com>
5
+ # Date: 2026-10-25
6
+ # Description: Download engine: probing, segmented/resumable transfer, retries, verification.
7
+ # License: MIT
8
+
9
+ """Download engine: probing, segmented/resumable transfer, retries, verification."""
10
+
11
+ from __future__ import annotations
12
+
13
+ import base64
14
+ import dataclasses
15
+ import errno
16
+ import functools
17
+ import http.client
18
+ import json
19
+ import logging
20
+ import os
21
+ import random
22
+ import re
23
+ import shutil
24
+ import socket
25
+ import ssl
26
+ import threading
27
+ import time
28
+ import urllib.error
29
+ import urllib.request
30
+ from contextlib import closing
31
+ from dataclasses import dataclass, field
32
+ from email.utils import parsedate_to_datetime
33
+ from pathlib import Path
34
+ from typing import Any, Callable, Dict, List, Optional, Tuple
35
+ from urllib.parse import urlsplit
36
+
37
+ from .__version__ import __version__
38
+ from .errors import (
39
+ ChecksumError,
40
+ DownloadCancelled,
41
+ HTTPStatusError,
42
+ NetworkError,
43
+ PywgetError,
44
+ StorageError,
45
+ )
46
+ from .progress import ProgressInfo, Tracker
47
+ from .utils import (
48
+ RateLimiter,
49
+ filename_from_content_disposition,
50
+ filename_from_url,
51
+ format_size,
52
+ hash_file,
53
+ normalize_url,
54
+ parse_checksum,
55
+ parse_size,
56
+ sanitize_filename,
57
+ unique_path,
58
+ )
59
+
60
+ log = logging.getLogger("pywget")
61
+
62
+ _MIB = 1024 * 1024
63
+ _STATE_VERSION = 1
64
+ _NETWORK_EXC = (
65
+ TimeoutError,
66
+ socket.timeout,
67
+ ConnectionError,
68
+ http.client.HTTPException,
69
+ ssl.SSLError,
70
+ urllib.error.URLError,
71
+ EOFError,
72
+ )
73
+ _FATAL_ERRNO = {
74
+ getattr(errno, name)
75
+ for name in ("ENOSPC", "EACCES", "EROFS", "ENOENT", "EISDIR", "ENOTDIR", "EDQUOT", "EPERM")
76
+ if hasattr(errno, name)
77
+ }
78
+
79
+
80
+ @dataclass
81
+ class Options:
82
+ """All tunables of a :class:`Downloader`."""
83
+
84
+ output_dir: str = "."
85
+ filename: Optional[str] = None
86
+ connections: int = 4
87
+ min_segment_size: int = 2 * _MIB
88
+ retries: int = 5
89
+ backoff: float = 1.0
90
+ max_backoff: float = 30.0
91
+ timeout: float = 30.0
92
+ limit_rate: Any = 0 # bytes/s; accepts "500k", "2M"
93
+ resume: bool = True
94
+ overwrite: bool = False
95
+ preserve_mtime: bool = True
96
+ headers: Dict[str, str] = field(default_factory=dict)
97
+ user_agent: str = f"pywget/{__version__}"
98
+ username: Optional[str] = None
99
+ password: Optional[str] = None
100
+ proxy: Optional[str] = None
101
+ verify_ssl: bool = True
102
+ ca_file: Optional[str] = None
103
+ checksum: Optional[str] = None # "sha256:<hex>" or bare hex digest
104
+ chunk_size: int = 64 * 1024
105
+ progress: Optional[Callable[[ProgressInfo], None]] = None
106
+
107
+ def __post_init__(self) -> None:
108
+ if not 1 <= int(self.connections) <= 32:
109
+ raise ValueError("connections must be between 1 and 32")
110
+ if self.retries < 0:
111
+ raise ValueError("retries must be >= 0")
112
+ if self.timeout <= 0:
113
+ raise ValueError("timeout must be > 0")
114
+ if self.chunk_size < 1024:
115
+ raise ValueError("chunk_size must be >= 1024")
116
+ if self.min_segment_size < 1:
117
+ raise ValueError("min_segment_size must be >= 1")
118
+ self.limit_rate = parse_size(self.limit_rate) if self.limit_rate else 0
119
+ if self.checksum:
120
+ parse_checksum(self.checksum)
121
+
122
+
123
+ @dataclass
124
+ class RemoteInfo:
125
+ url: str
126
+ size: Optional[int]
127
+ accept_ranges: bool
128
+ etag: Optional[str] = None
129
+ last_modified: Optional[str] = None
130
+ filename: Optional[str] = None
131
+
132
+
133
+ @dataclass
134
+ class DownloadResult:
135
+ url: str
136
+ path: Path
137
+ size: int
138
+ elapsed: float
139
+ connections: int = 1
140
+ resumed: bool = False
141
+ skipped: bool = False
142
+ checksum: Optional[str] = None
143
+
144
+ @property
145
+ def speed(self) -> float:
146
+ return self.size / self.elapsed if self.elapsed > 0 and not self.skipped else 0.0
147
+
148
+ def to_dict(self) -> Dict[str, Any]:
149
+ return {
150
+ "url": self.url,
151
+ "path": str(self.path),
152
+ "size": self.size,
153
+ "elapsed": round(self.elapsed, 3),
154
+ "connections": self.connections,
155
+ "resumed": self.resumed,
156
+ "skipped": self.skipped,
157
+ "checksum": self.checksum,
158
+ }
159
+
160
+
161
+ class _SafeRedirectHandler(urllib.request.HTTPRedirectHandler):
162
+ """Follow only http(s) redirects; never leak credentials across origins."""
163
+
164
+ def redirect_request(self, req, fp, code, msg, headers, newurl): # type: ignore[override]
165
+ target = urlsplit(newurl)
166
+ if target.scheme.lower() not in ("http", "https"):
167
+ raise urllib.error.HTTPError(
168
+ req.full_url, code, "redirect to unsupported scheme blocked", headers, fp
169
+ )
170
+ new = super().redirect_request(req, fp, code, msg, headers, newurl)
171
+ if new is not None:
172
+ old = urlsplit(req.full_url)
173
+ if (old.scheme, old.hostname, old.port) != (target.scheme, target.hostname, target.port):
174
+ for name in ("Authorization", "Cookie", "Proxy-authorization"):
175
+ new.headers.pop(name, None)
176
+ new.unredirected_hdrs.pop(name, None)
177
+ return new
178
+
179
+
180
+ class _Worker(threading.Thread):
181
+ def __init__(self, target: Callable[[], None], name: str) -> None:
182
+ super().__init__(name=name, daemon=True)
183
+ self._target_fn = target
184
+ self.error: Optional[BaseException] = None
185
+
186
+ def run(self) -> None:
187
+ try:
188
+ self._target_fn()
189
+ except BaseException as exc: # noqa: BLE001 - propagated by the monitor
190
+ self.error = exc
191
+
192
+
193
+ def _is_retryable(exc: BaseException) -> bool:
194
+ if isinstance(exc, NetworkError):
195
+ return exc.retryable
196
+ if isinstance(exc, PywgetError):
197
+ return False
198
+ if isinstance(exc, ssl.SSLCertVerificationError):
199
+ return False
200
+ if isinstance(exc, OSError):
201
+ if exc.errno in _FATAL_ERRNO:
202
+ return False
203
+ return not isinstance(
204
+ exc, (PermissionError, FileNotFoundError, IsADirectoryError, NotADirectoryError)
205
+ )
206
+ return isinstance(exc, (http.client.HTTPException, EOFError))
207
+
208
+
209
+ def _parse_retry_after(value: Optional[str]) -> Optional[float]:
210
+ if not value:
211
+ return None
212
+ try:
213
+ return min(float(value), 120.0)
214
+ except ValueError:
215
+ pass
216
+ try:
217
+ delta = parsedate_to_datetime(value).timestamp() - time.time()
218
+ return min(max(delta, 0.0), 120.0)
219
+ except (TypeError, ValueError, OverflowError):
220
+ return None
221
+
222
+
223
+ def _write_all(handle: Any, data: bytes) -> None:
224
+ view = memoryview(data)
225
+ while view:
226
+ written = handle.write(view)
227
+ view = view[written:]
228
+
229
+
230
+ def _unlink(*paths: Path) -> None:
231
+ for path in paths:
232
+ try:
233
+ path.unlink()
234
+ except FileNotFoundError:
235
+ pass
236
+
237
+
238
+ class Downloader:
239
+ """Resumable, multi-connection HTTP(S) downloader.
240
+
241
+ A ``Downloader`` runs one download at a time; create one per thread.
242
+ """
243
+
244
+ def __init__(self, options: Optional[Options] = None, **overrides: Any) -> None:
245
+ opts = options or Options()
246
+ if overrides:
247
+ opts = dataclasses.replace(opts, **overrides)
248
+ self.options = opts
249
+ self._cancel = threading.Event()
250
+ self._auth: Optional[Tuple[str, str]] = None
251
+ self._last_report = 0.0
252
+ self._tracker: Optional[Tracker] = None
253
+ self._opener = self._build_opener()
254
+
255
+ # ------------------------------------------------------------------ public
256
+
257
+ def cancel(self) -> None:
258
+ """Ask a running download to stop (state is kept for resuming)."""
259
+ self._cancel.set()
260
+
261
+ def download(
262
+ self, url: str, output_dir: Optional[str] = None, filename: Optional[str] = None
263
+ ) -> Path:
264
+ return self.fetch(url, output_dir=output_dir, filename=filename).path
265
+
266
+ def fetch(
267
+ self, url: str, output_dir: Optional[str] = None, filename: Optional[str] = None
268
+ ) -> DownloadResult:
269
+ o = self.options
270
+ started = time.monotonic()
271
+ clean_url, user, password = normalize_url(url)
272
+ self._auth = (user, password or "") if user is not None else None
273
+ self._cancel.clear()
274
+ self._last_report = 0.0
275
+
276
+ info = self._with_retries(lambda: self._probe(clean_url), "probe")
277
+ explicit = filename or o.filename
278
+ name = sanitize_filename(
279
+ explicit
280
+ or info.filename
281
+ or filename_from_url(info.url)
282
+ or filename_from_url(clean_url)
283
+ )
284
+ dest_dir = Path(output_dir if output_dir is not None else o.output_dir).expanduser()
285
+ dest_dir.mkdir(parents=True, exist_ok=True)
286
+ dest = dest_dir / name
287
+ part = dest.with_name(dest.name + ".part")
288
+ state = dest.with_name(dest.name + ".pywget")
289
+
290
+ if dest.exists() and not o.overwrite:
291
+ if info.size is not None and dest.stat().st_size == info.size and not part.exists():
292
+ digest = self._verify(dest)
293
+ log.info("already complete: %s", dest)
294
+ return DownloadResult(
295
+ clean_url, dest, info.size, time.monotonic() - started,
296
+ skipped=True, checksum=digest,
297
+ )
298
+ dest = unique_path(dest)
299
+ part = dest.with_name(dest.name + ".part")
300
+ state = dest.with_name(dest.name + ".pywget")
301
+ elif o.overwrite:
302
+ _unlink(part, state)
303
+
304
+ tracker = Tracker(clean_url, dest.name, info.size)
305
+ self._tracker = tracker
306
+ limiter = RateLimiter(o.limit_rate)
307
+ resumed = False
308
+ used = 1
309
+
310
+ if info.size == 0:
311
+ part.write_bytes(b"")
312
+ elif info.size is not None and info.accept_ranges:
313
+ used = max(1, min(o.connections, info.size // o.min_segment_size))
314
+ resumed, used = self._run_segmented(clean_url, info, part, state, tracker, limiter, used)
315
+ else:
316
+ if info.size is not None and o.connections > 1:
317
+ log.debug("server has no Range support: single connection, no resume")
318
+ _unlink(state)
319
+ if info.size is not None:
320
+ self._check_space(dest_dir, info.size)
321
+ self._run_stream(clean_url, info, part, tracker, limiter)
322
+
323
+ final_size = part.stat().st_size
324
+ if info.size is not None and final_size != info.size:
325
+ _unlink(part, state)
326
+ raise NetworkError(f"size mismatch: expected {info.size}, got {final_size}")
327
+ self._fsync(part)
328
+ try:
329
+ digest = self._verify(part)
330
+ except ChecksumError:
331
+ _unlink(part, state)
332
+ raise
333
+ os.replace(part, dest)
334
+ _unlink(state)
335
+ self._apply_mtime(dest, info.last_modified)
336
+ self._report(tracker, finished=True)
337
+ elapsed = time.monotonic() - started
338
+ log.info("saved %s (%s in %.1fs)", dest, format_size(final_size), elapsed)
339
+ return DownloadResult(
340
+ clean_url, dest, final_size, elapsed, connections=used, resumed=resumed, checksum=digest
341
+ )
342
+
343
+ # ------------------------------------------------------------- HTTP layer
344
+
345
+ def _build_opener(self) -> urllib.request.OpenerDirector:
346
+ o = self.options
347
+ if o.verify_ssl:
348
+ ctx = ssl.create_default_context(cafile=o.ca_file)
349
+ else:
350
+ ctx = ssl.create_default_context()
351
+ ctx.check_hostname = False
352
+ ctx.verify_mode = ssl.CERT_NONE
353
+ handlers: List[Any] = [_SafeRedirectHandler()]
354
+ if o.proxy:
355
+ handlers.append(urllib.request.ProxyHandler({"http": o.proxy, "https": o.proxy}))
356
+ handlers.append(urllib.request.HTTPSHandler(context=ctx))
357
+ return urllib.request.build_opener(*handlers)
358
+
359
+ def _headers(self, extra: Optional[Dict[str, str]] = None) -> Dict[str, str]:
360
+ o = self.options
361
+ headers = {"User-Agent": o.user_agent, "Accept": "*/*", "Accept-Encoding": "identity"}
362
+ user, pw = self._auth if self._auth else (o.username, o.password)
363
+ if user is not None:
364
+ token = base64.b64encode(f"{user}:{pw or ''}".encode()).decode("ascii")
365
+ headers["Authorization"] = "Basic " + token
366
+ headers.update(o.headers)
367
+ if extra:
368
+ headers.update(extra)
369
+ return headers
370
+
371
+ def _open(self, url: str, extra: Optional[Dict[str, str]] = None) -> Any:
372
+ request = urllib.request.Request(url, headers=self._headers(extra))
373
+ try:
374
+ return self._opener.open(request, timeout=self.options.timeout)
375
+ except urllib.error.HTTPError as exc:
376
+ retry_after = _parse_retry_after(exc.headers.get("Retry-After") if exc.headers else None)
377
+ reason = exc.reason if isinstance(exc.reason, str) else ""
378
+ exc.close()
379
+ raise HTTPStatusError(exc.code, url, reason, retry_after) from None
380
+ except urllib.error.URLError as exc:
381
+ if isinstance(exc.reason, ssl.SSLCertVerificationError):
382
+ raise NetworkError(
383
+ f"TLS certificate verification failed for {url}: {exc.reason} "
384
+ "(use --no-check-certificate or --ca-cert)",
385
+ retryable=False,
386
+ ) from None
387
+ raise NetworkError(f"{url}: {exc.reason}") from None
388
+
389
+ def _probe(self, url: str) -> RemoteInfo:
390
+ try:
391
+ response = self._open(url, {"Range": "bytes=0-0"})
392
+ except HTTPStatusError as exc:
393
+ if exc.status == 416: # empty resource
394
+ return RemoteInfo(url=url, size=0, accept_ranges=False)
395
+ raise
396
+ with closing(response):
397
+ headers = response.headers
398
+ size: Optional[int] = None
399
+ ranges = False
400
+ if response.status == 206:
401
+ match = re.match(r"\s*bytes\s+\d+-\d+/(\d+|\*)", headers.get("Content-Range", ""))
402
+ if match and match.group(1) != "*":
403
+ size, ranges = int(match.group(1)), True
404
+ else:
405
+ length = (headers.get("Content-Length") or "").strip()
406
+ size = int(length) if length.isdigit() else None
407
+ return RemoteInfo(
408
+ url=response.geturl(),
409
+ size=size,
410
+ accept_ranges=ranges,
411
+ etag=headers.get("ETag"),
412
+ last_modified=headers.get("Last-Modified"),
413
+ filename=filename_from_content_disposition(headers.get("Content-Disposition")),
414
+ )
415
+
416
+ # ---------------------------------------------------------------- retries
417
+
418
+ def _sleep_backoff(self, attempt: int, exc: BaseException, what: str) -> None:
419
+ o = self.options
420
+ delay = min(o.max_backoff, o.backoff * (2 ** (attempt - 1))) * random.uniform(0.75, 1.25)
421
+ retry_after = getattr(exc, "retry_after", None)
422
+ if retry_after:
423
+ delay = max(delay, retry_after)
424
+ log.warning("%s failed (%s); retry %d/%d in %.1fs", what, exc, attempt, o.retries, delay)
425
+ self._cancel.wait(delay)
426
+
427
+ @staticmethod
428
+ def _final_error(exc: BaseException, what: str) -> BaseException:
429
+ if isinstance(exc, PywgetError):
430
+ return exc
431
+ if isinstance(exc, _NETWORK_EXC):
432
+ return NetworkError(f"{what}: {exc}", retryable=False)
433
+ return exc
434
+
435
+ def _with_retries(self, func: Callable[[], Any], what: str) -> Any:
436
+ attempt = 0
437
+ while True:
438
+ try:
439
+ return func()
440
+ except Exception as exc: # noqa: BLE001
441
+ if self._cancel.is_set():
442
+ raise DownloadCancelled("download cancelled") from None
443
+ if not _is_retryable(exc):
444
+ raise self._final_error(exc, what) from (None if isinstance(exc, PywgetError) else exc)
445
+ attempt += 1
446
+ if attempt > self.options.retries:
447
+ raise self._final_error(exc, what) from (None if isinstance(exc, PywgetError) else exc)
448
+ self._sleep_backoff(attempt, exc, what)
449
+
450
+ # --------------------------------------------------------------- transfer
451
+
452
+ def _read_size(self) -> int:
453
+ o = self.options
454
+ if o.limit_rate:
455
+ return max(4096, min(o.chunk_size, o.limit_rate // 4))
456
+ return o.chunk_size
457
+
458
+ def _run_segmented(
459
+ self,
460
+ url: str,
461
+ info: RemoteInfo,
462
+ part: Path,
463
+ state: Path,
464
+ tracker: Tracker,
465
+ limiter: RateLimiter,
466
+ connections: int,
467
+ ) -> Tuple[bool, int]:
468
+ o = self.options
469
+ size = int(info.size or 0)
470
+ segments = self._load_state(state, part, url, info) if o.resume else None
471
+ resumed = segments is not None
472
+ if segments is None:
473
+ _unlink(part, state)
474
+ self._check_space(part.parent, size)
475
+ segments = self._plan(size, connections)
476
+ with open(part, "wb") as handle:
477
+ handle.truncate(size)
478
+ else:
479
+ connections = len(segments)
480
+ log.info("resuming %s (%s already on disk)", part.name, format_size(sum(s[2] for s in segments)))
481
+ tracker.set(sum(s[2] for s in segments))
482
+ todo = [s for s in segments if s[2] < s[1] - s[0] + 1]
483
+ workers = [
484
+ _Worker(
485
+ functools.partial(self._fetch_segment, url, part, seg, size, tracker, limiter),
486
+ f"pywget-{i}",
487
+ )
488
+ for i, seg in enumerate(todo)
489
+ ]
490
+ self._monitor(workers, tracker, lambda: self._save_state(state, url, info, segments))
491
+ if any(s[2] != s[1] - s[0] + 1 for s in segments):
492
+ raise NetworkError("download ended with missing segments")
493
+ return resumed, connections
494
+
495
+ @staticmethod
496
+ def _plan(size: int, connections: int) -> List[List[int]]:
497
+ step = size // connections
498
+ return [
499
+ [i * step, (i + 1) * step - 1 if i < connections - 1 else size - 1, 0]
500
+ for i in range(connections)
501
+ ]
502
+
503
+ def _fetch_segment(
504
+ self,
505
+ url: str,
506
+ part: Path,
507
+ seg: List[int],
508
+ size: int,
509
+ tracker: Tracker,
510
+ limiter: RateLimiter,
511
+ ) -> None:
512
+ attempt = 0
513
+ read_size = self._read_size()
514
+ while not self._cancel.is_set():
515
+ start = seg[0] + seg[2]
516
+ if start > seg[1]:
517
+ return
518
+ before = seg[2]
519
+ try:
520
+ with closing(self._open(url, {"Range": f"bytes={start}-{seg[1]}"})) as resp:
521
+ if resp.status != 206 and not (
522
+ resp.status == 200 and start == 0 and seg[1] == size - 1
523
+ ):
524
+ raise NetworkError("server did not honour the Range request", retryable=False)
525
+ with open(part, "r+b", buffering=0) as handle:
526
+ handle.seek(start)
527
+ while not self._cancel.is_set():
528
+ remaining = seg[1] - (seg[0] + seg[2]) + 1
529
+ if remaining <= 0:
530
+ return
531
+ chunk = resp.read(min(read_size, remaining))
532
+ if not chunk:
533
+ raise http.client.IncompleteRead(b"", remaining)
534
+ limiter.acquire(len(chunk), self._cancel)
535
+ _write_all(handle, chunk)
536
+ seg[2] += len(chunk)
537
+ tracker.add(len(chunk))
538
+ except Exception as exc: # noqa: BLE001
539
+ if self._cancel.is_set():
540
+ return
541
+ if not _is_retryable(exc):
542
+ raise self._final_error(exc, "segment") from (None if isinstance(exc, PywgetError) else exc)
543
+ if seg[2] > before:
544
+ attempt = 0
545
+ attempt += 1
546
+ if attempt > self.options.retries:
547
+ raise self._final_error(exc, "segment") from (None if isinstance(exc, PywgetError) else exc)
548
+ self._sleep_backoff(attempt, exc, f"segment {seg[0]}-{seg[1]}")
549
+
550
+ def _run_stream(
551
+ self, url: str, info: RemoteInfo, part: Path, tracker: Tracker, limiter: RateLimiter
552
+ ) -> None:
553
+ read_size = self._read_size()
554
+
555
+ def attempt_once() -> None:
556
+ tracker.reset()
557
+ total = 0
558
+ with closing(self._open(url)) as resp, open(part, "wb") as handle:
559
+ while True:
560
+ if self._cancel.is_set():
561
+ raise DownloadCancelled("download cancelled")
562
+ chunk = resp.read(read_size)
563
+ if not chunk:
564
+ break
565
+ limiter.acquire(len(chunk), self._cancel)
566
+ handle.write(chunk)
567
+ total += len(chunk)
568
+ tracker.add(len(chunk))
569
+ if info.size is not None and total != info.size:
570
+ raise http.client.IncompleteRead(b"", info.size - total)
571
+
572
+ worker = _Worker(lambda: self._with_retries(attempt_once, "download"), "pywget-stream")
573
+ self._monitor([worker], tracker, None)
574
+
575
+ def _monitor(
576
+ self, workers: List[_Worker], tracker: Tracker, tick: Optional[Callable[[], None]]
577
+ ) -> None:
578
+ last_tick = 0.0
579
+ try:
580
+ for worker in workers:
581
+ worker.start()
582
+ while True:
583
+ error = next((w.error for w in workers if w.error is not None), None)
584
+ if error is not None:
585
+ raise error
586
+ alive = [w for w in workers if w.is_alive()]
587
+ if not alive:
588
+ break
589
+ alive[0].join(0.2)
590
+ self._report(tracker)
591
+ now = time.monotonic()
592
+ if tick and now - last_tick >= 1.0:
593
+ tick()
594
+ last_tick = now
595
+ except BaseException:
596
+ self._cancel.set()
597
+ if tick:
598
+ try:
599
+ tick()
600
+ except Exception: # noqa: BLE001
601
+ pass
602
+ for worker in workers:
603
+ if worker.is_alive():
604
+ worker.join(2.0)
605
+ raise
606
+ if self._cancel.is_set():
607
+ if tick:
608
+ tick()
609
+ raise DownloadCancelled("download cancelled")
610
+
611
+ def _report(self, tracker: Tracker, finished: bool = False) -> None:
612
+ callback = self.options.progress
613
+ if callback is None:
614
+ return
615
+ now = time.monotonic()
616
+ if not finished and now - self._last_report < 0.1:
617
+ return
618
+ self._last_report = now
619
+ callback(tracker.snapshot(finished))
620
+
621
+ # ------------------------------------------------------------------ state
622
+
623
+ @staticmethod
624
+ def _save_state(state: Path, url: str, info: RemoteInfo, segments: List[List[int]]) -> None:
625
+ data = {
626
+ "version": _STATE_VERSION,
627
+ "url": url,
628
+ "size": info.size,
629
+ "etag": info.etag,
630
+ "last_modified": info.last_modified,
631
+ "segments": [list(s) for s in segments],
632
+ }
633
+ tmp = state.with_name(state.name + ".tmp")
634
+ tmp.write_text(json.dumps(data), encoding="utf-8")
635
+ os.replace(tmp, state)
636
+
637
+ @staticmethod
638
+ def _load_state(
639
+ state: Path, part: Path, url: str, info: RemoteInfo
640
+ ) -> Optional[List[List[int]]]:
641
+ try:
642
+ data = json.loads(state.read_text(encoding="utf-8"))
643
+ if (
644
+ data["version"] != _STATE_VERSION
645
+ or data["url"] != url
646
+ or data["size"] != info.size
647
+ or (data.get("etag") or None) != (info.etag or None)
648
+ or (data.get("last_modified") or None) != (info.last_modified or None)
649
+ ):
650
+ return None
651
+ segments = [[int(a), int(b), int(c)] for a, b, c in data["segments"]]
652
+ expected_start = 0
653
+ for begin, end, done in sorted(segments):
654
+ if begin != expected_start or end < begin or not 0 <= done <= end - begin + 1:
655
+ return None
656
+ expected_start = end + 1
657
+ if expected_start != info.size or part.stat().st_size != info.size:
658
+ return None
659
+ return sorted(segments)
660
+ except (OSError, ValueError, KeyError, TypeError):
661
+ return None
662
+
663
+ # ---------------------------------------------------------------- helpers
664
+
665
+ @staticmethod
666
+ def _check_space(directory: Path, needed: int) -> None:
667
+ try:
668
+ free = shutil.disk_usage(directory).free
669
+ except OSError:
670
+ return
671
+ if needed > free:
672
+ raise StorageError(
673
+ f"not enough disk space in {directory}: need {format_size(needed)}, "
674
+ f"have {format_size(free)}"
675
+ )
676
+
677
+ @staticmethod
678
+ def _fsync(path: Path) -> None:
679
+ try:
680
+ with open(path, "rb") as handle:
681
+ os.fsync(handle.fileno())
682
+ except OSError:
683
+ pass
684
+
685
+ def _verify(self, path: Path) -> Optional[str]:
686
+ spec = self.options.checksum
687
+ if not spec:
688
+ return None
689
+ algo, expected = parse_checksum(spec)
690
+ actual = hash_file(path, algo)
691
+ if actual != expected:
692
+ raise ChecksumError(f"{algo} mismatch for {path.name}: expected {expected}, got {actual}")
693
+ return f"{algo}:{actual}"
694
+
695
+ def _apply_mtime(self, path: Path, last_modified: Optional[str]) -> None:
696
+ if not (self.options.preserve_mtime and last_modified):
697
+ return
698
+ try:
699
+ stamp = parsedate_to_datetime(last_modified).timestamp()
700
+ os.utime(path, (stamp, stamp))
701
+ except (TypeError, ValueError, OverflowError, OSError):
702
+ pass