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 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
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
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)