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 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
+ ]