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/__init__.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
# File: pywget/__init__.py
|
|
4
|
+
# Author: Hadi Cahyadi <cumulus13@gmail.com>
|
|
5
|
+
# Date: 2026-10-25
|
|
6
|
+
# Description: pywget - a robust, resumable, multi-connection wget alternative and library.
|
|
7
|
+
# License: MIT
|
|
8
|
+
|
|
9
|
+
"""pywget - a robust, resumable, multi-connection wget alternative and library."""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Union
|
|
15
|
+
|
|
16
|
+
from .__version__ import __version__
|
|
17
|
+
from .core import Downloader, DownloadResult, Options
|
|
18
|
+
from .errors import (
|
|
19
|
+
ChecksumError,
|
|
20
|
+
DownloadCancelled,
|
|
21
|
+
HTTPStatusError,
|
|
22
|
+
InvalidURLError,
|
|
23
|
+
NetworkError,
|
|
24
|
+
PywgetError,
|
|
25
|
+
StorageError,
|
|
26
|
+
)
|
|
27
|
+
from .progress import ProgressBar, ProgressInfo
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"__version__",
|
|
31
|
+
"download",
|
|
32
|
+
"Downloader",
|
|
33
|
+
"DownloadResult",
|
|
34
|
+
"Options",
|
|
35
|
+
"ProgressBar",
|
|
36
|
+
"ProgressInfo",
|
|
37
|
+
"PywgetError",
|
|
38
|
+
"InvalidURLError",
|
|
39
|
+
"NetworkError",
|
|
40
|
+
"HTTPStatusError",
|
|
41
|
+
"ChecksumError",
|
|
42
|
+
"StorageError",
|
|
43
|
+
"DownloadCancelled",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def download(url: str, path: Union[str, Path] = ".", *, progress: bool = False, **options: Any) -> Path:
|
|
48
|
+
"""Download *url* into directory *path*; return the final file path.
|
|
49
|
+
|
|
50
|
+
Keyword options are those of :class:`Options` (``connections``, ``retries``,
|
|
51
|
+
``checksum``, ``limit_rate`` ...). ``progress=True`` shows a terminal bar.
|
|
52
|
+
"""
|
|
53
|
+
bar = ProgressBar() if progress else None
|
|
54
|
+
try:
|
|
55
|
+
return Downloader(Options(output_dir=str(path), progress=bar, **options)).download(url)
|
|
56
|
+
finally:
|
|
57
|
+
if bar is not None:
|
|
58
|
+
bar.close()
|
pywget/__main__.py
ADDED
pywget/__version__.py
ADDED
pywget/cli.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
# File: pywget/cli.py
|
|
4
|
+
# Author: Hadi Cahyadi <cumulus13@gmail.com>
|
|
5
|
+
# Date: 2026-10-25
|
|
6
|
+
# Description: Command line interface.
|
|
7
|
+
# License: MIT
|
|
8
|
+
|
|
9
|
+
"""Command line interface."""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import getpass
|
|
15
|
+
import json
|
|
16
|
+
import logging
|
|
17
|
+
import os
|
|
18
|
+
import signal
|
|
19
|
+
import sys
|
|
20
|
+
from typing import Any, Dict, List, Optional
|
|
21
|
+
|
|
22
|
+
from .__version__ import __version__
|
|
23
|
+
from .core import Downloader, Options
|
|
24
|
+
from .errors import PywgetError
|
|
25
|
+
from .progress import ProgressBar
|
|
26
|
+
from .utils import format_size
|
|
27
|
+
|
|
28
|
+
EPILOG = """\
|
|
29
|
+
examples:
|
|
30
|
+
pywget https://example.com/file.iso
|
|
31
|
+
pywget https://example.com/file.iso -p ~/Downloads -n 8
|
|
32
|
+
pywget -i urls.txt --limit-rate 2M --checksum sha256:<hex>
|
|
33
|
+
pywget URL -H "Authorization: Bearer $TOKEN" -O out.bin
|
|
34
|
+
cat urls.txt | pywget -i - --json
|
|
35
|
+
|
|
36
|
+
exit codes: 0 ok | 2 bad usage/URL | 3 network | 4 HTTP status | 5 checksum
|
|
37
|
+
6 storage/I-O | 130 interrupted
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
42
|
+
p = argparse.ArgumentParser(
|
|
43
|
+
prog="pywget",
|
|
44
|
+
description="Robust, resumable, multi-connection wget alternative.",
|
|
45
|
+
epilog=EPILOG,
|
|
46
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
47
|
+
)
|
|
48
|
+
p.add_argument("urls", nargs="*", metavar="URL", help="http(s) URLs to download")
|
|
49
|
+
p.add_argument("-i", "--input-file", metavar="FILE", help="read URLs from FILE ('-' = stdin)")
|
|
50
|
+
p.add_argument("-p", "-P", "--path", "--directory-prefix", dest="path", default=".",
|
|
51
|
+
metavar="DIR", help="download directory (default: .)")
|
|
52
|
+
p.add_argument("-O", "--output", metavar="NAME", help="output file name (single URL only)")
|
|
53
|
+
p.add_argument("-n", "--connections", type=int, default=4, metavar="N",
|
|
54
|
+
help="parallel connections per file, 1-32 (default: 4)")
|
|
55
|
+
p.add_argument("-r", "--retries", type=int, default=5, metavar="N",
|
|
56
|
+
help="retries per request/segment (default: 5)")
|
|
57
|
+
p.add_argument("-T", "--timeout", type=float, default=30.0, metavar="SEC",
|
|
58
|
+
help="network timeout in seconds (default: 30)")
|
|
59
|
+
p.add_argument("--limit-rate", metavar="RATE", help="max total speed, e.g. 500k, 2M")
|
|
60
|
+
p.add_argument("-H", "--header", action="append", default=[], metavar="'K: V'",
|
|
61
|
+
help="extra request header (repeatable)")
|
|
62
|
+
p.add_argument("-U", "--user-agent", metavar="UA", help="custom User-Agent")
|
|
63
|
+
p.add_argument("--user", metavar="NAME", help="HTTP basic-auth user name")
|
|
64
|
+
p.add_argument("--password", metavar="PASS",
|
|
65
|
+
help="HTTP basic-auth password (or env PYWGET_PASSWORD, or prompt)")
|
|
66
|
+
p.add_argument("--proxy", metavar="URL", help="proxy URL (default: environment)")
|
|
67
|
+
p.add_argument("--no-check-certificate", action="store_true", help="skip TLS verification")
|
|
68
|
+
p.add_argument("--ca-cert", metavar="FILE", help="custom CA bundle")
|
|
69
|
+
p.add_argument("--checksum", metavar="ALGO:HEX", help="verify downloaded file, e.g. sha256:ab12..")
|
|
70
|
+
p.add_argument("--overwrite", action="store_true", help="replace existing file, restart download")
|
|
71
|
+
p.add_argument("--no-resume", action="store_true", help="never resume partial downloads")
|
|
72
|
+
p.add_argument("-c", "--continue", dest="cont", action="store_true",
|
|
73
|
+
help="accepted for wget compatibility (resume is on by default)")
|
|
74
|
+
p.add_argument("--no-mtime", action="store_true", help="do not copy Last-Modified to the file")
|
|
75
|
+
p.add_argument("-q", "--quiet", action="store_true", help="no progress or status output")
|
|
76
|
+
p.add_argument("-v", "--verbose", action="store_true", help="debug logging")
|
|
77
|
+
p.add_argument("--json", action="store_true", help="print machine-readable results to stdout")
|
|
78
|
+
p.add_argument("-V", "--version", action="version", version=f"pywget {__version__}")
|
|
79
|
+
return p
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _read_urls(source: str) -> List[str]:
|
|
83
|
+
handle = sys.stdin if source == "-" else open(source, encoding="utf-8")
|
|
84
|
+
try:
|
|
85
|
+
lines = [line.strip() for line in handle]
|
|
86
|
+
finally:
|
|
87
|
+
if handle is not sys.stdin:
|
|
88
|
+
handle.close()
|
|
89
|
+
return [line for line in lines if line and not line.startswith("#")]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _parse_headers(items: List[str]) -> Dict[str, str]:
|
|
93
|
+
headers: Dict[str, str] = {}
|
|
94
|
+
for item in items:
|
|
95
|
+
name, sep, value = item.partition(":")
|
|
96
|
+
if not sep or not name.strip():
|
|
97
|
+
raise ValueError(f"invalid header (expected 'Name: value'): {item!r}")
|
|
98
|
+
headers[name.strip()] = value.strip()
|
|
99
|
+
return headers
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _build_options(args: argparse.Namespace, progress: Optional[ProgressBar]) -> Options:
|
|
103
|
+
password = args.password or os.environ.get("PYWGET_PASSWORD")
|
|
104
|
+
if args.user and password is None and sys.stdin.isatty():
|
|
105
|
+
password = getpass.getpass(f"Password for {args.user}: ")
|
|
106
|
+
extra: Dict[str, Any] = {}
|
|
107
|
+
if args.user_agent:
|
|
108
|
+
extra["user_agent"] = args.user_agent
|
|
109
|
+
return Options(
|
|
110
|
+
output_dir=args.path,
|
|
111
|
+
connections=args.connections,
|
|
112
|
+
retries=args.retries,
|
|
113
|
+
timeout=args.timeout,
|
|
114
|
+
limit_rate=args.limit_rate or 0,
|
|
115
|
+
resume=not args.no_resume,
|
|
116
|
+
overwrite=args.overwrite,
|
|
117
|
+
preserve_mtime=not args.no_mtime,
|
|
118
|
+
headers=_parse_headers(args.header),
|
|
119
|
+
username=args.user,
|
|
120
|
+
password=password,
|
|
121
|
+
proxy=args.proxy,
|
|
122
|
+
verify_ssl=not args.no_check_certificate,
|
|
123
|
+
ca_file=args.ca_cert,
|
|
124
|
+
checksum=args.checksum,
|
|
125
|
+
progress=progress,
|
|
126
|
+
**extra,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _on_sigterm(signum: int, frame: Any) -> None:
|
|
131
|
+
raise KeyboardInterrupt
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
135
|
+
parser = build_parser()
|
|
136
|
+
args = parser.parse_args(argv)
|
|
137
|
+
urls = list(args.urls)
|
|
138
|
+
if args.input_file:
|
|
139
|
+
try:
|
|
140
|
+
urls += _read_urls(args.input_file)
|
|
141
|
+
except OSError as exc:
|
|
142
|
+
parser.error(f"cannot read input file: {exc}")
|
|
143
|
+
if not urls:
|
|
144
|
+
parser.error("no URLs given")
|
|
145
|
+
if args.output and len(urls) > 1:
|
|
146
|
+
parser.error("--output can only be used with a single URL")
|
|
147
|
+
|
|
148
|
+
logging.basicConfig(
|
|
149
|
+
level=logging.DEBUG if args.verbose else logging.WARNING,
|
|
150
|
+
format="pywget: %(levelname)s: %(message)s",
|
|
151
|
+
stream=sys.stderr,
|
|
152
|
+
)
|
|
153
|
+
try:
|
|
154
|
+
signal.signal(signal.SIGTERM, _on_sigterm)
|
|
155
|
+
except (ValueError, OSError): # not in main thread / unsupported platform
|
|
156
|
+
pass
|
|
157
|
+
|
|
158
|
+
bar = ProgressBar(enabled=not args.quiet)
|
|
159
|
+
try:
|
|
160
|
+
options = _build_options(args, bar)
|
|
161
|
+
except ValueError as exc:
|
|
162
|
+
parser.error(str(exc))
|
|
163
|
+
downloader = Downloader(options)
|
|
164
|
+
|
|
165
|
+
results: List[Dict[str, Any]] = []
|
|
166
|
+
exit_code = 0
|
|
167
|
+
for url in urls:
|
|
168
|
+
try:
|
|
169
|
+
result = downloader.fetch(url, filename=args.output)
|
|
170
|
+
except KeyboardInterrupt:
|
|
171
|
+
bar.close()
|
|
172
|
+
print("pywget: interrupted; partial data kept, re-run to resume", file=sys.stderr)
|
|
173
|
+
return 130
|
|
174
|
+
except (PywgetError, OSError) as exc:
|
|
175
|
+
bar.close()
|
|
176
|
+
print(f"pywget: error: {exc}", file=sys.stderr)
|
|
177
|
+
results.append({"ok": False, "url": url, "error": str(exc)})
|
|
178
|
+
exit_code = exit_code or getattr(exc, "exit_code", 6)
|
|
179
|
+
continue
|
|
180
|
+
results.append({"ok": True, **result.to_dict()})
|
|
181
|
+
if not args.quiet:
|
|
182
|
+
extra = ", skipped (already complete)" if result.skipped else ""
|
|
183
|
+
extra += ", resumed" if result.resumed else ""
|
|
184
|
+
print(
|
|
185
|
+
f"OK {result.path} ({format_size(result.size)}, {result.elapsed:.1f}s, "
|
|
186
|
+
f"{format_size(result.speed)}/s, {result.connections} conn{extra})",
|
|
187
|
+
file=sys.stderr,
|
|
188
|
+
)
|
|
189
|
+
if args.json:
|
|
190
|
+
print(json.dumps(results, indent=2))
|
|
191
|
+
return exit_code
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
if __name__ == "__main__": # pragma: no cover
|
|
195
|
+
sys.exit(main())
|