pywget 2.0.2__tar.gz → 2.0.4__tar.gz

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.
Files changed (32) hide show
  1. {pywget-2.0.2/pywget.egg-info → pywget-2.0.4}/PKG-INFO +611 -370
  2. pywget-2.0.4/README.md +541 -0
  3. {pywget-2.0.2 → pywget-2.0.4}/pyproject.toml +1 -5
  4. {pywget-2.0.2 → pywget-2.0.4}/pywget/__init__.py +9 -0
  5. pywget-2.0.4/pywget/cli.py +514 -0
  6. pywget-2.0.4/pywget/client.py +127 -0
  7. pywget-2.0.4/pywget/clipboard.py +44 -0
  8. pywget-2.0.4/pywget/config.py +93 -0
  9. {pywget-2.0.2 → pywget-2.0.4}/pywget/core.py +5 -2
  10. {pywget-2.0.2 → pywget-2.0.4}/pywget/errors.py +11 -0
  11. pywget-2.0.4/pywget/jobs.py +612 -0
  12. pywget-2.0.4/pywget/server.py +320 -0
  13. {pywget-2.0.2 → pywget-2.0.4}/pywget/utils.py +19 -0
  14. pywget-2.0.4/pywget/webui.py +52 -0
  15. {pywget-2.0.2 → pywget-2.0.4/pywget.egg-info}/PKG-INFO +611 -370
  16. {pywget-2.0.2 → pywget-2.0.4}/pywget.egg-info/SOURCES.txt +7 -0
  17. {pywget-2.0.2 → pywget-2.0.4}/setup.cfg +4 -4
  18. pywget-2.0.4/tests/test_server.py +269 -0
  19. pywget-2.0.2/README.md +0 -300
  20. pywget-2.0.2/pywget/cli.py +0 -195
  21. {pywget-2.0.2 → pywget-2.0.4}/LICENSE +0 -0
  22. {pywget-2.0.2 → pywget-2.0.4}/pywget/__main__.py +0 -0
  23. {pywget-2.0.2 → pywget-2.0.4}/pywget/__version__.py +0 -0
  24. {pywget-2.0.2 → pywget-2.0.4}/pywget/progress.py +0 -0
  25. {pywget-2.0.2 → pywget-2.0.4}/pywget/py.typed +0 -0
  26. {pywget-2.0.2 → pywget-2.0.4}/pywget/wget.py +0 -0
  27. {pywget-2.0.2 → pywget-2.0.4}/pywget.egg-info/dependency_links.txt +0 -0
  28. {pywget-2.0.2 → pywget-2.0.4}/pywget.egg-info/entry_points.txt +0 -0
  29. {pywget-2.0.2 → pywget-2.0.4}/pywget.egg-info/requires.txt +0 -0
  30. {pywget-2.0.2 → pywget-2.0.4}/pywget.egg-info/top_level.txt +0 -0
  31. {pywget-2.0.2 → pywget-2.0.4}/tests/test_download.py +0 -0
  32. {pywget-2.0.2 → pywget-2.0.4}/tests/test_utils.py +0 -0
