mapkgsutils 0.0.2__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.
- mapkgsutils/__init__.py +1 -0
- mapkgsutils/config/__init__.py +1 -0
- mapkgsutils/config/schema.py +170 -0
- mapkgsutils/context.py +358 -0
- mapkgsutils/diff.py +338 -0
- mapkgsutils/download.py +661 -0
- mapkgsutils/exports.py +180 -0
- mapkgsutils/logging.py +65 -0
- mapkgsutils/parsers/__init__.py +1 -0
- mapkgsutils/parsers/base.py +1899 -0
- mapkgsutils/py.typed +1 -0
- mapkgsutils/version.py +39 -0
- mapkgsutils-0.0.2.dist-info/METADATA +385 -0
- mapkgsutils-0.0.2.dist-info/RECORD +16 -0
- mapkgsutils-0.0.2.dist-info/WHEEL +4 -0
- mapkgsutils-0.0.2.dist-info/licenses/LICENSE +21 -0
mapkgsutils/download.py
ADDED
|
@@ -0,0 +1,661 @@
|
|
|
1
|
+
"""Generic file-download primitives and release/version dispatch."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import gzip
|
|
6
|
+
from collections.abc import Callable, Generator, Iterable, Iterator, Mapping
|
|
7
|
+
from contextlib import contextmanager
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Protocol
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
from tqdm import tqdm
|
|
15
|
+
|
|
16
|
+
from mapkgsutils.logging import logger
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"CloudflareBlockedError",
|
|
20
|
+
"ReleaseInfo",
|
|
21
|
+
"check_release",
|
|
22
|
+
"download_datasource",
|
|
23
|
+
"download_datasource_with_release",
|
|
24
|
+
"download_file",
|
|
25
|
+
"download_urls",
|
|
26
|
+
"get_download_urls",
|
|
27
|
+
"get_file_last_modified",
|
|
28
|
+
"get_latest_release_info",
|
|
29
|
+
"iter_with_progress",
|
|
30
|
+
"list_versions",
|
|
31
|
+
"resolve_datasource_urls",
|
|
32
|
+
"resolve_release_date",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
_CLOUDFLARE_HINTS = (
|
|
36
|
+
"cf-ray",
|
|
37
|
+
"cloudflare",
|
|
38
|
+
"cf-mitigated",
|
|
39
|
+
"__cf_bm",
|
|
40
|
+
"cf-request-id",
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class CloudflareBlockedError(Exception):
|
|
45
|
+
"""Raised when a download is blocked by Cloudflare bot protection.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
url: The URL that was blocked.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(self, url: str) -> None:
|
|
52
|
+
"""Store *url* and build a message explaining the manual-download workaround."""
|
|
53
|
+
self.url = url
|
|
54
|
+
super().__init__(
|
|
55
|
+
f"Download of '{url}' was blocked by Cloudflare bot protection.\n"
|
|
56
|
+
"Please download the file manually in a web browser and pass the "
|
|
57
|
+
"local path as an argument instead.\n"
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _is_cloudflare_blocked(response: httpx.Response) -> bool:
|
|
62
|
+
"""Return True if *response* looks like a Cloudflare block page."""
|
|
63
|
+
if response.status_code in (403, 503):
|
|
64
|
+
headers_lower = {k.lower() for k in response.headers}
|
|
65
|
+
if any(hint in headers_lower for hint in _CLOUDFLARE_HINTS):
|
|
66
|
+
return True
|
|
67
|
+
# Cloudflare sometimes returns 403 with an HTML page even without the
|
|
68
|
+
# header being present, check the body.
|
|
69
|
+
content_type = response.headers.get("content-type", "")
|
|
70
|
+
if "text/html" in content_type:
|
|
71
|
+
text = response.text
|
|
72
|
+
if "cloudflare" in text.lower() or "cf-ray" in text.lower():
|
|
73
|
+
return True
|
|
74
|
+
return False
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass
|
|
78
|
+
class ReleaseInfo:
|
|
79
|
+
"""Information about a datasource release."""
|
|
80
|
+
|
|
81
|
+
datasource: str
|
|
82
|
+
version: str | None
|
|
83
|
+
release_date: datetime | None
|
|
84
|
+
is_new: bool
|
|
85
|
+
files: dict[str, str] # key -> URL mapping
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def download_file(
|
|
89
|
+
url: str,
|
|
90
|
+
output_path: Path,
|
|
91
|
+
decompress_gz: bool = True,
|
|
92
|
+
timeout: float | None = None,
|
|
93
|
+
show_progress: bool = True,
|
|
94
|
+
description: str | None = None,
|
|
95
|
+
) -> Path:
|
|
96
|
+
"""Download a file from URL to the specified path.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
url: URL to download from.
|
|
100
|
+
output_path: Where to save the file.
|
|
101
|
+
decompress_gz: Whether to decompress .gz files automatically.
|
|
102
|
+
timeout: Request timeout in seconds.
|
|
103
|
+
show_progress: Whether to show a progress bar.
|
|
104
|
+
description: Description for the progress bar.
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
Path to the downloaded (and optionally decompressed) file.
|
|
108
|
+
"""
|
|
109
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
110
|
+
|
|
111
|
+
# Get filename for progress bar description
|
|
112
|
+
if description is None:
|
|
113
|
+
description = output_path.name
|
|
114
|
+
with httpx.Client(timeout=timeout, follow_redirects=True) as client:
|
|
115
|
+
with client.stream("GET", url) as response:
|
|
116
|
+
if _is_cloudflare_blocked(response):
|
|
117
|
+
raise CloudflareBlockedError(url)
|
|
118
|
+
response.raise_for_status()
|
|
119
|
+
|
|
120
|
+
# Get total size if available
|
|
121
|
+
total_size = int(response.headers.get("content-length", 0))
|
|
122
|
+
|
|
123
|
+
# Determine if we need to decompress
|
|
124
|
+
is_gzip = url.endswith(".gz") and decompress_gz
|
|
125
|
+
final_path = output_path
|
|
126
|
+
|
|
127
|
+
if is_gzip:
|
|
128
|
+
_download_gzip(
|
|
129
|
+
output_path,
|
|
130
|
+
show_progress,
|
|
131
|
+
response,
|
|
132
|
+
total_size,
|
|
133
|
+
final_path,
|
|
134
|
+
description,
|
|
135
|
+
)
|
|
136
|
+
else:
|
|
137
|
+
_download_nogzip(
|
|
138
|
+
output_path,
|
|
139
|
+
total_size,
|
|
140
|
+
response,
|
|
141
|
+
show_progress,
|
|
142
|
+
description,
|
|
143
|
+
)
|
|
144
|
+
return final_path
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def get_file_last_modified(url: str, timeout: float = 30.0) -> datetime | None:
|
|
148
|
+
"""Get the Last-Modified date from a URL via HEAD request.
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
url: URL to check.
|
|
152
|
+
timeout: Request timeout in seconds.
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
The Last-Modified datetime or None if unavailable.
|
|
156
|
+
"""
|
|
157
|
+
try:
|
|
158
|
+
with httpx.Client(timeout=timeout, follow_redirects=True) as client:
|
|
159
|
+
response = client.head(url)
|
|
160
|
+
if _is_cloudflare_blocked(response):
|
|
161
|
+
raise CloudflareBlockedError(url)
|
|
162
|
+
if "last-modified" in response.headers:
|
|
163
|
+
from email.utils import parsedate_to_datetime
|
|
164
|
+
|
|
165
|
+
return parsedate_to_datetime(response.headers["last-modified"])
|
|
166
|
+
except (httpx.HTTPError, ValueError):
|
|
167
|
+
pass
|
|
168
|
+
return None
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@contextmanager
|
|
172
|
+
def iter_with_progress(
|
|
173
|
+
iterator: Iterable[bytes],
|
|
174
|
+
*,
|
|
175
|
+
enabled: bool,
|
|
176
|
+
total: int | None,
|
|
177
|
+
description: str,
|
|
178
|
+
) -> Iterator[Iterable[bytes]]:
|
|
179
|
+
"""Wrap an iterator with optional progress bar.
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
iterator: The byte iterator to wrap.
|
|
183
|
+
enabled: Whether to show progress.
|
|
184
|
+
total: Total size in bytes.
|
|
185
|
+
description: Description for progress bar.
|
|
186
|
+
|
|
187
|
+
Yields:
|
|
188
|
+
The wrapped iterator.
|
|
189
|
+
"""
|
|
190
|
+
if enabled and total and total > 0:
|
|
191
|
+
with tqdm(
|
|
192
|
+
total=total,
|
|
193
|
+
unit="B",
|
|
194
|
+
unit_scale=True,
|
|
195
|
+
desc=description,
|
|
196
|
+
) as pbar:
|
|
197
|
+
|
|
198
|
+
def gen() -> Generator[bytes, None, None]:
|
|
199
|
+
"""Yield chunks."""
|
|
200
|
+
for chunk in iterator:
|
|
201
|
+
pbar.update(len(chunk))
|
|
202
|
+
yield chunk
|
|
203
|
+
|
|
204
|
+
yield gen()
|
|
205
|
+
else:
|
|
206
|
+
yield iterator
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _download_gzip(
|
|
210
|
+
output_path: Path,
|
|
211
|
+
show_progress: bool,
|
|
212
|
+
response: httpx.Response,
|
|
213
|
+
total_size: int,
|
|
214
|
+
final_path: Path,
|
|
215
|
+
description: str | None = None,
|
|
216
|
+
) -> None:
|
|
217
|
+
"""Help download gzipped files.
|
|
218
|
+
|
|
219
|
+
Args:
|
|
220
|
+
output_path: Path for the output file.
|
|
221
|
+
show_progress: Whether to show progress bar.
|
|
222
|
+
response: HTTP response object.
|
|
223
|
+
total_size: Total size in bytes.
|
|
224
|
+
final_path: Final destination path.
|
|
225
|
+
description: Description for progress bar.
|
|
226
|
+
"""
|
|
227
|
+
temp_path = output_path.with_suffix(output_path.suffix + ".gz")
|
|
228
|
+
|
|
229
|
+
with temp_path.open("wb") as f:
|
|
230
|
+
with iter_with_progress(
|
|
231
|
+
response.iter_bytes(chunk_size=8192),
|
|
232
|
+
enabled=show_progress,
|
|
233
|
+
total=total_size,
|
|
234
|
+
description=f"Downloading {description}",
|
|
235
|
+
) as chunks:
|
|
236
|
+
for chunk in chunks:
|
|
237
|
+
f.write(chunk)
|
|
238
|
+
|
|
239
|
+
if show_progress:
|
|
240
|
+
compressed_size = temp_path.stat().st_size
|
|
241
|
+
else:
|
|
242
|
+
compressed_size = None
|
|
243
|
+
|
|
244
|
+
with gzip.open(temp_path, "rb") as f_in, final_path.open("wb") as f_out:
|
|
245
|
+
with iter_with_progress(
|
|
246
|
+
iter(lambda: f_in.read(8192), b""),
|
|
247
|
+
enabled=show_progress,
|
|
248
|
+
total=compressed_size,
|
|
249
|
+
description=f"Decompressing {description}",
|
|
250
|
+
) as chunks:
|
|
251
|
+
for chunk in chunks:
|
|
252
|
+
f_out.write(chunk)
|
|
253
|
+
|
|
254
|
+
temp_path.unlink()
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _download_nogzip(
|
|
258
|
+
output_path: Path,
|
|
259
|
+
total_size: int,
|
|
260
|
+
response: httpx.Response,
|
|
261
|
+
show_progress: bool,
|
|
262
|
+
description: str | None,
|
|
263
|
+
) -> None:
|
|
264
|
+
"""Help download non-gzipped files.
|
|
265
|
+
|
|
266
|
+
Args:
|
|
267
|
+
output_path: Path for the output file.
|
|
268
|
+
total_size: Total size in bytes.
|
|
269
|
+
response: HTTP response object.
|
|
270
|
+
show_progress: Whether to show progress bar.
|
|
271
|
+
description: Description for progress bar.
|
|
272
|
+
"""
|
|
273
|
+
with output_path.open("wb") as f:
|
|
274
|
+
with iter_with_progress(
|
|
275
|
+
response.iter_bytes(chunk_size=8192),
|
|
276
|
+
enabled=show_progress,
|
|
277
|
+
total=total_size,
|
|
278
|
+
description=f"Downloading {description}",
|
|
279
|
+
) as chunks:
|
|
280
|
+
for chunk in chunks:
|
|
281
|
+
f.write(chunk)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
class _HasDownloadUrls(Protocol):
|
|
285
|
+
"""Structural type for a datasource config: just enough for URL/date resolution."""
|
|
286
|
+
|
|
287
|
+
name: str
|
|
288
|
+
download_urls: dict[str, Any]
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
class _HasListVersions(Protocol):
|
|
292
|
+
"""Structural type for a downloader class: just enough to list archive versions."""
|
|
293
|
+
|
|
294
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
295
|
+
"""Construct a downloader instance."""
|
|
296
|
+
...
|
|
297
|
+
|
|
298
|
+
def list_versions(self) -> list[str]:
|
|
299
|
+
"""List all available archive versions for this datasource."""
|
|
300
|
+
...
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
#: Per-datasource hook resolving download URLs and the upstream release date
|
|
304
|
+
#: for a given version (``None`` meaning "latest"). Every entry shares this
|
|
305
|
+
#: signature regardless of what each datasource actually needs from
|
|
306
|
+
#: ``**kwargs`` (e.g. ``subset`` for ChEBI, ``species`` for Ensembl).
|
|
307
|
+
UrlsAndDate = Callable[..., tuple[dict[str, str], datetime | None]]
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _default_urls_and_date(
|
|
311
|
+
version: str | None, config: _HasDownloadUrls, **kwargs: Any
|
|
312
|
+
) -> tuple[dict[str, str], datetime | None]:
|
|
313
|
+
"""Fall back for datasources with no registered resolver: latest config URLs, no date."""
|
|
314
|
+
if version:
|
|
315
|
+
logger.warning(
|
|
316
|
+
"%s does not have versioned archives. Downloading latest version instead.",
|
|
317
|
+
config.name,
|
|
318
|
+
)
|
|
319
|
+
return dict(config.download_urls), None
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def resolve_datasource_urls(
|
|
323
|
+
datasource_lower: str,
|
|
324
|
+
config: _HasDownloadUrls,
|
|
325
|
+
version: str | None = None,
|
|
326
|
+
*,
|
|
327
|
+
urls_and_date: Mapping[str, UrlsAndDate],
|
|
328
|
+
**kwargs: Any,
|
|
329
|
+
) -> tuple[dict[str, str], datetime | None]:
|
|
330
|
+
"""Resolve download URLs and the source release date for a datasource.
|
|
331
|
+
|
|
332
|
+
The release date drives the SSSOM ``mapping_date`` of generated mapping
|
|
333
|
+
sets, so it should reflect when the upstream data was released rather
|
|
334
|
+
than when it was downloaded. *urls_and_date* supplies each datasource's
|
|
335
|
+
own most-specific resolution logic; when *datasource_lower* has no entry,
|
|
336
|
+
:func:`_default_urls_and_date` is used. Either way, a generic HTTP
|
|
337
|
+
``Last-Modified`` lookup on the first URL is the final fallback when no
|
|
338
|
+
release date could be determined.
|
|
339
|
+
|
|
340
|
+
Args:
|
|
341
|
+
datasource_lower: Lowercase datasource name.
|
|
342
|
+
config: Datasource configuration.
|
|
343
|
+
version: Specific version to get URLs for.
|
|
344
|
+
urls_and_date: ``{datasource_name: resolver}`` registry.
|
|
345
|
+
**kwargs: Forwarded to the resolved per-datasource hook.
|
|
346
|
+
|
|
347
|
+
Returns:
|
|
348
|
+
Tuple of (file-key -> URL mapping, release date or None).
|
|
349
|
+
"""
|
|
350
|
+
resolver: UrlsAndDate = urls_and_date.get(datasource_lower, _default_urls_and_date)
|
|
351
|
+
urls, release_date = resolver(version, config, **kwargs)
|
|
352
|
+
|
|
353
|
+
# Generic fallback: derive the release date from the file's Last-Modified
|
|
354
|
+
# header when a more specific signal was not available above. Must never
|
|
355
|
+
# raise: some sources (e.g. HMDB) sit behind Cloudflare and block even
|
|
356
|
+
# this lightweight HEAD request, and a date-resolution failure must not
|
|
357
|
+
# break the actual file download.
|
|
358
|
+
if release_date is None and urls:
|
|
359
|
+
first_url = next(iter(urls.values()))
|
|
360
|
+
try:
|
|
361
|
+
release_date = get_file_last_modified(first_url)
|
|
362
|
+
except CloudflareBlockedError:
|
|
363
|
+
logger.debug("Could not resolve release date for %s: Cloudflare-blocked.", first_url)
|
|
364
|
+
|
|
365
|
+
return urls, release_date
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def get_download_urls(
|
|
369
|
+
datasource: str,
|
|
370
|
+
version: str | None = None,
|
|
371
|
+
*,
|
|
372
|
+
all_datasources: Mapping[str, _HasDownloadUrls],
|
|
373
|
+
urls_and_date: Mapping[str, UrlsAndDate],
|
|
374
|
+
**kwargs: Any,
|
|
375
|
+
) -> dict[str, str]:
|
|
376
|
+
"""Get download URLs for a datasource.
|
|
377
|
+
|
|
378
|
+
Args:
|
|
379
|
+
datasource: Name of the datasource.
|
|
380
|
+
version: Specific version to get URLs for.
|
|
381
|
+
all_datasources: ``{datasource_name: config}`` registry.
|
|
382
|
+
urls_and_date: ``{datasource_name: resolver}`` registry, see
|
|
383
|
+
:func:`resolve_datasource_urls`.
|
|
384
|
+
**kwargs: Datasource-specific knobs forwarded to the resolved hook.
|
|
385
|
+
|
|
386
|
+
Returns:
|
|
387
|
+
Dictionary mapping file keys to URLs.
|
|
388
|
+
"""
|
|
389
|
+
datasource_lower = datasource.lower()
|
|
390
|
+
config = all_datasources.get(datasource_lower)
|
|
391
|
+
if not config:
|
|
392
|
+
raise ValueError(f"Unknown datasource: {datasource}")
|
|
393
|
+
urls, _ = resolve_datasource_urls(
|
|
394
|
+
datasource_lower, config, version, urls_and_date=urls_and_date, **kwargs
|
|
395
|
+
)
|
|
396
|
+
return urls
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def resolve_release_date(
|
|
400
|
+
datasource: str,
|
|
401
|
+
version: str | None = None,
|
|
402
|
+
*,
|
|
403
|
+
all_datasources: Mapping[str, _HasDownloadUrls],
|
|
404
|
+
urls_and_date: Mapping[str, UrlsAndDate],
|
|
405
|
+
**kwargs: Any,
|
|
406
|
+
) -> datetime | None:
|
|
407
|
+
"""Resolve the upstream release date for a datasource/version.
|
|
408
|
+
|
|
409
|
+
This is the date used for the SSSOM ``mapping_date`` of generated mapping
|
|
410
|
+
sets. It does not download the data files (it may issue a lightweight
|
|
411
|
+
``HEAD`` request to read a ``Last-Modified`` header). Prefer
|
|
412
|
+
:func:`download_datasource_with_release` when you are downloading
|
|
413
|
+
anyway, to avoid an extra round-trip.
|
|
414
|
+
|
|
415
|
+
Args:
|
|
416
|
+
datasource: Name of the datasource.
|
|
417
|
+
version: Specific version, when applicable.
|
|
418
|
+
all_datasources: ``{datasource_name: config}`` registry.
|
|
419
|
+
urls_and_date: ``{datasource_name: resolver}`` registry, see
|
|
420
|
+
:func:`resolve_datasource_urls`.
|
|
421
|
+
**kwargs: Datasource-specific knobs; see :func:`get_download_urls`.
|
|
422
|
+
|
|
423
|
+
Returns:
|
|
424
|
+
The release date, or None when it cannot be determined.
|
|
425
|
+
"""
|
|
426
|
+
datasource_lower = datasource.lower()
|
|
427
|
+
config = all_datasources.get(datasource_lower)
|
|
428
|
+
if not config:
|
|
429
|
+
raise ValueError(f"Unknown datasource: {datasource}")
|
|
430
|
+
_, release_date = resolve_datasource_urls(
|
|
431
|
+
datasource_lower, config, version, urls_and_date=urls_and_date, **kwargs
|
|
432
|
+
)
|
|
433
|
+
return release_date
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def get_latest_release_info(
|
|
437
|
+
datasource: str, *, checkers: Mapping[str, Callable[[], ReleaseInfo]]
|
|
438
|
+
) -> ReleaseInfo:
|
|
439
|
+
"""Get release information for a datasource.
|
|
440
|
+
|
|
441
|
+
Args:
|
|
442
|
+
datasource: Name of the datasource.
|
|
443
|
+
checkers: ``{datasource_name: check_release_fn}`` registry.
|
|
444
|
+
|
|
445
|
+
Returns:
|
|
446
|
+
ReleaseInfo with the latest release details.
|
|
447
|
+
|
|
448
|
+
Raises:
|
|
449
|
+
ValueError: If the datasource is not supported.
|
|
450
|
+
"""
|
|
451
|
+
checker = checkers.get(datasource.lower())
|
|
452
|
+
if checker is None:
|
|
453
|
+
raise ValueError(f"Unknown datasource: {datasource}. Supported: {sorted(checkers)}")
|
|
454
|
+
return checker()
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def check_release(
|
|
458
|
+
datasource: str,
|
|
459
|
+
current_version: str | None = None,
|
|
460
|
+
current_date: datetime | None = None,
|
|
461
|
+
*,
|
|
462
|
+
checkers: Mapping[str, Callable[[], ReleaseInfo]],
|
|
463
|
+
) -> ReleaseInfo:
|
|
464
|
+
"""Check if a new release is available for a datasource.
|
|
465
|
+
|
|
466
|
+
Args:
|
|
467
|
+
datasource: Name of the datasource.
|
|
468
|
+
current_version: Current version string to compare against.
|
|
469
|
+
current_date: Current release date to compare against.
|
|
470
|
+
checkers: ``{datasource_name: check_release_fn}`` registry.
|
|
471
|
+
|
|
472
|
+
Returns:
|
|
473
|
+
ReleaseInfo with is_new indicating if update is available.
|
|
474
|
+
"""
|
|
475
|
+
info = get_latest_release_info(datasource, checkers=checkers)
|
|
476
|
+
|
|
477
|
+
if current_version and info.version:
|
|
478
|
+
info = ReleaseInfo(
|
|
479
|
+
datasource=info.datasource,
|
|
480
|
+
version=info.version,
|
|
481
|
+
release_date=info.release_date,
|
|
482
|
+
is_new=info.version != current_version,
|
|
483
|
+
files=info.files,
|
|
484
|
+
)
|
|
485
|
+
elif current_date and info.release_date:
|
|
486
|
+
info = ReleaseInfo(
|
|
487
|
+
datasource=info.datasource,
|
|
488
|
+
version=info.version,
|
|
489
|
+
release_date=info.release_date,
|
|
490
|
+
is_new=info.release_date > current_date,
|
|
491
|
+
files=info.files,
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
return info
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def list_versions(
|
|
498
|
+
datasource: str,
|
|
499
|
+
*,
|
|
500
|
+
known_datasources: Iterable[str],
|
|
501
|
+
downloaders: Mapping[str, type[_HasListVersions]],
|
|
502
|
+
) -> list[str]:
|
|
503
|
+
"""List all available archive versions for a datasource.
|
|
504
|
+
|
|
505
|
+
Delegates to the datasource's downloader class ``list_versions()``
|
|
506
|
+
method, which contains all source-specific retrieval logic.
|
|
507
|
+
|
|
508
|
+
Args:
|
|
509
|
+
datasource: Datasource name.
|
|
510
|
+
known_datasources: Every datasource name this package supports
|
|
511
|
+
(used only to distinguish "unknown datasource" from "known
|
|
512
|
+
datasource with no versioned archive").
|
|
513
|
+
downloaders: ``{datasource_name: downloader_class}`` registry;
|
|
514
|
+
datasources with no versioned archive are simply absent.
|
|
515
|
+
|
|
516
|
+
Returns:
|
|
517
|
+
Sorted list of version strings available for download.
|
|
518
|
+
|
|
519
|
+
Raises:
|
|
520
|
+
ValueError: If the datasource is unknown or has no versioned archive.
|
|
521
|
+
"""
|
|
522
|
+
lower = datasource.lower()
|
|
523
|
+
known_lower = {d.lower() for d in known_datasources}
|
|
524
|
+
if lower not in known_lower:
|
|
525
|
+
raise ValueError(f"Unknown datasource: {datasource!r}. Supported: {sorted(known_lower)}")
|
|
526
|
+
|
|
527
|
+
cls = downloaders.get(lower)
|
|
528
|
+
if cls is None:
|
|
529
|
+
raise ValueError(
|
|
530
|
+
f"{datasource.upper()} does not maintain a versioned archive. "
|
|
531
|
+
"Only the latest release is available for download."
|
|
532
|
+
)
|
|
533
|
+
return cls().list_versions()
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def download_datasource_with_release(
|
|
537
|
+
datasource: str,
|
|
538
|
+
output_dir: Path,
|
|
539
|
+
*,
|
|
540
|
+
all_datasources: Mapping[str, _HasDownloadUrls],
|
|
541
|
+
urls_and_date: Mapping[str, UrlsAndDate],
|
|
542
|
+
decompress: bool = True,
|
|
543
|
+
version: str | None = None,
|
|
544
|
+
keys: list[str] | None = None,
|
|
545
|
+
tar_extractors: Mapping[str, Callable[[Path, Path], dict[str, Path]]] | None = None,
|
|
546
|
+
**kwargs: Any,
|
|
547
|
+
) -> tuple[dict[str, Path], datetime | None]:
|
|
548
|
+
"""Download all files for a datasource and report its release date.
|
|
549
|
+
|
|
550
|
+
Args:
|
|
551
|
+
datasource: Name of the datasource.
|
|
552
|
+
output_dir: Directory to save files.
|
|
553
|
+
all_datasources: ``{datasource_name: config}`` registry.
|
|
554
|
+
urls_and_date: ``{datasource_name: resolver}`` registry, see
|
|
555
|
+
:func:`resolve_datasource_urls`.
|
|
556
|
+
decompress: Whether to decompress .gz files.
|
|
557
|
+
version: Specific version to download. Format depends on datasource.
|
|
558
|
+
keys: Optional list of file-key names to download.
|
|
559
|
+
tar_extractors: ``{datasource_name: extractor}`` registry for
|
|
560
|
+
datasources that publish a ``.tar.gz`` archive needing
|
|
561
|
+
member-level extraction rather than a plain download (e.g.
|
|
562
|
+
UniProt). Datasources without one are downloaded as-is.
|
|
563
|
+
**kwargs: Datasource-specific knobs forwarded to the resolved hook.
|
|
564
|
+
|
|
565
|
+
Returns:
|
|
566
|
+
Tuple of (file-key -> downloaded path mapping, release date or None).
|
|
567
|
+
"""
|
|
568
|
+
datasource_lower = datasource.lower()
|
|
569
|
+
config = all_datasources.get(datasource_lower)
|
|
570
|
+
if not config:
|
|
571
|
+
raise ValueError(f"Unknown datasource: {datasource}")
|
|
572
|
+
|
|
573
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
574
|
+
urls, release_date = resolve_datasource_urls(
|
|
575
|
+
datasource_lower, config, version, urls_and_date=urls_and_date, **kwargs
|
|
576
|
+
)
|
|
577
|
+
if keys is not None:
|
|
578
|
+
urls = {k: v for k, v in urls.items() if k in keys}
|
|
579
|
+
|
|
580
|
+
extractor = (tar_extractors or {}).get(datasource_lower)
|
|
581
|
+
return download_urls(urls, output_dir, decompress, tar_extractor=extractor), release_date
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def download_datasource(
|
|
585
|
+
datasource: str,
|
|
586
|
+
output_dir: Path,
|
|
587
|
+
*,
|
|
588
|
+
all_datasources: Mapping[str, _HasDownloadUrls],
|
|
589
|
+
urls_and_date: Mapping[str, UrlsAndDate],
|
|
590
|
+
decompress: bool = True,
|
|
591
|
+
version: str | None = None,
|
|
592
|
+
keys: list[str] | None = None,
|
|
593
|
+
tar_extractors: Mapping[str, Callable[[Path, Path], dict[str, Path]]] | None = None,
|
|
594
|
+
**kwargs: Any,
|
|
595
|
+
) -> dict[str, Path]:
|
|
596
|
+
"""Download all files for a datasource.
|
|
597
|
+
|
|
598
|
+
Same as :func:`download_datasource_with_release`, but discards the
|
|
599
|
+
resolved release date.
|
|
600
|
+
|
|
601
|
+
Returns:
|
|
602
|
+
Dictionary mapping file keys to downloaded paths.
|
|
603
|
+
"""
|
|
604
|
+
files, _ = download_datasource_with_release(
|
|
605
|
+
datasource,
|
|
606
|
+
output_dir,
|
|
607
|
+
all_datasources=all_datasources,
|
|
608
|
+
urls_and_date=urls_and_date,
|
|
609
|
+
decompress=decompress,
|
|
610
|
+
version=version,
|
|
611
|
+
keys=keys,
|
|
612
|
+
tar_extractors=tar_extractors,
|
|
613
|
+
**kwargs,
|
|
614
|
+
)
|
|
615
|
+
return files
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def download_urls(
|
|
619
|
+
urls: dict[str, str],
|
|
620
|
+
output_dir: Path,
|
|
621
|
+
decompress: bool = True,
|
|
622
|
+
*,
|
|
623
|
+
tar_extractor: Callable[[Path, Path], dict[str, Path]] | None = None,
|
|
624
|
+
) -> dict[str, Path]:
|
|
625
|
+
"""Download files from URLs to output directory.
|
|
626
|
+
|
|
627
|
+
Args:
|
|
628
|
+
urls: Dictionary mapping file keys to URLs.
|
|
629
|
+
output_dir: Directory to save files.
|
|
630
|
+
decompress: Whether to decompress .gz files.
|
|
631
|
+
tar_extractor: When given, any ``.tar.gz`` URL is downloaded then
|
|
632
|
+
passed to this callable for member-level extraction instead of
|
|
633
|
+
being treated as a single downloaded file.
|
|
634
|
+
|
|
635
|
+
Returns:
|
|
636
|
+
Dictionary mapping file keys to downloaded paths.
|
|
637
|
+
"""
|
|
638
|
+
downloaded: dict[str, Path] = {}
|
|
639
|
+
|
|
640
|
+
for key, url in urls.items():
|
|
641
|
+
filename = url.split("/")[-1]
|
|
642
|
+
|
|
643
|
+
if tar_extractor is not None and filename.endswith(".tar.gz"):
|
|
644
|
+
output_path = output_dir / filename
|
|
645
|
+
logger.info("Downloading %s: %s", key, url)
|
|
646
|
+
download_file(url, output_path, decompress_gz=False)
|
|
647
|
+
extracted = tar_extractor(output_path, output_dir)
|
|
648
|
+
downloaded.update(extracted)
|
|
649
|
+
logger.info("Extracted: %s", list(extracted.keys()))
|
|
650
|
+
continue
|
|
651
|
+
|
|
652
|
+
if decompress and filename.endswith(".gz"):
|
|
653
|
+
filename = filename[:-3]
|
|
654
|
+
|
|
655
|
+
output_path = output_dir / filename
|
|
656
|
+
logger.info("Downloading %s: %s", key, url)
|
|
657
|
+
download_file(url, output_path, decompress_gz=decompress)
|
|
658
|
+
downloaded[key] = output_path
|
|
659
|
+
logger.info("Saved to: %s", output_path)
|
|
660
|
+
|
|
661
|
+
return downloaded
|