pywget 2.0.4__tar.gz → 2.0.6__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.
- {pywget-2.0.4/pywget.egg-info → pywget-2.0.6}/PKG-INFO +56 -2
- {pywget-2.0.4 → pywget-2.0.6}/README.md +55 -1
- {pywget-2.0.4 → pywget-2.0.6}/pyproject.toml +4 -1
- {pywget-2.0.4 → pywget-2.0.6}/pywget/cli.py +173 -19
- pywget-2.0.6/pywget/config.py +220 -0
- {pywget-2.0.4 → pywget-2.0.6}/pywget/core.py +48 -3
- {pywget-2.0.4 → pywget-2.0.6}/pywget/errors.py +9 -1
- {pywget-2.0.4 → pywget-2.0.6}/pywget/jobs.py +10 -1
- {pywget-2.0.4 → pywget-2.0.6}/pywget/server.py +2 -0
- {pywget-2.0.4 → pywget-2.0.6}/pywget/utils.py +11 -0
- {pywget-2.0.4 → pywget-2.0.6}/pywget/webui.py +1 -1
- {pywget-2.0.4 → pywget-2.0.6/pywget.egg-info}/PKG-INFO +56 -2
- {pywget-2.0.4 → pywget-2.0.6}/pywget.egg-info/SOURCES.txt +1 -0
- pywget-2.0.6/tests/test_config.py +138 -0
- {pywget-2.0.4 → pywget-2.0.6}/tests/test_server.py +5 -1
- pywget-2.0.4/pywget/config.py +0 -93
- {pywget-2.0.4 → pywget-2.0.6}/LICENSE +0 -0
- {pywget-2.0.4 → pywget-2.0.6}/pywget/__init__.py +0 -0
- {pywget-2.0.4 → pywget-2.0.6}/pywget/__main__.py +0 -0
- {pywget-2.0.4 → pywget-2.0.6}/pywget/__version__.py +0 -0
- {pywget-2.0.4 → pywget-2.0.6}/pywget/client.py +0 -0
- {pywget-2.0.4 → pywget-2.0.6}/pywget/clipboard.py +0 -0
- {pywget-2.0.4 → pywget-2.0.6}/pywget/progress.py +0 -0
- {pywget-2.0.4 → pywget-2.0.6}/pywget/py.typed +0 -0
- {pywget-2.0.4 → pywget-2.0.6}/pywget/wget.py +0 -0
- {pywget-2.0.4 → pywget-2.0.6}/pywget.egg-info/dependency_links.txt +0 -0
- {pywget-2.0.4 → pywget-2.0.6}/pywget.egg-info/entry_points.txt +0 -0
- {pywget-2.0.4 → pywget-2.0.6}/pywget.egg-info/requires.txt +0 -0
- {pywget-2.0.4 → pywget-2.0.6}/pywget.egg-info/top_level.txt +0 -0
- {pywget-2.0.4 → pywget-2.0.6}/setup.cfg +0 -0
- {pywget-2.0.4 → pywget-2.0.6}/tests/test_download.py +0 -0
- {pywget-2.0.4 → pywget-2.0.6}/tests/test_utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pywget
|
|
3
|
-
Version: 2.0.
|
|
3
|
+
Version: 2.0.6
|
|
4
4
|
Summary: Robust, resumable, multi-connection wget alternative - CLI and Python library, zero dependencies
|
|
5
5
|
Author-email: Hadi Cahyadi <cumulus13@gmail.com>
|
|
6
6
|
Maintainer-email: cumulus13 <cumulus13@gmail.com>
|
|
@@ -231,6 +231,58 @@ Errors derive from `pywget.PywgetError` (`InvalidURLError`, `NetworkError`,
|
|
|
231
231
|
`HTTPStatusError`, `ChecksumError`, `StorageError`, `DownloadCancelled`).
|
|
232
232
|
A `Downloader` runs one download at a time - use one instance per thread.
|
|
233
233
|
|
|
234
|
+
## Configuration and API token
|
|
235
|
+
|
|
236
|
+
### Config file (optional)
|
|
237
|
+
|
|
238
|
+
There is no config file unless you create one; every option also has a command-line flag.
|
|
239
|
+
|
|
240
|
+
```bash
|
|
241
|
+
pywget config path # where it lives
|
|
242
|
+
pywget config init # write a fully commented template there (mode 0600)
|
|
243
|
+
pywget config show
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
| OS | Default location |
|
|
247
|
+
|----|------------------|
|
|
248
|
+
| Windows | `%APPDATA%\pywget\config.ini` |
|
|
249
|
+
| Linux / macOS | `~/.config/pywget/config.ini` (`$XDG_CONFIG_HOME` respected) |
|
|
250
|
+
|
|
251
|
+
Override the directory with `PYWGET_CONFIG_DIR` or the file with `PYWGET_CONFIG`.
|
|
252
|
+
Precedence: **command line > environment > config file > built-in defaults**.
|
|
253
|
+
Sections: `[download]`, `[client]`, `[serve]`, `[agent]` (the generated template lists every key).
|
|
254
|
+
Unknown keys or bad values are reported with the file path and exit code 2.
|
|
255
|
+
|
|
256
|
+
```ini
|
|
257
|
+
[serve]
|
|
258
|
+
root = C:\DOWNLOADS
|
|
259
|
+
max_jobs = 3
|
|
260
|
+
connections = 2
|
|
261
|
+
|
|
262
|
+
[client]
|
|
263
|
+
server = http://127.0.0.1:8765
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
Job queue state lives in `<config dir>/server/jobs.json` (`agent/` for an agent); change it with `state_dir`.
|
|
267
|
+
|
|
268
|
+
### API token
|
|
269
|
+
|
|
270
|
+
The server and agent **always** require a token.
|
|
271
|
+
|
|
272
|
+
- **Default:** on the first `pywget serve` / `pywget agent` a random 256-bit token is generated and saved to `<config dir>/token` (mode 0600). It is reused on every start, and local `send` / `jobs` commands read it automatically.
|
|
273
|
+
- **Show / create / set:**
|
|
274
|
+
|
|
275
|
+
```bash
|
|
276
|
+
pywget token show # print the current token and where it comes from
|
|
277
|
+
pywget token new # generate and save a new one (restart the server afterwards)
|
|
278
|
+
pywget token set "my-own-secret-123" # or: echo -n ... | pywget token set -
|
|
279
|
+
pywget token path
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
- **Override order:** `--token` > `$PYWGET_TOKEN` > token file.
|
|
283
|
+
- **Format:** not arbitrary text - 16 to 256 characters from `A-Z a-z 0-9 . _ ~ + / = -` (safe inside an HTTP header). Weak or malformed tokens are rejected at startup.
|
|
284
|
+
- **Remote clients:** copy the token to the other machine (`pywget token set -` there, or `export PYWGET_TOKEN=...`).
|
|
285
|
+
|
|
234
286
|
## Server mode and agent
|
|
235
287
|
|
|
236
288
|
Run pywget as a long-lived download manager and push links to it from anywhere - the
|
|
@@ -573,7 +625,7 @@ classDiagram
|
|
|
573
625
|
| `pywget.server` | HTTP API, SSE, web UI hosting (`ServerHandle`, `run_server`) |
|
|
574
626
|
| `pywget.jobs` | `Job`, `JobManager`, persistence, `LocalExecutor` / `RelayExecutor` |
|
|
575
627
|
| `pywget.client` | `ServerClient` (used by `send`, `jobs` and the agent) |
|
|
576
|
-
| `pywget.config` |
|
|
628
|
+
| `pywget.config` | Config file, token file and validation, `ServerConfig` |
|
|
577
629
|
| `pywget.clipboard` | `c` input: clipboard and URL extraction |
|
|
578
630
|
| `pywget.webui` | Embedded single-page web UI |
|
|
579
631
|
| `pywget.wget` | Backward-compatible `wget.download()` |
|
|
@@ -582,6 +634,8 @@ classDiagram
|
|
|
582
634
|
|
|
583
635
|
- **Probing:** one `Range: bytes=0-0` request yields size, range support, ETag, `Last-Modified` and `Content-Disposition`.
|
|
584
636
|
- **Segments:** a file is split into `min(connections, size / 2 MiB)` parts; small files use one connection.
|
|
637
|
+
- **Rate-limited servers:** on HTTP 429 the number of simultaneous segment requests is reduced automatically (down to 1) instead of failing the job; failed jobs keep partial data, so `pywget jobs retry ID` resumes.
|
|
638
|
+
- **Output hygiene:** messages and job lists show `host/../file` only - query strings (often signed tokens) are never printed.
|
|
585
639
|
- **Existing file:** same size means skipped; different size is saved as `name.1` (wget style) unless `--overwrite`.
|
|
586
640
|
- **Partial files:** stored as `name.part` + `name.pywget`; removed on success. Changed remote file (size/ETag/mtime) restarts cleanly.
|
|
587
641
|
- **Redirects:** only http/https; `Authorization`/`Cookie` are dropped when the origin changes.
|
|
@@ -161,6 +161,58 @@ Errors derive from `pywget.PywgetError` (`InvalidURLError`, `NetworkError`,
|
|
|
161
161
|
`HTTPStatusError`, `ChecksumError`, `StorageError`, `DownloadCancelled`).
|
|
162
162
|
A `Downloader` runs one download at a time - use one instance per thread.
|
|
163
163
|
|
|
164
|
+
## Configuration and API token
|
|
165
|
+
|
|
166
|
+
### Config file (optional)
|
|
167
|
+
|
|
168
|
+
There is no config file unless you create one; every option also has a command-line flag.
|
|
169
|
+
|
|
170
|
+
```bash
|
|
171
|
+
pywget config path # where it lives
|
|
172
|
+
pywget config init # write a fully commented template there (mode 0600)
|
|
173
|
+
pywget config show
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
| OS | Default location |
|
|
177
|
+
|----|------------------|
|
|
178
|
+
| Windows | `%APPDATA%\pywget\config.ini` |
|
|
179
|
+
| Linux / macOS | `~/.config/pywget/config.ini` (`$XDG_CONFIG_HOME` respected) |
|
|
180
|
+
|
|
181
|
+
Override the directory with `PYWGET_CONFIG_DIR` or the file with `PYWGET_CONFIG`.
|
|
182
|
+
Precedence: **command line > environment > config file > built-in defaults**.
|
|
183
|
+
Sections: `[download]`, `[client]`, `[serve]`, `[agent]` (the generated template lists every key).
|
|
184
|
+
Unknown keys or bad values are reported with the file path and exit code 2.
|
|
185
|
+
|
|
186
|
+
```ini
|
|
187
|
+
[serve]
|
|
188
|
+
root = C:\DOWNLOADS
|
|
189
|
+
max_jobs = 3
|
|
190
|
+
connections = 2
|
|
191
|
+
|
|
192
|
+
[client]
|
|
193
|
+
server = http://127.0.0.1:8765
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
Job queue state lives in `<config dir>/server/jobs.json` (`agent/` for an agent); change it with `state_dir`.
|
|
197
|
+
|
|
198
|
+
### API token
|
|
199
|
+
|
|
200
|
+
The server and agent **always** require a token.
|
|
201
|
+
|
|
202
|
+
- **Default:** on the first `pywget serve` / `pywget agent` a random 256-bit token is generated and saved to `<config dir>/token` (mode 0600). It is reused on every start, and local `send` / `jobs` commands read it automatically.
|
|
203
|
+
- **Show / create / set:**
|
|
204
|
+
|
|
205
|
+
```bash
|
|
206
|
+
pywget token show # print the current token and where it comes from
|
|
207
|
+
pywget token new # generate and save a new one (restart the server afterwards)
|
|
208
|
+
pywget token set "my-own-secret-123" # or: echo -n ... | pywget token set -
|
|
209
|
+
pywget token path
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
- **Override order:** `--token` > `$PYWGET_TOKEN` > token file.
|
|
213
|
+
- **Format:** not arbitrary text - 16 to 256 characters from `A-Z a-z 0-9 . _ ~ + / = -` (safe inside an HTTP header). Weak or malformed tokens are rejected at startup.
|
|
214
|
+
- **Remote clients:** copy the token to the other machine (`pywget token set -` there, or `export PYWGET_TOKEN=...`).
|
|
215
|
+
|
|
164
216
|
## Server mode and agent
|
|
165
217
|
|
|
166
218
|
Run pywget as a long-lived download manager and push links to it from anywhere - the
|
|
@@ -503,7 +555,7 @@ classDiagram
|
|
|
503
555
|
| `pywget.server` | HTTP API, SSE, web UI hosting (`ServerHandle`, `run_server`) |
|
|
504
556
|
| `pywget.jobs` | `Job`, `JobManager`, persistence, `LocalExecutor` / `RelayExecutor` |
|
|
505
557
|
| `pywget.client` | `ServerClient` (used by `send`, `jobs` and the agent) |
|
|
506
|
-
| `pywget.config` |
|
|
558
|
+
| `pywget.config` | Config file, token file and validation, `ServerConfig` |
|
|
507
559
|
| `pywget.clipboard` | `c` input: clipboard and URL extraction |
|
|
508
560
|
| `pywget.webui` | Embedded single-page web UI |
|
|
509
561
|
| `pywget.wget` | Backward-compatible `wget.download()` |
|
|
@@ -512,6 +564,8 @@ classDiagram
|
|
|
512
564
|
|
|
513
565
|
- **Probing:** one `Range: bytes=0-0` request yields size, range support, ETag, `Last-Modified` and `Content-Disposition`.
|
|
514
566
|
- **Segments:** a file is split into `min(connections, size / 2 MiB)` parts; small files use one connection.
|
|
567
|
+
- **Rate-limited servers:** on HTTP 429 the number of simultaneous segment requests is reduced automatically (down to 1) instead of failing the job; failed jobs keep partial data, so `pywget jobs retry ID` resumes.
|
|
568
|
+
- **Output hygiene:** messages and job lists show `host/../file` only - query strings (often signed tokens) are never printed.
|
|
515
569
|
- **Existing file:** same size means skipped; different size is saved as `name.1` (wget style) unless `--overwrite`.
|
|
516
570
|
- **Partial files:** stored as `name.part` + `name.pywget`; removed on success. Changed remote file (size/ETag/mtime) restarts cleanly.
|
|
517
571
|
- **Redirects:** only http/https; `Authorization`/`Cookie` are dropped when the origin changes.
|
|
@@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta"
|
|
|
6
6
|
|
|
7
7
|
[project]
|
|
8
8
|
name = "pywget"
|
|
9
|
-
version = "2.0.
|
|
9
|
+
version = "2.0.6"
|
|
10
10
|
description = "Robust, resumable, multi-connection wget alternative - CLI and Python library, zero dependencies"
|
|
11
11
|
readme = "README.md"
|
|
12
12
|
requires-python = ">=3.8"
|
|
@@ -78,6 +78,9 @@ pywget = [
|
|
|
78
78
|
"py.typed",
|
|
79
79
|
]
|
|
80
80
|
|
|
81
|
+
[tool.setuptools.dynamic.version]
|
|
82
|
+
attr = "pywget.__version__.__version__"
|
|
83
|
+
|
|
81
84
|
[tool.pytest.ini_options]
|
|
82
85
|
testpaths = [
|
|
83
86
|
"tests",
|
|
@@ -15,10 +15,11 @@ import getpass
|
|
|
15
15
|
import json
|
|
16
16
|
import logging
|
|
17
17
|
import os
|
|
18
|
+
import shutil
|
|
18
19
|
import signal
|
|
19
20
|
import sys
|
|
20
21
|
from pathlib import Path
|
|
21
|
-
from typing import Any, Dict, List, Optional
|
|
22
|
+
from typing import Any, Callable, Dict, List, Optional, Tuple
|
|
22
23
|
|
|
23
24
|
from .__version__ import __version__
|
|
24
25
|
from .client import ServerClient
|
|
@@ -29,10 +30,17 @@ from .config import (
|
|
|
29
30
|
DEFAULT_PORT,
|
|
30
31
|
ServerConfig,
|
|
31
32
|
config_dir,
|
|
32
|
-
|
|
33
|
+
config_file_path,
|
|
34
|
+
generate_token,
|
|
35
|
+
load_config,
|
|
36
|
+
resolve_token,
|
|
37
|
+
save_token,
|
|
38
|
+
token_path,
|
|
39
|
+
validate_token,
|
|
40
|
+
write_default_config,
|
|
33
41
|
)
|
|
34
42
|
from .core import Downloader, Options
|
|
35
|
-
from .errors import PywgetError
|
|
43
|
+
from .errors import ConfigError, PywgetError
|
|
36
44
|
from .progress import ProgressBar
|
|
37
45
|
from .utils import build_headers, format_size
|
|
38
46
|
|
|
@@ -49,12 +57,14 @@ other commands (pywget COMMAND -h):
|
|
|
49
57
|
agent run a relay that forwards links to an upstream server
|
|
50
58
|
send send links to a server/agent ("c" = clipboard, or a text file of links)
|
|
51
59
|
jobs list / show / pause / resume / cancel / retry / remove server jobs
|
|
60
|
+
token show / new / set / path - the API token (auto-generated on first serve)
|
|
61
|
+
config path / init / show - the optional config file (config.ini)
|
|
52
62
|
|
|
53
63
|
exit codes: 0 ok | 2 bad usage/URL | 3 network | 4 HTTP status | 5 checksum
|
|
54
64
|
6 storage/I-O | 7 server/agent error | 130 interrupted
|
|
55
65
|
"""
|
|
56
66
|
|
|
57
|
-
COMMANDS = ("serve", "agent", "send", "jobs")
|
|
67
|
+
COMMANDS = ("serve", "agent", "send", "jobs", "token", "config")
|
|
58
68
|
|
|
59
69
|
|
|
60
70
|
# ----------------------------------------------------------------- shared bits
|
|
@@ -161,6 +171,125 @@ def _client(args: argparse.Namespace) -> ServerClient:
|
|
|
161
171
|
)
|
|
162
172
|
|
|
163
173
|
|
|
174
|
+
# ------------------------------------------------------------------ config file
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _bool(value: str) -> bool:
|
|
178
|
+
low = value.strip().lower()
|
|
179
|
+
if low in ("1", "true", "yes", "on"):
|
|
180
|
+
return True
|
|
181
|
+
if low in ("0", "false", "no", "off"):
|
|
182
|
+
return False
|
|
183
|
+
raise ValueError(f"not a boolean: {value!r}")
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _list(value: str) -> List[str]:
|
|
187
|
+
return [item.strip() for item in value.split(",") if item.strip()]
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
Spec = Dict[str, Tuple[str, Callable[[str], Any]]]
|
|
191
|
+
|
|
192
|
+
DOWNLOAD_SPEC: Spec = {
|
|
193
|
+
"path": ("path", str), "connections": ("connections", int), "retries": ("retries", int),
|
|
194
|
+
"timeout": ("timeout", float), "limit_rate": ("limit_rate", str), "user_agent": ("user_agent", str),
|
|
195
|
+
"proxy": ("proxy", str), "ca_cert": ("ca_cert", str),
|
|
196
|
+
}
|
|
197
|
+
CLIENT_SPEC: Spec = {
|
|
198
|
+
"server": ("server", str), "verify": ("server_no_check_certificate", lambda v: not _bool(v)),
|
|
199
|
+
"ca_cert": ("server_ca_cert", str),
|
|
200
|
+
}
|
|
201
|
+
_COMMON_SERVER: Spec = {
|
|
202
|
+
"host": ("host", str), "port": ("port", int), "state_dir": ("state_dir", str),
|
|
203
|
+
"max_jobs": ("max_jobs", int), "allowed_host": ("allowed_host", _list),
|
|
204
|
+
"tls_cert": ("tls_cert", str), "tls_key": ("tls_key", str), "ui": ("no_ui", lambda v: not _bool(v)),
|
|
205
|
+
}
|
|
206
|
+
SERVE_SPEC: Spec = dict(_COMMON_SERVER, **{
|
|
207
|
+
"root": ("root", str), "connections": ("connections", int), "retries": ("retries", int),
|
|
208
|
+
"limit_rate": ("limit_rate", str), "deny_private_networks": ("deny_private_networks", _bool),
|
|
209
|
+
})
|
|
210
|
+
AGENT_SPEC: Spec = dict(_COMMON_SERVER, **{
|
|
211
|
+
"upstream": ("upstream", str), "upstream_token": ("upstream_token", str),
|
|
212
|
+
"upstream_verify": ("upstream_no_check_certificate", lambda v: not _bool(v)),
|
|
213
|
+
"upstream_ca_cert": ("upstream_ca", str),
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _apply_config(parser: argparse.ArgumentParser, section: str, spec: Spec) -> None:
|
|
218
|
+
"""Make config-file values the parser defaults (command line still wins)."""
|
|
219
|
+
values = load_config().get(section, {})
|
|
220
|
+
unknown = sorted(set(values) - set(spec))
|
|
221
|
+
if unknown:
|
|
222
|
+
raise ConfigError(
|
|
223
|
+
f"unknown option(s) in [{section}] of {config_file_path()}: {', '.join(unknown)}"
|
|
224
|
+
)
|
|
225
|
+
defaults: Dict[str, Any] = {}
|
|
226
|
+
for key, raw in values.items():
|
|
227
|
+
dest, convert = spec[key]
|
|
228
|
+
if raw == "":
|
|
229
|
+
continue
|
|
230
|
+
try:
|
|
231
|
+
defaults[dest] = convert(raw)
|
|
232
|
+
except ValueError:
|
|
233
|
+
raise ConfigError(f"invalid value for [{section}] {key} in {config_file_path()}: {raw!r}") from None
|
|
234
|
+
parser.set_defaults(**defaults)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _config_main(argv: List[str]) -> int:
|
|
238
|
+
p = argparse.ArgumentParser(prog="pywget config", description="Manage the optional config file.")
|
|
239
|
+
p.add_argument("action", choices=["path", "init", "show"])
|
|
240
|
+
p.add_argument("--force", action="store_true", help="init: overwrite an existing file")
|
|
241
|
+
args = p.parse_args(argv)
|
|
242
|
+
path = config_file_path()
|
|
243
|
+
if args.action == "path":
|
|
244
|
+
print(path)
|
|
245
|
+
elif args.action == "init":
|
|
246
|
+
print(write_default_config(args.force))
|
|
247
|
+
else:
|
|
248
|
+
if not path.exists():
|
|
249
|
+
print(f"(no config file; create one with: pywget config init) [{path}]", file=sys.stderr)
|
|
250
|
+
return 0
|
|
251
|
+
shown = []
|
|
252
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
253
|
+
key = line.split("=", 1)[0].strip().lower()
|
|
254
|
+
shown.append(f"{key} = ********" if key == "upstream_token" and "=" in line else line)
|
|
255
|
+
print("\n".join(shown))
|
|
256
|
+
return 0
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _token_main(argv: List[str]) -> int:
|
|
260
|
+
p = argparse.ArgumentParser(
|
|
261
|
+
prog="pywget token",
|
|
262
|
+
description="Manage the API token (stored in the token file, mode 0600).",
|
|
263
|
+
)
|
|
264
|
+
p.add_argument("action", choices=["show", "new", "set", "path"])
|
|
265
|
+
p.add_argument("value", nargs="?", help="set: the token to use ('-' = read from stdin)")
|
|
266
|
+
p.add_argument("--length", type=int, default=32, metavar="BYTES", help="new: random bytes (default 32)")
|
|
267
|
+
args = p.parse_args(argv)
|
|
268
|
+
if args.action == "path":
|
|
269
|
+
print(token_path())
|
|
270
|
+
return 0
|
|
271
|
+
if args.action == "show":
|
|
272
|
+
token, source = resolve_token()
|
|
273
|
+
if not token:
|
|
274
|
+
print("no token yet: it is generated the first time you run 'pywget serve' (or run 'pywget token new')",
|
|
275
|
+
file=sys.stderr)
|
|
276
|
+
return 1
|
|
277
|
+
print(f"token source: {source}", file=sys.stderr)
|
|
278
|
+
print(token)
|
|
279
|
+
return 0
|
|
280
|
+
if args.action == "new":
|
|
281
|
+
token = generate_token(args.length)
|
|
282
|
+
else:
|
|
283
|
+
if not args.value:
|
|
284
|
+
p.error("'set' needs a value (or '-' to read stdin)")
|
|
285
|
+
token = (sys.stdin.readline() if args.value == "-" else args.value).strip()
|
|
286
|
+
validate_token(token)
|
|
287
|
+
path = save_token(token)
|
|
288
|
+
print(token)
|
|
289
|
+
print(f"saved to {path}; restart 'pywget serve' / 'pywget agent' to apply it", file=sys.stderr)
|
|
290
|
+
return 0
|
|
291
|
+
|
|
292
|
+
|
|
164
293
|
# ------------------------------------------------------------- direct download
|
|
165
294
|
|
|
166
295
|
|
|
@@ -223,6 +352,7 @@ def _build_options(args: argparse.Namespace, progress: Optional[ProgressBar]) ->
|
|
|
223
352
|
|
|
224
353
|
def _download_main(argv: List[str]) -> int:
|
|
225
354
|
parser = build_parser()
|
|
355
|
+
_apply_config(parser, "download", DOWNLOAD_SPEC)
|
|
226
356
|
args = parser.parse_args(argv)
|
|
227
357
|
try:
|
|
228
358
|
urls = _expand_inputs(args.urls, args.input_file)
|
|
@@ -300,12 +430,19 @@ def _job_specs(args: argparse.Namespace, urls: List[str]) -> List[Dict[str, Any]
|
|
|
300
430
|
return specs
|
|
301
431
|
|
|
302
432
|
|
|
433
|
+
def _clip(text: str, width: int) -> str:
|
|
434
|
+
return text if len(text) <= width else text[: max(width - 2, 1)] + ".."
|
|
435
|
+
|
|
436
|
+
|
|
303
437
|
def _fmt_job(job: Dict[str, Any]) -> str:
|
|
438
|
+
cols = shutil.get_terminal_size((100, 24)).columns
|
|
304
439
|
pct = f"{job['percent']:5.1f}%" if job.get("percent") is not None else " --%"
|
|
305
440
|
speed = f"{format_size(job['speed'])}/s" if job.get("status") == "running" else ""
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
441
|
+
prefix = f"{job['id']} {job['status']:<9} {pct} {speed:>11} "
|
|
442
|
+
line = prefix + _clip(job.get("name") or job["url"], max(20, cols - len(prefix) - 1))
|
|
443
|
+
if job.get("error"):
|
|
444
|
+
line += "\n ! " + _clip(job["error"], max(20, cols - 8))
|
|
445
|
+
return line
|
|
309
446
|
|
|
310
447
|
|
|
311
448
|
def _forward(args: argparse.Namespace, urls: List[str], parser: argparse.ArgumentParser) -> int:
|
|
@@ -329,7 +466,7 @@ def _forward(args: argparse.Namespace, urls: List[str], parser: argparse.Argumen
|
|
|
329
466
|
last = [""]
|
|
330
467
|
|
|
331
468
|
def show(j: Dict[str, Any], last: List[str] = last) -> None:
|
|
332
|
-
line = _fmt_job(j)
|
|
469
|
+
line = _fmt_job(j).split("\n")[0]
|
|
333
470
|
if not quiet and line != last[0] and sys.stderr.isatty():
|
|
334
471
|
sys.stderr.write("\r" + line + " " * max(0, len(last[0]) - len(line)))
|
|
335
472
|
sys.stderr.flush()
|
|
@@ -368,6 +505,7 @@ def _send_main(argv: List[str]) -> int:
|
|
|
368
505
|
p.add_argument("-q", "--quiet", action="store_true")
|
|
369
506
|
p.add_argument("-v", "--verbose", action="store_true")
|
|
370
507
|
p.add_argument("--json", action="store_true", help="print jobs as JSON on stdout")
|
|
508
|
+
_apply_config(p, "client", CLIENT_SPEC)
|
|
371
509
|
args = p.parse_args(argv)
|
|
372
510
|
try:
|
|
373
511
|
urls = _expand_inputs(args.urls, args.input_file)
|
|
@@ -392,6 +530,7 @@ def _jobs_main(argv: List[str]) -> int:
|
|
|
392
530
|
p.add_argument("--status", help="filter for 'list' (queued, running, paused, completed, failed, cancelled)")
|
|
393
531
|
p.add_argument("--json", action="store_true")
|
|
394
532
|
_add_server_args(p)
|
|
533
|
+
_apply_config(p, "client", CLIENT_SPEC)
|
|
395
534
|
args = p.parse_args(argv)
|
|
396
535
|
if args.action not in ("list", "stats") and not args.job_id:
|
|
397
536
|
p.error(f"'{args.action}' needs a job id")
|
|
@@ -436,7 +575,7 @@ def _serve_main(argv: List[str], agent: bool) -> int:
|
|
|
436
575
|
p.add_argument("--port", type=int, default=DEFAULT_AGENT_PORT if agent else DEFAULT_PORT, help="TCP port")
|
|
437
576
|
p.add_argument("--token", help="API token (default: $PYWGET_TOKEN, token file, or generated)")
|
|
438
577
|
if agent:
|
|
439
|
-
p.add_argument("--upstream",
|
|
578
|
+
p.add_argument("--upstream", metavar="URL", help="server to forward links to (or 'upstream' in [agent])")
|
|
440
579
|
p.add_argument("--upstream-token", metavar="TOKEN", help="token of the upstream server")
|
|
441
580
|
p.add_argument("--upstream-no-check-certificate", action="store_true")
|
|
442
581
|
p.add_argument("--upstream-ca-cert", metavar="FILE")
|
|
@@ -456,13 +595,16 @@ def _serve_main(argv: List[str], agent: bool) -> int:
|
|
|
456
595
|
p.add_argument("--tls-key", metavar="FILE")
|
|
457
596
|
p.add_argument("--no-ui", action="store_true", help="disable the web UI")
|
|
458
597
|
p.add_argument("-v", "--verbose", action="store_true")
|
|
598
|
+
_apply_config(p, "agent" if agent else "serve", AGENT_SPEC if agent else SERVE_SPEC)
|
|
459
599
|
args = p.parse_args(argv)
|
|
600
|
+
if agent and not args.upstream:
|
|
601
|
+
p.error("--upstream is required (or set 'upstream' in the [agent] section of the config file)")
|
|
460
602
|
_setup_logging(args.verbose)
|
|
461
603
|
_install_signals()
|
|
462
604
|
try:
|
|
463
|
-
token =
|
|
605
|
+
token, token_source = resolve_token(args.token, create=True)
|
|
464
606
|
kwargs: Dict[str, Any] = dict(
|
|
465
|
-
host=args.host, port=args.port, token=token or "", max_jobs=args.max_jobs,
|
|
607
|
+
host=args.host, port=args.port, token=token or "", token_source=token_source, max_jobs=args.max_jobs,
|
|
466
608
|
allowed_hosts=args.allowed_host, tls_cert=args.tls_cert, tls_key=args.tls_key, ui=not args.no_ui,
|
|
467
609
|
)
|
|
468
610
|
if args.state_dir:
|
|
@@ -500,14 +642,26 @@ def main(argv: Optional[List[str]] = None) -> int:
|
|
|
500
642
|
args = list(sys.argv[1:] if argv is None else argv)
|
|
501
643
|
if args and args[0] in COMMANDS:
|
|
502
644
|
command, rest = args[0], args[1:]
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
645
|
+
try:
|
|
646
|
+
if command == "serve":
|
|
647
|
+
return _serve_main(rest, agent=False)
|
|
648
|
+
if command == "agent":
|
|
649
|
+
return _serve_main(rest, agent=True)
|
|
650
|
+
if command == "send":
|
|
651
|
+
return _send_main(rest)
|
|
652
|
+
if command == "token":
|
|
653
|
+
return _token_main(rest)
|
|
654
|
+
if command == "config":
|
|
655
|
+
return _config_main(rest)
|
|
656
|
+
return _jobs_main(rest)
|
|
657
|
+
except ConfigError as exc:
|
|
658
|
+
print(f"pywget: error: {exc}", file=sys.stderr)
|
|
659
|
+
return exc.exit_code
|
|
660
|
+
try:
|
|
661
|
+
return _download_main(args)
|
|
662
|
+
except ConfigError as exc:
|
|
663
|
+
print(f"pywget: error: {exc}", file=sys.stderr)
|
|
664
|
+
return exc.exit_code
|
|
511
665
|
|
|
512
666
|
|
|
513
667
|
if __name__ == "__main__": # pragma: no cover
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""Configuration helpers: config dir, API token, server configuration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import configparser
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import secrets
|
|
9
|
+
import sys
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
13
|
+
|
|
14
|
+
from .errors import ConfigError
|
|
15
|
+
|
|
16
|
+
DEFAULT_HOST = "127.0.0.1"
|
|
17
|
+
DEFAULT_PORT = 8765
|
|
18
|
+
DEFAULT_AGENT_PORT = 8766
|
|
19
|
+
LOOPBACK_HOSTS = ("127.0.0.1", "localhost", "::1")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def config_dir() -> Path:
|
|
23
|
+
override = os.environ.get("PYWGET_CONFIG_DIR")
|
|
24
|
+
if override:
|
|
25
|
+
return Path(override).expanduser()
|
|
26
|
+
if sys.platform == "win32":
|
|
27
|
+
base = os.environ.get("APPDATA") or str(Path.home() / "AppData" / "Roaming")
|
|
28
|
+
return Path(base) / "pywget"
|
|
29
|
+
base = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
|
|
30
|
+
return Path(base) / "pywget"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def token_path() -> Path:
|
|
34
|
+
return config_dir() / "token"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def config_file_path() -> Path:
|
|
38
|
+
override = os.environ.get("PYWGET_CONFIG")
|
|
39
|
+
return Path(override).expanduser() if override else config_dir() / "config.ini"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
_TOKEN_RE = re.compile(r"[A-Za-z0-9._~+/=-]{16,256}")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def validate_token(token: str) -> str:
|
|
46
|
+
"""A token is 16-256 characters of A-Z a-z 0-9 . _ ~ + / = - (safe in HTTP headers)."""
|
|
47
|
+
if not _TOKEN_RE.fullmatch(token or ""):
|
|
48
|
+
raise ConfigError(
|
|
49
|
+
"invalid token: use 16-256 characters from A-Z a-z 0-9 . _ ~ + / = - "
|
|
50
|
+
"(generate one with: pywget token new)"
|
|
51
|
+
)
|
|
52
|
+
return token
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def generate_token(nbytes: int = 32) -> str:
|
|
56
|
+
return secrets.token_urlsafe(max(16, nbytes))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _write_private(path: Path, text: str) -> None:
|
|
60
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
61
|
+
tmp = path.with_name(path.name + ".tmp")
|
|
62
|
+
fd = os.open(str(tmp), os.O_CREAT | os.O_TRUNC | os.O_WRONLY, 0o600)
|
|
63
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
64
|
+
handle.write(text)
|
|
65
|
+
os.replace(tmp, path)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def save_token(token: str) -> Path:
|
|
69
|
+
validate_token(token)
|
|
70
|
+
path = token_path()
|
|
71
|
+
_write_private(path, token + "\n")
|
|
72
|
+
return path
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def resolve_token(explicit: Optional[str] = None, create: bool = False) -> Tuple[Optional[str], str]:
|
|
76
|
+
"""Return (token, source). Order: explicit > $PYWGET_TOKEN > token file > generated."""
|
|
77
|
+
if explicit:
|
|
78
|
+
return explicit.strip(), "command line"
|
|
79
|
+
env = os.environ.get("PYWGET_TOKEN")
|
|
80
|
+
if env:
|
|
81
|
+
return env.strip(), "$PYWGET_TOKEN"
|
|
82
|
+
path = token_path()
|
|
83
|
+
try:
|
|
84
|
+
text = path.read_text(encoding="utf-8").strip()
|
|
85
|
+
if text:
|
|
86
|
+
return text, str(path)
|
|
87
|
+
except OSError:
|
|
88
|
+
pass
|
|
89
|
+
if not create:
|
|
90
|
+
return None, ""
|
|
91
|
+
token = generate_token()
|
|
92
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
93
|
+
try:
|
|
94
|
+
fd = os.open(str(path), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
|
95
|
+
except FileExistsError: # lost a race with another process
|
|
96
|
+
return path.read_text(encoding="utf-8").strip(), str(path)
|
|
97
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
98
|
+
handle.write(token + "\n")
|
|
99
|
+
return token, f"{path} (generated)"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def load_token(explicit: Optional[str] = None, create: bool = False) -> Optional[str]:
|
|
103
|
+
return resolve_token(explicit, create)[0]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
DEFAULT_CONFIG = """\
|
|
107
|
+
# pywget configuration file (INI).
|
|
108
|
+
# Precedence: command line > environment > this file > built-in defaults.
|
|
109
|
+
# Everything below is commented out: uncomment and edit what you need.
|
|
110
|
+
# Booleans: true / false. Lists: comma separated. The API token is NOT stored here
|
|
111
|
+
# (see: pywget token show | new | set).
|
|
112
|
+
|
|
113
|
+
[download]
|
|
114
|
+
# options for direct downloads: pywget URL
|
|
115
|
+
;path = ~/Downloads
|
|
116
|
+
;connections = 4
|
|
117
|
+
;retries = 5
|
|
118
|
+
;timeout = 30
|
|
119
|
+
;limit_rate = 2M
|
|
120
|
+
;user_agent =
|
|
121
|
+
;proxy =
|
|
122
|
+
;ca_cert =
|
|
123
|
+
|
|
124
|
+
[client]
|
|
125
|
+
# options for: pywget send / jobs (the server to talk to)
|
|
126
|
+
;server = http://127.0.0.1:8765
|
|
127
|
+
;verify = true
|
|
128
|
+
;ca_cert =
|
|
129
|
+
|
|
130
|
+
[serve]
|
|
131
|
+
# options for: pywget serve
|
|
132
|
+
;host = 127.0.0.1
|
|
133
|
+
;port = 8765
|
|
134
|
+
;root = ~/Downloads/pywget
|
|
135
|
+
;state_dir =
|
|
136
|
+
;max_jobs = 3
|
|
137
|
+
;connections = 4
|
|
138
|
+
;retries = 5
|
|
139
|
+
;limit_rate =
|
|
140
|
+
;deny_private_networks = false
|
|
141
|
+
;allowed_host =
|
|
142
|
+
;tls_cert =
|
|
143
|
+
;tls_key =
|
|
144
|
+
;ui = true
|
|
145
|
+
|
|
146
|
+
[agent]
|
|
147
|
+
# options for: pywget agent
|
|
148
|
+
;host = 127.0.0.1
|
|
149
|
+
;port = 8766
|
|
150
|
+
;upstream = https://nas:8765
|
|
151
|
+
;upstream_token =
|
|
152
|
+
;upstream_verify = true
|
|
153
|
+
;upstream_ca_cert =
|
|
154
|
+
;max_jobs = 32
|
|
155
|
+
;state_dir =
|
|
156
|
+
;allowed_host =
|
|
157
|
+
;tls_cert =
|
|
158
|
+
;tls_key =
|
|
159
|
+
;ui = true
|
|
160
|
+
"""
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def load_config() -> Dict[str, Dict[str, str]]:
|
|
164
|
+
path = config_file_path()
|
|
165
|
+
if not path.exists():
|
|
166
|
+
return {}
|
|
167
|
+
parser = configparser.ConfigParser(interpolation=None)
|
|
168
|
+
try:
|
|
169
|
+
with open(path, encoding="utf-8") as handle:
|
|
170
|
+
parser.read_file(handle)
|
|
171
|
+
except (configparser.Error, OSError, UnicodeDecodeError) as exc:
|
|
172
|
+
raise ConfigError(f"cannot read config file {path}: {exc}") from None
|
|
173
|
+
return {name: dict(parser.items(name)) for name in parser.sections()}
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def write_default_config(force: bool = False) -> Path:
|
|
177
|
+
path = config_file_path()
|
|
178
|
+
if path.exists() and not force:
|
|
179
|
+
raise ConfigError(f"{path} already exists (use --force to overwrite)")
|
|
180
|
+
_write_private(path, DEFAULT_CONFIG)
|
|
181
|
+
return path
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@dataclass
|
|
185
|
+
class ServerConfig:
|
|
186
|
+
host: str = DEFAULT_HOST
|
|
187
|
+
port: int = DEFAULT_PORT
|
|
188
|
+
token: str = ""
|
|
189
|
+
root: Path = field(default_factory=lambda: Path.home() / "Downloads" / "pywget")
|
|
190
|
+
state_dir: Path = field(default_factory=lambda: config_dir() / "server")
|
|
191
|
+
max_jobs: int = 3
|
|
192
|
+
connections: int = 4
|
|
193
|
+
retries: int = 5
|
|
194
|
+
limit_rate: Any = 0
|
|
195
|
+
deny_private: bool = False
|
|
196
|
+
allowed_hosts: List[str] = field(default_factory=list)
|
|
197
|
+
tls_cert: Optional[str] = None
|
|
198
|
+
tls_key: Optional[str] = None
|
|
199
|
+
ui: bool = True
|
|
200
|
+
upstream: Optional[str] = None
|
|
201
|
+
upstream_token: Optional[str] = None
|
|
202
|
+
upstream_verify: bool = True
|
|
203
|
+
upstream_ca: Optional[str] = None
|
|
204
|
+
token_source: str = ""
|
|
205
|
+
|
|
206
|
+
def __post_init__(self) -> None:
|
|
207
|
+
self.root = Path(self.root).expanduser()
|
|
208
|
+
self.state_dir = Path(self.state_dir).expanduser()
|
|
209
|
+
try:
|
|
210
|
+
validate_token(self.token)
|
|
211
|
+
except ConfigError as exc:
|
|
212
|
+
raise ValueError(str(exc)) from None
|
|
213
|
+
if not 1 <= self.max_jobs <= 256:
|
|
214
|
+
raise ValueError("max_jobs must be between 1 and 256")
|
|
215
|
+
if bool(self.tls_cert) != bool(self.tls_key):
|
|
216
|
+
raise ValueError("--tls-cert and --tls-key must be given together")
|
|
217
|
+
|
|
218
|
+
@property
|
|
219
|
+
def is_agent(self) -> bool:
|
|
220
|
+
return bool(self.upstream)
|
|
@@ -54,6 +54,7 @@ from .utils import (
|
|
|
54
54
|
parse_checksum,
|
|
55
55
|
parse_size,
|
|
56
56
|
sanitize_filename,
|
|
57
|
+
short_url,
|
|
57
58
|
unique_path,
|
|
58
59
|
)
|
|
59
60
|
|
|
@@ -191,6 +192,36 @@ class _Worker(threading.Thread):
|
|
|
191
192
|
self.error = exc
|
|
192
193
|
|
|
193
194
|
|
|
195
|
+
class _ConcurrencyGate:
|
|
196
|
+
"""Limits simultaneous segment requests; shrinks when the server answers 429."""
|
|
197
|
+
|
|
198
|
+
def __init__(self, limit: int) -> None:
|
|
199
|
+
self.limit = max(1, limit)
|
|
200
|
+
self._active = 0
|
|
201
|
+
self._cond = threading.Condition()
|
|
202
|
+
|
|
203
|
+
def acquire(self, cancel: threading.Event) -> bool:
|
|
204
|
+
with self._cond:
|
|
205
|
+
while self._active >= self.limit:
|
|
206
|
+
if cancel.is_set():
|
|
207
|
+
return False
|
|
208
|
+
self._cond.wait(0.2)
|
|
209
|
+
self._active += 1
|
|
210
|
+
return True
|
|
211
|
+
|
|
212
|
+
def release(self) -> None:
|
|
213
|
+
with self._cond:
|
|
214
|
+
self._active -= 1
|
|
215
|
+
self._cond.notify_all()
|
|
216
|
+
|
|
217
|
+
def shrink(self) -> bool:
|
|
218
|
+
with self._cond:
|
|
219
|
+
if self.limit > 1:
|
|
220
|
+
self.limit -= 1
|
|
221
|
+
return True
|
|
222
|
+
return False
|
|
223
|
+
|
|
224
|
+
|
|
194
225
|
def _is_retryable(exc: BaseException) -> bool:
|
|
195
226
|
if isinstance(exc, NetworkError):
|
|
196
227
|
return exc.retryable
|
|
@@ -252,6 +283,7 @@ class Downloader:
|
|
|
252
283
|
self._last_report = 0.0
|
|
253
284
|
self._tracker: Optional[Tracker] = None
|
|
254
285
|
self.partial: Optional[Tuple[Path, Path]] = None # (.part, .pywget) of the current download
|
|
286
|
+
self._gate = _ConcurrencyGate(self.options.connections)
|
|
255
287
|
self._opener = self._build_opener()
|
|
256
288
|
|
|
257
289
|
# ------------------------------------------------------------------ public
|
|
@@ -383,11 +415,11 @@ class Downloader:
|
|
|
383
415
|
except urllib.error.URLError as exc:
|
|
384
416
|
if isinstance(exc.reason, ssl.SSLCertVerificationError):
|
|
385
417
|
raise NetworkError(
|
|
386
|
-
f"TLS certificate verification failed for {url}: {exc.reason} "
|
|
418
|
+
f"TLS certificate verification failed for {short_url(url)}: {exc.reason} "
|
|
387
419
|
"(use --no-check-certificate or --ca-cert)",
|
|
388
420
|
retryable=False,
|
|
389
421
|
) from None
|
|
390
|
-
raise NetworkError(f"{url}: {exc.reason}") from None
|
|
422
|
+
raise NetworkError(f"{short_url(url)}: {exc.reason}") from None
|
|
391
423
|
|
|
392
424
|
def _probe(self, url: str) -> RemoteInfo:
|
|
393
425
|
try:
|
|
@@ -481,6 +513,7 @@ class Downloader:
|
|
|
481
513
|
else:
|
|
482
514
|
connections = len(segments)
|
|
483
515
|
log.info("resuming %s (%s already on disk)", part.name, format_size(sum(s[2] for s in segments)))
|
|
516
|
+
self._gate = _ConcurrencyGate(connections)
|
|
484
517
|
tracker.set(sum(s[2] for s in segments))
|
|
485
518
|
todo = [s for s in segments if s[2] < s[1] - s[0] + 1]
|
|
486
519
|
workers = [
|
|
@@ -514,10 +547,14 @@ class Downloader:
|
|
|
514
547
|
) -> None:
|
|
515
548
|
attempt = 0
|
|
516
549
|
read_size = self._read_size()
|
|
550
|
+
gate = self._gate
|
|
517
551
|
while not self._cancel.is_set():
|
|
518
552
|
start = seg[0] + seg[2]
|
|
519
553
|
if start > seg[1]:
|
|
520
554
|
return
|
|
555
|
+
if not gate.acquire(self._cancel):
|
|
556
|
+
return
|
|
557
|
+
held = True
|
|
521
558
|
before = seg[2]
|
|
522
559
|
try:
|
|
523
560
|
with closing(self._open(url, {"Range": f"bytes={start}-{seg[1]}"})) as resp:
|
|
@@ -539,6 +576,8 @@ class Downloader:
|
|
|
539
576
|
seg[2] += len(chunk)
|
|
540
577
|
tracker.add(len(chunk))
|
|
541
578
|
except Exception as exc: # noqa: BLE001
|
|
579
|
+
gate.release()
|
|
580
|
+
held = False
|
|
542
581
|
if self._cancel.is_set():
|
|
543
582
|
return
|
|
544
583
|
if not _is_retryable(exc):
|
|
@@ -546,9 +585,15 @@ class Downloader:
|
|
|
546
585
|
if seg[2] > before:
|
|
547
586
|
attempt = 0
|
|
548
587
|
attempt += 1
|
|
588
|
+
if isinstance(exc, HTTPStatusError) and exc.status == 429 and gate.shrink():
|
|
589
|
+
log.warning("server limits parallel connections: reducing to %d", gate.limit)
|
|
590
|
+
attempt -= 1 # a concurrency 429 is not a failed attempt
|
|
549
591
|
if attempt > self.options.retries:
|
|
550
592
|
raise self._final_error(exc, "segment") from (None if isinstance(exc, PywgetError) else exc)
|
|
551
|
-
self._sleep_backoff(attempt, exc, f"segment {seg[0]}-{seg[1]}")
|
|
593
|
+
self._sleep_backoff(max(attempt, 1), exc, f"segment {seg[0]}-{seg[1]}")
|
|
594
|
+
finally:
|
|
595
|
+
if held:
|
|
596
|
+
gate.release()
|
|
552
597
|
|
|
553
598
|
def _run_stream(
|
|
554
599
|
self, url: str, info: RemoteInfo, part: Path, tracker: Tracker, limiter: RateLimiter
|
|
@@ -46,8 +46,10 @@ class HTTPStatusError(NetworkError):
|
|
|
46
46
|
reason: str = "",
|
|
47
47
|
retry_after: Optional[float] = None,
|
|
48
48
|
) -> None:
|
|
49
|
+
from .utils import short_url # lazy: utils imports this module
|
|
50
|
+
|
|
49
51
|
text = f"HTTP {status} {reason}".strip()
|
|
50
|
-
super().__init__(f"{text} for {url}", retryable=status in RETRY_STATUS)
|
|
52
|
+
super().__init__(f"{text} for {short_url(url)}", retryable=status in RETRY_STATUS)
|
|
51
53
|
self.status = status
|
|
52
54
|
self.url = url
|
|
53
55
|
self.retry_after = retry_after
|
|
@@ -74,3 +76,9 @@ class ServerError(PywgetError):
|
|
|
74
76
|
super().__init__(message)
|
|
75
77
|
self.status = status
|
|
76
78
|
self.retryable = retryable
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class ConfigError(PywgetError):
|
|
82
|
+
"""Invalid configuration file or token."""
|
|
83
|
+
|
|
84
|
+
exit_code = 2
|
|
@@ -18,7 +18,14 @@ from .config import ServerConfig
|
|
|
18
18
|
from .core import Downloader, Options
|
|
19
19
|
from .errors import DownloadCancelled, InvalidURLError, PywgetError, ServerError
|
|
20
20
|
from .progress import ProgressInfo
|
|
21
|
-
from .utils import
|
|
21
|
+
from .utils import (
|
|
22
|
+
RateLimiter,
|
|
23
|
+
build_headers,
|
|
24
|
+
filename_from_url,
|
|
25
|
+
normalize_url,
|
|
26
|
+
parse_checksum,
|
|
27
|
+
parse_size,
|
|
28
|
+
)
|
|
22
29
|
|
|
23
30
|
log = logging.getLogger("pywget.jobs")
|
|
24
31
|
|
|
@@ -82,6 +89,8 @@ class Job:
|
|
|
82
89
|
data = self.to_dict()
|
|
83
90
|
for name in _SECRET_FIELDS:
|
|
84
91
|
data.pop(name, None)
|
|
92
|
+
shown = self.result_path.replace("\\", "/").rsplit("/", 1)[-1] if self.result_path else None
|
|
93
|
+
data["name"] = self.filename or shown or filename_from_url(self.url) or self.url
|
|
85
94
|
data["has_auth"] = bool(self.username)
|
|
86
95
|
data["has_cookie"] = bool(self.cookie)
|
|
87
96
|
data["header_names"] = sorted(self.headers)
|
|
@@ -307,6 +307,8 @@ def run_server(cfg: ServerConfig) -> int:
|
|
|
307
307
|
import sys
|
|
308
308
|
|
|
309
309
|
print(f"pywget {kind} listening on {handle.url}", file=sys.stderr)
|
|
310
|
+
if cfg.token_source:
|
|
311
|
+
print(f"api token: {cfg.token_source} (show it with: pywget token show)", file=sys.stderr)
|
|
310
312
|
if cfg.is_agent:
|
|
311
313
|
print(f"forwarding links to {cfg.upstream}", file=sys.stderr)
|
|
312
314
|
else:
|
|
@@ -197,3 +197,14 @@ def build_headers(
|
|
|
197
197
|
if not name or any(c in name + value for c in "\r\n\x00") or ":" in name:
|
|
198
198
|
raise ValueError(f"invalid header: {name!r}")
|
|
199
199
|
return merged
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def short_url(url: str, limit: int = 70) -> str:
|
|
203
|
+
"""``host/../file.zip`` - no query string (it often holds signed tokens), bounded length."""
|
|
204
|
+
parts = urlsplit(url)
|
|
205
|
+
name = unquote(parts.path.rsplit("/", 1)[-1]) or "/"
|
|
206
|
+
host = parts.hostname or ""
|
|
207
|
+
room = max(limit - len(host) - 5, 12)
|
|
208
|
+
if len(name) > room:
|
|
209
|
+
name = name[: room - 12] + ".." + name[-10:]
|
|
210
|
+
return f"{host}/../{name}" if parts.path.count("/") > 1 else f"{host}/{name}".rstrip("/")
|
|
@@ -35,7 +35,7 @@ function cell(tr,text,cls){const td=document.createElement('td');td.textContent=
|
|
|
35
35
|
async function refresh(){try{const[s,j]=await Promise.all([api('GET','/api/v1/stats'),api('GET','/api/v1/jobs')]);
|
|
36
36
|
$('mode').textContent=s.mode;$('stats').textContent=fmt(s.speed)+'/s';$('msg').textContent='';const body=$('rows');body.replaceChildren();
|
|
37
37
|
for(const x of j.jobs.slice().reverse()){const tr=document.createElement('tr');
|
|
38
|
-
const name=x.
|
|
38
|
+
const name=x.name||x.url;cell(tr,name,'n').title=x.url;
|
|
39
39
|
cell(tr,x.status+(x.error?' - '+x.error:''));const td=document.createElement('td');const pr=document.createElement('progress');pr.max=100;pr.value=x.percent||0;td.appendChild(pr);td.append(' '+(x.percent!=null?x.percent+'%':''));tr.appendChild(td);
|
|
40
40
|
cell(tr,x.status==='running'?fmt(x.speed)+'/s':'-','mut');const act=document.createElement('td');
|
|
41
41
|
const btn=(label,fn)=>{const b=document.createElement('button');b.textContent=label;b.onclick=async()=>{try{await fn();refresh()}catch(e){$('msg').textContent=e.message}};act.appendChild(b)};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pywget
|
|
3
|
-
Version: 2.0.
|
|
3
|
+
Version: 2.0.6
|
|
4
4
|
Summary: Robust, resumable, multi-connection wget alternative - CLI and Python library, zero dependencies
|
|
5
5
|
Author-email: Hadi Cahyadi <cumulus13@gmail.com>
|
|
6
6
|
Maintainer-email: cumulus13 <cumulus13@gmail.com>
|
|
@@ -231,6 +231,58 @@ Errors derive from `pywget.PywgetError` (`InvalidURLError`, `NetworkError`,
|
|
|
231
231
|
`HTTPStatusError`, `ChecksumError`, `StorageError`, `DownloadCancelled`).
|
|
232
232
|
A `Downloader` runs one download at a time - use one instance per thread.
|
|
233
233
|
|
|
234
|
+
## Configuration and API token
|
|
235
|
+
|
|
236
|
+
### Config file (optional)
|
|
237
|
+
|
|
238
|
+
There is no config file unless you create one; every option also has a command-line flag.
|
|
239
|
+
|
|
240
|
+
```bash
|
|
241
|
+
pywget config path # where it lives
|
|
242
|
+
pywget config init # write a fully commented template there (mode 0600)
|
|
243
|
+
pywget config show
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
| OS | Default location |
|
|
247
|
+
|----|------------------|
|
|
248
|
+
| Windows | `%APPDATA%\pywget\config.ini` |
|
|
249
|
+
| Linux / macOS | `~/.config/pywget/config.ini` (`$XDG_CONFIG_HOME` respected) |
|
|
250
|
+
|
|
251
|
+
Override the directory with `PYWGET_CONFIG_DIR` or the file with `PYWGET_CONFIG`.
|
|
252
|
+
Precedence: **command line > environment > config file > built-in defaults**.
|
|
253
|
+
Sections: `[download]`, `[client]`, `[serve]`, `[agent]` (the generated template lists every key).
|
|
254
|
+
Unknown keys or bad values are reported with the file path and exit code 2.
|
|
255
|
+
|
|
256
|
+
```ini
|
|
257
|
+
[serve]
|
|
258
|
+
root = C:\DOWNLOADS
|
|
259
|
+
max_jobs = 3
|
|
260
|
+
connections = 2
|
|
261
|
+
|
|
262
|
+
[client]
|
|
263
|
+
server = http://127.0.0.1:8765
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
Job queue state lives in `<config dir>/server/jobs.json` (`agent/` for an agent); change it with `state_dir`.
|
|
267
|
+
|
|
268
|
+
### API token
|
|
269
|
+
|
|
270
|
+
The server and agent **always** require a token.
|
|
271
|
+
|
|
272
|
+
- **Default:** on the first `pywget serve` / `pywget agent` a random 256-bit token is generated and saved to `<config dir>/token` (mode 0600). It is reused on every start, and local `send` / `jobs` commands read it automatically.
|
|
273
|
+
- **Show / create / set:**
|
|
274
|
+
|
|
275
|
+
```bash
|
|
276
|
+
pywget token show # print the current token and where it comes from
|
|
277
|
+
pywget token new # generate and save a new one (restart the server afterwards)
|
|
278
|
+
pywget token set "my-own-secret-123" # or: echo -n ... | pywget token set -
|
|
279
|
+
pywget token path
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
- **Override order:** `--token` > `$PYWGET_TOKEN` > token file.
|
|
283
|
+
- **Format:** not arbitrary text - 16 to 256 characters from `A-Z a-z 0-9 . _ ~ + / = -` (safe inside an HTTP header). Weak or malformed tokens are rejected at startup.
|
|
284
|
+
- **Remote clients:** copy the token to the other machine (`pywget token set -` there, or `export PYWGET_TOKEN=...`).
|
|
285
|
+
|
|
234
286
|
## Server mode and agent
|
|
235
287
|
|
|
236
288
|
Run pywget as a long-lived download manager and push links to it from anywhere - the
|
|
@@ -573,7 +625,7 @@ classDiagram
|
|
|
573
625
|
| `pywget.server` | HTTP API, SSE, web UI hosting (`ServerHandle`, `run_server`) |
|
|
574
626
|
| `pywget.jobs` | `Job`, `JobManager`, persistence, `LocalExecutor` / `RelayExecutor` |
|
|
575
627
|
| `pywget.client` | `ServerClient` (used by `send`, `jobs` and the agent) |
|
|
576
|
-
| `pywget.config` |
|
|
628
|
+
| `pywget.config` | Config file, token file and validation, `ServerConfig` |
|
|
577
629
|
| `pywget.clipboard` | `c` input: clipboard and URL extraction |
|
|
578
630
|
| `pywget.webui` | Embedded single-page web UI |
|
|
579
631
|
| `pywget.wget` | Backward-compatible `wget.download()` |
|
|
@@ -582,6 +634,8 @@ classDiagram
|
|
|
582
634
|
|
|
583
635
|
- **Probing:** one `Range: bytes=0-0` request yields size, range support, ETag, `Last-Modified` and `Content-Disposition`.
|
|
584
636
|
- **Segments:** a file is split into `min(connections, size / 2 MiB)` parts; small files use one connection.
|
|
637
|
+
- **Rate-limited servers:** on HTTP 429 the number of simultaneous segment requests is reduced automatically (down to 1) instead of failing the job; failed jobs keep partial data, so `pywget jobs retry ID` resumes.
|
|
638
|
+
- **Output hygiene:** messages and job lists show `host/../file` only - query strings (often signed tokens) are never printed.
|
|
585
639
|
- **Existing file:** same size means skipped; different size is saved as `name.1` (wget style) unless `--overwrite`.
|
|
586
640
|
- **Partial files:** stored as `name.part` + `name.pywget`; removed on success. Changed remote file (size/ETag/mtime) restarts cleanly.
|
|
587
641
|
- **Redirects:** only http/https; `Authorization`/`Cookie` are dropped when the origin changes.
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from conftest import DATA
|
|
3
|
+
|
|
4
|
+
from pywget import Downloader, Options
|
|
5
|
+
from pywget.cli import _fmt_job, build_parser, main
|
|
6
|
+
from pywget.config import ServerConfig, load_config, resolve_token, token_path, validate_token
|
|
7
|
+
from pywget.errors import ConfigError, HTTPStatusError
|
|
8
|
+
from pywget.utils import short_url
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@pytest.fixture
|
|
12
|
+
def cfgdir(tmp_path, monkeypatch):
|
|
13
|
+
monkeypatch.setenv("PYWGET_CONFIG_DIR", str(tmp_path / "cfg"))
|
|
14
|
+
monkeypatch.delenv("PYWGET_TOKEN", raising=False)
|
|
15
|
+
monkeypatch.delenv("PYWGET_CONFIG", raising=False)
|
|
16
|
+
monkeypatch.delenv("PYWGET_SERVER", raising=False)
|
|
17
|
+
return tmp_path / "cfg"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_token_auto_generated_once_and_reused(cfgdir):
|
|
21
|
+
assert resolve_token() == (None, "")
|
|
22
|
+
token, source = resolve_token(create=True)
|
|
23
|
+
assert len(token) >= 40 and "generated" in source
|
|
24
|
+
assert token_path().read_text().strip() == token
|
|
25
|
+
assert resolve_token(create=True)[0] == token # reused, not regenerated
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_token_precedence(cfgdir, monkeypatch):
|
|
29
|
+
file_token, _ = resolve_token(create=True)
|
|
30
|
+
monkeypatch.setenv("PYWGET_TOKEN", "e" * 20)
|
|
31
|
+
assert resolve_token()[0] == "e" * 20
|
|
32
|
+
assert resolve_token("x" * 20)[0] == "x" * 20
|
|
33
|
+
assert file_token not in ("e" * 20, "x" * 20)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@pytest.mark.parametrize("bad", ["short", "has space " * 3, "ünicode-" * 4, "a" * 300, ""])
|
|
37
|
+
def test_token_validation_rejects(bad):
|
|
38
|
+
with pytest.raises(ConfigError):
|
|
39
|
+
validate_token(bad)
|
|
40
|
+
with pytest.raises(ValueError):
|
|
41
|
+
ServerConfig(token=bad)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_token_accepts_urlsafe():
|
|
45
|
+
assert validate_token("Abc-123_xyz.~+/=0123456789")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_token_cli(cfgdir, capsys):
|
|
49
|
+
assert main(["token", "show"]) == 1
|
|
50
|
+
assert main(["token", "new"]) == 0
|
|
51
|
+
new = capsys.readouterr().out.strip().splitlines()[0]
|
|
52
|
+
assert main(["token", "show"]) == 0
|
|
53
|
+
assert capsys.readouterr().out.strip() == new
|
|
54
|
+
assert main(["token", "set", "tooshort"]) == 2
|
|
55
|
+
assert main(["token", "set", "my-own-secret-token-123"]) == 0
|
|
56
|
+
assert token_path().read_text().strip() == "my-own-secret-token-123"
|
|
57
|
+
assert main(["token", "path"]) == 0
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_config_init_show_and_defaults(cfgdir, capsys):
|
|
61
|
+
assert main(["config", "init"]) == 0
|
|
62
|
+
assert (cfgdir / "config.ini").exists()
|
|
63
|
+
assert main(["config", "init"]) == 2 # exists
|
|
64
|
+
assert main(["config", "init", "--force"]) == 0
|
|
65
|
+
assert all(not values for values in load_config().values()) # template is fully commented out
|
|
66
|
+
path = cfgdir / "config.ini"
|
|
67
|
+
path.write_text("[download]\nconnections = 2\nretries = 9\nlimit_rate = 1M\n[client]\nserver = http://nas:1\n"
|
|
68
|
+
"verify = false\n")
|
|
69
|
+
from pywget.cli import CLIENT_SPEC, DOWNLOAD_SPEC, _apply_config
|
|
70
|
+
|
|
71
|
+
parser = build_parser()
|
|
72
|
+
_apply_config(parser, "download", DOWNLOAD_SPEC)
|
|
73
|
+
args = parser.parse_args(["http://x/y"])
|
|
74
|
+
assert (args.connections, args.retries, args.limit_rate) == (2, 9, "1M")
|
|
75
|
+
assert parser.parse_args(["http://x/y", "-n", "7"]).connections == 7 # command line wins
|
|
76
|
+
_apply_config(parser, "client", CLIENT_SPEC)
|
|
77
|
+
args = parser.parse_args(["http://x/y"])
|
|
78
|
+
assert args.server == "http://nas:1" and args.server_no_check_certificate is True
|
|
79
|
+
assert main(["config", "show"]) == 0
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_config_errors(cfgdir, capsys):
|
|
83
|
+
cfgdir.mkdir(parents=True)
|
|
84
|
+
(cfgdir / "config.ini").write_text("[download]\nbogus = 1\n")
|
|
85
|
+
assert main(["http://x/y", "-q"]) == 2
|
|
86
|
+
assert "bogus" in capsys.readouterr().err
|
|
87
|
+
(cfgdir / "config.ini").write_text("[download]\nconnections = many\n")
|
|
88
|
+
assert main(["http://x/y", "-q"]) == 2
|
|
89
|
+
(cfgdir / "config.ini").write_text("not an ini file")
|
|
90
|
+
assert main(["http://x/y", "-q"]) == 2
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_config_serve_section_and_agent_upstream(cfgdir, capsys):
|
|
94
|
+
cfgdir.mkdir(parents=True)
|
|
95
|
+
(cfgdir / "config.ini").write_text("[serve]\nmax_jobs = 5\nui = false\nallowed_host = a, b\n")
|
|
96
|
+
import argparse
|
|
97
|
+
|
|
98
|
+
from pywget.cli import SERVE_SPEC, _apply_config
|
|
99
|
+
parser = argparse.ArgumentParser()
|
|
100
|
+
parser.add_argument("--max-jobs", dest="max_jobs", type=int, default=3)
|
|
101
|
+
parser.add_argument("--no-ui", dest="no_ui", action="store_true")
|
|
102
|
+
parser.add_argument("--allowed-host", dest="allowed_host", action="append", default=[])
|
|
103
|
+
_apply_config(parser, "serve", {k: v for k, v in SERVE_SPEC.items() if k in ("max_jobs", "ui", "allowed_host")})
|
|
104
|
+
args = parser.parse_args([])
|
|
105
|
+
assert (args.max_jobs, args.no_ui, args.allowed_host) == (5, True, ["a", "b"])
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_agent_requires_upstream(cfgdir):
|
|
109
|
+
with pytest.raises(SystemExit) as err:
|
|
110
|
+
main(["agent"])
|
|
111
|
+
assert err.value.code == 2
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_short_url_hides_query_and_truncates():
|
|
115
|
+
url = "https://nw28.seedr.cc/get_zip/123/" + "Very%20Long%20Name-" * 8 + ".zip?st=SECRET&e=1"
|
|
116
|
+
short = short_url(url)
|
|
117
|
+
assert "SECRET" not in short and len(short) <= 75 and short.endswith(".zip")
|
|
118
|
+
assert short_url("http://h/file.bin") == "h/file.bin"
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_http_error_message_has_no_query(server, tmp_path):
|
|
122
|
+
with pytest.raises(HTTPStatusError) as err:
|
|
123
|
+
Downloader(Options(output_dir=str(tmp_path), retries=0)).fetch(server + "/nope.zip?st=SECRET")
|
|
124
|
+
assert "SECRET" not in str(err.value) and "nope.zip" in str(err.value)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def test_fmt_job_is_one_line_plus_error(monkeypatch):
|
|
128
|
+
monkeypatch.setattr("shutil.get_terminal_size", lambda fallback=(100, 24): __import__("os").terminal_size((100, 24)))
|
|
129
|
+
job = {"id": "abc", "status": "failed", "percent": 24.3, "speed": 0, "url": "http://h/" + "x" * 500,
|
|
130
|
+
"name": "n" * 500, "error": "boom " * 100}
|
|
131
|
+
lines = _fmt_job(job).split("\n")
|
|
132
|
+
assert len(lines) == 2 and all(len(line) <= 100 for line in lines)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_adaptive_connections_on_429(server, tmp_path):
|
|
136
|
+
res = Downloader(Options(output_dir=str(tmp_path), connections=3, backoff=0.01, max_backoff=0.05,
|
|
137
|
+
retries=2)).fetch(server + "/limited")
|
|
138
|
+
assert res.path.read_bytes() == DATA
|
|
@@ -228,8 +228,12 @@ def test_agent_waits_for_offline_upstream(tmp_path, server):
|
|
|
228
228
|
with ServerHandle(agent_cfg) as agent:
|
|
229
229
|
ac = client_for(agent, "a" * 40)
|
|
230
230
|
job = ac.submit({"url": server + "/small.txt"})[0]
|
|
231
|
-
|
|
231
|
+
# Windows needs ~2s per refused connection attempt, so poll instead of a fixed sleep
|
|
232
|
+
deadline = time.time() + 20
|
|
232
233
|
current = ac.get(job["id"])
|
|
234
|
+
while time.time() < deadline and "upstream unavailable" not in (current["error"] or ""):
|
|
235
|
+
time.sleep(0.2)
|
|
236
|
+
current = ac.get(job["id"])
|
|
233
237
|
assert current["status"] == "running" and "upstream unavailable" in (current["error"] or "")
|
|
234
238
|
ac.action(job["id"], "cancel")
|
|
235
239
|
assert wait_for(ac, job["id"], {"cancelled"})["status"] == "cancelled"
|
pywget-2.0.4/pywget/config.py
DELETED
|
@@ -1,93 +0,0 @@
|
|
|
1
|
-
"""Configuration helpers: config dir, API token, server configuration."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import os
|
|
6
|
-
import secrets
|
|
7
|
-
import sys
|
|
8
|
-
from dataclasses import dataclass, field
|
|
9
|
-
from pathlib import Path
|
|
10
|
-
from typing import Any, List, Optional
|
|
11
|
-
|
|
12
|
-
DEFAULT_HOST = "127.0.0.1"
|
|
13
|
-
DEFAULT_PORT = 8765
|
|
14
|
-
DEFAULT_AGENT_PORT = 8766
|
|
15
|
-
LOOPBACK_HOSTS = ("127.0.0.1", "localhost", "::1")
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
def config_dir() -> Path:
|
|
19
|
-
override = os.environ.get("PYWGET_CONFIG_DIR")
|
|
20
|
-
if override:
|
|
21
|
-
return Path(override).expanduser()
|
|
22
|
-
if sys.platform == "win32":
|
|
23
|
-
base = os.environ.get("APPDATA") or str(Path.home() / "AppData" / "Roaming")
|
|
24
|
-
return Path(base) / "pywget"
|
|
25
|
-
base = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
|
|
26
|
-
return Path(base) / "pywget"
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
def token_path() -> Path:
|
|
30
|
-
return config_dir() / "token"
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
def load_token(explicit: Optional[str] = None, create: bool = False) -> Optional[str]:
|
|
34
|
-
"""Resolve the API token: explicit > $PYWGET_TOKEN > token file (> generate)."""
|
|
35
|
-
if explicit:
|
|
36
|
-
return explicit.strip()
|
|
37
|
-
env = os.environ.get("PYWGET_TOKEN")
|
|
38
|
-
if env:
|
|
39
|
-
return env.strip()
|
|
40
|
-
path = token_path()
|
|
41
|
-
try:
|
|
42
|
-
text = path.read_text(encoding="utf-8").strip()
|
|
43
|
-
if text:
|
|
44
|
-
return text
|
|
45
|
-
except OSError:
|
|
46
|
-
pass
|
|
47
|
-
if not create:
|
|
48
|
-
return None
|
|
49
|
-
token = secrets.token_urlsafe(32)
|
|
50
|
-
path.parent.mkdir(parents=True, exist_ok=True)
|
|
51
|
-
try:
|
|
52
|
-
fd = os.open(str(path), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
|
53
|
-
except FileExistsError: # lost a race with another process
|
|
54
|
-
return path.read_text(encoding="utf-8").strip()
|
|
55
|
-
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
56
|
-
handle.write(token + "\n")
|
|
57
|
-
return token
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
@dataclass
|
|
61
|
-
class ServerConfig:
|
|
62
|
-
host: str = DEFAULT_HOST
|
|
63
|
-
port: int = DEFAULT_PORT
|
|
64
|
-
token: str = ""
|
|
65
|
-
root: Path = field(default_factory=lambda: Path.home() / "Downloads" / "pywget")
|
|
66
|
-
state_dir: Path = field(default_factory=lambda: config_dir() / "server")
|
|
67
|
-
max_jobs: int = 3
|
|
68
|
-
connections: int = 4
|
|
69
|
-
retries: int = 5
|
|
70
|
-
limit_rate: Any = 0
|
|
71
|
-
deny_private: bool = False
|
|
72
|
-
allowed_hosts: List[str] = field(default_factory=list)
|
|
73
|
-
tls_cert: Optional[str] = None
|
|
74
|
-
tls_key: Optional[str] = None
|
|
75
|
-
ui: bool = True
|
|
76
|
-
upstream: Optional[str] = None
|
|
77
|
-
upstream_token: Optional[str] = None
|
|
78
|
-
upstream_verify: bool = True
|
|
79
|
-
upstream_ca: Optional[str] = None
|
|
80
|
-
|
|
81
|
-
def __post_init__(self) -> None:
|
|
82
|
-
self.root = Path(self.root).expanduser()
|
|
83
|
-
self.state_dir = Path(self.state_dir).expanduser()
|
|
84
|
-
if not self.token:
|
|
85
|
-
raise ValueError("a non-empty API token is required")
|
|
86
|
-
if not 1 <= self.max_jobs <= 256:
|
|
87
|
-
raise ValueError("max_jobs must be between 1 and 256")
|
|
88
|
-
if bool(self.tls_cert) != bool(self.tls_key):
|
|
89
|
-
raise ValueError("--tls-cert and --tls-key must be given together")
|
|
90
|
-
|
|
91
|
-
@property
|
|
92
|
-
def is_agent(self) -> bool:
|
|
93
|
-
return bool(self.upstream)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|