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