machyon 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- machyon/__init__.py +35 -0
- machyon/__main__.py +5 -0
- machyon/cli.py +182 -0
- machyon/compat.py +186 -0
- machyon/config.py +161 -0
- machyon/diagnostics.py +125 -0
- machyon/disk.py +114 -0
- machyon/downloader.py +315 -0
- machyon/engine.py +258 -0
- machyon/errors.py +11 -0
- machyon/fetcher.py +238 -0
- machyon/jobs.py +49 -0
- machyon/ledger.py +201 -0
- machyon/limiter.py +171 -0
- machyon/metrics.py +160 -0
- machyon/naming.py +178 -0
- machyon/planner.py +167 -0
- machyon/py.typed +0 -0
- machyon/session.py +102 -0
- machyon/sources.py +163 -0
- machyon/utils.py +187 -0
- machyon-1.0.0.dist-info/METADATA +305 -0
- machyon-1.0.0.dist-info/RECORD +26 -0
- machyon-1.0.0.dist-info/WHEEL +4 -0
- machyon-1.0.0.dist-info/entry_points.txt +2 -0
- machyon-1.0.0.dist-info/licenses/LICENSE +21 -0
machyon/__init__.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Machyon - download any number of images, fast, and account for every one.
|
|
2
|
+
|
|
3
|
+
import machyon
|
|
4
|
+
report = machyon.download("urls.csv", "images/")
|
|
5
|
+
print(report)
|
|
6
|
+
|
|
7
|
+
Docs: https://divyanshusingh96.github.io/machyon/
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import importlib.metadata
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
__version__ = importlib.metadata.version("machyon")
|
|
15
|
+
except importlib.metadata.PackageNotFoundError: # running from a source checkout
|
|
16
|
+
__version__ = "1.0.0.dev0"
|
|
17
|
+
|
|
18
|
+
from .compat import ImageDownloader, download_images
|
|
19
|
+
from .config import Config, DownloadConfig
|
|
20
|
+
from .downloader import Downloader, adownload, download
|
|
21
|
+
from .errors import FatalDiskError, MachyonError
|
|
22
|
+
from .limiter import AdaptiveLimiter
|
|
23
|
+
from .metrics import Report
|
|
24
|
+
from .naming import ImageRenamer, NameTemplate, generate_hashed_name
|
|
25
|
+
from .sources import read_rows
|
|
26
|
+
from .utils import run, setup_logging
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"download", "adownload", "Downloader", "DownloadConfig", "Report",
|
|
30
|
+
"NameTemplate", "read_rows", "run", "setup_logging",
|
|
31
|
+
"MachyonError", "FatalDiskError", "AdaptiveLimiter",
|
|
32
|
+
# class-based API
|
|
33
|
+
"ImageDownloader", "ImageRenamer", "generate_hashed_name", "download_images", "Config",
|
|
34
|
+
"__version__",
|
|
35
|
+
]
|
machyon/__main__.py
ADDED
machyon/cli.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Command line: machyon urls.csv -o images/ (see `machyon --help`)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import csv
|
|
6
|
+
import itertools
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
from . import __version__
|
|
10
|
+
from .config import DownloadConfig
|
|
11
|
+
from .downloader import Downloader
|
|
12
|
+
from .naming import DEFAULT_TEMPLATE, as_namer
|
|
13
|
+
from .sources import iter_pairs, read_rows
|
|
14
|
+
from .utils import run, setup_logging
|
|
15
|
+
|
|
16
|
+
COMMANDS = ("download", "retry", "names", "doctor")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _tuning(p: argparse.ArgumentParser) -> None:
|
|
20
|
+
g = p.add_argument_group("tuning (defaults are fine for almost everyone)")
|
|
21
|
+
g.add_argument("--concurrency", type=int, help="starting in-flight requests (adapts automatically)")
|
|
22
|
+
g.add_argument("--max-concurrency", type=int, help="ceiling for in-flight requests")
|
|
23
|
+
g.add_argument("--retries", type=int, help="retries per image after the first attempt")
|
|
24
|
+
g.add_argument("--timeout", type=float, help="connect / read-stall timeout in seconds")
|
|
25
|
+
g.add_argument("--no-adaptive", action="store_true", help="fixed concurrency")
|
|
26
|
+
g.add_argument("--no-hedge", action="store_true", help="don't race duplicate requests against stragglers")
|
|
27
|
+
g.add_argument("--header", action="append", default=[], metavar="'Name: value'", help="extra request header")
|
|
28
|
+
g.add_argument("--referer", help="Referer header to send")
|
|
29
|
+
g.add_argument("--verify-existing", action="store_true", help="check files already on disk are real images")
|
|
30
|
+
g.add_argument("--tls-trust", choices=("native", "certifi", "python"),
|
|
31
|
+
help="CA source for HTTPS: native = OS store, like your browser (default)")
|
|
32
|
+
g.add_argument("--insecure", action="store_true",
|
|
33
|
+
help="do NOT verify HTTPS certificates (only for hosts you trust; see `machyon doctor`)")
|
|
34
|
+
g.add_argument("--no-validate", action="store_true", help="keep non-image responses too")
|
|
35
|
+
g.add_argument("--no-progress", action="store_true", help="no progress bar")
|
|
36
|
+
g.add_argument("--metrics", metavar="DIR", help="append a run summary to DIR/metrics_json_info.json")
|
|
37
|
+
g.add_argument("--log", metavar="DIR", help="write DIR/machyon.log")
|
|
38
|
+
g.add_argument("--config", metavar="INI", help="load settings from an INI file ([SETTINGS] section)")
|
|
39
|
+
g.add_argument("--strict", action="store_true", help="exit with status 1 if anything failed")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _source_args(p: argparse.ArgumentParser) -> None:
|
|
43
|
+
p.add_argument("source", help="CSV, TSV, TXT, JSON, JSONL or Parquet file of image URLs")
|
|
44
|
+
p.add_argument("--url-col", help="column with the URLs (auto-detected if omitted)")
|
|
45
|
+
p.add_argument("--name-col", help="column with ready-made filenames")
|
|
46
|
+
p.add_argument("--names", default=DEFAULT_TEMPLATE,
|
|
47
|
+
help="filename template, e.g. '{index}_{hash}{ext}'. Keys: index, hash, stem, ext, host, "
|
|
48
|
+
"and any column name. Default: %(default)s")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _config(a) -> DownloadConfig:
|
|
52
|
+
cfg = DownloadConfig.from_ini(a.config) if a.config else DownloadConfig()
|
|
53
|
+
headers = dict(cfg.headers)
|
|
54
|
+
for h in a.header:
|
|
55
|
+
k, _, v = h.partition(":")
|
|
56
|
+
if k.strip():
|
|
57
|
+
headers[k.strip()] = v.strip()
|
|
58
|
+
changes = {k: v for k, v in {
|
|
59
|
+
"concurrency": a.concurrency, "max_concurrency": a.max_concurrency, "max_retries": a.retries,
|
|
60
|
+
"timeout": a.timeout, "referer": a.referer, "metrics_dir": a.metrics, "log_dir": a.log,
|
|
61
|
+
"tls_trust": a.tls_trust,
|
|
62
|
+
}.items() if v is not None}
|
|
63
|
+
if a.insecure:
|
|
64
|
+
changes["verify_ssl"] = False
|
|
65
|
+
print("⚠ --insecure: HTTPS certificates are NOT verified for this run.", file=sys.stderr)
|
|
66
|
+
if a.max_concurrency is not None and a.concurrency is None:
|
|
67
|
+
changes["concurrency"] = min(cfg.concurrency, a.max_concurrency)
|
|
68
|
+
flags = {"adaptive": not a.no_adaptive, "hedge": not a.no_hedge, "validate": not a.no_validate,
|
|
69
|
+
"progress": not a.no_progress}
|
|
70
|
+
changes.update({k: v for k, v in flags.items() if v is False})
|
|
71
|
+
if a.verify_existing:
|
|
72
|
+
changes["verify_existing"] = True
|
|
73
|
+
return cfg.replace(headers=headers, **changes)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
async def _download(a) -> int:
|
|
77
|
+
cfg = _config(a)
|
|
78
|
+
async with Downloader(a.output, cfg) as dl:
|
|
79
|
+
r = await dl.download(a.source, a.names, url_col=a.url_col, name_col=a.name_col, start=a.start, end=a.end)
|
|
80
|
+
print(f"\n{'✔' if r.ok else '⚠'} {r}")
|
|
81
|
+
_explain(r)
|
|
82
|
+
if dl.failures:
|
|
83
|
+
print(f" Failures: {dl.save_dir / 'failed_downloads.jsonl'} (retry with: machyon retry -o {a.output})")
|
|
84
|
+
return 1 if (a.strict and r.failed) else 0
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _explain(r) -> None:
|
|
88
|
+
for reason, ex in (r.failure_examples or {}).items():
|
|
89
|
+
print(f" {reason} ({r.failure_reasons.get(reason, 0)}x): {ex.get('error') or '-'}")
|
|
90
|
+
if ex.get("url"):
|
|
91
|
+
print(f" e.g. {ex['url']}")
|
|
92
|
+
if ex.get("hint"):
|
|
93
|
+
print(f" -> {ex['hint']}")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
async def _retry(a) -> int:
|
|
97
|
+
async with Downloader(a.output, _config(a)) as dl:
|
|
98
|
+
r = await dl.retry_failed(include_permanent=a.all)
|
|
99
|
+
left = len(dl.failures)
|
|
100
|
+
print(f"\n{'✔' if r.ok else '⚠'} {r}" if r else "\nNothing to retry.")
|
|
101
|
+
if r:
|
|
102
|
+
_explain(r)
|
|
103
|
+
if left:
|
|
104
|
+
print(f" Still failing: {left} (see {dl.save_dir / 'failed_downloads.jsonl'})")
|
|
105
|
+
return 1 if (a.strict and left) else 0
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _names(a) -> int:
|
|
109
|
+
pairs = iter_pairs(read_rows(a.source, a.url_col), as_namer(a.names), a.name_col)
|
|
110
|
+
if not a.output and a.limit is not None: # --limit previews on screen; --output writes every row
|
|
111
|
+
pairs = itertools.islice(pairs, a.limit)
|
|
112
|
+
out = open(a.output, "w", newline="", encoding="utf-8") if a.output else sys.stdout
|
|
113
|
+
try:
|
|
114
|
+
w = csv.writer(out, lineterminator="\n") # "\r\n" would become "\r\r\n" on a Windows console
|
|
115
|
+
w.writerow(["url", "filename"])
|
|
116
|
+
w.writerows(pairs)
|
|
117
|
+
finally:
|
|
118
|
+
if a.output:
|
|
119
|
+
out.close()
|
|
120
|
+
return 0
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def main(argv: list[str] | None = None) -> int:
|
|
124
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
125
|
+
if argv and argv[0] not in COMMANDS and argv[0] not in ("-h", "--help", "--version"):
|
|
126
|
+
argv.insert(0, "download") # `machyon urls.csv -o images/` and `machyon -o images/ urls.csv`
|
|
127
|
+
for stream in (sys.stdout, sys.stderr): # ✔/⚠ must never crash a redirected Windows console
|
|
128
|
+
try:
|
|
129
|
+
stream.reconfigure(errors="replace")
|
|
130
|
+
except (AttributeError, ValueError):
|
|
131
|
+
pass
|
|
132
|
+
|
|
133
|
+
parser = argparse.ArgumentParser(prog="machyon",
|
|
134
|
+
description="Download any number of images, fast, without losing a single file.")
|
|
135
|
+
parser.add_argument("--version", action="version", version=f"machyon {__version__}")
|
|
136
|
+
sub = parser.add_subparsers(dest="command")
|
|
137
|
+
|
|
138
|
+
p = sub.add_parser("download", help="download every URL in a file (default command)")
|
|
139
|
+
_source_args(p)
|
|
140
|
+
p.add_argument("-o", "--output", default="images", help="folder to save into (default: %(default)s)")
|
|
141
|
+
p.add_argument("--start", type=int, default=0, help="first row to download")
|
|
142
|
+
p.add_argument("--end", type=int, default=None, help="stop before this row")
|
|
143
|
+
_tuning(p)
|
|
144
|
+
|
|
145
|
+
p = sub.add_parser("retry", help="retry the failures recorded in a folder")
|
|
146
|
+
p.add_argument("-o", "--output", default="images", help="folder that was downloaded into")
|
|
147
|
+
p.add_argument("--all", action="store_true", help="also retry permanent failures (404, not an image, ...)")
|
|
148
|
+
_tuning(p)
|
|
149
|
+
|
|
150
|
+
p = sub.add_parser("names", help="preview the url -> filename mapping without downloading")
|
|
151
|
+
_source_args(p)
|
|
152
|
+
p.add_argument("--limit", type=int, default=20, help="rows to show (default: %(default)s)")
|
|
153
|
+
p.add_argument("--output", help="write the full mapping to this CSV instead")
|
|
154
|
+
|
|
155
|
+
p = sub.add_parser("doctor", help="diagnose why a URL fails: DNS, proxy, TLS trust, HTTP response")
|
|
156
|
+
p.add_argument("url", help="one image URL from your list")
|
|
157
|
+
p.add_argument("--referer", help="Referer header to send")
|
|
158
|
+
p.add_argument("--header", action="append", default=[], metavar="'Name: value'", help="extra request header")
|
|
159
|
+
|
|
160
|
+
a = parser.parse_args(argv)
|
|
161
|
+
if a.command == "doctor":
|
|
162
|
+
from .diagnostics import doctor
|
|
163
|
+
|
|
164
|
+
headers = {}
|
|
165
|
+
for h in a.header:
|
|
166
|
+
k, _, v = h.partition(":")
|
|
167
|
+
if k.strip():
|
|
168
|
+
headers[k.strip()] = v.strip()
|
|
169
|
+
return run(doctor(a.url, headers, a.referer))
|
|
170
|
+
if a.command == "names":
|
|
171
|
+
if a.output:
|
|
172
|
+
a.limit = None
|
|
173
|
+
return _names(a)
|
|
174
|
+
if a.command in ("download", "retry"):
|
|
175
|
+
setup_logging(a.log)
|
|
176
|
+
return run(_download(a) if a.command == "download" else _retry(a))
|
|
177
|
+
parser.print_help()
|
|
178
|
+
return 0
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
if __name__ == "__main__":
|
|
182
|
+
sys.exit(main())
|
machyon/compat.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Class-based API: ``ImageDownloader`` with explicit lists, step-by-step methods and a JSON run log."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
|
|
10
|
+
from .config import Config, DownloadConfig
|
|
11
|
+
from .disk import write_atomic_sync
|
|
12
|
+
from .downloader import Downloader
|
|
13
|
+
from .jobs import Job
|
|
14
|
+
from .metrics import MetricsEngine, Report
|
|
15
|
+
from .utils import setup_logging, split_host, url_path_parts
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger("machyon")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ImageDownloader(Downloader):
|
|
21
|
+
"""Class-based downloader on top of :class:`machyon.Downloader`: explicit (links, names) lists,
|
|
22
|
+
INI configuration, and step-by-step methods (``metrics_json``, ``retry_failed_downloads``, ...)."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, save_dir=None, cache_dir=None, concurrent_limit=None, max_connections=None,
|
|
25
|
+
max_retries=None, backoff_factor=None, timeout=None, config_path=None,
|
|
26
|
+
memory_cache_size=None, prefetch_dns=True, use_compression=True, metrics_dir=None,
|
|
27
|
+
tuning_profile_path=None, *, log_dir=None, adaptive=None, use_warm_start=True, **options):
|
|
28
|
+
ini = Config(config_path)
|
|
29
|
+
S = "SETTINGS"
|
|
30
|
+
|
|
31
|
+
def pick(value, getter, key):
|
|
32
|
+
return value if value is not None else getter(S, key)
|
|
33
|
+
|
|
34
|
+
log_dir = log_dir if log_dir is not None else (ini.get(S, "LOG_DIR") or None)
|
|
35
|
+
cfg = DownloadConfig(
|
|
36
|
+
concurrency=pick(concurrent_limit, ini.get_int, "CONCURRENT_LIMIT"),
|
|
37
|
+
max_concurrency=pick(max_connections, ini.get_int, "MAX_CONNECTIONS"),
|
|
38
|
+
max_retries=pick(max_retries, ini.get_int, "MAX_RETRIES"),
|
|
39
|
+
backoff=pick(backoff_factor, ini.get_float, "BACKOFF_FACTOR"),
|
|
40
|
+
timeout=pick(timeout, ini.get_float, "TIMEOUT"),
|
|
41
|
+
max_request_time=pick(options.pop("max_request_time", None), ini.get_float, "MAX_REQUEST_TIME"),
|
|
42
|
+
adaptive=pick(adaptive, ini.get_bool, "ADAPTIVE"),
|
|
43
|
+
use_compression=use_compression,
|
|
44
|
+
metrics_dir=metrics_dir or ini.get(S, "METRICS_DIR") or "metrics",
|
|
45
|
+
profile_path=tuning_profile_path,
|
|
46
|
+
warm_start=use_warm_start,
|
|
47
|
+
log_dir=log_dir,
|
|
48
|
+
**options,
|
|
49
|
+
)
|
|
50
|
+
setup_logging(log_dir) # the class-based API logs to the console
|
|
51
|
+
super().__init__(save_dir or ini.get(S, "SAVE_DIR"), cfg)
|
|
52
|
+
self._auto_metrics = False # here metrics are written by metrics_json()
|
|
53
|
+
self.cache_dir = cache_dir # accepted, unused (the filesystem is the cache)
|
|
54
|
+
self.memory_cache_size = memory_cache_size
|
|
55
|
+
self.prefetch_dns = prefetch_dns
|
|
56
|
+
self.save_image_dir = self.save_dir
|
|
57
|
+
self.metrics_dir = cfg.metrics_dir
|
|
58
|
+
|
|
59
|
+
# ------------------------------------------------------------------ attributes
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def concurrent_limit(self) -> int:
|
|
63
|
+
return self.limiter.limit
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def max_connections(self) -> int:
|
|
67
|
+
return self.config.max_concurrency
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def failed_downloads(self) -> list[str]:
|
|
71
|
+
"""URLs currently failed."""
|
|
72
|
+
return self.ledger.urls()
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def _failed(self) -> dict:
|
|
76
|
+
return self.ledger.failed
|
|
77
|
+
|
|
78
|
+
def _mark_failed(self, name, url, reason):
|
|
79
|
+
if self.ledger.mark_failed(name, url, reason):
|
|
80
|
+
self.metrics.c["failed"] += 1
|
|
81
|
+
|
|
82
|
+
async def get_session(self):
|
|
83
|
+
return await self.sessions.get()
|
|
84
|
+
|
|
85
|
+
async def _set_referer(self, referer):
|
|
86
|
+
if referer and referer != self.config.referer:
|
|
87
|
+
self.config.referer = referer
|
|
88
|
+
await self.sessions.reset()
|
|
89
|
+
|
|
90
|
+
# ------------------------------------------------------------------ calls
|
|
91
|
+
|
|
92
|
+
async def download(self, image_links, image_names=None, start=0, end=None, referer=None,
|
|
93
|
+
final_sweep=True, **kw) -> Report:
|
|
94
|
+
await self._set_referer(referer)
|
|
95
|
+
return await super().download(image_links, image_names, start=start, end=end,
|
|
96
|
+
retry_failures=final_sweep, **kw)
|
|
97
|
+
|
|
98
|
+
async def download_all_images(self, image_links, image_names, start=0, end=None, referer=None) -> Report:
|
|
99
|
+
await self._set_referer(referer)
|
|
100
|
+
return await self._pass(*self._pairs(image_links, image_names, None, None, start, end))
|
|
101
|
+
|
|
102
|
+
async def retry_failed_downloads(self, start=0, end=None, referer=None, include_permanent=False, only=None):
|
|
103
|
+
await self._set_referer(referer)
|
|
104
|
+
return await self.retry_failed(include_permanent=include_permanent, only=only, start=start, end=end)
|
|
105
|
+
|
|
106
|
+
async def metrics_json(self, image_links, image_names, start=0, end=None, referer=None) -> dict:
|
|
107
|
+
r = await self.download(image_links, image_names, start, end, referer=referer)
|
|
108
|
+
entry = MetricsEngine.entry(r)
|
|
109
|
+
if r.total > 0:
|
|
110
|
+
MetricsEngine.append_json(self.metrics_dir, entry)
|
|
111
|
+
return entry
|
|
112
|
+
|
|
113
|
+
async def log_session_metrics(self) -> dict:
|
|
114
|
+
zero = dict.fromkeys(self.metrics.c, 0)
|
|
115
|
+
r = self.metrics.report(zero, self.metrics.created_at, 0, len(self.ledger.counted),
|
|
116
|
+
self.ledger.reasons(), self.limiter.limit)
|
|
117
|
+
entry = MetricsEngine.entry(r, scope="whole session (since instance creation)")
|
|
118
|
+
MetricsEngine.append_json(self.metrics_dir, entry)
|
|
119
|
+
return entry
|
|
120
|
+
|
|
121
|
+
async def auto_tune_concurrency(self, image_links, image_names=None, sample_size=None, candidates=None,
|
|
122
|
+
use_warm_start=True, host=None):
|
|
123
|
+
"""Concurrency now tunes itself during the download; this only applies the warm-start
|
|
124
|
+
profile. Returns (concurrency, max_concurrency, consumed=0)."""
|
|
125
|
+
if use_warm_start and not self._warm_started:
|
|
126
|
+
urls = [str(u) for u in list(image_links)[:50]]
|
|
127
|
+
jobs = [Job(u, "", "", host or split_host(u) or "") for u in urls if host or split_host(u)]
|
|
128
|
+
if jobs:
|
|
129
|
+
await self._warm_start(jobs)
|
|
130
|
+
return self.limiter.limit, self.config.max_concurrency, 0
|
|
131
|
+
|
|
132
|
+
async def prefetch_urls(self, urls, port=443, batch_size=100) -> int:
|
|
133
|
+
"""Resolve each distinct host once ahead of time (warms the OS DNS cache)."""
|
|
134
|
+
if not self.prefetch_dns:
|
|
135
|
+
return 0
|
|
136
|
+
hosts = {split_host(str(u)) for u in urls} - {None}
|
|
137
|
+
loop = asyncio.get_running_loop()
|
|
138
|
+
|
|
139
|
+
async def resolve(h):
|
|
140
|
+
try:
|
|
141
|
+
await asyncio.wait_for(loop.getaddrinfo(h, port), 5)
|
|
142
|
+
except Exception as e:
|
|
143
|
+
logger.debug(f"DNS prefetch failed for {h}: {e}")
|
|
144
|
+
|
|
145
|
+
await asyncio.gather(*(resolve(h) for h in hosts))
|
|
146
|
+
return len(hosts)
|
|
147
|
+
|
|
148
|
+
def get_metrics(self) -> dict:
|
|
149
|
+
m = dict(self.metrics.c)
|
|
150
|
+
m.update(concurrency=self.limiter.snapshot(), failure_reasons=dict(self.ledger.reasons()),
|
|
151
|
+
peak_concurrency=self.metrics.peak_concurrency)
|
|
152
|
+
return m
|
|
153
|
+
|
|
154
|
+
def clear_cache(self, older_than=None) -> None:
|
|
155
|
+
"""Forget recorded failures (files on disk are the cache)."""
|
|
156
|
+
self.ledger.clear()
|
|
157
|
+
self.metrics.c["failed"] = 0
|
|
158
|
+
self.ledger.write()
|
|
159
|
+
|
|
160
|
+
def save_metrics_to_file(self, filename="download_metrics.json") -> None:
|
|
161
|
+
write_atomic_sync([os.fspath(self.save_dir / filename)],
|
|
162
|
+
json.dumps(self.get_metrics(), indent=2, default=str).encode(), False)
|
|
163
|
+
|
|
164
|
+
@classmethod
|
|
165
|
+
def from_config_file(cls, config_path):
|
|
166
|
+
return cls(config_path=config_path)
|
|
167
|
+
|
|
168
|
+
async def download_images_batch(self, image_urls, referer=None, **_ignored):
|
|
169
|
+
names = []
|
|
170
|
+
for u in image_urls:
|
|
171
|
+
_, ext = url_path_parts(u)
|
|
172
|
+
names.append(hashlib.blake2b(u.encode(), digest_size=16).hexdigest() +
|
|
173
|
+
(ext if ext in (".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".tiff", ".avif") else ".jpg"))
|
|
174
|
+
await self.download_all_images(list(image_urls), names, referer=referer)
|
|
175
|
+
failed = self.ledger.names()
|
|
176
|
+
return [(n, n not in failed) for n in names]
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
async def download_images(image_links, image_names=None, save_dir="downloads", **kwargs) -> Report:
|
|
180
|
+
"""One-call helper for the class-based API (names default to the URL's file name)."""
|
|
181
|
+
links = [str(u) for u in image_links]
|
|
182
|
+
if image_names is None:
|
|
183
|
+
image_names = [("".join(url_path_parts(u)) or f"image_{i}") for i, u in enumerate(links)]
|
|
184
|
+
async with ImageDownloader(save_dir=save_dir, **kwargs) as d:
|
|
185
|
+
return await d.download(links, list(image_names))
|
|
186
|
+
|
machyon/config.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""All tunables in one typed place, plus INI-file loading."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import configparser
|
|
5
|
+
import dataclasses
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from .utils import DEFAULT_USER_AGENT
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger("machyon.config")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class DownloadConfig:
|
|
19
|
+
"""Every knob Machyon has. The defaults are tuned for "a big list of image URLs on an
|
|
20
|
+
ordinary laptop and home connection": you normally change none of them."""
|
|
21
|
+
|
|
22
|
+
# --- concurrency ---------------------------------------------------------------
|
|
23
|
+
concurrency: int = 32
|
|
24
|
+
"""Starting number of in-flight requests. With ``adaptive=True`` this is only a starting point."""
|
|
25
|
+
max_concurrency: int = 256
|
|
26
|
+
"""Hard ceiling for in-flight requests and the size of the connection pool."""
|
|
27
|
+
adaptive: bool = True
|
|
28
|
+
"""Tune concurrency live: grow while throughput improves, back off on 429s and errors."""
|
|
29
|
+
hedge: bool = True
|
|
30
|
+
"""Near the end of a batch, race a duplicate request against stragglers; first one wins."""
|
|
31
|
+
warm_start: bool = True
|
|
32
|
+
"""Remember the best concurrency per host and start there next time."""
|
|
33
|
+
profile_path: str | None = None
|
|
34
|
+
"""Where warm-start profiles live. Default: ``~/.machyon/profiles.json``."""
|
|
35
|
+
|
|
36
|
+
# --- retries & timeouts -------------------------------------------------------
|
|
37
|
+
max_retries: int = 5
|
|
38
|
+
"""Retries per URL after the first attempt (timeouts, resets, 408/425/429/5xx)."""
|
|
39
|
+
backoff: float = 0.5
|
|
40
|
+
"""Base seconds for exponential backoff with jitter."""
|
|
41
|
+
timeout: float = 15.0
|
|
42
|
+
"""Connect timeout and maximum silence between received bytes, in seconds."""
|
|
43
|
+
max_request_time: float = 120.0
|
|
44
|
+
"""Hard cap for one attempt; extended automatically in proportion to large bodies."""
|
|
45
|
+
max_retry_after: float = 120.0
|
|
46
|
+
"""Upper bound on how long a server's ``Retry-After`` may pause a host."""
|
|
47
|
+
|
|
48
|
+
# --- HTTP -------------------------------------------------------------------
|
|
49
|
+
headers: dict[str, str] = field(default_factory=dict)
|
|
50
|
+
"""Extra request headers (auth tokens, cookies, ...)."""
|
|
51
|
+
referer: str | None = None
|
|
52
|
+
user_agent: str = DEFAULT_USER_AGENT
|
|
53
|
+
verify_ssl: bool = True
|
|
54
|
+
"""Verify TLS certificates. Turn off only for hosts you trust (``--insecure``)."""
|
|
55
|
+
tls_trust: str = "native"
|
|
56
|
+
"""Where trusted CAs come from: "native" (OS store via truststore, like your browser),
|
|
57
|
+
"certifi" (Mozilla bundle, like requests) or "python" (Python's defaults)."""
|
|
58
|
+
use_compression: bool = True
|
|
59
|
+
"""``False`` sends ``Accept-Encoding: identity``."""
|
|
60
|
+
|
|
61
|
+
# --- files ------------------------------------------------------------------
|
|
62
|
+
validate: bool = True
|
|
63
|
+
"""Reject bodies that aren't images (HTML "soft 404" pages, JSON errors, ...)."""
|
|
64
|
+
verify_existing: bool = False
|
|
65
|
+
"""Before skipping a file that already exists, check it really is an image."""
|
|
66
|
+
fsync: bool = False
|
|
67
|
+
"""Flush every file to physical disk before renaming it into place (slower, power-loss safe)."""
|
|
68
|
+
stream_threshold: int = 4 * 1024 * 1024
|
|
69
|
+
"""Bodies larger than this (or of unknown size) stream to disk and can resume with Range."""
|
|
70
|
+
write_threads: int | None = None
|
|
71
|
+
"""Threads for file I/O. Default: min(32, 4 x CPUs)."""
|
|
72
|
+
shard_size: int = 200_000
|
|
73
|
+
"""Rows planned at a time; keeps memory flat for inputs of any size."""
|
|
74
|
+
|
|
75
|
+
# --- reporting --------------------------------------------------------------
|
|
76
|
+
progress: bool = True
|
|
77
|
+
log_dir: str | None = None
|
|
78
|
+
"""Write a rotating ``machyon.log`` here. ``None`` = no log file."""
|
|
79
|
+
metrics_dir: str | None = None
|
|
80
|
+
"""Append a JSON entry per run to ``metrics_json_info.json`` here. ``None`` = off."""
|
|
81
|
+
|
|
82
|
+
def __post_init__(self) -> None:
|
|
83
|
+
self.max_concurrency = max(1, int(self.max_concurrency))
|
|
84
|
+
self.concurrency = max(1, min(int(self.concurrency), self.max_concurrency))
|
|
85
|
+
self.max_retries = max(0, int(self.max_retries))
|
|
86
|
+
self.backoff = max(0.0, float(self.backoff))
|
|
87
|
+
self.timeout = float(self.timeout)
|
|
88
|
+
self.max_request_time = float(self.max_request_time)
|
|
89
|
+
self.shard_size = max(1000, int(self.shard_size))
|
|
90
|
+
if self.tls_trust not in ("native", "certifi", "python"):
|
|
91
|
+
raise ValueError(f"tls_trust must be 'native', 'certifi' or 'python', not {self.tls_trust!r}")
|
|
92
|
+
|
|
93
|
+
def replace(self, **changes: Any) -> DownloadConfig:
|
|
94
|
+
"""A copy with some fields changed (unknown names raise TypeError)."""
|
|
95
|
+
return dataclasses.replace(self, **changes)
|
|
96
|
+
|
|
97
|
+
@classmethod
|
|
98
|
+
def from_ini(cls, path: str | os.PathLike, section: str = "SETTINGS") -> DownloadConfig:
|
|
99
|
+
"""Load from an INI file. Keys are field names, case-insensitive (``concurrency = 64``).
|
|
100
|
+
The aliases CONCURRENT_LIMIT, MAX_CONNECTIONS and BACKOFF_FACTOR are accepted too."""
|
|
101
|
+
cp = configparser.ConfigParser()
|
|
102
|
+
if not Path(path).exists():
|
|
103
|
+
raise FileNotFoundError(path)
|
|
104
|
+
cp.read(path, encoding="utf-8")
|
|
105
|
+
if not cp.has_section(section):
|
|
106
|
+
return cls()
|
|
107
|
+
aliases = {"concurrent_limit": "concurrency", "max_connections": "max_concurrency",
|
|
108
|
+
"backoff_factor": "backoff"}
|
|
109
|
+
types = {f.name: f.type for f in dataclasses.fields(cls)}
|
|
110
|
+
values: dict[str, Any] = {}
|
|
111
|
+
for key, raw in cp.items(section):
|
|
112
|
+
name = aliases.get(key.lower(), key.lower())
|
|
113
|
+
if name not in types:
|
|
114
|
+
continue
|
|
115
|
+
t = str(types[name])
|
|
116
|
+
if "bool" in t:
|
|
117
|
+
values[name] = cp.getboolean(section, key)
|
|
118
|
+
elif t.startswith("int"):
|
|
119
|
+
values[name] = int(raw)
|
|
120
|
+
elif t.startswith("float"):
|
|
121
|
+
values[name] = float(raw)
|
|
122
|
+
elif "dict" in t:
|
|
123
|
+
continue
|
|
124
|
+
else:
|
|
125
|
+
values[name] = raw or None
|
|
126
|
+
return cls(**values)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class Config:
|
|
130
|
+
"""Plain INI accessor used by ``ImageDownloader(config_path=...)`` (``Config(path).get("SETTINGS", "SAVE_DIR")``)."""
|
|
131
|
+
|
|
132
|
+
DEFAULTS = {"CONCURRENT_LIMIT": "32", "MAX_CONNECTIONS": "256", "MAX_RETRIES": "5",
|
|
133
|
+
"BACKOFF_FACTOR": "0.5", "TIMEOUT": "15", "MAX_REQUEST_TIME": "120", "ADAPTIVE": "true",
|
|
134
|
+
"LOG_DIR": "", "SAVE_DIR": "images", "CACHE_DIR": "image_cache", "METRICS_DIR": ""}
|
|
135
|
+
|
|
136
|
+
def __init__(self, config_path: str | os.PathLike | None = None) -> None:
|
|
137
|
+
self.config = configparser.ConfigParser()
|
|
138
|
+
self.config["SETTINGS"] = dict(self.DEFAULTS)
|
|
139
|
+
if config_path:
|
|
140
|
+
if Path(config_path).exists():
|
|
141
|
+
self.config.read(config_path, encoding="utf-8")
|
|
142
|
+
else:
|
|
143
|
+
logger.warning(f"Config file {config_path} not found. Using default values.")
|
|
144
|
+
|
|
145
|
+
def get(self, section, option, fallback=None):
|
|
146
|
+
return self.config.get(section, option, fallback=fallback)
|
|
147
|
+
|
|
148
|
+
def get_int(self, section, option, fallback=None):
|
|
149
|
+
return self.config.getint(section, option, fallback=fallback)
|
|
150
|
+
|
|
151
|
+
def get_float(self, section, option, fallback=None):
|
|
152
|
+
return self.config.getfloat(section, option, fallback=fallback)
|
|
153
|
+
|
|
154
|
+
def get_bool(self, section, option, fallback=None):
|
|
155
|
+
return self.config.getboolean(section, option, fallback=fallback)
|
|
156
|
+
|
|
157
|
+
def save(self, config_path):
|
|
158
|
+
config_path = Path(config_path)
|
|
159
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
160
|
+
with open(config_path, "w", encoding="utf-8") as f:
|
|
161
|
+
self.config.write(f)
|