pmcfetch 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.
- pmcfetch/__init__.py +102 -0
- pmcfetch/batch.py +278 -0
- pmcfetch/cli.py +228 -0
- pmcfetch/errors.py +61 -0
- pmcfetch/ids.py +41 -0
- pmcfetch/manifest.py +160 -0
- pmcfetch/pdf.py +44 -0
- pmcfetch/py.typed +0 -0
- pmcfetch/ratelimit.py +53 -0
- pmcfetch/s3source.py +251 -0
- pmcfetch-0.1.0.dist-info/METADATA +186 -0
- pmcfetch-0.1.0.dist-info/RECORD +16 -0
- pmcfetch-0.1.0.dist-info/WHEEL +5 -0
- pmcfetch-0.1.0.dist-info/entry_points.txt +2 -0
- pmcfetch-0.1.0.dist-info/licenses/LICENSE +21 -0
- pmcfetch-0.1.0.dist-info/top_level.txt +1 -0
pmcfetch/__init__.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Targeted PDF retrieval from the PMC Open Access Subset.
|
|
2
|
+
|
|
3
|
+
NLM publishes every PMC Open Access article version in the public, anonymous
|
|
4
|
+
bucket ``s3://pmc-oa-opendata``. No AWS account, no credentials, no rate limit,
|
|
5
|
+
and it is the access route PMC sanctions for systematic retrieval.
|
|
6
|
+
|
|
7
|
+
``aws s3 sync`` is all-or-nothing — there is no way to pull just the articles you
|
|
8
|
+
care about. This package fills that gap: give it a list of PMC ids and it
|
|
9
|
+
resolves each article's version, picks the article PDF (not the publisher's
|
|
10
|
+
supplementary PDFs sitting in the same prefix), and writes the results with
|
|
11
|
+
resume, retries, atomic writes, and per-namespace layout.
|
|
12
|
+
|
|
13
|
+
Zero third-party dependencies.
|
|
14
|
+
|
|
15
|
+
from pmcfetch import S3OpenAccessClient, load_records, download_many
|
|
16
|
+
|
|
17
|
+
records = load_records("corpus.json").for_namespace("cardiology")
|
|
18
|
+
with S3OpenAccessClient() as client:
|
|
19
|
+
report = download_many(records, "pdfs/", client=client, workers=16)
|
|
20
|
+
print(report.as_dict())
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
__version__ = "0.1.0"
|
|
26
|
+
|
|
27
|
+
from pmcfetch.batch import (
|
|
28
|
+
BatchReport,
|
|
29
|
+
DownloadResult,
|
|
30
|
+
FailureLog,
|
|
31
|
+
Outcome,
|
|
32
|
+
download_many,
|
|
33
|
+
download_one,
|
|
34
|
+
target_path,
|
|
35
|
+
write_pdf_atomic,
|
|
36
|
+
)
|
|
37
|
+
from pmcfetch.errors import (
|
|
38
|
+
DownloadError,
|
|
39
|
+
HTTPStatusError,
|
|
40
|
+
InvalidPMCIDError,
|
|
41
|
+
NotAPDFError,
|
|
42
|
+
PMCFetchError,
|
|
43
|
+
TransportError,
|
|
44
|
+
)
|
|
45
|
+
from pmcfetch.ids import normalize_pmcid, try_normalize_pmcid
|
|
46
|
+
from pmcfetch.manifest import CorpusRecord, LoadResult, iter_records, load_records
|
|
47
|
+
from pmcfetch.pdf import DEFAULT_MIN_PDF_BYTES, DEFAULT_TIMEOUT, PdfSource, is_pdf
|
|
48
|
+
from pmcfetch.ratelimit import MinIntervalLimiter, NullLimiter
|
|
49
|
+
from pmcfetch.s3source import (
|
|
50
|
+
OA_BASE_URL,
|
|
51
|
+
OA_BUCKET,
|
|
52
|
+
ArticleNotInOASubsetError,
|
|
53
|
+
ArticleVersion,
|
|
54
|
+
PDFNotAvailableError,
|
|
55
|
+
S3OpenAccessClient,
|
|
56
|
+
list_article_versions,
|
|
57
|
+
resolve_latest_version,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
__all__ = [
|
|
61
|
+
"__version__",
|
|
62
|
+
# source
|
|
63
|
+
"S3OpenAccessClient",
|
|
64
|
+
"OA_BUCKET",
|
|
65
|
+
"OA_BASE_URL",
|
|
66
|
+
"ArticleVersion",
|
|
67
|
+
"list_article_versions",
|
|
68
|
+
"resolve_latest_version",
|
|
69
|
+
# batch
|
|
70
|
+
"download_one",
|
|
71
|
+
"download_many",
|
|
72
|
+
"BatchReport",
|
|
73
|
+
"DownloadResult",
|
|
74
|
+
"Outcome",
|
|
75
|
+
"FailureLog",
|
|
76
|
+
"target_path",
|
|
77
|
+
"write_pdf_atomic",
|
|
78
|
+
# manifest
|
|
79
|
+
"load_records",
|
|
80
|
+
"iter_records",
|
|
81
|
+
"CorpusRecord",
|
|
82
|
+
"LoadResult",
|
|
83
|
+
# ids
|
|
84
|
+
"normalize_pmcid",
|
|
85
|
+
"try_normalize_pmcid",
|
|
86
|
+
# pdf / pacing
|
|
87
|
+
"PdfSource",
|
|
88
|
+
"is_pdf",
|
|
89
|
+
"DEFAULT_MIN_PDF_BYTES",
|
|
90
|
+
"DEFAULT_TIMEOUT",
|
|
91
|
+
"MinIntervalLimiter",
|
|
92
|
+
"NullLimiter",
|
|
93
|
+
# errors
|
|
94
|
+
"PMCFetchError",
|
|
95
|
+
"DownloadError",
|
|
96
|
+
"HTTPStatusError",
|
|
97
|
+
"NotAPDFError",
|
|
98
|
+
"PDFNotAvailableError",
|
|
99
|
+
"ArticleNotInOASubsetError",
|
|
100
|
+
"InvalidPMCIDError",
|
|
101
|
+
"TransportError",
|
|
102
|
+
]
|
pmcfetch/batch.py
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"""Concurrent, resumable batch downloads."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import threading
|
|
7
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from enum import Enum
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Callable, Iterable, Sequence
|
|
12
|
+
|
|
13
|
+
from pmcfetch.pdf import DEFAULT_MIN_PDF_BYTES, PdfSource, is_pdf
|
|
14
|
+
from pmcfetch.errors import NotAPDFError
|
|
15
|
+
from pmcfetch.manifest import CorpusRecord
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Outcome(str, Enum):
|
|
19
|
+
DOWNLOADED = "downloaded"
|
|
20
|
+
SKIPPED = "skipped"
|
|
21
|
+
FAILED = "failed"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class DownloadResult:
|
|
26
|
+
pmcid: str
|
|
27
|
+
outcome: Outcome
|
|
28
|
+
path: Path | None = None
|
|
29
|
+
bytes_written: int = 0
|
|
30
|
+
reason: str = ""
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def ok(self) -> bool:
|
|
34
|
+
return self.outcome in (Outcome.DOWNLOADED, Outcome.SKIPPED)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class BatchReport:
|
|
39
|
+
results: list[DownloadResult] = field(default_factory=list)
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def downloaded(self) -> int:
|
|
43
|
+
return sum(1 for r in self.results if r.outcome is Outcome.DOWNLOADED)
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def skipped(self) -> int:
|
|
47
|
+
return sum(1 for r in self.results if r.outcome is Outcome.SKIPPED)
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def failed(self) -> int:
|
|
51
|
+
return sum(1 for r in self.results if r.outcome is Outcome.FAILED)
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def total(self) -> int:
|
|
55
|
+
return len(self.results)
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def failures(self) -> list[DownloadResult]:
|
|
59
|
+
return [r for r in self.results if r.outcome is Outcome.FAILED]
|
|
60
|
+
|
|
61
|
+
def as_dict(self) -> dict[str, int]:
|
|
62
|
+
return {
|
|
63
|
+
"total": self.total,
|
|
64
|
+
"downloaded": self.downloaded,
|
|
65
|
+
"skipped": self.skipped,
|
|
66
|
+
"failed": self.failed,
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class FailureLog:
|
|
71
|
+
"""Thread-safe append-only failure log.
|
|
72
|
+
|
|
73
|
+
Each line is ``<pmcid> | Reason: <reason>``, which ``load_records`` reads
|
|
74
|
+
back as an id list, so a log doubles as the input for a retry run.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
def __init__(self, path: str | Path | None, *, truncate: bool = False) -> None:
|
|
78
|
+
self.path = Path(path).expanduser() if path else None
|
|
79
|
+
self._lock = threading.Lock()
|
|
80
|
+
if self.path and truncate:
|
|
81
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
82
|
+
self.path.write_text("--- Failed Downloads Log ---\n", encoding="utf-8")
|
|
83
|
+
|
|
84
|
+
def record(self, pmcid: str, reason: str) -> None:
|
|
85
|
+
if not self.path:
|
|
86
|
+
return
|
|
87
|
+
with self._lock:
|
|
88
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
89
|
+
with self.path.open("a", encoding="utf-8") as fh:
|
|
90
|
+
fh.write(f"{pmcid} | Reason: {reason}\n")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _namespace_dir(namespace: str) -> str:
|
|
94
|
+
"""*namespace* as a single path component, so corpus data cannot escape the output root."""
|
|
95
|
+
name = namespace.replace("/", "_").replace("\\", "_").replace("\0", "_")
|
|
96
|
+
return name if name.strip(".") else "_"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def target_path(
|
|
100
|
+
dest_root: str | Path,
|
|
101
|
+
record: CorpusRecord,
|
|
102
|
+
*,
|
|
103
|
+
by_namespace: bool = True,
|
|
104
|
+
) -> Path:
|
|
105
|
+
"""Where *record* should land: ``<root>/<namespace>/PMC<id>.pdf`` by default."""
|
|
106
|
+
root = Path(dest_root).expanduser()
|
|
107
|
+
folder = root / _namespace_dir(record.namespace) if by_namespace else root
|
|
108
|
+
return folder / f"{record.pmcid}.pdf"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _already_complete(path: Path, *, min_bytes: int, verify_magic: bool) -> bool:
|
|
112
|
+
"""True when *path* is a usable PDF already on disk."""
|
|
113
|
+
try:
|
|
114
|
+
if not path.is_file() or path.stat().st_size < max(1, min_bytes):
|
|
115
|
+
return False
|
|
116
|
+
except OSError:
|
|
117
|
+
return False
|
|
118
|
+
if not verify_magic:
|
|
119
|
+
return True
|
|
120
|
+
try:
|
|
121
|
+
with path.open("rb") as fh:
|
|
122
|
+
return is_pdf(fh.read(1024))
|
|
123
|
+
except OSError:
|
|
124
|
+
return False
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def write_pdf_atomic(path: Path, data: bytes) -> int:
|
|
128
|
+
"""Write *data* via a ``.part`` file + rename.
|
|
129
|
+
|
|
130
|
+
Writing straight to the target means an interrupted job (walltime kill,
|
|
131
|
+
node eviction) leaves a truncated file that a size-based resume check then
|
|
132
|
+
treats as complete forever. A write that fails removes its ``.part`` file.
|
|
133
|
+
"""
|
|
134
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
135
|
+
# Unique per writer, so two threads handed the same record cannot interleave.
|
|
136
|
+
tmp = path.with_name(f"{path.name}.{os.getpid()}-{threading.get_ident()}.part")
|
|
137
|
+
try:
|
|
138
|
+
with tmp.open("wb") as fh:
|
|
139
|
+
fh.write(data)
|
|
140
|
+
fh.flush()
|
|
141
|
+
os.fsync(fh.fileno())
|
|
142
|
+
tmp.replace(path)
|
|
143
|
+
except BaseException:
|
|
144
|
+
tmp.unlink(missing_ok=True)
|
|
145
|
+
raise
|
|
146
|
+
return len(data)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _check_body(data: bytes, pmcid: str, min_bytes: int) -> None:
|
|
150
|
+
"""Hold a download to the resume check's standard, so it is skipped next run."""
|
|
151
|
+
if not is_pdf(data):
|
|
152
|
+
raise NotAPDFError(pmcid=pmcid, head=data[:64])
|
|
153
|
+
if len(data) < min_bytes:
|
|
154
|
+
raise NotAPDFError(
|
|
155
|
+
f"response body is {len(data)} bytes, below the {min_bytes}-byte minimum",
|
|
156
|
+
pmcid=pmcid,
|
|
157
|
+
head=data[:64],
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def download_one(
|
|
162
|
+
client: PdfSource,
|
|
163
|
+
record: CorpusRecord,
|
|
164
|
+
dest_root: str | Path,
|
|
165
|
+
*,
|
|
166
|
+
by_namespace: bool = True,
|
|
167
|
+
overwrite: bool = False,
|
|
168
|
+
verify_existing: bool = True,
|
|
169
|
+
min_pdf_bytes: int = DEFAULT_MIN_PDF_BYTES,
|
|
170
|
+
failure_log: FailureLog | None = None,
|
|
171
|
+
) -> DownloadResult:
|
|
172
|
+
"""Fetch one article to disk. Never raises for expected download failures."""
|
|
173
|
+
path = target_path(dest_root, record, by_namespace=by_namespace)
|
|
174
|
+
|
|
175
|
+
if not overwrite and _already_complete(
|
|
176
|
+
path, min_bytes=min_pdf_bytes, verify_magic=verify_existing
|
|
177
|
+
):
|
|
178
|
+
return DownloadResult(record.pmcid, Outcome.SKIPPED, path=path, reason="already_present")
|
|
179
|
+
|
|
180
|
+
def _failed(reason: str) -> DownloadResult:
|
|
181
|
+
if failure_log:
|
|
182
|
+
failure_log.record(record.pmcid, reason)
|
|
183
|
+
return DownloadResult(record.pmcid, Outcome.FAILED, reason=reason)
|
|
184
|
+
|
|
185
|
+
try:
|
|
186
|
+
data = client.fetch_pdf(record.pmcid)
|
|
187
|
+
_check_body(data, record.pmcid, min_pdf_bytes)
|
|
188
|
+
except Exception as exc: # noqa: BLE001 - one bad paper must not kill the batch
|
|
189
|
+
return _failed(f"{exc.__class__.__name__}: {exc}")
|
|
190
|
+
|
|
191
|
+
try:
|
|
192
|
+
n = write_pdf_atomic(path, data)
|
|
193
|
+
except OSError as exc:
|
|
194
|
+
return _failed(f"WriteError: {exc}")
|
|
195
|
+
|
|
196
|
+
return DownloadResult(record.pmcid, Outcome.DOWNLOADED, path=path, bytes_written=n)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def download_many(
|
|
200
|
+
records: Sequence[CorpusRecord] | Iterable[CorpusRecord],
|
|
201
|
+
dest_root: str | Path,
|
|
202
|
+
*,
|
|
203
|
+
client: PdfSource,
|
|
204
|
+
workers: int = 4,
|
|
205
|
+
by_namespace: bool = True,
|
|
206
|
+
overwrite: bool = False,
|
|
207
|
+
verify_existing: bool = True,
|
|
208
|
+
min_pdf_bytes: int = DEFAULT_MIN_PDF_BYTES,
|
|
209
|
+
failure_log: FailureLog | None = None,
|
|
210
|
+
progress: Callable[[DownloadResult, int, int], None] | None = None,
|
|
211
|
+
) -> BatchReport:
|
|
212
|
+
"""Download *records* concurrently.
|
|
213
|
+
|
|
214
|
+
Politeness is governed by the client's rate limiter, not by ``workers``;
|
|
215
|
+
raising ``workers`` increases parallelism of connection setup and disk I/O
|
|
216
|
+
while the limiter still paces outbound requests.
|
|
217
|
+
|
|
218
|
+
*progress* runs on the calling thread as each record finishes. If it raises,
|
|
219
|
+
or the batch is interrupted (Ctrl-C), queued records are cancelled and only
|
|
220
|
+
the downloads already in flight are waited for.
|
|
221
|
+
"""
|
|
222
|
+
items = list(records)
|
|
223
|
+
report = BatchReport()
|
|
224
|
+
total = len(items)
|
|
225
|
+
if total == 0:
|
|
226
|
+
return report
|
|
227
|
+
|
|
228
|
+
n_workers = max(1, int(workers))
|
|
229
|
+
|
|
230
|
+
def _run(rec: CorpusRecord) -> DownloadResult:
|
|
231
|
+
try:
|
|
232
|
+
return download_one(
|
|
233
|
+
client,
|
|
234
|
+
rec,
|
|
235
|
+
dest_root,
|
|
236
|
+
by_namespace=by_namespace,
|
|
237
|
+
overwrite=overwrite,
|
|
238
|
+
verify_existing=verify_existing,
|
|
239
|
+
min_pdf_bytes=min_pdf_bytes,
|
|
240
|
+
failure_log=failure_log,
|
|
241
|
+
)
|
|
242
|
+
except Exception as exc: # noqa: BLE001 - defensive: one bad record must not kill the batch
|
|
243
|
+
reason = f"{exc.__class__.__name__}: {exc}"
|
|
244
|
+
if failure_log:
|
|
245
|
+
failure_log.record(rec.pmcid, reason)
|
|
246
|
+
return DownloadResult(rec.pmcid, Outcome.FAILED, reason=reason)
|
|
247
|
+
|
|
248
|
+
def _collect(i: int, res: DownloadResult) -> None:
|
|
249
|
+
report.results.append(res)
|
|
250
|
+
if progress:
|
|
251
|
+
progress(res, i, total)
|
|
252
|
+
|
|
253
|
+
if n_workers == 1:
|
|
254
|
+
for i, rec in enumerate(items, start=1):
|
|
255
|
+
_collect(i, _run(rec))
|
|
256
|
+
return report
|
|
257
|
+
|
|
258
|
+
pool = ThreadPoolExecutor(max_workers=n_workers)
|
|
259
|
+
try:
|
|
260
|
+
futures = [pool.submit(_run, rec) for rec in items]
|
|
261
|
+
for i, fut in enumerate(as_completed(futures), start=1):
|
|
262
|
+
_collect(i, fut.result())
|
|
263
|
+
finally:
|
|
264
|
+
# Leaving the pool via ``with`` would run every queued download first.
|
|
265
|
+
pool.shutdown(wait=True, cancel_futures=True)
|
|
266
|
+
return report
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
__all__ = [
|
|
270
|
+
"Outcome",
|
|
271
|
+
"DownloadResult",
|
|
272
|
+
"BatchReport",
|
|
273
|
+
"FailureLog",
|
|
274
|
+
"target_path",
|
|
275
|
+
"write_pdf_atomic",
|
|
276
|
+
"download_one",
|
|
277
|
+
"download_many",
|
|
278
|
+
]
|
pmcfetch/cli.py
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""``pmcfetch`` command line interface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from pmcfetch import __version__
|
|
12
|
+
from pmcfetch.batch import DownloadResult, FailureLog, Outcome, download_many
|
|
13
|
+
from pmcfetch.pdf import DEFAULT_MIN_PDF_BYTES, DEFAULT_TIMEOUT
|
|
14
|
+
from pmcfetch.errors import PMCFetchError
|
|
15
|
+
from pmcfetch.manifest import load_records
|
|
16
|
+
from pmcfetch.ratelimit import MinIntervalLimiter, NullLimiter
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
20
|
+
parser = argparse.ArgumentParser(
|
|
21
|
+
prog="pmcfetch",
|
|
22
|
+
description="Download open-access PDFs from the PMC Open Access Subset on AWS.",
|
|
23
|
+
)
|
|
24
|
+
parser.add_argument("--version", action="version", version=f"pmcfetch {__version__}")
|
|
25
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
26
|
+
|
|
27
|
+
dl = sub.add_parser("download", help="Download PDFs listed in a corpus file")
|
|
28
|
+
dl.add_argument("--corpus", required=True, help="Corpus JSON (or .txt id list)")
|
|
29
|
+
dl.add_argument("--out", required=True, help="Output root directory")
|
|
30
|
+
dl.add_argument(
|
|
31
|
+
"--namespace",
|
|
32
|
+
default=None,
|
|
33
|
+
help="Only download this namespace (e.g. cardiology)",
|
|
34
|
+
)
|
|
35
|
+
dl.add_argument(
|
|
36
|
+
"--namespaces",
|
|
37
|
+
default="",
|
|
38
|
+
help="Comma-separated namespace order used to resolve --task-id",
|
|
39
|
+
)
|
|
40
|
+
dl.add_argument(
|
|
41
|
+
"--task-id",
|
|
42
|
+
type=int,
|
|
43
|
+
default=None,
|
|
44
|
+
help="1-based array index selecting a namespace (defaults to $SGE_TASK_ID)",
|
|
45
|
+
)
|
|
46
|
+
dl.add_argument("--workers", type=int, default=4, help="Concurrent downloads (default 4)")
|
|
47
|
+
dl.add_argument(
|
|
48
|
+
"--rps",
|
|
49
|
+
type=float,
|
|
50
|
+
default=2.0,
|
|
51
|
+
help=(
|
|
52
|
+
"Max outbound requests per second across all workers. The PMC S3 bucket "
|
|
53
|
+
"is not rate limited, so 0 disables pacing entirely (default 2.0)"
|
|
54
|
+
),
|
|
55
|
+
)
|
|
56
|
+
dl.add_argument(
|
|
57
|
+
"--jitter",
|
|
58
|
+
type=float,
|
|
59
|
+
default=0.2,
|
|
60
|
+
help="Extra random spacing per request, seconds (only applies with --rps > 0)",
|
|
61
|
+
)
|
|
62
|
+
dl.add_argument("--retries", type=int, default=4, help="Retries per article (default 4)")
|
|
63
|
+
dl.add_argument("--timeout", type=float, default=DEFAULT_TIMEOUT, help="Per-request timeout")
|
|
64
|
+
dl.add_argument("--limit", type=int, default=0, help="Stop after N articles (0 = all)")
|
|
65
|
+
dl.add_argument("--min-bytes", type=int, default=DEFAULT_MIN_PDF_BYTES, help="Minimum PDF size")
|
|
66
|
+
dl.add_argument("--overwrite", action="store_true", help="Re-download files already on disk")
|
|
67
|
+
dl.add_argument("--flat", action="store_true", help="Write all PDFs into --out, no subfolders")
|
|
68
|
+
dl.add_argument(
|
|
69
|
+
"--no-verify-existing",
|
|
70
|
+
action="store_true",
|
|
71
|
+
help="Resume on file size alone, skipping the %%PDF magic re-check",
|
|
72
|
+
)
|
|
73
|
+
dl.add_argument("--failed-log", default=None, help="Append failures here")
|
|
74
|
+
dl.add_argument("--json", action="store_true", help="Print the summary as JSON")
|
|
75
|
+
dl.add_argument("--quiet", action="store_true", help="Suppress per-article progress")
|
|
76
|
+
dl.add_argument("--dry-run", action="store_true", help="List what would be fetched, then exit")
|
|
77
|
+
|
|
78
|
+
ns = sub.add_parser("namespaces", help="Show namespace counts in a corpus file")
|
|
79
|
+
ns.add_argument("--corpus", required=True)
|
|
80
|
+
ns.add_argument("--json", action="store_true")
|
|
81
|
+
|
|
82
|
+
return parser
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _resolve_namespace(args: argparse.Namespace, available: list[str]) -> str | None:
|
|
86
|
+
"""Pick a namespace from --namespace, or from --task-id/$SGE_TASK_ID."""
|
|
87
|
+
if args.namespace:
|
|
88
|
+
return str(args.namespace).strip()
|
|
89
|
+
|
|
90
|
+
task_id = args.task_id
|
|
91
|
+
if task_id is None:
|
|
92
|
+
raw = os.environ.get("SGE_TASK_ID", "").strip()
|
|
93
|
+
if raw and raw.isdigit():
|
|
94
|
+
task_id = int(raw)
|
|
95
|
+
if task_id is None:
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
ordered = [s.strip() for s in str(args.namespaces or "").split(",") if s.strip()] or available
|
|
99
|
+
idx = task_id - 1
|
|
100
|
+
if idx < 0 or idx >= len(ordered):
|
|
101
|
+
raise PMCFetchError(
|
|
102
|
+
f"--task-id {task_id} is out of range for {len(ordered)} namespaces: {ordered}"
|
|
103
|
+
)
|
|
104
|
+
return ordered[idx]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _build_limiter(rps: float, jitter: float) -> MinIntervalLimiter | NullLimiter:
|
|
108
|
+
"""``--rps 0`` switches pacing off entirely; jitter only widens a positive rate."""
|
|
109
|
+
if rps <= 0:
|
|
110
|
+
return NullLimiter()
|
|
111
|
+
return MinIntervalLimiter(min_interval=1.0 / rps, jitter=max(0.0, jitter))
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _cmd_namespaces(args: argparse.Namespace) -> int:
|
|
115
|
+
loaded = load_records(args.corpus)
|
|
116
|
+
groups = loaded.namespaces
|
|
117
|
+
if args.json:
|
|
118
|
+
print(json.dumps({k: len(v) for k, v in groups.items()}, indent=2))
|
|
119
|
+
return 0
|
|
120
|
+
width = max((len(k) for k in groups), default=9)
|
|
121
|
+
print(f"{'namespace'.ljust(width)} count")
|
|
122
|
+
for name, ids in sorted(groups.items(), key=lambda kv: -len(kv[1])):
|
|
123
|
+
print(f"{name.ljust(width)} {len(ids)}")
|
|
124
|
+
print(f"{'TOTAL'.ljust(width)} {len(loaded.records)}")
|
|
125
|
+
if loaded.skipped:
|
|
126
|
+
print(f"\nskipped {len(loaded.skipped)} unparseable key(s), e.g. {loaded.skipped[:3]}")
|
|
127
|
+
return 0
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _cmd_download(args: argparse.Namespace) -> int:
|
|
131
|
+
loaded = load_records(args.corpus)
|
|
132
|
+
if loaded.skipped and not args.quiet:
|
|
133
|
+
print(
|
|
134
|
+
f"[pmcfetch] skipped {len(loaded.skipped)} unparseable key(s)",
|
|
135
|
+
file=sys.stderr,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
namespace = _resolve_namespace(args, sorted(loaded.namespaces))
|
|
139
|
+
records = loaded.for_namespace(namespace)
|
|
140
|
+
if args.limit and args.limit > 0:
|
|
141
|
+
records = records[: args.limit]
|
|
142
|
+
|
|
143
|
+
label = namespace or "ALL"
|
|
144
|
+
if not args.quiet:
|
|
145
|
+
print(f"[pmcfetch] corpus={args.corpus} namespace={label} n={len(records)}")
|
|
146
|
+
|
|
147
|
+
if args.dry_run:
|
|
148
|
+
for rec in records[:20]:
|
|
149
|
+
print(f" would fetch {rec.pmcid} -> {rec.namespace}/")
|
|
150
|
+
if len(records) > 20:
|
|
151
|
+
print(f" ... and {len(records) - 20} more")
|
|
152
|
+
return 0
|
|
153
|
+
|
|
154
|
+
if not records:
|
|
155
|
+
print("[pmcfetch] nothing to do", file=sys.stderr)
|
|
156
|
+
return 0
|
|
157
|
+
|
|
158
|
+
limiter = _build_limiter(args.rps, args.jitter)
|
|
159
|
+
failure_log = FailureLog(args.failed_log) if args.failed_log else None
|
|
160
|
+
|
|
161
|
+
def _progress(res: DownloadResult, i: int, total: int) -> None:
|
|
162
|
+
if args.quiet:
|
|
163
|
+
return
|
|
164
|
+
mark = {
|
|
165
|
+
Outcome.DOWNLOADED: "ok ",
|
|
166
|
+
Outcome.SKIPPED: "skip",
|
|
167
|
+
Outcome.FAILED: "FAIL",
|
|
168
|
+
}[res.outcome]
|
|
169
|
+
suffix = f" {res.reason}" if res.outcome is Outcome.FAILED else ""
|
|
170
|
+
print(f"[{i}/{total}] {mark} {res.pmcid}{suffix}", flush=True)
|
|
171
|
+
|
|
172
|
+
from pmcfetch.s3source import S3OpenAccessClient
|
|
173
|
+
|
|
174
|
+
with S3OpenAccessClient(
|
|
175
|
+
timeout=args.timeout,
|
|
176
|
+
retries=args.retries,
|
|
177
|
+
limiter=limiter,
|
|
178
|
+
) as client:
|
|
179
|
+
report = download_many(
|
|
180
|
+
records,
|
|
181
|
+
Path(args.out),
|
|
182
|
+
client=client,
|
|
183
|
+
workers=args.workers,
|
|
184
|
+
by_namespace=not args.flat,
|
|
185
|
+
overwrite=args.overwrite,
|
|
186
|
+
verify_existing=not args.no_verify_existing,
|
|
187
|
+
min_pdf_bytes=args.min_bytes,
|
|
188
|
+
failure_log=failure_log,
|
|
189
|
+
progress=_progress,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
if args.json:
|
|
193
|
+
print(json.dumps({"namespace": label, **report.as_dict()}, indent=2))
|
|
194
|
+
else:
|
|
195
|
+
print(
|
|
196
|
+
f"\n[pmcfetch] {label}: downloaded={report.downloaded} "
|
|
197
|
+
f"skipped={report.skipped} failed={report.failed} total={report.total}"
|
|
198
|
+
)
|
|
199
|
+
if failure_log and report.failed:
|
|
200
|
+
print(f"[pmcfetch] failures logged to {failure_log.path}")
|
|
201
|
+
|
|
202
|
+
return 1 if report.failed else 0
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def main(argv: list[str] | None = None) -> int:
|
|
206
|
+
args = _build_parser().parse_args(argv)
|
|
207
|
+
try:
|
|
208
|
+
if args.command == "download":
|
|
209
|
+
return _cmd_download(args)
|
|
210
|
+
if args.command == "namespaces":
|
|
211
|
+
return _cmd_namespaces(args)
|
|
212
|
+
except PMCFetchError as exc:
|
|
213
|
+
print(f"pmcfetch: {exc}", file=sys.stderr)
|
|
214
|
+
return 2
|
|
215
|
+
except json.JSONDecodeError as exc:
|
|
216
|
+
print(f"pmcfetch: corpus file is not valid JSON: {exc}", file=sys.stderr)
|
|
217
|
+
return 2
|
|
218
|
+
except OSError as exc: # unreadable corpus, unwritable --out or --failed-log
|
|
219
|
+
print(f"pmcfetch: {exc}", file=sys.stderr)
|
|
220
|
+
return 2
|
|
221
|
+
except KeyboardInterrupt:
|
|
222
|
+
print("\npmcfetch: interrupted", file=sys.stderr)
|
|
223
|
+
return 130
|
|
224
|
+
return 2
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
if __name__ == "__main__":
|
|
228
|
+
raise SystemExit(main())
|
pmcfetch/errors.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Exception hierarchy for pmcfetch."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class PMCFetchError(Exception):
|
|
7
|
+
"""Base for every pmcfetch failure."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class InvalidPMCIDError(PMCFetchError, ValueError):
|
|
11
|
+
"""A supplied identifier is not a usable PMC id."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class DownloadError(PMCFetchError):
|
|
15
|
+
"""Base for per-article download failures."""
|
|
16
|
+
|
|
17
|
+
retryable: bool = False
|
|
18
|
+
|
|
19
|
+
def __init__(self, message: str, *, pmcid: str = "") -> None:
|
|
20
|
+
self.pmcid = pmcid
|
|
21
|
+
super().__init__(message)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class HTTPStatusError(DownloadError):
|
|
25
|
+
"""Server returned a non-200 status."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, status_code: int, *, pmcid: str = "", retry_after: float | None = None) -> None:
|
|
28
|
+
self.status_code = int(status_code)
|
|
29
|
+
# Seconds the server asked us to wait (``Retry-After``), when it said.
|
|
30
|
+
self.retry_after = retry_after
|
|
31
|
+
# 429 and 5xx are worth another attempt; 403/404 are not.
|
|
32
|
+
self.retryable = self.status_code == 429 or 500 <= self.status_code < 600
|
|
33
|
+
super().__init__(f"HTTP {self.status_code}", pmcid=pmcid)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class NotAPDFError(DownloadError):
|
|
37
|
+
"""Body came back 200 but is not a usable PDF (error page, truncated object, HTML)."""
|
|
38
|
+
|
|
39
|
+
retryable = False
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self, message: str = "response body is not a PDF", *, pmcid: str = "", head: bytes = b""
|
|
43
|
+
) -> None:
|
|
44
|
+
self.head = head
|
|
45
|
+
super().__init__(message, pmcid=pmcid)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class TransportError(DownloadError):
|
|
49
|
+
"""Network or TLS level failure; usually worth retrying."""
|
|
50
|
+
|
|
51
|
+
retryable = True
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
__all__ = [
|
|
55
|
+
"PMCFetchError",
|
|
56
|
+
"InvalidPMCIDError",
|
|
57
|
+
"DownloadError",
|
|
58
|
+
"HTTPStatusError",
|
|
59
|
+
"NotAPDFError",
|
|
60
|
+
"TransportError",
|
|
61
|
+
]
|