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/__init__.py +58 -0
- pywget/__main__.py +13 -0
- pywget/__version__.py +4 -0
- pywget/cli.py +195 -0
- pywget/core.py +702 -0
- pywget/errors.py +65 -0
- pywget/progress.py +158 -0
- pywget/py.typed +0 -0
- pywget/utils.py +180 -0
- pywget/wget.py +41 -0
- pywget-2.0.2.dist-info/METADATA +370 -0
- pywget-2.0.2.dist-info/RECORD +16 -0
- pywget-2.0.2.dist-info/WHEEL +5 -0
- pywget-2.0.2.dist-info/entry_points.txt +2 -0
- pywget-2.0.2.dist-info/licenses/LICENSE +29 -0
- pywget-2.0.2.dist-info/top_level.txt +1 -0
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
|