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/ids.py ADDED
@@ -0,0 +1,41 @@
1
+ """PMC identifier normalisation.
2
+
3
+ Corpus files key papers inconsistently: bare digits (``"10007932"``), lowercase
4
+ (``"pmc10007932"``), or canonical (``"PMC10007932"``). S3 object prefixes use the
5
+ canonical form, so everything is normalised on the way in.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+
12
+ from pmcfetch.errors import InvalidPMCIDError
13
+
14
+ # PMC ids are sequential integers with no minimum width, so match any digit run.
15
+ # Leading zeros are stripped so "PMC0010007932" and "PMC10007932" collapse.
16
+ _PMCID_RE = re.compile(r"^(?:pmc)?0*(\d+)$", re.IGNORECASE)
17
+
18
+
19
+ def normalize_pmcid(raw: object) -> str:
20
+ """Return the canonical ``PMC<digits>`` form.
21
+
22
+ Raises :class:`InvalidPMCIDError` for anything that is not a PMC id, so a
23
+ malformed corpus key fails loudly instead of producing a 404 at fetch time.
24
+ """
25
+ s = str(raw or "").strip()
26
+ if not s:
27
+ raise InvalidPMCIDError("empty PMC id")
28
+ m = _PMCID_RE.match(s)
29
+ if not m:
30
+ raise InvalidPMCIDError(f"not a PMC id: {s!r}")
31
+ return f"PMC{m.group(1)}"
32
+
33
+
34
+ def try_normalize_pmcid(raw: object) -> str | None:
35
+ """Non-raising variant; returns ``None`` when *raw* is not a PMC id."""
36
+ try:
37
+ return normalize_pmcid(raw)
38
+ except InvalidPMCIDError:
39
+ return None
40
+
41
+ __all__ = ["normalize_pmcid", "try_normalize_pmcid"]
pmcfetch/manifest.py ADDED
@@ -0,0 +1,160 @@
1
+ """Read corpus JSON files into a uniform record stream.
2
+
3
+ Three JSON shapes are supported, matching what corpus tooling commonly emits:
4
+
5
+ * assignment map -- ``{"<id>": {"metadata": {"namespace": "cardiology"}}}``
6
+ * full-text dump -- ``{"<id>": "<full text>"}``
7
+ * extraction out -- ``{"pmc10007932": {"paper_id": ..., "sections": [...]}}``
8
+
9
+ A plain JSON array of ids and a newline-delimited ``.txt`` id list also work.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ from dataclasses import dataclass, field
16
+ from pathlib import Path
17
+ from typing import Any, Iterable, Iterator
18
+
19
+ from pmcfetch.ids import try_normalize_pmcid
20
+
21
+ DEFAULT_NAMESPACE = "general"
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class CorpusRecord:
26
+ """One downloadable article."""
27
+
28
+ pmcid: str
29
+ namespace: str = DEFAULT_NAMESPACE
30
+ source_key: str = ""
31
+
32
+
33
+ @dataclass
34
+ class LoadResult:
35
+ """Records plus the keys that could not be parsed as PMC ids."""
36
+
37
+ records: list[CorpusRecord] = field(default_factory=list)
38
+ skipped: list[str] = field(default_factory=list)
39
+
40
+ def __len__(self) -> int:
41
+ return len(self.records)
42
+
43
+ @property
44
+ def namespaces(self) -> dict[str, list[str]]:
45
+ """``{namespace: [pmcid, ...]}`` preserving first-seen order."""
46
+ out: dict[str, list[str]] = {}
47
+ for rec in self.records:
48
+ out.setdefault(rec.namespace, []).append(rec.pmcid)
49
+ return out
50
+
51
+ def for_namespace(self, namespace: str | None) -> list[CorpusRecord]:
52
+ """Records in *namespace*; all records when *namespace* is falsy."""
53
+ if not namespace:
54
+ return list(self.records)
55
+ want = str(namespace).strip()
56
+ return [r for r in self.records if r.namespace == want]
57
+
58
+
59
+ def _namespace_of(value: Any, default: str) -> str:
60
+ """Pull ``metadata.namespace`` (or a top-level ``namespace``) from a record."""
61
+ if not isinstance(value, dict):
62
+ return default
63
+ meta = value.get("metadata")
64
+ if isinstance(meta, dict):
65
+ ns = str(meta.get("namespace") or "").strip()
66
+ if ns:
67
+ return ns
68
+ ns = str(value.get("namespace") or "").strip()
69
+ return ns or default
70
+
71
+
72
+ def iter_records(data: Any, *, default_namespace: str = DEFAULT_NAMESPACE) -> Iterator[CorpusRecord]:
73
+ """Yield :class:`CorpusRecord` for every recognisable PMC id in *data*."""
74
+ if isinstance(data, dict):
75
+ items: Iterable[tuple[Any, Any]] = data.items()
76
+ elif isinstance(data, list):
77
+ items = ((x, None) for x in data)
78
+ else:
79
+ return
80
+
81
+ for key, value in items:
82
+ pmcid = try_normalize_pmcid(key)
83
+ if pmcid is None:
84
+ continue
85
+ yield CorpusRecord(
86
+ pmcid=pmcid,
87
+ namespace=_namespace_of(value, default_namespace),
88
+ source_key=str(key),
89
+ )
90
+
91
+
92
+ def load_records(
93
+ path: str | Path,
94
+ *,
95
+ default_namespace: str = DEFAULT_NAMESPACE,
96
+ ) -> LoadResult:
97
+ """Load a corpus file into a :class:`LoadResult`.
98
+
99
+ ``.txt`` inputs are treated as newline-delimited id lists; anything else is
100
+ parsed as JSON. Unparseable keys are collected in ``skipped`` rather than
101
+ raising, so one bad key does not abort a 20k-paper batch.
102
+ """
103
+ p = Path(path).expanduser()
104
+ if p.suffix.lower() == ".txt":
105
+ return _load_id_list(p, default_namespace=default_namespace)
106
+
107
+ data = json.loads(p.read_text(encoding="utf-8"))
108
+ result = LoadResult()
109
+ seen: set[str] = set()
110
+
111
+ keys: Iterable[Any]
112
+ if isinstance(data, dict):
113
+ keys = data.keys()
114
+ elif isinstance(data, list):
115
+ keys = data
116
+ else:
117
+ keys = []
118
+
119
+ for rec in iter_records(data, default_namespace=default_namespace):
120
+ if rec.pmcid in seen:
121
+ continue
122
+ seen.add(rec.pmcid)
123
+ result.records.append(rec)
124
+
125
+ resolved = {r.source_key for r in result.records}
126
+ for key in keys:
127
+ sk = str(key)
128
+ if sk not in resolved and try_normalize_pmcid(sk) is None:
129
+ result.skipped.append(sk)
130
+
131
+ return result
132
+
133
+
134
+ def _load_id_list(path: Path, *, default_namespace: str) -> LoadResult:
135
+ result = LoadResult()
136
+ seen: set[str] = set()
137
+ for line in path.read_text(encoding="utf-8").splitlines():
138
+ s = line.strip()
139
+ if not s or s.startswith("#") or s.startswith("---"):
140
+ continue
141
+ # tolerate "PMC123 | Reason: ..." lines, so a failure log works as input
142
+ token = s.split("|", 1)[0].strip()
143
+ pmcid = try_normalize_pmcid(token)
144
+ if pmcid is None:
145
+ result.skipped.append(s)
146
+ continue
147
+ if pmcid in seen:
148
+ continue
149
+ seen.add(pmcid)
150
+ result.records.append(CorpusRecord(pmcid=pmcid, namespace=default_namespace, source_key=s))
151
+ return result
152
+
153
+
154
+ __all__ = [
155
+ "DEFAULT_NAMESPACE",
156
+ "CorpusRecord",
157
+ "LoadResult",
158
+ "iter_records",
159
+ "load_records",
160
+ ]
pmcfetch/pdf.py ADDED
@@ -0,0 +1,44 @@
1
+ """PDF validation and the source contract that batch downloads depend on."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Protocol, runtime_checkable
6
+
7
+ PDF_MAGIC = b"%PDF"
8
+
9
+ #: Error pages and truncated bodies are small; a real article is not. Used as
10
+ #: the floor for both download validation and the resume check, so a truncated
11
+ #: leftover is never mistaken for a complete file, and every file written passes
12
+ #: the resume check on the next run.
13
+ DEFAULT_MIN_PDF_BYTES = 1000
14
+
15
+ #: Default per-request timeout, seconds.
16
+ DEFAULT_TIMEOUT = 60.0
17
+
18
+
19
+ def is_pdf(data: bytes, *, min_bytes: int = 0) -> bool:
20
+ """True when *data* looks like a PDF payload.
21
+
22
+ Checks the magic anywhere in the first 1 KiB rather than only at offset 0,
23
+ because some responses carry a short byte-order mark or stray whitespace
24
+ before ``%PDF``.
25
+ """
26
+ if not data:
27
+ return False
28
+ if min_bytes and len(data) < min_bytes:
29
+ return False
30
+ return data[:4] == PDF_MAGIC or PDF_MAGIC in data[:1024]
31
+
32
+
33
+ @runtime_checkable
34
+ class PdfSource(Protocol):
35
+ """Anything that can produce PDF bytes for a PMC id.
36
+
37
+ :func:`pmcfetch.batch.download_one` needs nothing else, so alternative
38
+ sources can be dropped in without touching the batch runner.
39
+ """
40
+
41
+ def fetch_pdf(self, pmcid: str) -> bytes: ...
42
+
43
+
44
+ __all__ = ["PDF_MAGIC", "DEFAULT_MIN_PDF_BYTES", "DEFAULT_TIMEOUT", "is_pdf", "PdfSource"]
pmcfetch/py.typed ADDED
File without changes
pmcfetch/ratelimit.py ADDED
@@ -0,0 +1,53 @@
1
+ """Process-wide request pacing.
2
+
3
+ Sleeping inside each worker thread (``time.sleep(random.uniform(0.1, 0.5))``)
4
+ bounds nothing globally: with 10 workers that is still ~30 requests/second in
5
+ the worst case. This limiter serialises the *spacing* across all threads so
6
+ ``--workers`` and politeness are independent knobs.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import random
12
+ import threading
13
+ import time
14
+
15
+
16
+ class MinIntervalLimiter:
17
+ """Allow at most one acquisition per ``min_interval`` seconds, across threads."""
18
+
19
+ def __init__(self, min_interval: float = 0.0, *, jitter: float = 0.0) -> None:
20
+ if min_interval < 0:
21
+ raise ValueError("min_interval must be >= 0")
22
+ if jitter < 0:
23
+ raise ValueError("jitter must be >= 0")
24
+ self.min_interval = float(min_interval)
25
+ self.jitter = float(jitter)
26
+ self._lock = threading.Lock()
27
+ self._next_at = 0.0
28
+
29
+ def acquire(self, *, sleep=time.sleep, now=time.monotonic) -> float:
30
+ """Block until the next slot is due. Returns seconds actually slept."""
31
+ if self.min_interval <= 0 and self.jitter <= 0:
32
+ return 0.0
33
+ with self._lock:
34
+ t = now()
35
+ wait = max(0.0, self._next_at - t)
36
+ spacing = self.min_interval + (random.uniform(0.0, self.jitter) if self.jitter else 0.0)
37
+ self._next_at = max(t, self._next_at) + spacing
38
+ if wait > 0:
39
+ sleep(wait)
40
+ return wait
41
+
42
+
43
+ class NullLimiter:
44
+ """No pacing. Useful in tests and for single-shot fetches."""
45
+
46
+ min_interval = 0.0
47
+ jitter = 0.0
48
+
49
+ def acquire(self, *, sleep=time.sleep, now=time.monotonic) -> float: # noqa: ARG002
50
+ return 0.0
51
+
52
+
53
+ __all__ = ["MinIntervalLimiter", "NullLimiter"]
pmcfetch/s3source.py ADDED
@@ -0,0 +1,251 @@
1
+ """PMC Open Access Subset on AWS S3 — the sanctioned bulk source.
2
+
3
+ NLM publishes every PMC OA article version in the public, anonymous bucket
4
+ ``s3://pmc-oa-opendata``. Since the February 2026 restructure the layout is one
5
+ prefix per article version::
6
+
7
+ PMC10007932.1/
8
+ PMC10007932.1.pdf <- article PDF (when the licence permits)
9
+ PMC10007932.1.xml <- JATS XML
10
+ PMC10007932.1.txt <- plain text
11
+ PMC10007932.1.json <- metadata
12
+ <publisher files> <- figures, supplementary PDFs
13
+
14
+ No rate limiting, no login, and it is the access route PMC sanctions for
15
+ systematic retrieval.
16
+
17
+ Needs no third-party packages: S3 is plain anonymous HTTPS, so this module
18
+ uses :mod:`urllib` only.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import random
25
+ import re
26
+ import time
27
+ import urllib.error
28
+ import urllib.parse
29
+ import urllib.request
30
+ import xml.etree.ElementTree as ET
31
+ from dataclasses import dataclass
32
+ from email.message import Message
33
+ from typing import Callable
34
+
35
+ from pmcfetch import __version__
36
+ from pmcfetch.pdf import is_pdf
37
+ from pmcfetch.errors import DownloadError, HTTPStatusError, NotAPDFError, TransportError
38
+ from pmcfetch.ids import normalize_pmcid
39
+ from pmcfetch.ratelimit import MinIntervalLimiter, NullLimiter
40
+
41
+ OA_BUCKET = "pmc-oa-opendata"
42
+ OA_BASE_URL = f"https://{OA_BUCKET}.s3.amazonaws.com"
43
+ _S3_NS = {"s3": "http://s3.amazonaws.com/doc/2006-03-01/"}
44
+ _USER_AGENT = f"pmcfetch/{__version__} (+https://pypi.org/project/pmcfetch/)"
45
+
46
+ #: Matches the canonical per-version object name, e.g. ``PMC10007932.1.pdf``.
47
+ #: Deliberately anchored so publisher supplementary PDFs sitting in the same
48
+ #: prefix (``jgo-14-01-302-coif.pdf``) are never mistaken for the article.
49
+ _ARTICLE_OBJECT_RE = re.compile(r"^PMC(\d+)\.(\d+)/PMC\1\.\2\.(pdf|xml|txt|json)$")
50
+
51
+
52
+ class ArticleNotInOASubsetError(DownloadError):
53
+ """No object prefix for this PMCID — not in the Open Access Subset."""
54
+
55
+ retryable = False
56
+
57
+
58
+ class PDFNotAvailableError(DownloadError):
59
+ """Article exists but carries no PDF (licence does not permit redistribution)."""
60
+
61
+ retryable = False
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class ArticleVersion:
66
+ """One resolved article version and the objects available for it."""
67
+
68
+ pmcid: str
69
+ version: int
70
+ keys: dict[str, str] # extension -> full S3 key
71
+
72
+ @property
73
+ def prefix(self) -> str:
74
+ return f"{self.pmcid}.{self.version}"
75
+
76
+ def url(self, ext: str) -> str | None:
77
+ key = self.keys.get(ext)
78
+ return f"{OA_BASE_URL}/{key}" if key else None
79
+
80
+ @property
81
+ def has_pdf(self) -> bool:
82
+ return "pdf" in self.keys
83
+
84
+
85
+ def _retry_after_seconds(headers: Message | None) -> float | None:
86
+ """``Retry-After`` in seconds; the rarely used HTTP-date form is ignored."""
87
+ raw = headers.get("Retry-After") if headers is not None else None
88
+ try:
89
+ return max(0.0, float(raw)) if raw else None
90
+ except ValueError:
91
+ return None
92
+
93
+
94
+ def _http_get(url: str, *, timeout: float) -> bytes:
95
+ """Anonymous GET returning the body, with typed errors."""
96
+ req = urllib.request.Request(url, headers={"User-Agent": _USER_AGENT})
97
+ try:
98
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
99
+ return resp.read()
100
+ except urllib.error.HTTPError as exc:
101
+ raise HTTPStatusError(int(exc.code), retry_after=_retry_after_seconds(exc.headers)) from exc
102
+ except Exception as exc: # noqa: BLE001 - socket/DNS/TLS
103
+ raise TransportError(f"{exc.__class__.__name__}: {exc}") from exc
104
+
105
+
106
+ def list_article_versions(pmcid: str, *, timeout: float = 60.0, http_get=_http_get) -> list[ArticleVersion]:
107
+ """List every version of *pmcid* present in the bucket, lowest version first."""
108
+ canonical = normalize_pmcid(pmcid)
109
+ by_version: dict[int, dict[str, str]] = {}
110
+ token = ""
111
+ while True:
112
+ # Trailing dot keeps PMC1000 from matching PMC10000000.
113
+ url = f"{OA_BASE_URL}/?list-type=2&prefix={canonical}."
114
+ if token:
115
+ url += f"&continuation-token={urllib.parse.quote(token, safe='')}"
116
+ raw = http_get(url, timeout=timeout)
117
+
118
+ try:
119
+ root = ET.fromstring(raw)
120
+ except ET.ParseError as exc:
121
+ raise TransportError(f"malformed S3 listing for {canonical}: {exc}") from exc
122
+
123
+ for node in root.findall("s3:Contents/s3:Key", _S3_NS):
124
+ key = (node.text or "").strip()
125
+ m = _ARTICLE_OBJECT_RE.match(key)
126
+ if not m or f"PMC{m.group(1)}" != canonical:
127
+ continue
128
+ by_version.setdefault(int(m.group(2)), {})[m.group(3)] = key
129
+
130
+ # S3 pages listings at 1,000 keys. An article with many versions or files
131
+ # exceeds that, and the newest version can sort onto a later page.
132
+ if root.findtext("s3:IsTruncated", default="", namespaces=_S3_NS) != "true":
133
+ break
134
+ token = root.findtext("s3:NextContinuationToken", default="", namespaces=_S3_NS)
135
+ if not token:
136
+ raise TransportError(f"truncated S3 listing for {canonical} has no continuation token")
137
+
138
+ return [
139
+ ArticleVersion(pmcid=canonical, version=v, keys=by_version[v])
140
+ for v in sorted(by_version)
141
+ ]
142
+
143
+
144
+ def resolve_latest_version(pmcid: str, *, timeout: float = 60.0, http_get=_http_get) -> ArticleVersion:
145
+ """Highest-numbered version of *pmcid*, or raise :class:`ArticleNotInOASubsetError`."""
146
+ versions = list_article_versions(pmcid, timeout=timeout, http_get=http_get)
147
+ if not versions:
148
+ raise ArticleNotInOASubsetError(
149
+ f"{normalize_pmcid(pmcid)} is not in the PMC Open Access Subset",
150
+ pmcid=normalize_pmcid(pmcid),
151
+ )
152
+ return versions[-1]
153
+
154
+
155
+ class S3OpenAccessClient:
156
+ """Fetch article PDFs from ``s3://pmc-oa-opendata``.
157
+
158
+ Implements the :class:`pmcfetch.pdf.PdfSource` contract
159
+ (``fetch_pdf(pmcid) -> bytes``), so it drops straight into ``download_one``
160
+ and ``download_many``.
161
+ """
162
+
163
+ def __init__(
164
+ self,
165
+ *,
166
+ timeout: float = 60.0,
167
+ retries: int = 3,
168
+ backoff_base: float = 0.5,
169
+ backoff_cap: float = 15.0,
170
+ limiter: MinIntervalLimiter | NullLimiter | None = None,
171
+ http_get: Callable[..., bytes] = _http_get,
172
+ sleep: Callable[[float], None] = time.sleep,
173
+ ) -> None:
174
+ self.timeout = float(timeout)
175
+ self.retries = max(0, int(retries))
176
+ self.backoff_base = float(backoff_base)
177
+ self.backoff_cap = float(backoff_cap)
178
+ self.limiter = limiter if limiter is not None else NullLimiter()
179
+ self._http_get = http_get
180
+ self._sleep = sleep
181
+
182
+ def _get(self, url: str) -> bytes:
183
+ """GET with retry on 429, 5xx and transport errors."""
184
+ total = self.retries + 1
185
+ for attempt in range(total):
186
+ self.limiter.acquire(sleep=self._sleep)
187
+ try:
188
+ return self._http_get(url, timeout=self.timeout)
189
+ except DownloadError as exc:
190
+ if not exc.retryable or attempt == total - 1:
191
+ raise
192
+ self._sleep(self._backoff(attempt, getattr(exc, "retry_after", None)))
193
+ raise TransportError(f"exhausted retries for {url}") # pragma: no cover
194
+
195
+ def _backoff(self, attempt: int, retry_after: float | None) -> float:
196
+ """Full-jitter exponential delay, stretched to any ``Retry-After``, capped at ``backoff_cap``."""
197
+ delay = random.uniform(0.0, min(self.backoff_cap, self.backoff_base * (2**attempt)))
198
+ if retry_after is not None:
199
+ delay = max(delay, retry_after)
200
+ return min(delay, self.backoff_cap)
201
+
202
+ def metadata(self, pmcid: str) -> dict:
203
+ """Article metadata JSON (doi, pmid, licence, is_retracted, urls…)."""
204
+ version = resolve_latest_version(
205
+ pmcid, timeout=self.timeout, http_get=lambda u, timeout: self._get(u)
206
+ )
207
+ url = version.url("json")
208
+ if not url:
209
+ raise ArticleNotInOASubsetError(
210
+ f"{version.prefix} has no metadata object", pmcid=version.pmcid
211
+ )
212
+ return json.loads(self._get(url).decode("utf-8"))
213
+
214
+ def fetch_pdf(self, pmcid: str) -> bytes:
215
+ """PDF bytes for the latest version of *pmcid*."""
216
+ version = resolve_latest_version(
217
+ pmcid, timeout=self.timeout, http_get=lambda u, timeout: self._get(u)
218
+ )
219
+ url = version.url("pdf")
220
+ if not url:
221
+ raise PDFNotAvailableError(
222
+ f"{version.prefix} has no PDF in the OA subset "
223
+ "(publisher licence does not permit PDF redistribution); "
224
+ "XML and plain text are still available",
225
+ pmcid=version.pmcid,
226
+ )
227
+ data = self._get(url)
228
+ if not is_pdf(data):
229
+ raise NotAPDFError(pmcid=version.pmcid, head=data[:64])
230
+ return data
231
+
232
+ def close(self) -> None:
233
+ """No persistent connection to release; present for API symmetry."""
234
+
235
+ def __enter__(self) -> "S3OpenAccessClient":
236
+ return self
237
+
238
+ def __exit__(self, *exc_info: object) -> None:
239
+ self.close()
240
+
241
+
242
+ __all__ = [
243
+ "OA_BUCKET",
244
+ "OA_BASE_URL",
245
+ "ArticleVersion",
246
+ "ArticleNotInOASubsetError",
247
+ "PDFNotAvailableError",
248
+ "S3OpenAccessClient",
249
+ "list_article_versions",
250
+ "resolve_latest_version",
251
+ ]