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/errors.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
# File: pywget/errors.py
|
|
4
|
+
# Author: Hadi Cahyadi <cumulus13@gmail.com>
|
|
5
|
+
# Date: 2026-10-25
|
|
6
|
+
# Description: Exception hierarchy. Every exception carries a CLI ``exit_code``.
|
|
7
|
+
# License: MIT
|
|
8
|
+
|
|
9
|
+
"""Exception hierarchy. Every exception carries a CLI ``exit_code``."""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
RETRY_STATUS = frozenset({408, 425, 429, 500, 502, 503, 504})
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class PywgetError(Exception):
|
|
19
|
+
"""Base class for all pywget errors."""
|
|
20
|
+
|
|
21
|
+
exit_code = 1
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class InvalidURLError(PywgetError):
|
|
25
|
+
exit_code = 2
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class NetworkError(PywgetError):
|
|
29
|
+
"""Transport-level failure. ``retryable`` tells the engine whether to retry."""
|
|
30
|
+
|
|
31
|
+
exit_code = 3
|
|
32
|
+
|
|
33
|
+
def __init__(self, message: str, retryable: bool = True) -> None:
|
|
34
|
+
super().__init__(message)
|
|
35
|
+
self.retryable = retryable
|
|
36
|
+
self.retry_after: Optional[float] = None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class HTTPStatusError(NetworkError):
|
|
40
|
+
exit_code = 4
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
status: int,
|
|
45
|
+
url: str,
|
|
46
|
+
reason: str = "",
|
|
47
|
+
retry_after: Optional[float] = None,
|
|
48
|
+
) -> None:
|
|
49
|
+
text = f"HTTP {status} {reason}".strip()
|
|
50
|
+
super().__init__(f"{text} for {url}", retryable=status in RETRY_STATUS)
|
|
51
|
+
self.status = status
|
|
52
|
+
self.url = url
|
|
53
|
+
self.retry_after = retry_after
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class ChecksumError(PywgetError):
|
|
57
|
+
exit_code = 5
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class StorageError(PywgetError):
|
|
61
|
+
exit_code = 6
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class DownloadCancelled(PywgetError):
|
|
65
|
+
exit_code = 130
|
pywget/progress.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
# File: pywget/progress.py
|
|
4
|
+
# Author: Hadi Cahyadi <cumulus13@gmail.com>
|
|
5
|
+
# Date: 2026-10-25
|
|
6
|
+
# Description: Progress tracking and terminal rendering.
|
|
7
|
+
# License: MIT
|
|
8
|
+
|
|
9
|
+
"""Progress tracking and terminal rendering."""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import collections
|
|
14
|
+
import shutil
|
|
15
|
+
import sys
|
|
16
|
+
import threading
|
|
17
|
+
import time
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from typing import Deque, Optional, TextIO, Tuple
|
|
20
|
+
|
|
21
|
+
from .utils import format_duration, format_size
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class ProgressInfo:
|
|
26
|
+
url: str
|
|
27
|
+
filename: str
|
|
28
|
+
downloaded: int
|
|
29
|
+
total: Optional[int]
|
|
30
|
+
speed: float
|
|
31
|
+
eta: Optional[float]
|
|
32
|
+
elapsed: float
|
|
33
|
+
finished: bool = False
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def percent(self) -> Optional[float]:
|
|
37
|
+
if self.total is None:
|
|
38
|
+
return None
|
|
39
|
+
if self.total == 0:
|
|
40
|
+
return 100.0
|
|
41
|
+
return min(100.0, self.downloaded * 100.0 / self.total)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Tracker:
|
|
45
|
+
"""Thread-safe byte counter producing speed/ETA snapshots."""
|
|
46
|
+
|
|
47
|
+
WINDOW = 5.0
|
|
48
|
+
|
|
49
|
+
def __init__(self, url: str, filename: str, total: Optional[int]) -> None:
|
|
50
|
+
self.url = url
|
|
51
|
+
self.filename = filename
|
|
52
|
+
self.total = total
|
|
53
|
+
self._lock = threading.Lock()
|
|
54
|
+
self._downloaded = 0
|
|
55
|
+
self._start = time.monotonic()
|
|
56
|
+
self._samples: Deque[Tuple[float, int]] = collections.deque()
|
|
57
|
+
self._samples.append((self._start, 0))
|
|
58
|
+
|
|
59
|
+
def add(self, nbytes: int) -> None:
|
|
60
|
+
with self._lock:
|
|
61
|
+
self._downloaded += nbytes
|
|
62
|
+
|
|
63
|
+
def set(self, nbytes: int) -> None:
|
|
64
|
+
with self._lock:
|
|
65
|
+
self._downloaded = nbytes
|
|
66
|
+
self._samples.clear()
|
|
67
|
+
self._samples.append((time.monotonic(), nbytes))
|
|
68
|
+
|
|
69
|
+
def reset(self) -> None:
|
|
70
|
+
self.set(0)
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def downloaded(self) -> int:
|
|
74
|
+
with self._lock:
|
|
75
|
+
return self._downloaded
|
|
76
|
+
|
|
77
|
+
def snapshot(self, finished: bool = False) -> ProgressInfo:
|
|
78
|
+
now = time.monotonic()
|
|
79
|
+
with self._lock:
|
|
80
|
+
done = self._downloaded
|
|
81
|
+
self._samples.append((now, done))
|
|
82
|
+
while len(self._samples) > 2 and now - self._samples[0][0] > self.WINDOW:
|
|
83
|
+
self._samples.popleft()
|
|
84
|
+
t0, b0 = self._samples[0]
|
|
85
|
+
speed = max((done - b0) / (now - t0), 0.0) if now > t0 else 0.0
|
|
86
|
+
eta = None
|
|
87
|
+
if self.total is not None and speed > 0:
|
|
88
|
+
eta = max(self.total - done, 0) / speed
|
|
89
|
+
return ProgressInfo(
|
|
90
|
+
self.url, self.filename, done, self.total, speed, eta, now - self._start, finished
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _truncate(text: str, width: int) -> str:
|
|
95
|
+
return text if len(text) <= width else text[: max(width - 2, 1)] + ".."
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class ProgressBar:
|
|
99
|
+
"""Single-line ASCII progress bar; silent when the stream is not a TTY."""
|
|
100
|
+
|
|
101
|
+
def __init__(
|
|
102
|
+
self,
|
|
103
|
+
stream: Optional[TextIO] = None,
|
|
104
|
+
enabled: bool = True,
|
|
105
|
+
min_interval: float = 0.1,
|
|
106
|
+
) -> None:
|
|
107
|
+
self.stream = stream if stream is not None else sys.stderr
|
|
108
|
+
self.enabled = enabled
|
|
109
|
+
self.min_interval = min_interval
|
|
110
|
+
isatty = getattr(self.stream, "isatty", None)
|
|
111
|
+
self._tty = bool(isatty and isatty())
|
|
112
|
+
self._last = 0.0
|
|
113
|
+
self._last_len = 0
|
|
114
|
+
self._dirty = False
|
|
115
|
+
|
|
116
|
+
def __call__(self, info: ProgressInfo) -> None:
|
|
117
|
+
self.update(info)
|
|
118
|
+
|
|
119
|
+
def update(self, info: ProgressInfo) -> None:
|
|
120
|
+
if not (self.enabled and self._tty):
|
|
121
|
+
return
|
|
122
|
+
now = time.monotonic()
|
|
123
|
+
if not info.finished and now - self._last < self.min_interval:
|
|
124
|
+
return
|
|
125
|
+
self._last = now
|
|
126
|
+
line = self._format(info)
|
|
127
|
+
pad = " " * max(0, self._last_len - len(line))
|
|
128
|
+
self.stream.write("\r" + line + pad)
|
|
129
|
+
self.stream.flush()
|
|
130
|
+
self._last_len = len(line)
|
|
131
|
+
self._dirty = True
|
|
132
|
+
if info.finished:
|
|
133
|
+
self.close()
|
|
134
|
+
|
|
135
|
+
def close(self) -> None:
|
|
136
|
+
if self._dirty:
|
|
137
|
+
self.stream.write("\n")
|
|
138
|
+
self.stream.flush()
|
|
139
|
+
self._dirty = False
|
|
140
|
+
self._last_len = 0
|
|
141
|
+
|
|
142
|
+
@staticmethod
|
|
143
|
+
def _format(info: ProgressInfo) -> str:
|
|
144
|
+
cols = shutil.get_terminal_size((80, 24)).columns - 1
|
|
145
|
+
pct = f"{info.percent:5.1f}%" if info.percent is not None else " --%"
|
|
146
|
+
size = format_size(info.downloaded)
|
|
147
|
+
if info.total is not None:
|
|
148
|
+
size += f"/{format_size(info.total)}"
|
|
149
|
+
tail = f"{pct} {size} {format_size(info.speed)}/s ETA {format_duration(info.eta)}"
|
|
150
|
+
name_w = max(8, min(28, cols // 4))
|
|
151
|
+
name = _truncate(info.filename, name_w).ljust(name_w)
|
|
152
|
+
room = cols - len(name) - len(tail) - 4
|
|
153
|
+
if info.percent is not None and room >= 12:
|
|
154
|
+
inner_w = room - 2
|
|
155
|
+
filled = int(inner_w * info.percent / 100)
|
|
156
|
+
inner = ("=" * filled + (">" if filled < inner_w else "")).ljust(inner_w)[:inner_w]
|
|
157
|
+
return f"{name} [{inner}] {tail}"
|
|
158
|
+
return f"{name} {tail}"[:cols]
|
pywget/py.typed
ADDED
|
File without changes
|
pywget/utils.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
# File: pywget/utils.py
|
|
4
|
+
# Author: Hadi Cahyadi <cumulus13@gmail.com>
|
|
5
|
+
# Date: 2026-10-25
|
|
6
|
+
# Description: Pure helpers: parsing, formatting, filenames, hashing, rate limiting.
|
|
7
|
+
# License: MIT
|
|
8
|
+
|
|
9
|
+
"""Pure helpers: parsing, formatting, filenames, hashing, rate limiting."""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import hashlib
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import threading
|
|
17
|
+
import time
|
|
18
|
+
import unicodedata
|
|
19
|
+
from email.message import Message
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Optional, Tuple, Union
|
|
22
|
+
from urllib.parse import quote, unquote, urlsplit, urlunsplit
|
|
23
|
+
|
|
24
|
+
from .errors import InvalidURLError
|
|
25
|
+
|
|
26
|
+
_SIZE_RE = re.compile(r"^\s*(\d+(?:\.\d+)?)\s*([kmgt]?)(i?b)?\s*$", re.I)
|
|
27
|
+
_UNITS = {"": 1, "k": 1024, "m": 1024**2, "g": 1024**3, "t": 1024**4}
|
|
28
|
+
_BAD_CHARS = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
|
|
29
|
+
_RESERVED = {"con", "prn", "aux", "nul"} | {f"com{i}" for i in range(1, 10)} | {
|
|
30
|
+
f"lpt{i}" for i in range(1, 10)
|
|
31
|
+
}
|
|
32
|
+
_HEX = re.compile(r"^[0-9a-f]+$")
|
|
33
|
+
_GUESS_ALGO = {32: "md5", 40: "sha1", 64: "sha256", 128: "sha512"}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def parse_size(value: Union[str, int, float]) -> int:
|
|
37
|
+
"""Parse ``500k`` / ``2MiB`` / ``1.5G`` into bytes."""
|
|
38
|
+
if isinstance(value, (int, float)):
|
|
39
|
+
return int(value)
|
|
40
|
+
match = _SIZE_RE.match(str(value))
|
|
41
|
+
if not match:
|
|
42
|
+
raise ValueError(f"invalid size: {value!r}")
|
|
43
|
+
return int(float(match.group(1)) * _UNITS[match.group(2).lower()])
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def format_size(num: float) -> str:
|
|
47
|
+
num = float(num)
|
|
48
|
+
for unit in ("B", "KiB", "MiB", "GiB", "TiB"):
|
|
49
|
+
if abs(num) < 1024 or unit == "TiB":
|
|
50
|
+
return f"{int(num)} B" if unit == "B" else f"{num:.1f} {unit}"
|
|
51
|
+
num /= 1024
|
|
52
|
+
return f"{num:.1f} TiB" # pragma: no cover
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def format_duration(seconds: Optional[float]) -> str:
|
|
56
|
+
if seconds is None or seconds != seconds or seconds < 0 or seconds > 99 * 3600:
|
|
57
|
+
return "--:--"
|
|
58
|
+
seconds = int(seconds)
|
|
59
|
+
hours, rest = divmod(seconds, 3600)
|
|
60
|
+
minutes, secs = divmod(rest, 60)
|
|
61
|
+
return f"{hours:d}:{minutes:02d}:{secs:02d}" if hours else f"{minutes:02d}:{secs:02d}"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def sanitize_filename(name: Optional[str], default: str = "index.html") -> str:
|
|
65
|
+
"""Return a safe single path component (no traversal, no reserved names)."""
|
|
66
|
+
name = unicodedata.normalize("NFC", name or "")
|
|
67
|
+
name = name.replace("\\", "/").rsplit("/", 1)[-1]
|
|
68
|
+
name = _BAD_CHARS.sub("_", name).strip().rstrip(" .")
|
|
69
|
+
if not name:
|
|
70
|
+
return default
|
|
71
|
+
if name.split(".")[0].lower() in _RESERVED:
|
|
72
|
+
name = "_" + name
|
|
73
|
+
if len(name.encode("utf-8")) > 200:
|
|
74
|
+
root, ext = os.path.splitext(name)
|
|
75
|
+
ext = ext[:20]
|
|
76
|
+
budget = 200 - len(ext.encode("utf-8"))
|
|
77
|
+
name = root.encode("utf-8")[:budget].decode("utf-8", "ignore") + ext
|
|
78
|
+
return name
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def filename_from_content_disposition(value: Optional[str]) -> Optional[str]:
|
|
82
|
+
if not value:
|
|
83
|
+
return None
|
|
84
|
+
msg = Message()
|
|
85
|
+
msg["Content-Disposition"] = value
|
|
86
|
+
try:
|
|
87
|
+
filename = msg.get_filename()
|
|
88
|
+
except Exception: # malformed header
|
|
89
|
+
return None
|
|
90
|
+
return filename or None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def filename_from_url(url: str) -> Optional[str]:
|
|
94
|
+
return unquote(urlsplit(url).path.rsplit("/", 1)[-1]) or None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def unique_path(path: Path) -> Path:
|
|
98
|
+
"""``file.txt`` -> ``file.txt.1`` -> ``file.txt.2`` (wget style)."""
|
|
99
|
+
if not path.exists():
|
|
100
|
+
return path
|
|
101
|
+
for index in range(1, 10_000):
|
|
102
|
+
candidate = path.with_name(f"{path.name}.{index}")
|
|
103
|
+
if not candidate.exists() and not Path(f"{candidate}.part").exists():
|
|
104
|
+
return candidate
|
|
105
|
+
raise FileExistsError(f"cannot find a free name for {path}")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def parse_checksum(spec: str) -> Tuple[str, str]:
|
|
109
|
+
"""``sha256:<hex>`` or a bare hex digest (algorithm guessed by length)."""
|
|
110
|
+
algo, sep, digest = spec.strip().partition(":")
|
|
111
|
+
if not sep:
|
|
112
|
+
digest = algo
|
|
113
|
+
guessed = _GUESS_ALGO.get(len(digest))
|
|
114
|
+
if not guessed:
|
|
115
|
+
raise ValueError("cannot guess hash algorithm; use ALGO:HEX")
|
|
116
|
+
algo = guessed
|
|
117
|
+
algo = algo.lower().replace("-", "")
|
|
118
|
+
digest = digest.strip().lower()
|
|
119
|
+
if algo not in hashlib.algorithms_guaranteed or algo.startswith("shake"):
|
|
120
|
+
raise ValueError(f"unsupported hash algorithm: {algo}")
|
|
121
|
+
if not digest or not _HEX.match(digest):
|
|
122
|
+
raise ValueError("checksum digest must be hexadecimal")
|
|
123
|
+
return algo, digest
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def hash_file(path: Union[str, Path], algo: str) -> str:
|
|
127
|
+
digest = hashlib.new(algo)
|
|
128
|
+
with open(path, "rb") as handle:
|
|
129
|
+
for block in iter(lambda: handle.read(1 << 20), b""):
|
|
130
|
+
digest.update(block)
|
|
131
|
+
return digest.hexdigest()
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def normalize_url(url: str) -> Tuple[str, Optional[str], Optional[str]]:
|
|
135
|
+
"""Validate an http(s) URL; return (clean_url, username, password)."""
|
|
136
|
+
url = (url or "").strip()
|
|
137
|
+
try:
|
|
138
|
+
parts = urlsplit(url)
|
|
139
|
+
host = parts.hostname
|
|
140
|
+
port = parts.port
|
|
141
|
+
except ValueError as exc:
|
|
142
|
+
raise InvalidURLError(f"invalid URL {url!r}: {exc}") from None
|
|
143
|
+
if parts.scheme.lower() not in ("http", "https") or not host:
|
|
144
|
+
raise InvalidURLError(f"unsupported or malformed URL: {url!r} (only http/https)")
|
|
145
|
+
try:
|
|
146
|
+
host = host.encode("idna").decode("ascii")
|
|
147
|
+
except UnicodeError:
|
|
148
|
+
raise InvalidURLError(f"invalid host name in URL: {url!r}") from None
|
|
149
|
+
if ":" in host:
|
|
150
|
+
host = f"[{host}]"
|
|
151
|
+
netloc = host + (f":{port}" if port else "")
|
|
152
|
+
path = quote(parts.path or "/", safe="/%:@!$&'()*+,;=~")
|
|
153
|
+
query = quote(parts.query, safe="=&%+/?:@!$'()*,;~[]")
|
|
154
|
+
clean = urlunsplit((parts.scheme.lower(), netloc, path, query, ""))
|
|
155
|
+
user = unquote(parts.username) if parts.username is not None else None
|
|
156
|
+
password = unquote(parts.password) if parts.password is not None else None
|
|
157
|
+
return clean, user, password
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class RateLimiter:
|
|
161
|
+
"""Thread-safe global bandwidth limiter (bytes/second, 0 = unlimited)."""
|
|
162
|
+
|
|
163
|
+
def __init__(self, rate: int = 0) -> None:
|
|
164
|
+
self.rate = max(0, int(rate))
|
|
165
|
+
self._lock = threading.Lock()
|
|
166
|
+
self._next = 0.0
|
|
167
|
+
|
|
168
|
+
def acquire(self, nbytes: int, cancel: Optional[threading.Event] = None) -> None:
|
|
169
|
+
if not self.rate:
|
|
170
|
+
return
|
|
171
|
+
with self._lock:
|
|
172
|
+
now = time.monotonic()
|
|
173
|
+
start = max(now, self._next)
|
|
174
|
+
self._next = start + nbytes / self.rate
|
|
175
|
+
delay = start - now
|
|
176
|
+
if delay > 0.001:
|
|
177
|
+
if cancel is not None:
|
|
178
|
+
cancel.wait(delay)
|
|
179
|
+
else:
|
|
180
|
+
time.sleep(delay)
|
pywget/wget.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
# File: pywget/wget.py
|
|
4
|
+
# Author: Hadi Cahyadi <cumulus13@gmail.com>
|
|
5
|
+
# Date: 2026-10-25
|
|
6
|
+
# Description: Backward-compatible API: ``from pywget import wget; wget.download(url, dir)``.
|
|
7
|
+
# License: MIT
|
|
8
|
+
|
|
9
|
+
"""Backward-compatible API: ``from pywget import wget; wget.download(url, dir)``."""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import sys
|
|
14
|
+
from typing import Any, List, Optional
|
|
15
|
+
|
|
16
|
+
from .core import Downloader, Options
|
|
17
|
+
from .progress import ProgressBar
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def download(
|
|
21
|
+
url: str,
|
|
22
|
+
path: Optional[str] = None,
|
|
23
|
+
filename: Optional[str] = None,
|
|
24
|
+
bar: bool = True,
|
|
25
|
+
**options: Any,
|
|
26
|
+
) -> str:
|
|
27
|
+
"""Download *url* into directory *path* and return the saved file path as ``str``."""
|
|
28
|
+
progress = ProgressBar() if bar else None
|
|
29
|
+
opts = Options(output_dir=path or ".", progress=progress, **options)
|
|
30
|
+
try:
|
|
31
|
+
return str(Downloader(opts).download(url, filename=filename))
|
|
32
|
+
finally:
|
|
33
|
+
if progress is not None:
|
|
34
|
+
progress.close()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def usage(argv: Optional[List[str]] = None) -> int:
|
|
38
|
+
"""Legacy console entry point (kept so old installs/scripts keep working)."""
|
|
39
|
+
from .cli import main
|
|
40
|
+
|
|
41
|
+
return main(argv if argv is not None else sys.argv[1:])
|