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.
@@ -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