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.
Files changed (32) hide show
  1. {pywget-2.0.4/pywget.egg-info → pywget-2.0.6}/PKG-INFO +56 -2
  2. {pywget-2.0.4 → pywget-2.0.6}/README.md +55 -1
  3. {pywget-2.0.4 → pywget-2.0.6}/pyproject.toml +4 -1
  4. {pywget-2.0.4 → pywget-2.0.6}/pywget/cli.py +173 -19
  5. pywget-2.0.6/pywget/config.py +220 -0
  6. {pywget-2.0.4 → pywget-2.0.6}/pywget/core.py +48 -3
  7. {pywget-2.0.4 → pywget-2.0.6}/pywget/errors.py +9 -1
  8. {pywget-2.0.4 → pywget-2.0.6}/pywget/jobs.py +10 -1
  9. {pywget-2.0.4 → pywget-2.0.6}/pywget/server.py +2 -0
  10. {pywget-2.0.4 → pywget-2.0.6}/pywget/utils.py +11 -0
  11. {pywget-2.0.4 → pywget-2.0.6}/pywget/webui.py +1 -1
  12. {pywget-2.0.4 → pywget-2.0.6/pywget.egg-info}/PKG-INFO +56 -2
  13. {pywget-2.0.4 → pywget-2.0.6}/pywget.egg-info/SOURCES.txt +1 -0
  14. pywget-2.0.6/tests/test_config.py +138 -0
  15. {pywget-2.0.4 → pywget-2.0.6}/tests/test_server.py +5 -1
  16. pywget-2.0.4/pywget/config.py +0 -93
  17. {pywget-2.0.4 → pywget-2.0.6}/LICENSE +0 -0
  18. {pywget-2.0.4 → pywget-2.0.6}/pywget/__init__.py +0 -0
  19. {pywget-2.0.4 → pywget-2.0.6}/pywget/__main__.py +0 -0
  20. {pywget-2.0.4 → pywget-2.0.6}/pywget/__version__.py +0 -0
  21. {pywget-2.0.4 → pywget-2.0.6}/pywget/client.py +0 -0
  22. {pywget-2.0.4 → pywget-2.0.6}/pywget/clipboard.py +0 -0
  23. {pywget-2.0.4 → pywget-2.0.6}/pywget/progress.py +0 -0
  24. {pywget-2.0.4 → pywget-2.0.6}/pywget/py.typed +0 -0
  25. {pywget-2.0.4 → pywget-2.0.6}/pywget/wget.py +0 -0
  26. {pywget-2.0.4 → pywget-2.0.6}/pywget.egg-info/dependency_links.txt +0 -0
  27. {pywget-2.0.4 → pywget-2.0.6}/pywget.egg-info/entry_points.txt +0 -0
  28. {pywget-2.0.4 → pywget-2.0.6}/pywget.egg-info/requires.txt +0 -0
  29. {pywget-2.0.4 → pywget-2.0.6}/pywget.egg-info/top_level.txt +0 -0
  30. {pywget-2.0.4 → pywget-2.0.6}/setup.cfg +0 -0
  31. {pywget-2.0.4 → pywget-2.0.6}/tests/test_download.py +0 -0
  32. {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.4
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` | Token file, config dir, `ServerConfig` |
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` | Token file, config dir, `ServerConfig` |
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.4"
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
- load_token,
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
- label = job.get("result_path") or job.get("filename") or job["url"]
307
- err = f" ! {job['error']}" if job.get("error") else ""
308
- return f"{job['id']} {job['status']:<9} {pct} {speed:>11} {label}{err}"
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", required=True, metavar="URL", help="server to forward links to")
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 = load_token(args.token, create=True)
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
- if command == "serve":
504
- return _serve_main(rest, agent=False)
505
- if command == "agent":
506
- return _serve_main(rest, agent=True)
507
- if command == "send":
508
- return _send_main(rest)
509
- return _jobs_main(rest)
510
- return _download_main(args)
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 RateLimiter, build_headers, normalize_url, parse_checksum, parse_size
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.result_path?x.result_path.split(/[\\\\/]/).pop():(x.filename||x.url);cell(tr,name,'n').title=x.url;
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.4
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` | Token file, config dir, `ServerConfig` |
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.
@@ -23,6 +23,7 @@ pywget.egg-info/dependency_links.txt
23
23
  pywget.egg-info/entry_points.txt
24
24
  pywget.egg-info/requires.txt
25
25
  pywget.egg-info/top_level.txt
26
+ tests/test_config.py
26
27
  tests/test_download.py
27
28
  tests/test_server.py
28
29
  tests/test_utils.py
@@ -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
- time.sleep(1.5)
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"
@@ -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