qpkit 0.1.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.
qpkit/__init__.py ADDED
@@ -0,0 +1,54 @@
1
+ from __future__ import annotations
2
+
3
+ from .api import QPKit, Request
4
+ from .exceptions import DownloadError, PlanBuildError, QPKitError
5
+ from .http import HttpClient, HttpClientProtocol
6
+ from .models import (
7
+ BoundingBox,
8
+ DownloadDSSResult,
9
+ DownloadItem,
10
+ DownloadPlan,
11
+ DownloadResult,
12
+ DSSWriteResult,
13
+ GridOptions,
14
+ HRRRGridOptions,
15
+ HRRRRequest,
16
+ QPEGridOptions,
17
+ QPERequest,
18
+ QPFGridOptions,
19
+ QPFRequest,
20
+ )
21
+
22
+ try:
23
+ from ._version import version as __version__
24
+ except ImportError:
25
+ try:
26
+ from importlib.metadata import version
27
+
28
+ __version__ = version("qpkit")
29
+ except Exception:
30
+ __version__ = "0.0.0"
31
+
32
+ __all__ = [
33
+ "BoundingBox",
34
+ "DSSWriteResult",
35
+ "DownloadDSSResult",
36
+ "DownloadError",
37
+ "DownloadItem",
38
+ "DownloadPlan",
39
+ "DownloadResult",
40
+ "GridOptions",
41
+ "HRRRGridOptions",
42
+ "HRRRRequest",
43
+ "HttpClient",
44
+ "QPEGridOptions",
45
+ "HttpClientProtocol",
46
+ "PlanBuildError",
47
+ "QPERequest",
48
+ "QPFGridOptions",
49
+ "QPFRequest",
50
+ "QPKit",
51
+ "QPKitError",
52
+ "Request",
53
+ "__version__",
54
+ ]
qpkit/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
qpkit/_version.py ADDED
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.1.0'
22
+ __version_tuple__ = version_tuple = (0, 1, 0)
23
+
24
+ __commit_id__ = commit_id = None
qpkit/api.py ADDED
@@ -0,0 +1,310 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import os
5
+ import warnings
6
+ from datetime import datetime, timezone
7
+ from pathlib import Path
8
+ from types import TracebackType
9
+
10
+ from .downloader import Downloader
11
+ from .http import HttpClient, HttpClientProtocol
12
+ from .models import (
13
+ DownloadDSSResult,
14
+ DownloadPlan,
15
+ DownloadResult,
16
+ GridOptions,
17
+ HRRRGridOptions,
18
+ HRRRRequest,
19
+ QPEGridOptions,
20
+ QPERequest,
21
+ QPFGridOptions,
22
+ QPFRequest,
23
+ )
24
+ from .services import (
25
+ HRRRDSSWriter,
26
+ HRRRService,
27
+ QPEDSSWriter,
28
+ QPEService,
29
+ QPFDSSWriter,
30
+ QPFService,
31
+ )
32
+ from .utils import list_local_files
33
+
34
+ Request = QPERequest | QPFRequest | HRRRRequest
35
+
36
+
37
+ class QPKit:
38
+ """High-level API for building plans and downloading precipitation products."""
39
+
40
+ def __init__(
41
+ self,
42
+ *,
43
+ http_client: HttpClientProtocol | None = None,
44
+ logger: logging.Logger | None = None,
45
+ max_workers: int = 4,
46
+ ) -> None:
47
+ self._owns_http = http_client is None
48
+ self.http: HttpClientProtocol = http_client or HttpClient()
49
+ self.logger = logger or logging.getLogger("qpkit")
50
+ self.qpe = QPEService(self.http)
51
+ self.qpf = QPFService(self.http)
52
+ self.hrrr = HRRRService(self.http)
53
+ self.downloader = Downloader(self.http, self.logger, max_workers=max_workers)
54
+ self.hrrr_dss = HRRRDSSWriter()
55
+ self.qpe_dss = QPEDSSWriter()
56
+ self.qpf_dss = QPFDSSWriter()
57
+
58
+ def close(self) -> None:
59
+ if self._owns_http:
60
+ self.http.close()
61
+
62
+ def __enter__(self) -> QPKit:
63
+ return self
64
+
65
+ def __exit__(
66
+ self,
67
+ exc_type: type[BaseException] | None,
68
+ exc: BaseException | None,
69
+ tb: TracebackType | None,
70
+ ) -> None:
71
+ self.close()
72
+
73
+ @staticmethod
74
+ def _resolve(output_dir: str | os.PathLike[str]) -> Path:
75
+ return Path(os.fspath(output_dir))
76
+
77
+ def build_plan_qpe(
78
+ self,
79
+ request: QPERequest,
80
+ output_dir: str | os.PathLike[str],
81
+ *,
82
+ force: bool = False,
83
+ ) -> DownloadPlan:
84
+ path = self._resolve(output_dir)
85
+ return self.qpe.build_plan(request, path, list_local_files(path), force=force)
86
+
87
+ def build_plan_qpf(
88
+ self,
89
+ request: QPFRequest,
90
+ output_dir: str | os.PathLike[str],
91
+ *,
92
+ force: bool = False,
93
+ ) -> DownloadPlan:
94
+ path = self._resolve(output_dir)
95
+ return self.qpf.build_plan(request, path, list_local_files(path), force=force)
96
+
97
+ def build_plan_hrrr(
98
+ self,
99
+ request: HRRRRequest,
100
+ output_dir: str | os.PathLike[str],
101
+ *,
102
+ force: bool = False,
103
+ ) -> DownloadPlan:
104
+ path = self._resolve(output_dir)
105
+ return self.hrrr.build_plan(request, path, list_local_files(path), force=force)
106
+
107
+ def download_qpe(
108
+ self,
109
+ request: QPERequest,
110
+ output_dir: str | os.PathLike[str],
111
+ *,
112
+ force: bool = False,
113
+ ) -> DownloadResult:
114
+ plan = self.build_plan_qpe(request, output_dir, force=force)
115
+ return self.downloader.download(plan)
116
+
117
+ def download_qpf(
118
+ self,
119
+ request: QPFRequest,
120
+ output_dir: str | os.PathLike[str],
121
+ *,
122
+ force: bool = False,
123
+ ) -> DownloadResult:
124
+ plan = self.build_plan_qpf(request, output_dir, force=force)
125
+ return self.downloader.download(plan)
126
+
127
+ def download_hrrr(
128
+ self,
129
+ request: HRRRRequest,
130
+ output_dir: str | os.PathLike[str],
131
+ *,
132
+ force: bool = False,
133
+ cycle_hour_latest: bool | None = None,
134
+ ) -> DownloadResult:
135
+ if cycle_hour_latest:
136
+ today = datetime.now(timezone.utc).date()
137
+ if request.cycle_date is not None and request.cycle_date != today:
138
+ warnings.warn(
139
+ "cycle_hour_latest=True requires cycle_date to be unset or today's "
140
+ f"UTC date; got {request.cycle_date}. Using the latest available "
141
+ "cycle instead.",
142
+ stacklevel=2,
143
+ )
144
+ latest_date, latest_hour = self.hrrr.find_latest_cycle()
145
+ request = request.model_copy(
146
+ update={"cycle_date": latest_date, "cycle_hour": latest_hour}
147
+ )
148
+ plan = self.build_plan_hrrr(request, output_dir, force=force)
149
+ return self.downloader.download(plan)
150
+
151
+ def build_plan(
152
+ self,
153
+ request: Request,
154
+ output_dir: str | os.PathLike[str],
155
+ *,
156
+ force: bool = False,
157
+ ) -> DownloadPlan:
158
+ match request:
159
+ case QPERequest():
160
+ return self.build_plan_qpe(request, output_dir, force=force)
161
+ case QPFRequest():
162
+ return self.build_plan_qpf(request, output_dir, force=force)
163
+ case HRRRRequest():
164
+ return self.build_plan_hrrr(request, output_dir, force=force)
165
+
166
+ def download(
167
+ self,
168
+ request: Request,
169
+ output_dir: str | os.PathLike[str],
170
+ *,
171
+ force: bool = False,
172
+ ) -> DownloadResult:
173
+ plan = self.build_plan(request, output_dir, force=force)
174
+ return self.downloader.download(plan)
175
+
176
+ def download_to_dss(
177
+ self,
178
+ request: Request,
179
+ output_dir: str | os.PathLike[str],
180
+ dss_file: str | os.PathLike[str],
181
+ *,
182
+ grid_options: GridOptions | None = None,
183
+ force: bool = False,
184
+ cycle_hour_latest: bool | None = None,
185
+ dry_run: bool = False,
186
+ dss_version: int | None = None,
187
+ interval: int | None = None,
188
+ ) -> DownloadDSSResult:
189
+ """Download a precipitation product and write the resulting grids to DSS.
190
+
191
+ Single end-to-end entry point for every supported product. The request
192
+ type drives the download dispatch; the ``grid_options`` type drives the
193
+ DSS-writer dispatch. When ``grid_options`` is None, sensible defaults
194
+ are chosen based on the product.
195
+
196
+ ``interval`` (HRRR APCP only) — compute per-interval accumulations
197
+ instead of storing the raw running total. Provide a single forecast hour
198
+ in ``request.forecast_hours`` (the end hour); the required intermediate
199
+ files are derived and downloaded automatically. Not applicable to QPE
200
+ (its accumulation window is set via ``QPERequest.interval``).
201
+
202
+ Two-stage workflows (writing GRIBs that came from elsewhere, or
203
+ re-writing the same set with different conventions) go through the
204
+ public writer-service attribute, e.g. ``kit.hrrr_dss.write(...)``.
205
+ """
206
+ if dss_version not in (None, 6, 7):
207
+ raise ValueError(f"dss_version must be 6, 7, or None; got {dss_version!r}")
208
+
209
+ # Argument coherence — surface the most specific user mistake first.
210
+ if cycle_hour_latest and not isinstance(request, HRRRRequest):
211
+ raise ValueError(
212
+ "cycle_hour_latest is HRRR-only; "
213
+ f"got request of type {type(request).__name__}"
214
+ )
215
+ if interval is not None and isinstance(request, QPERequest):
216
+ raise ValueError(
217
+ "interval is not applicable to QPE; the accumulation window is "
218
+ "set via QPERequest.interval"
219
+ )
220
+ if grid_options is not None and not _grid_options_matches(request, grid_options):
221
+ raise ValueError(
222
+ f"grid_options type {type(grid_options).__name__} "
223
+ f"does not match request type {type(request).__name__}"
224
+ )
225
+
226
+ dss_path = Path(os.fspath(dss_file))
227
+
228
+ match request:
229
+ case HRRRRequest():
230
+ if interval is not None:
231
+ if request.precip_type != "APCP":
232
+ raise ValueError(
233
+ f"interval is only valid for APCP; "
234
+ f"got precip_type={request.precip_type!r}"
235
+ )
236
+ if len(request.forecast_hours) != 1:
237
+ raise ValueError(
238
+ "interval mode requires exactly one forecast hour "
239
+ f"(the end hour); got {len(request.forecast_hours)}"
240
+ )
241
+ end_hour = request.forecast_hours[0]
242
+ if end_hour % interval != 0:
243
+ warnings.warn(
244
+ f"end_hour={end_hour} is not divisible by "
245
+ f"interval={interval}; the last incomplete interval "
246
+ "will be omitted.",
247
+ stacklevel=2,
248
+ )
249
+ download_hours = tuple(range(0, end_hour + 1, interval))
250
+ request = request.model_copy(
251
+ update={"forecast_hours": download_hours}
252
+ )
253
+
254
+ if grid_options is None:
255
+ if request.precip_type == "APCP":
256
+ grid_options = HRRRGridOptions.for_apcp()
257
+ else:
258
+ grid_options = HRRRGridOptions.for_prate(as_depth=False)
259
+
260
+ download = self.download_hrrr(
261
+ request, output_dir, force=force,
262
+ cycle_hour_latest=cycle_hour_latest,
263
+ )
264
+ dss = self.hrrr_dss.write(
265
+ download.all_files,
266
+ dss_path,
267
+ grid_options=grid_options,
268
+ dry_run=dry_run,
269
+ dss_version=dss_version,
270
+ interval=interval,
271
+ )
272
+
273
+ case QPERequest():
274
+ if grid_options is None:
275
+ grid_options = QPEGridOptions()
276
+
277
+ download = self.download_qpe(request, output_dir, force=force)
278
+ dss = self.qpe_dss.write(
279
+ download.all_files,
280
+ dss_path,
281
+ grid_options=grid_options,
282
+ dry_run=dry_run,
283
+ dss_version=dss_version,
284
+ )
285
+
286
+ case QPFRequest():
287
+ if grid_options is None:
288
+ grid_options = QPFGridOptions()
289
+
290
+ download = self.download_qpf(request, output_dir, force=force)
291
+ dss = self.qpf_dss.write(
292
+ download.all_files,
293
+ dss_path,
294
+ grid_options=grid_options,
295
+ dry_run=dry_run,
296
+ dss_version=dss_version,
297
+ )
298
+
299
+ return DownloadDSSResult(download=download, dss=dss)
300
+
301
+
302
+ def _grid_options_matches(request: Request, grid_options: GridOptions) -> bool:
303
+ """Return True if the grid_options type is consistent with the request type."""
304
+ match request:
305
+ case HRRRRequest():
306
+ return isinstance(grid_options, HRRRGridOptions)
307
+ case QPERequest():
308
+ return isinstance(grid_options, QPEGridOptions)
309
+ case QPFRequest():
310
+ return isinstance(grid_options, QPFGridOptions)
qpkit/cli.py ADDED
@@ -0,0 +1,218 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import datetime as dt
5
+ import json
6
+ import os
7
+ from collections.abc import Sequence
8
+ from importlib.metadata import PackageNotFoundError, version
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from pydantic import ValidationError
13
+
14
+ from .api import QPKit, Request
15
+ from .exceptions import QPKitError
16
+ from .logging_utils import build_logger
17
+ from .models import (
18
+ BoundingBox,
19
+ DownloadResult,
20
+ HRRRRequest,
21
+ QPERequest,
22
+ QPFRequest,
23
+ )
24
+ from .utils import parse_forecast_hours
25
+
26
+
27
+ def _qpkit_version() -> str:
28
+ try:
29
+ return version("qpkit")
30
+ except PackageNotFoundError:
31
+ return "0.0.0+unknown"
32
+
33
+
34
+ class PathExpandAction(argparse.Action):
35
+ def __init__(
36
+ self,
37
+ option_strings: Sequence[str],
38
+ dest: str,
39
+ nargs: int | str | None = None,
40
+ **kwargs: Any,
41
+ ) -> None:
42
+ if nargs is not None:
43
+ raise ValueError('"nargs" is not supported for PathExpandAction')
44
+ super().__init__(option_strings, dest, **kwargs)
45
+
46
+ def __call__(
47
+ self,
48
+ parser: argparse.ArgumentParser,
49
+ namespace: argparse.Namespace,
50
+ values: str | Sequence[Any] | None,
51
+ option_string: str | None = None,
52
+ ) -> None:
53
+ if values == "-":
54
+ setattr(namespace, self.dest, None)
55
+ return
56
+ if not isinstance(values, str):
57
+ raise ValueError("PathExpandAction expects a string value")
58
+ expanded = os.path.expandvars(values)
59
+ setattr(namespace, self.dest, str(Path(expanded).expanduser()))
60
+
61
+
62
+ def _build_request(args: argparse.Namespace) -> Request:
63
+ match args.subcommand:
64
+ case "qpe":
65
+ return QPERequest(product=args.product, interval=args.interval)
66
+ case "qpf":
67
+ return QPFRequest(interval=args.interval, cycle_hour=args.cycle)
68
+ case "hrrr":
69
+ return HRRRRequest(
70
+ cycle_hour=args.cycle,
71
+ forecast_hours=tuple(args.fct_hour),
72
+ bbox=BoundingBox(
73
+ left_lon=args.llon,
74
+ right_lon=args.rlon,
75
+ top_lat=args.tlat,
76
+ bottom_lat=args.blat,
77
+ ),
78
+ )
79
+ case _:
80
+ raise ValueError(f"unknown subcommand: {args.subcommand}")
81
+
82
+
83
+ def _summary(
84
+ result: DownloadResult,
85
+ *,
86
+ dry_run: bool,
87
+ subcommand: str,
88
+ output_dir: Path,
89
+ ) -> dict[str, Any]:
90
+ return {
91
+ "subcommand": subcommand,
92
+ "dry_run": dry_run,
93
+ "planned": len(result.plan.items),
94
+ "downloaded": len(result.succeeded),
95
+ "skipped": len(result.skipped),
96
+ "failed": len(result.failed),
97
+ "output_dir": str(output_dir),
98
+ "files": [item.filename for item in result.plan.items],
99
+ }
100
+
101
+
102
+ def build_parser() -> argparse.ArgumentParser:
103
+ parser = argparse.ArgumentParser(description="qpkit: NOAA precipitation GRIB2 downloader")
104
+ parser.add_argument("--version", action="version", version=f"qpkit {_qpkit_version()}")
105
+
106
+ common = argparse.ArgumentParser(add_help=False)
107
+ common.add_argument(
108
+ "-o", "--output-dir", required=True, action=PathExpandAction, dest="output_dir"
109
+ )
110
+ common.add_argument("-w", "--working-dir", action=PathExpandAction, dest="working_dir")
111
+ common.add_argument("--force", action="store_true")
112
+ common.add_argument("--dry-run", action="store_true", dest="dry_run")
113
+ common.add_argument("--json", action="store_true", dest="json_output")
114
+ common.add_argument("-l", "--log-file", action=PathExpandAction, dest="log_file")
115
+ common.add_argument(
116
+ "-n", "--log-level", type=int, choices=range(6), default=2, dest="log_level"
117
+ )
118
+
119
+ sub = parser.add_subparsers(dest="subcommand")
120
+
121
+ qpe = sub.add_parser("qpe", parents=[common])
122
+ qpe.add_argument(
123
+ "-p",
124
+ "--product",
125
+ choices=["MultiSensor_Pass1", "MultiSensor_Pass2", "RadarOnly"],
126
+ default="MultiSensor_Pass1",
127
+ )
128
+ qpe.add_argument("-i", "--interval", type=int, choices=[1, 3, 6, 12, 24, 48, 72], default=1)
129
+
130
+ utc_hour = dt.datetime.now(dt.timezone.utc).hour
131
+
132
+ qpf = sub.add_parser("qpf", parents=[common])
133
+ qpf.add_argument("-i", "--interval", type=int, choices=[6, 24, 48, 120], default=6)
134
+ qpf.add_argument("-c", "--cycle", type=int, default=utc_hour)
135
+
136
+ hrrr = sub.add_parser("hrrr", parents=[common])
137
+ hrrr.add_argument("-c", "--cycle", type=int, default=utc_hour)
138
+ hrrr.add_argument(
139
+ "-f",
140
+ "--fct-hour",
141
+ type=parse_forecast_hours,
142
+ default=parse_forecast_hours("0-18"),
143
+ dest="fct_hour",
144
+ )
145
+ hrrr.add_argument("--left-lon", type=float, default=0.0, dest="llon")
146
+ hrrr.add_argument("--right-lon", type=float, default=360.0, dest="rlon")
147
+ hrrr.add_argument("--top-lat", type=float, default=90.0, dest="tlat")
148
+ hrrr.add_argument("--bottom-lat", type=float, default=-90.0, dest="blat")
149
+
150
+ return parser
151
+
152
+
153
+ def main(argv: list[str] | None = None) -> int:
154
+ parser = build_parser()
155
+ args = parser.parse_args(argv)
156
+
157
+ if args.subcommand not in {"qpe", "qpf", "hrrr"}:
158
+ parser.print_help()
159
+ return 2
160
+
161
+ logger = build_logger(log_file=args.log_file, log_level=args.log_level)
162
+
163
+ if args.working_dir:
164
+ os.chdir(args.working_dir)
165
+
166
+ output_dir = Path(args.output_dir).expanduser().resolve()
167
+ if not output_dir.is_dir():
168
+ logger.error("Output directory does not exist: %s", output_dir)
169
+ return 1
170
+
171
+ try:
172
+ request = _build_request(args)
173
+ except ValidationError as exc:
174
+ if args.json_output:
175
+ print(json.dumps({"error": str(exc)}))
176
+ else:
177
+ logger.error("Invalid request: %s", exc)
178
+ return 1
179
+
180
+ try:
181
+ with QPKit(logger=logger) as kit:
182
+ plan = kit.build_plan(request, output_dir, force=args.force)
183
+ if args.dry_run:
184
+ result = DownloadResult(plan=plan, skipped=plan.skipped)
185
+ else:
186
+ result = kit.downloader.download(plan)
187
+
188
+ summary = _summary(
189
+ result,
190
+ dry_run=args.dry_run,
191
+ subcommand=args.subcommand,
192
+ output_dir=output_dir,
193
+ )
194
+
195
+ if args.json_output:
196
+ print(json.dumps(summary, sort_keys=True))
197
+ else:
198
+ logger.info(
199
+ "Summary: planned=%s downloaded=%s skipped=%s failed=%s",
200
+ summary["planned"],
201
+ summary["downloaded"],
202
+ summary["skipped"],
203
+ summary["failed"],
204
+ )
205
+ if args.dry_run:
206
+ logger.info("Dry run: no files were downloaded.")
207
+
208
+ return 1 if summary["failed"] > 0 else 0
209
+ except QPKitError as exc:
210
+ if args.json_output:
211
+ print(json.dumps({"error": str(exc)}))
212
+ else:
213
+ logger.error(str(exc))
214
+ return 1
215
+
216
+
217
+ if __name__ == "__main__":
218
+ raise SystemExit(main())
qpkit/downloader.py ADDED
@@ -0,0 +1,89 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import time
5
+ from concurrent.futures import ThreadPoolExecutor, as_completed
6
+ from pathlib import Path
7
+
8
+ import httpx
9
+
10
+ from .http import HttpClientProtocol
11
+ from .models import DownloadItem, DownloadPlan, DownloadResult
12
+
13
+
14
+ class Downloader:
15
+ def __init__(
16
+ self,
17
+ http: HttpClientProtocol,
18
+ logger: logging.Logger,
19
+ *,
20
+ max_workers: int = 4,
21
+ max_retries: int = 3,
22
+ backoff_seconds: float = 1.0,
23
+ ) -> None:
24
+ self.http = http
25
+ self.logger = logger
26
+ self.max_workers = max_workers
27
+ self.max_retries = max_retries
28
+ self.backoff_seconds = backoff_seconds
29
+
30
+ def _download_one(self, item: DownloadItem, output_path: Path) -> Path | None:
31
+ temp_path = output_path.with_name(f"{output_path.name}.part")
32
+ temp_path.unlink(missing_ok=True)
33
+
34
+ for attempt in range(self.max_retries + 1):
35
+ try:
36
+ self.http.download(item.url, temp_path)
37
+ temp_path.replace(output_path)
38
+ self.logger.info("Download saved to: %s", output_path)
39
+ return output_path
40
+ except (httpx.HTTPError, OSError) as exc:
41
+ temp_path.unlink(missing_ok=True)
42
+ if attempt < self.max_retries:
43
+ delay = self.backoff_seconds * (2**attempt)
44
+ self.logger.warning(
45
+ "Download retry %s/%s for %s in %.1fs: %s",
46
+ attempt + 1,
47
+ self.max_retries,
48
+ output_path.name,
49
+ delay,
50
+ exc,
51
+ )
52
+ time.sleep(delay)
53
+ continue
54
+
55
+ self.logger.warning(
56
+ "Failed download %s after retries: %s", output_path.name, exc
57
+ )
58
+ return None
59
+
60
+ return None
61
+
62
+ def download(self, plan: DownloadPlan) -> DownloadResult:
63
+ if not plan.items:
64
+ return DownloadResult(plan=plan, skipped=plan.skipped)
65
+
66
+ output_dir = plan.output_dir
67
+ workers = min(self.max_workers, len(plan.items))
68
+ succeeded: list[Path] = []
69
+ failed: list[str] = []
70
+
71
+ with ThreadPoolExecutor(max_workers=workers) as pool:
72
+ future_to_item = {
73
+ pool.submit(self._download_one, item, output_dir / item.filename): item
74
+ for item in plan.items
75
+ }
76
+ for future in as_completed(future_to_item):
77
+ item = future_to_item[future]
78
+ result = future.result()
79
+ if result is None:
80
+ failed.append(item.url)
81
+ else:
82
+ succeeded.append(result)
83
+
84
+ return DownloadResult(
85
+ succeeded=tuple(succeeded),
86
+ failed=tuple(failed),
87
+ skipped=plan.skipped,
88
+ plan=plan,
89
+ )