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/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:])