@@ -1,370 +1,611 @@
1
- Metadata-Version: 2.4
2
- Name: pywget
3
- Version: 2.0.2
4
- Summary: Robust, resumable, multi-connection wget alternative - CLI and Python library, zero dependencies
5
- Author-email: Hadi Cahyadi <cumulus13@gmail.com>
6
- Maintainer-email: cumulus13 <cumulus13@gmail.com>
7
- License: BSD 3-Clause License
8
-
9
- Copyright (c) 2021-2026, Hadi Cahyadi
10
- All rights reserved.
11
-
12
- Redistribution and use in source and binary forms, with or without
13
- modification, are permitted provided that the following conditions are met:
14
-
15
- 1. Redistributions of source code must retain the above copyright notice, this
16
- list of conditions and the following disclaimer.
17
-
18
- 2. Redistributions in binary form must reproduce the above copyright notice,
19
- this list of conditions and the following disclaimer in the documentation
20
- and/or other materials provided with the distribution.
21
-
22
- 3. Neither the name of the copyright holder nor the names of its
23
- contributors may be used to endorse or promote products derived from
24
- this software without specific prior written permission.
25
-
26
- THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
27
- AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
28
- IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
29
- DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
30
- FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
31
- DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
32
- SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
33
- CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
34
- OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
35
- OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
36
-
37
- Project-URL: Homepage, https://github.com/cumulus13/pywget
38
- Project-URL: Documentation, https://github.com/cumulus13/pywget#readme
39
- Project-URL: Source, https://github.com/cumulus13/pywget
40
- Project-URL: Issues, https://github.com/cumulus13/pywget/issues
41
- Project-URL: Changelog, https://github.com/cumulus13/pywget/blob/master/CHANGELOG.md
42
- Keywords: wget,download,downloader,resume,multi-connection,cli,http
43
- Classifier: Development Status :: 5 - Production/Stable
44
- Classifier: Environment :: Console
45
- Classifier: Intended Audience :: Developers
46
- Classifier: Intended Audience :: System Administrators
47
- Classifier: License :: OSI Approved :: BSD License
48
- Classifier: Operating System :: OS Independent
49
- Classifier: Programming Language :: Python :: 3
50
- Classifier: Programming Language :: Python :: 3.8
51
- Classifier: Programming Language :: Python :: 3.9
52
- Classifier: Programming Language :: Python :: 3.10
53
- Classifier: Programming Language :: Python :: 3.11
54
- Classifier: Programming Language :: Python :: 3.12
55
- Classifier: Programming Language :: Python :: 3.13
56
- Classifier: Topic :: Internet :: WWW/HTTP
57
- Classifier: Topic :: System :: Networking
58
- Classifier: Typing :: Typed
59
- Requires-Python: >=3.8
60
- Description-Content-Type: text/markdown
61
- License-File: LICENSE
62
- Provides-Extra: dev
63
- Requires-Dist: pytest>=7; extra == "dev"
64
- Requires-Dist: pytest-cov; extra == "dev"
65
- Requires-Dist: ruff; extra == "dev"
66
- Requires-Dist: mypy; extra == "dev"
67
- Requires-Dist: build; extra == "dev"
68
- Requires-Dist: twine; extra == "dev"
69
- Dynamic: license-file
70
-
71
- # pywget
72
-
73
- [![CI](https://github.com/cumulus13/pywget/actions/workflows/ci.yml/badge.svg)](https://github.com/cumulus13/pywget/actions/workflows/ci.yml)
74
- [![PyPI](https://img.shields.io/pypi/v/pywget.svg)](https://pypi.org/project/pywget/)
75
- [![Python](https://img.shields.io/pypi/pyversions/pywget.svg)](https://pypi.org/project/pywget/)
76
- [![License](https://img.shields.io/badge/license-BSD-blue.svg)](LICENSE)
77
-
78
- A robust, resumable, **multi-connection** command-line downloader and Python library.
79
- A drop-in-style `wget` alternative with zero third-party dependencies.
80
-
81
- ## Features
82
-
83
- | Area | What you get |
84
- |------|--------------|
85
- | Speed | Parallel segmented download (`-n 1..32`), automatic single-stream fallback |
86
- | Reliability | Crash-safe resume, retries with exponential backoff + jitter, `Retry-After`, atomic rename |
87
- | Integrity | `--checksum sha256:...`, size verification, disk-space pre-check |
88
- | Security | http/https only, credentials stripped on cross-origin redirects, path-traversal-proof filenames, TLS verification on by default |
89
- | Control | Bandwidth limit, custom headers, basic auth, proxy, custom CA, timeouts |
90
- | Automation | Batch input (`-i`), `--json` output, stable exit codes, SIGINT/SIGTERM-safe |
91
- | Library | Typed API, progress callbacks, cancel support, legacy `wget.download()` kept |
92
- | Portability | Python 3.8+, Windows / Linux / macOS, no dependencies |
93
-
94
- ## Installation
95
-
96
- ```bash
97
- pip install pywget
98
- ```
99
-
100
- > **v2 note:** Python 2 is no longer supported. Python 3.8+ is required. The CLI and
101
- > `wget.download(url, dir)` keep working as before.
102
-
103
- ## Quick start
104
-
105
- ```bash
106
- # simple download into a directory (same as before)
107
- pywget "https://example.com/file.iso" -p ~/Downloads
108
-
109
- # 8 parallel connections, 2 MiB/s cap, verify checksum
110
- pywget https://example.com/file.iso -n 8 --limit-rate 2M --checksum sha256:<hex>
111
-
112
- # many URLs from a file (or stdin with -i -), JSON report
113
- pywget -i urls.txt -p downloads --json
114
-
115
- # auth + custom header
116
- pywget https://api.example.com/export.zip --user me -H "Accept: application/zip"
117
- ```
118
-
119
- Interrupted (Ctrl+C, crash, network loss)? Run the same command again - it resumes.
120
-
121
- ## CLI reference
122
-
123
- | Option | Description |
124
- |--------|-------------|
125
- | `URL ...` | One or more http(s) URLs |
126
- | `-i, --input-file FILE` | Read URLs from file (`-` = stdin); `#` comments allowed |
127
- | `-p, -P, --path DIR` | Download directory (default `.`) |
128
- | `-O, --output NAME` | Output file name (single URL) |
129
- | `-n, --connections N` | Parallel connections per file, 1-32 (default 4) |
130
- | `-r, --retries N` | Retries per request/segment (default 5) |
131
- | `-T, --timeout SEC` | Network timeout (default 30) |
132
- | `--limit-rate RATE` | Total speed cap, e.g. `500k`, `2M` |
133
- | `-H, --header 'K: V'` | Extra header (repeatable) |
134
- | `-U, --user-agent UA` | Custom User-Agent |
135
- | `--user`, `--password` | Basic auth (password also from `PYWGET_PASSWORD` or prompt) |
136
- | `--proxy URL` | Proxy (default: environment) |
137
- | `--no-check-certificate` | Disable TLS verification (not recommended) |
138
- | `--ca-cert FILE` | Custom CA bundle |
139
- | `--checksum ALGO:HEX` | Verify result (md5, sha1, sha256, sha512, ...) |
140
- | `--overwrite` | Replace existing file and restart |
141
- | `--no-resume` | Never resume partial files |
142
- | `--no-mtime` | Do not copy `Last-Modified` to the file |
143
- | `-q`, `-v` | Quiet / debug logging |
144
- | `--json` | Machine-readable results on stdout |
145
- | `-c` | Accepted for wget compatibility (resume is default) |
146
-
147
- ### Exit codes
148
-
149
- | Code | Meaning |
150
- |------|---------|
151
- | 0 | Success |
152
- | 1 | Unexpected error |
153
- | 2 | Bad usage / invalid URL |
154
- | 3 | Network error |
155
- | 4 | HTTP error status (404, 403, ...) |
156
- | 5 | Checksum mismatch |
157
- | 6 | Storage / I/O error (disk full, permissions) |
158
- | 130 | Interrupted (partial data kept for resume) |
159
-
160
- ## Python API
161
-
162
- ```python
163
- import pywget
164
-
165
- path = pywget.download(
166
- "https://example.com/file.iso",
167
- "/data/downloads",
168
- progress=True, # terminal bar
169
- connections=8,
170
- limit_rate="5M",
171
- checksum="sha256:ab12...",
172
- )
173
- print(path)
174
- ```
175
-
176
- Full control:
177
-
178
- ```python
179
- from pywget import Downloader, Options, ProgressInfo
180
-
181
- def on_progress(p: ProgressInfo) -> None:
182
- print(f"{p.filename}: {p.percent or 0:.1f}% {p.speed/1e6:.1f} MB/s ETA {p.eta}")
183
-
184
- dl = Downloader(Options(output_dir="out", retries=8, progress=on_progress,
185
- headers={"Authorization": "Bearer TOKEN"}))
186
- result = dl.fetch("https://example.com/big.bin")
187
- print(result.path, result.size, result.resumed, result.connections)
188
-
189
- # from another thread: dl.cancel() -> partial state is kept for resume
190
- ```
191
-
192
- Legacy API (unchanged):
193
-
194
- ```python
195
- from pywget import wget
196
-
197
- wget.download("http://www.test.com/test.tar.gz", "/home/root/Downloads")
198
- ```
199
-
200
- Errors derive from `pywget.PywgetError` (`InvalidURLError`, `NetworkError`,
201
- `HTTPStatusError`, `ChecksumError`, `StorageError`, `DownloadCancelled`).
202
- A `Downloader` runs one download at a time - use one instance per thread.
203
-
204
- ## How it works
205
-
206
- ### Architecture
207
-
208
- ```mermaid
209
- flowchart LR
210
- CLI["CLI<br/>pywget.cli"] --> DL
211
- API["Python API<br/>pywget.download()"] --> DL
212
- LEG["Legacy shim<br/>pywget.wget"] --> DL
213
-
214
- subgraph Engine["pywget.core"]
215
- DL["Downloader"] --> PROBE["Probe<br/>Range: bytes=0-0"]
216
- PROBE --> PLAN{"Size known and<br/>ranges supported?"}
217
- PLAN -- yes --> SEG["Segment workers<br/>1..N threads"]
218
- PLAN -- no --> STR["Single stream<br/>worker"]
219
- SEG --> MON["Monitor loop<br/>progress + state save"]
220
- STR --> MON
221
- MON --> VER["Verify<br/>size + checksum"]
222
- end
223
-
224
- SEG -. throttled by .-> RL["RateLimiter"]
225
- STR -. throttled by .-> RL
226
- MON --> TR["Tracker / ProgressBar"]
227
- SEG <--> ST[("state file<br/>.pywget")]
228
- VER --> OUT[("final file<br/>atomic rename")]
229
- ```
230
-
231
- [![Architecture 1](https://i.ibb.co/mr6hxjYg/pywget-architecture1.jpg)](https://i.ibb.co/mr6hxjYg/pywget-architecture1.jpg)
232
-
233
- ### Download sequence
234
-
235
- ```mermaid
236
- sequenceDiagram
237
- autonumber
238
- actor U as User
239
- participant D as Downloader
240
- participant S as Server
241
- participant F as Disk
242
-
243
- U->>D: fetch(url)
244
- D->>S: GET Range: bytes=0-0
245
- S-->>D: 206 + Content-Range (size, ETag)
246
- D->>F: load .pywget state (if any)
247
- alt state matches size / ETag / mtime
248
- D->>D: resume remaining segments
249
- else fresh start
250
- D->>F: preallocate .part, split into N segments
251
- end
252
- par segment 1..N
253
- D->>S: GET Range: bytes=a-b
254
- S-->>D: 206 data
255
- D->>F: write at offset
256
- end
257
- loop every second
258
- D->>F: save state
259
- D-->>U: progress callback
260
- end
261
- D->>F: verify size and checksum
262
- D->>F: rename .part to final name
263
- D-->>U: DownloadResult
264
- ```
265
-
266
- [![Architecture 2](https://i.ibb.co/DPgLTD6D/pywget-architecture2.jpg)](https://i.ibb.co/DPgLTD6D/pywget-architecture2.jpg)
267
-
268
- ### Segment lifecycle and retries
269
-
270
- ```mermaid
271
- stateDiagram-v2
272
- [*] --> Probing
273
- Probing --> Planning: size and ranges known
274
- Probing --> Streaming: no range support
275
- Planning --> Downloading
276
- Streaming --> Verifying
277
- Downloading --> Retrying: timeout / reset / 5xx / 429
278
- Retrying --> Downloading: backoff + jitter, resume at offset
279
- Retrying --> Failed: retries exhausted
280
- Downloading --> Interrupted: Ctrl+C / SIGTERM / cancel()
281
- Interrupted --> Planning: re-run, state validated
282
- Downloading --> Verifying: all segments complete
283
- Verifying --> Completed: size and checksum OK
284
- Verifying --> Failed: mismatch, partial files removed
285
- Completed --> [*]
286
- Failed --> [*]
287
- ```
288
-
289
- [![Architecture 3](https://i.ibb.co/YBZKFsxG/pywget-architecture3.jpg)](https://i.ibb.co/YBZKFsxG/pywget-architecture3.jpg)
290
-
291
- ### Resume decision
292
-
293
- ```mermaid
294
- flowchart TD
295
- A["Start"] --> B{".pywget state<br/>and .part exist?"}
296
- B -- no --> N["Fresh download"]
297
- B -- yes --> C{"URL, size, ETag,<br/>Last-Modified equal?"}
298
- C -- no --> N
299
- C -- yes --> D{"Segments valid and<br/>.part size correct?"}
300
- D -- no --> N
301
- D -- yes --> R["Resume each segment<br/>from its saved offset"]
302
- ```
303
-
304
- [![Architecture 4](https://i.ibb.co/nqGmzdpw/pywget-architecture4.jpg)](https://i.ibb.co/nqGmzdpw/pywget-architecture4.jpg)
305
-
306
- ### Main types
307
-
308
- ```mermaid
309
- classDiagram
310
- class Downloader {
311
- +fetch(url) DownloadResult
312
- +download(url) Path
313
- +cancel()
314
- }
315
- class Options {
316
- connections
317
- retries
318
- limit_rate
319
- checksum
320
- progress
321
- }
322
- class DownloadResult {
323
- path
324
- size
325
- resumed
326
- connections
327
- checksum
328
- }
329
- class Tracker
330
- class ProgressBar
331
- class RateLimiter
332
- Downloader o-- Options
333
- Downloader --> DownloadResult
334
- Downloader --> Tracker
335
- Downloader --> RateLimiter
336
- ProgressBar ..> Tracker : renders snapshots
337
- ```
338
-
339
- [![Architecture 5](https://i.ibb.co/9HYQ3WrT/pywget-architecture5.jpg)](https://i.ibb.co/9HYQ3WrT/pywget-architecture5.jpg)
340
-
341
- ## Behaviour details
342
-
343
- - **Probing:** one `Range: bytes=0-0` request yields size, range support, ETag, `Last-Modified` and `Content-Disposition`.
344
- - **Segments:** a file is split into `min(connections, size / 2 MiB)` parts; small files use one connection.
345
- - **Existing file:** same size means skipped; different size is saved as `name.1` (wget style) unless `--overwrite`.
346
- - **Partial files:** stored as `name.part` + `name.pywget`; removed on success. Changed remote file (size/ETag/mtime) restarts cleanly.
347
- - **Redirects:** only http/https; `Authorization`/`Cookie` are dropped when the origin changes.
348
- - **Not supported (by design):** FTP, recursive mirroring.
349
-
350
- ## Development
351
-
352
- ```bash
353
- git clone https://github.com/cumulus13/pywget && cd pywget
354
- pip install -e ".[dev]"
355
- pytest --cov=pywget
356
- ruff check pywget tests && mypy pywget
357
- python -m build
358
- ```
359
-
360
- ## License
361
-
362
- [BSD](LICENSE)
363
-
364
- ## Author
365
-
366
- [Hadi Cahyadi](https://github.com/cumulus13)
367
-
368
- [![Buy Me a Coffee](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://www.buymeacoffee.com/cumulus13)
369
- [![Donate via Ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/cumulus13)
370
- [Support me on Patreon](https://www.patreon.com/cumulus13)
1
+ Metadata-Version: 2.4
2
+ Name: pywget
3
+ Version: 2.0.4
4
+ Summary: Robust, resumable, multi-connection wget alternative - CLI and Python library, zero dependencies
5
+ Author-email: Hadi Cahyadi <cumulus13@gmail.com>
6
+ Maintainer-email: cumulus13 <cumulus13@gmail.com>
7
+ License: BSD 3-Clause License
8
+
9
+ Copyright (c) 2021-2026, Hadi Cahyadi
10
+ All rights reserved.
11
+
12
+ Redistribution and use in source and binary forms, with or without
13
+ modification, are permitted provided that the following conditions are met:
14
+
15
+ 1. Redistributions of source code must retain the above copyright notice, this
16
+ list of conditions and the following disclaimer.
17
+
18
+ 2. Redistributions in binary form must reproduce the above copyright notice,
19
+ this list of conditions and the following disclaimer in the documentation
20
+ and/or other materials provided with the distribution.
21
+
22
+ 3. Neither the name of the copyright holder nor the names of its
23
+ contributors may be used to endorse or promote products derived from
24
+ this software without specific prior written permission.
25
+
26
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
27
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
28
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
29
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
30
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
31
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
32
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
33
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
34
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
35
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
36
+
37
+ Project-URL: Homepage, https://github.com/cumulus13/pywget
38
+ Project-URL: Documentation, https://github.com/cumulus13/pywget#readme
39
+ Project-URL: Source, https://github.com/cumulus13/pywget
40
+ Project-URL: Issues, https://github.com/cumulus13/pywget/issues
41
+ Project-URL: Changelog, https://github.com/cumulus13/pywget/blob/master/CHANGELOG.md
42
+ Keywords: wget,download,downloader,resume,multi-connection,cli,http
43
+ Classifier: Development Status :: 5 - Production/Stable
44
+ Classifier: Environment :: Console
45
+ Classifier: Intended Audience :: Developers
46
+ Classifier: Intended Audience :: System Administrators
47
+ Classifier: License :: OSI Approved :: BSD License
48
+ Classifier: Operating System :: OS Independent
49
+ Classifier: Programming Language :: Python :: 3
50
+ Classifier: Programming Language :: Python :: 3.8
51
+ Classifier: Programming Language :: Python :: 3.9
52
+ Classifier: Programming Language :: Python :: 3.10
53
+ Classifier: Programming Language :: Python :: 3.11
54
+ Classifier: Programming Language :: Python :: 3.12
55
+ Classifier: Programming Language :: Python :: 3.13
56
+ Classifier: Topic :: Internet :: WWW/HTTP
57
+ Classifier: Topic :: System :: Networking
58
+ Classifier: Typing :: Typed
59
+ Requires-Python: >=3.8
60
+ Description-Content-Type: text/markdown
61
+ License-File: LICENSE
62
+ Provides-Extra: dev
63
+ Requires-Dist: pytest>=7; extra == "dev"
64
+ Requires-Dist: pytest-cov; extra == "dev"
65
+ Requires-Dist: ruff; extra == "dev"
66
+ Requires-Dist: mypy; extra == "dev"
67
+ Requires-Dist: build; extra == "dev"
68
+ Requires-Dist: twine; extra == "dev"
69
+ Dynamic: license-file
70
+
71
+ # pywget
72
+
73
+ [![CI](https://github.com/cumulus13/pywget/actions/workflows/ci.yml/badge.svg)](https://github.com/cumulus13/pywget/actions/workflows/ci.yml)
74
+ [![PyPI](https://img.shields.io/pypi/v/pywget.svg)](https://pypi.org/project/pywget/)
75
+ [![Python](https://img.shields.io/pypi/pyversions/pywget.svg)](https://pypi.org/project/pywget/)
76
+ [![License](https://img.shields.io/badge/license-BSD-blue.svg)](LICENSE)
77
+
78
+ A robust, resumable, **multi-connection** command-line downloader and Python library.
79
+ A drop-in-style `wget` alternative with zero third-party dependencies.
80
+
81
+ ## Features
82
+
83
+ | Area | What you get |
84
+ |------|--------------|
85
+ | Speed | Parallel segmented download (`-n 1..32`), automatic single-stream fallback |
86
+ | Reliability | Crash-safe resume, retries with exponential backoff + jitter, `Retry-After`, atomic rename |
87
+ | Integrity | `--checksum sha256:...`, size verification, disk-space pre-check |
88
+ | Security | http/https only, credentials stripped on cross-origin redirects, path-traversal-proof filenames, TLS verification on by default |
89
+ | Control | Bandwidth limit, custom headers, basic auth, proxy, custom CA, timeouts |
90
+ | Automation | Batch input (`-i`), `--json` output, stable exit codes, SIGINT/SIGTERM-safe |
91
+ | Server mode | `pywget serve`: persistent queue, HTTP/JSON API, SSE events, web UI, token auth |
92
+ | Agent mode | `pywget agent` / `--server`: send links from anywhere; offline-tolerant relay to a server |
93
+ | Library | Typed API, progress callbacks, cancel support, legacy `wget.download()` kept |
94
+ | Portability | Python 3.8+, Windows / Linux / macOS, no dependencies |
95
+
96
+ ## Installation
97
+
98
+ ```bash
99
+ pip install pywget
100
+ ```
101
+
102
+ > **v2 note:** Python 2 is no longer supported. Python 3.8+ is required. The CLI and
103
+ > `wget.download(url, dir)` keep working as before.
104
+
105
+ ## Quick start
106
+
107
+ ```bash
108
+ # simple download into a directory (same as before)
109
+ pywget "https://example.com/file.iso" -p ~/Downloads
110
+
111
+ # 8 parallel connections, 2 MiB/s cap, verify checksum
112
+ pywget https://example.com/file.iso -n 8 --limit-rate 2M --checksum sha256:<hex>
113
+
114
+ # many URLs from a file (or stdin with -i -), JSON report
115
+ pywget -i urls.txt -p downloads --json
116
+
117
+ # auth + custom header
118
+ pywget https://api.example.com/export.zip --user me -H "Accept: application/zip"
119
+ ```
120
+
121
+ ```bash
122
+ # download manager mode: run a server, push links to it (see "Server mode and agent")
123
+ pywget serve --root ~/Downloads/pywget &
124
+ pywget send https://example.com/file.iso -r https://example.com/ --wait
125
+ pywget jobs list
126
+ ```
127
+
128
+ Interrupted (Ctrl+C, crash, network loss)? Run the same command again - it resumes.
129
+
130
+ ## CLI reference
131
+
132
+ | Option | Description |
133
+ |--------|-------------|
134
+ | `URL ...` | http(s) URLs, `c` = read links from the clipboard, or a text file of links |
135
+ | `-i, --input-file FILE` | Read URLs from file (`-` = stdin); `#` comments allowed |
136
+ | `-p, -P, --path DIR` | Download directory (default `.`) |
137
+ | `-O, --output NAME` | Output file name (single URL) |
138
+ | `-n, --connections N` | Parallel connections per file, 1-32 (default 4) |
139
+ | `-t, --retries N` | Retries per request/segment (default 5) |
140
+ | `-r, --referrer URL` | Referer header (many sites require it) |
141
+ | `-C, --cookie STR` | Cookie string `a=b; c=d` or JSON object |
142
+ | `-T, --timeout SEC` | Network timeout (default 30) |
143
+ | `--limit-rate RATE` | Total speed cap, e.g. `500k`, `2M` |
144
+ | `-H, --header 'K: V'` | Extra header (repeatable) |
145
+ | `-U, -ua, --user-agent UA` | Custom User-Agent |
146
+ | `--user`, `--password` | Basic auth (password also from `PYWGET_PASSWORD` or prompt) |
147
+ | `--proxy URL` | Proxy (default: environment) |
148
+ | `--no-check-certificate` | Disable TLS verification (not recommended) |
149
+ | `--ca-cert FILE` | Custom CA bundle |
150
+ | `--checksum ALGO:HEX` | Verify result (md5, sha1, sha256, sha512, ...) |
151
+ | `--overwrite` | Replace existing file and restart |
152
+ | `--no-resume` | Never resume partial files |
153
+ | `--no-mtime` | Do not copy `Last-Modified` to the file |
154
+ | `-q`, `-v` | Quiet / debug logging |
155
+ | `--json` | Machine-readable results on stdout |
156
+ | `-c` | Accepted for wget compatibility (resume is default) |
157
+ | `-s, --server URL` | Send the links to a pywget server/agent instead of downloading locally |
158
+ | `--token TOKEN` | API token for `--server` (default: `$PYWGET_TOKEN` or token file) |
159
+ | `-a, --add-only` | With `--server`: queue paused, do not start (like IDM's *add*) |
160
+ | `--wait` | With `--server`: wait for the jobs to finish |
161
+
162
+ ### Exit codes
163
+
164
+ | Code | Meaning |
165
+ |------|---------|
166
+ | 0 | Success |
167
+ | 1 | Unexpected error |
168
+ | 2 | Bad usage / invalid URL |
169
+ | 3 | Network error |
170
+ | 4 | HTTP error status (404, 403, ...) |
171
+ | 5 | Checksum mismatch |
172
+ | 6 | Storage / I/O error (disk full, permissions) |
173
+ | 7 | Server / agent error (unreachable, 401, 409, ...) |
174
+ | 130 | Interrupted (partial data kept for resume) |
175
+
176
+ ## Python API
177
+
178
+ ```python
179
+ import pywget
180
+
181
+ path = pywget.download(
182
+ "https://example.com/file.iso",
183
+ "/data/downloads",
184
+ progress=True, # terminal bar
185
+ connections=8,
186
+ limit_rate="5M",
187
+ checksum="sha256:ab12...",
188
+ )
189
+ print(path)
190
+ ```
191
+
192
+ Full control:
193
+
194
+ ```python
195
+ from pywget import Downloader, Options, ProgressInfo
196
+
197
+ def on_progress(p: ProgressInfo) -> None:
198
+ print(f"{p.filename}: {p.percent or 0:.1f}% {p.speed/1e6:.1f} MB/s ETA {p.eta}")
199
+
200
+ dl = Downloader(Options(output_dir="out", retries=8, progress=on_progress,
201
+ headers={"Authorization": "Bearer TOKEN"}))
202
+ result = dl.fetch("https://example.com/big.bin")
203
+ print(result.path, result.size, result.resumed, result.connections)
204
+
205
+ # from another thread: dl.cancel() -> partial state is kept for resume
206
+ ```
207
+
208
+ Talk to a server / agent, or embed one:
209
+
210
+ ```python
211
+ from pywget import ServerClient, ServerConfig, ServerHandle
212
+
213
+ client = ServerClient("http://127.0.0.1:8765") # token: $PYWGET_TOKEN or token file
214
+ job = client.submit({"url": "https://example.com/f.iso", "path": "isos"})[0]
215
+ print(client.wait(job["id"])["status"])
216
+
217
+ cfg = ServerConfig(port=0, token="secret-token", root="/srv/dl", state_dir="/var/lib/pywget")
218
+ with ServerHandle(cfg) as server: # background server (tests, embedding)
219
+ print(server.url)
220
+ ```
221
+
222
+ Legacy API (unchanged):
223
+
224
+ ```python
225
+ from pywget import wget
226
+
227
+ wget.download("http://www.test.com/test.tar.gz", "/home/root/Downloads")
228
+ ```
229
+
230
+ Errors derive from `pywget.PywgetError` (`InvalidURLError`, `NetworkError`,
231
+ `HTTPStatusError`, `ChecksumError`, `StorageError`, `DownloadCancelled`).
232
+ A `Downloader` runs one download at a time - use one instance per thread.
233
+
234
+ ## Server mode and agent
235
+
236
+ Run pywget as a long-lived download manager and push links to it from anywhere - the
237
+ same idea as handing a link to IDM, but over a small authenticated HTTP API.
238
+
239
+ ```bash
240
+ # 1. start the server (jobs may only write below --root)
241
+ pywget serve --root ~/Downloads/pywget # http://127.0.0.1:8765, token auto-created
242
+
243
+ # 2. send links (token is read from the token file on the same machine)
244
+ pywget send https://example.com/a.iso -p isos -r https://example.com/ --wait
245
+ pywget https://example.com/b.zip --server http://127.0.0.1:8765 # same, legacy form
246
+ pywget send c # links found in the clipboard
247
+ pywget send links.txt -a # a text file of links, queued paused (add only)
248
+
249
+ # 3. manage
250
+ pywget jobs list
251
+ pywget jobs pause|resume|cancel|retry|remove <id>
252
+ ```
253
+
254
+ Open `http://127.0.0.1:8765/` for the web UI (paste the token once).
255
+
256
+ ### Agent (relay)
257
+
258
+ An **agent** is a tiny server that does not download: it accepts links on a local port
259
+ and forwards them to an upstream server, keeping them in a persistent outbox when the
260
+ upstream is offline and mirroring progress back. Put it on the machine where links
261
+ appear (laptop, browser helper script) and let a server/NAS do the downloading.
262
+
263
+ ```bash
264
+ # on the download box
265
+ pywget serve --host 0.0.0.0 --root /srv/downloads --tls-cert c.pem --tls-key k.pem
266
+
267
+ # on your workstation
268
+ pywget agent --upstream https://nas:8765 --upstream-token <TOKEN> --port 8766
269
+ pywget send https://example.com/file.iso --server http://127.0.0.1:8766 --wait
270
+ ```
271
+
272
+ ### System overview
273
+
274
+ ```mermaid
275
+ flowchart LR
276
+ CLI["pywget send / --server"] --> AG
277
+ BR["Browser helper,<br/>scripts, curl"] --> AG
278
+ subgraph WS["Workstation"]
279
+ AG["Agent<br/>pywget agent<br/>persistent outbox"]
280
+ end
281
+ AG -- "HTTPS + token<br/>forward job, poll status" --> API
282
+ CLI -. "direct" .-> API
283
+ subgraph SV["Download server"]
284
+ API["HTTP API + web UI<br/>auth, Host check, root jail"] --> MGR["JobManager<br/>queue, N workers"]
285
+ MGR --> ENG["Downloader engine<br/>segments, resume, retry"]
286
+ MGR <--> ST[("jobs.json<br/>0600")]
287
+ ENG --> FS[("download root")]
288
+ end
289
+ ```
290
+
291
+ [![Architecture 7](https://i.ibb.co/96D8hz7/pywget-architecture7.jpg)](https://i.ibb.co/96D8hz7/pywget-architecture7.jpg)
292
+
293
+
294
+ ### Job flow
295
+
296
+ ```mermaid
297
+ sequenceDiagram
298
+ autonumber
299
+ actor U as User
300
+ participant A as Agent
301
+ participant S as Server
302
+ participant W as Worker
303
+ U->>A: pywget send URL --wait
304
+ A->>A: persist job (outbox)
305
+ loop until accepted
306
+ A->>S: POST /api/v1/jobs (Bearer token)
307
+ S-->>A: 201 job id or error / offline then backoff
308
+ end
309
+ S->>W: enqueue
310
+ W->>W: Downloader.fetch (segments, resume)
311
+ loop every second
312
+ A->>S: GET /api/v1/jobs/id
313
+ S-->>A: status, percent, speed, ETA
314
+ end
315
+ W-->>S: completed (atomic rename in root)
316
+ A-->>U: OK path
317
+ ```
318
+
319
+ [![Architecture 8](https://i.ibb.co/4n0GBmSD/pywget-architecture8.jpg)](https://i.ibb.co/4n0GBmSD/pywget-architecture8.jpg)
320
+
321
+ ### Job states
322
+
323
+ ```mermaid
324
+ stateDiagram-v2
325
+ [*] --> queued: add
326
+ [*] --> paused: add --add-only
327
+ queued --> running: worker free
328
+ running --> paused: pause (partial data kept)
329
+ paused --> queued: resume
330
+ running --> completed
331
+ running --> failed: error after retries
332
+ running --> cancelled: cancel (partial files removed)
333
+ queued --> cancelled
334
+ paused --> cancelled
335
+ failed --> queued: retry
336
+ cancelled --> queued: retry
337
+ running --> queued: server shutdown (auto-resumes on start)
338
+ completed --> [*]: remove
339
+ failed --> [*]: remove
340
+ cancelled --> [*]: remove
341
+ ```
342
+
343
+ [![Architecture 9](https://i.ibb.co/wNkVB4fp/pywget-architecture9.jpg)](https://i.ibb.co/wNkVB4fp/pywget-architecture9.jpg)
344
+
345
+ ### HTTP API
346
+
347
+ All `/api/v1/*` calls need `Authorization: Bearer <token>` (or `X-Pywget-Token`).
348
+
349
+ | Method and path | Purpose |
350
+ |-----------------|---------|
351
+ | `GET /health` | Liveness, version, mode (no auth) |
352
+ | `GET /` | Web UI (no auth, token entered in the page) |
353
+ | `POST /api/v1/jobs` | Create job(s): a job object, `{"urls": [...], ...common}` or `{"jobs": [...]}` |
354
+ | `GET /api/v1/jobs[?status=]` | List jobs |
355
+ | `GET /api/v1/jobs/{id}` | One job (secrets are never returned) |
356
+ | `POST /api/v1/jobs/{id}/pause\|resume\|cancel\|retry` | Control a job |
357
+ | `DELETE /api/v1/jobs/{id}` | Remove a finished job record |
358
+ | `GET /api/v1/stats` | Counts, total speed |
359
+ | `GET /api/v1/events` | Server-sent events (`event: job`) |
360
+
361
+ Job fields: `url`, `filename`, `path` (folder below the root), `referrer`, `cookie`
362
+ (string or object), `headers`, `user_agent`, `username`, `password`, `connections`,
363
+ `checksum`, `limit_rate`, `overwrite`, `add_only`.
364
+
365
+ ```bash
366
+ curl -s -H "Authorization: Bearer $(cat ~/.config/pywget/token)" \
367
+ -d '{"url":"https://example.com/f.iso","path":"isos","referrer":"https://example.com/"}' \
368
+ http://127.0.0.1:8765/api/v1/jobs
369
+ ```
370
+
371
+ ### Security model
372
+
373
+ - **Token required** for every API call; generated once (`token` file, mode 0600) or given via `--token` / `$PYWGET_TOKEN`; compared in constant time.
374
+ - **Loopback by default.** Binding elsewhere prints a warning; use `--tls-cert/--tls-key` or an HTTPS reverse proxy.
375
+ - **Root jail:** job `path` can never leave `--root` (absolute paths and `..` are rejected, symlinks resolved).
376
+ - **No CORS** and a `Host` header allow-list (`--allowed-host`) against DNS-rebinding and drive-by browser requests.
377
+ - **`--deny-private-networks`** refuses URLs resolving to private/loopback/link-local addresses (SSRF guard for exposed servers).
378
+ - Request bodies capped at 1 MiB; headers checked for CR/LF injection; credentials and cookies are stored in `jobs.json` (0600) and never returned by the API.
379
+ - Not supported: POST-body downloads (IDM `post data`), FTP.
380
+
381
+ ## How it works
382
+
383
+ ### Download engine
384
+
385
+ ```mermaid
386
+ flowchart LR
387
+ CLI["CLI<br/>pywget.cli"] --> DL
388
+ API["Python API<br/>pywget.download()"] --> DL
389
+ LEG["Legacy shim<br/>pywget.wget"] --> DL
390
+ JOB["LocalExecutor<br/>(server jobs)"] --> DL
391
+
392
+ subgraph Engine["pywget.core"]
393
+ DL["Downloader"] --> PROBE["Probe<br/>Range: bytes=0-0"]
394
+ PROBE --> PLAN{"Size known and<br/>ranges supported?"}
395
+ PLAN -- yes --> SEG["Segment workers<br/>1..N threads"]
396
+ PLAN -- no --> STR["Single stream<br/>worker"]
397
+ SEG --> MON["Monitor loop<br/>progress + state save"]
398
+ STR --> MON
399
+ MON --> VER["Verify<br/>size + checksum"]
400
+ end
401
+
402
+ SEG -. throttled by .-> RL["RateLimiter"]
403
+ STR -. throttled by .-> RL
404
+ MON --> TR["Tracker / ProgressBar"]
405
+ SEG <--> ST[("state file<br/>.pywget")]
406
+ VER --> OUT[("final file<br/>atomic rename")]
407
+ ```
408
+
409
+ [![Architecture 1](https://i.ibb.co/BVCnttHQ/pywget-architecture1.jpg)](https://i.ibb.co/BVCnttHQ/pywget-architecture1.jpg)
410
+
411
+ ### Download sequence
412
+
413
+ ```mermaid
414
+ sequenceDiagram
415
+ autonumber
416
+ actor U as User
417
+ participant D as Downloader
418
+ participant S as Server
419
+ participant F as Disk
420
+
421
+ U->>D: fetch(url)
422
+ D->>S: GET Range: bytes=0-0
423
+ S-->>D: 206 + Content-Range (size, ETag)
424
+ D->>F: load .pywget state (if any)
425
+ alt state matches size / ETag / mtime
426
+ D->>D: resume remaining segments
427
+ else fresh start
428
+ D->>F: preallocate .part, split into N segments
429
+ end
430
+ par segment 1..N
431
+ D->>S: GET Range: bytes=a-b
432
+ S-->>D: 206 data
433
+ D->>F: write at offset
434
+ end
435
+ loop every second
436
+ D->>F: save state
437
+ D-->>U: progress callback
438
+ end
439
+ D->>F: verify size and checksum
440
+ D->>F: rename .part to final name
441
+ D-->>U: DownloadResult
442
+ ```
443
+
444
+ [![Architecture 2](https://i.ibb.co/DPgLTD6D/pywget-architecture2.jpg)](https://i.ibb.co/DPgLTD6D/pywget-architecture2.jpg)
445
+
446
+ ### Segment lifecycle and retries
447
+
448
+ ```mermaid
449
+ stateDiagram-v2
450
+ [*] --> Probing
451
+ Probing --> Planning: size and ranges known
452
+ Probing --> Streaming: no range support
453
+ Planning --> Downloading
454
+ Streaming --> Verifying
455
+ Downloading --> Retrying: timeout / reset / 5xx / 429
456
+ Retrying --> Downloading: backoff + jitter, resume at offset
457
+ Retrying --> Failed: retries exhausted
458
+ Downloading --> Interrupted: Ctrl+C / SIGTERM / cancel()
459
+ Interrupted --> Planning: re-run, state validated
460
+ Downloading --> Verifying: all segments complete
461
+ Verifying --> Completed: size and checksum OK
462
+ Verifying --> Failed: mismatch, partial files removed
463
+ Completed --> [*]
464
+ Failed --> [*]
465
+ ```
466
+
467
+ [![Architecture 3](https://i.ibb.co/YBZKFsxG/pywget-architecture3.jpg)](https://i.ibb.co/YBZKFsxG/pywget-architecture3.jpg)
468
+
469
+ ### Resume decision
470
+
471
+ ```mermaid
472
+ flowchart TD
473
+ A["Start"] --> B{".pywget state<br/>and .part exist?"}
474
+ B -- no --> N["Fresh download"]
475
+ B -- yes --> C{"URL, size, ETag,<br/>Last-Modified equal?"}
476
+ C -- no --> N
477
+ C -- yes --> D{"Segments valid and<br/>.part size correct?"}
478
+ D -- no --> N
479
+ D -- yes --> R["Resume each segment<br/>from its saved offset"]
480
+ ```
481
+
482
+ [![Architecture 4](https://i.ibb.co/nqGmzdpw/pywget-architecture4.jpg)](https://i.ibb.co/nqGmzdpw/pywget-architecture4.jpg)
483
+
484
+ ### Main types - engine
485
+
486
+ ```mermaid
487
+ classDiagram
488
+ class Downloader {
489
+ +fetch(url) DownloadResult
490
+ +download(url) Path
491
+ +cancel()
492
+ +partial
493
+ }
494
+ class Options {
495
+ connections
496
+ retries
497
+ limit_rate
498
+ rate_limiter
499
+ checksum
500
+ progress
501
+ }
502
+ class DownloadResult {
503
+ path
504
+ size
505
+ resumed
506
+ connections
507
+ checksum
508
+ }
509
+ class Tracker
510
+ class ProgressBar
511
+ class RateLimiter
512
+ Downloader o-- Options
513
+ Downloader --> DownloadResult
514
+ Downloader --> Tracker
515
+ Downloader --> RateLimiter
516
+ ProgressBar ..> Tracker : renders snapshots
517
+ ```
518
+
519
+ [![Architecture 5](https://i.ibb.co/d0kCy05W/pywget-architecture5.jpg)](https://i.ibb.co/d0kCy05W/pywget-architecture5.jpg)
520
+
521
+ ### Main types - server and agent
522
+
523
+ ```mermaid
524
+ classDiagram
525
+ class ServerHandle {
526
+ +start()
527
+ +close()
528
+ +url
529
+ }
530
+ class PywgetHTTPServer
531
+ class ApiHandler {
532
+ auth, Host check
533
+ routes /api/v1/*
534
+ }
535
+ class JobManager {
536
+ +add(spec) Job
537
+ +pause(id)
538
+ +resume(id)
539
+ +cancel(id)
540
+ +retry(id)
541
+ +remove(id)
542
+ }
543
+ class Job
544
+ class JobStore
545
+ class EventBus
546
+ class LocalExecutor
547
+ class RelayExecutor
548
+ class ServerClient
549
+ class ServerConfig
550
+ ServerHandle --> PywgetHTTPServer
551
+ ServerHandle --> JobManager
552
+ PywgetHTTPServer --> ApiHandler
553
+ ApiHandler --> JobManager
554
+ JobManager o-- Job
555
+ JobManager --> JobStore : persists
556
+ JobManager --> EventBus : SSE
557
+ JobManager --> LocalExecutor : server mode
558
+ JobManager --> RelayExecutor : agent mode
559
+ LocalExecutor --> Downloader
560
+ RelayExecutor --> ServerClient : forwards to upstream
561
+ ServerHandle o-- ServerConfig
562
+ ```
563
+
564
+ [![Architecture 5](https://i.ibb.co/zVMXCFtz/pywget-architecture6.jpg)](https://i.ibb.co/zVMXCFtz/pywget-architecture6.jpg)
565
+
566
+ ### Module map
567
+
568
+ | Module | Role |
569
+ |--------|------|
570
+ | `pywget.core` | Download engine (`Downloader`, `Options`) |
571
+ | `pywget.progress`, `pywget.utils`, `pywget.errors` | Progress bar, helpers, exceptions and exit codes |
572
+ | `pywget.cli` | CLI: direct download, `serve`, `agent`, `send`, `jobs` |
573
+ | `pywget.server` | HTTP API, SSE, web UI hosting (`ServerHandle`, `run_server`) |
574
+ | `pywget.jobs` | `Job`, `JobManager`, persistence, `LocalExecutor` / `RelayExecutor` |
575
+ | `pywget.client` | `ServerClient` (used by `send`, `jobs` and the agent) |
576
+ | `pywget.config` | Token file, config dir, `ServerConfig` |
577
+ | `pywget.clipboard` | `c` input: clipboard and URL extraction |
578
+ | `pywget.webui` | Embedded single-page web UI |
579
+ | `pywget.wget` | Backward-compatible `wget.download()` |
580
+
581
+ ## Behaviour details
582
+
583
+ - **Probing:** one `Range: bytes=0-0` request yields size, range support, ETag, `Last-Modified` and `Content-Disposition`.
584
+ - **Segments:** a file is split into `min(connections, size / 2 MiB)` parts; small files use one connection.
585
+ - **Existing file:** same size means skipped; different size is saved as `name.1` (wget style) unless `--overwrite`.
586
+ - **Partial files:** stored as `name.part` + `name.pywget`; removed on success. Changed remote file (size/ETag/mtime) restarts cleanly.
587
+ - **Redirects:** only http/https; `Authorization`/`Cookie` are dropped when the origin changes.
588
+ - **Server jobs:** persisted in `jobs.json`; a job running at shutdown is re-queued and resumes from its `.part` data.
589
+ - **Not supported (by design):** FTP, recursive mirroring, POST-body downloads.
590
+
591
+ ## Development
592
+
593
+ ```bash
594
+ git clone https://github.com/cumulus13/pywget && cd pywget
595
+ pip install -e ".[dev]"
596
+ pytest --cov=pywget
597
+ ruff check pywget tests && mypy pywget
598
+ python -m build
599
+ ```
600
+
601
+ ## License
602
+
603
+ [BSD](LICENSE)
604
+
605
+ ## Author
606
+
607
+ [Hadi Cahyadi](https://github.com/cumulus13)
608
+
609
+ [![Buy Me a Coffee](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://www.buymeacoffee.com/cumulus13)
610
+ [![Donate via Ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/cumulus13)
611
+ [Support me on Patreon](https://www.patreon.com/cumulus13)