fetchpdf 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.
Files changed (64) hide show
  1. fetchpdf/__init__.py +49 -0
  2. fetchpdf/__main__.py +6 -0
  3. fetchpdf/_env.py +63 -0
  4. fetchpdf/_http.py +40 -0
  5. fetchpdf/fetch_abstract_from_doi.py +549 -0
  6. fetchpdf/fetch_metadata_from_doi.py +225 -0
  7. fetchpdf/fetchpdf.py +7339 -0
  8. fetchpdf/retrieval/__init__.py +36 -0
  9. fetchpdf/retrieval/_util.py +150 -0
  10. fetchpdf/retrieval/artifact.py +87 -0
  11. fetchpdf/retrieval/backends/__init__.py +109 -0
  12. fetchpdf/retrieval/backends/claude_cli.py +111 -0
  13. fetchpdf/retrieval/backends/openrouter.py +244 -0
  14. fetchpdf/retrieval/cache.py +123 -0
  15. fetchpdf/retrieval/chunk.py +195 -0
  16. fetchpdf/retrieval/classify.py +110 -0
  17. fetchpdf/retrieval/context.py +69 -0
  18. fetchpdf/retrieval/corresponding.py +349 -0
  19. fetchpdf/retrieval/engine.py +666 -0
  20. fetchpdf/retrieval/extract_images.py +593 -0
  21. fetchpdf/retrieval/fulltext_scan.py +472 -0
  22. fetchpdf/retrieval/goals.py +145 -0
  23. fetchpdf/retrieval/http.py +669 -0
  24. fetchpdf/retrieval/identifiers.py +149 -0
  25. fetchpdf/retrieval/ladder.json +362 -0
  26. fetchpdf/retrieval/linked_artifacts.py +144 -0
  27. fetchpdf/retrieval/llm_adjudicate.py +278 -0
  28. fetchpdf/retrieval/llm_agent_retrieval.py +495 -0
  29. fetchpdf/retrieval/normalize.py +301 -0
  30. fetchpdf/retrieval/pdf_identity.py +533 -0
  31. fetchpdf/retrieval/pdf_text.py +242 -0
  32. fetchpdf/retrieval/provenance.py +185 -0
  33. fetchpdf/retrieval/ratelimit.py +113 -0
  34. fetchpdf/retrieval/repository_waf.py +152 -0
  35. fetchpdf/retrieval/request_drafts.py +304 -0
  36. fetchpdf/retrieval/resolve.py +481 -0
  37. fetchpdf/retrieval/sources/__init__.py +15 -0
  38. fetchpdf/retrieval/sources/arxiv.py +102 -0
  39. fetchpdf/retrieval/sources/core_text.py +64 -0
  40. fetchpdf/retrieval/sources/crossref_tdm.py +193 -0
  41. fetchpdf/retrieval/sources/epmc.py +133 -0
  42. fetchpdf/retrieval/sources/landing.py +34 -0
  43. fetchpdf/retrieval/sources/legacy_pdf.py +99 -0
  44. fetchpdf/retrieval/sources/pmc.py +59 -0
  45. fetchpdf/retrieval/sources/preprint.py +101 -0
  46. fetchpdf/retrieval/sources/publisher_html.py +154 -0
  47. fetchpdf/retrieval/sources/supplements.py +183 -0
  48. fetchpdf/retrieval/supplement_atypon.py +269 -0
  49. fetchpdf/retrieval/supplement_dspace.py +187 -0
  50. fetchpdf/retrieval/supplement_graph.py +842 -0
  51. fetchpdf/retrieval/supplement_index.py +820 -0
  52. fetchpdf/retrieval/supplement_pmc.py +351 -0
  53. fetchpdf/retrieval/supplement_publishers.py +374 -0
  54. fetchpdf/retrieval/supplementary.py +1770 -0
  55. fetchpdf/retrieval/tiers.py +213 -0
  56. fetchpdf/retrieval/to_markdown.py +661 -0
  57. fetchpdf/retrieval/validate.py +368 -0
  58. fetchpdf/retrieval/verify.py +149 -0
  59. fetchpdf-0.1.0.dist-info/METADATA +1392 -0
  60. fetchpdf-0.1.0.dist-info/RECORD +64 -0
  61. fetchpdf-0.1.0.dist-info/WHEEL +5 -0
  62. fetchpdf-0.1.0.dist-info/entry_points.txt +5 -0
  63. fetchpdf-0.1.0.dist-info/licenses/LICENSE +21 -0
  64. fetchpdf-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,669 @@
1
+ """The one way this package talks to the network.
2
+
3
+ Everything goes through HttpClient so that four things are true by construction
4
+ rather than by remembering: every request passes a per-host token bucket, every
5
+ response carries the metadata provenance needs, transient failures back off, and
6
+ no URL containing a secret is ever logged or written to disk.
7
+
8
+ Politeness parameters (mailto, tool/email, api keys) are injected here from the
9
+ environment rather than being spelled out at each call site, so a new source
10
+ cannot accidentally opt out of the polite pool.
11
+ """
12
+
13
+ import hashlib
14
+ import os
15
+ import re
16
+ import tempfile
17
+ import time
18
+ from dataclasses import dataclass
19
+ from typing import Dict, Optional
20
+ from urllib.parse import urlparse, urlsplit, urlunsplit, parse_qsl, urlencode
21
+
22
+ import requests
23
+ from requests.exceptions import (
24
+ ChunkedEncodingError,
25
+ ConnectionError as _ReqConnErr,
26
+ SSLError,
27
+ Timeout,
28
+ )
29
+
30
+ from .._env import EMAIL as _DEFAULT_EMAIL
31
+ from .._http import USER_AGENT
32
+ from .ratelimit import HostRateLimiter
33
+
34
+ #: Query parameters whose values must never appear in a log line or a sidecar.
35
+ _SECRET_PARAMS = {"apikey", "api_key", "key", "token", "access_token", "auth"}
36
+ _SECRET_HEADERS = {"authorization", "x-api-key", "apikey"}
37
+
38
+ #: Headers that carry a credential, i.e. the ones worth REMOVING and retrying
39
+ #: when a host rejects them. Same membership as `_SECRET_HEADERS` today but a
40
+ #: separate name on purpose: that set answers "must this be redacted before it
41
+ #: is logged", this one answers "is this what the host just refused". A future
42
+ #: header could easily need one and not the other.
43
+ _CREDENTIAL_HEADERS = {"authorization", "x-api-key", "apikey"}
44
+
45
+ #: Which environment variable supplies the credential for a host, so a rejection
46
+ #: names the variable the user has to fix rather than "an API key somewhere".
47
+ _CREDENTIAL_ENV = {
48
+ "api.semanticscholar.org": "SEMANTIC_SCHOLAR_API_KEY",
49
+ "api.openalex.org": "OPENALEXAPIKEY",
50
+ "api.core.ac.uk": "COREAPIKEY",
51
+ }
52
+
53
+
54
+ def _credential_env_for(host: str) -> Optional[str]:
55
+ return _CREDENTIAL_ENV.get(host)
56
+
57
+
58
+ #: Hosts already reported as rejecting their credential, so the warning is
59
+ #: emitted once per process rather than once per request.
60
+ _WARNED_CREDENTIALS: set = set()
61
+
62
+ _SECRET_IN_PATH_RE = re.compile(
63
+ r"(?i)\b(apikey|api_key|access_token|token)[=/]([^&/?#\s]+)"
64
+ )
65
+
66
+
67
+ def redact(url: Optional[str]) -> Optional[str]:
68
+ """Replace secret query-parameter values with REDACTED.
69
+
70
+ Applied to every URL before it is logged or persisted. Elsevier takes its
71
+ key as a query parameter, so an un-redacted provenance sidecar would be a
72
+ credential file that looks like an audit record.
73
+ """
74
+ if not url:
75
+ return url
76
+ try:
77
+ parts = urlsplit(url)
78
+ if parts.query:
79
+ cleaned = [
80
+ (k, "REDACTED" if k.lower() in _SECRET_PARAMS else v)
81
+ for k, v in parse_qsl(parts.query, keep_blank_values=True)
82
+ ]
83
+ url = urlunsplit(
84
+ (parts.scheme, parts.netloc, parts.path, urlencode(cleaned), parts.fragment)
85
+ )
86
+ except ValueError:
87
+ pass
88
+ return _SECRET_IN_PATH_RE.sub(r"\1=REDACTED", url)
89
+
90
+
91
+ @dataclass
92
+ class Response:
93
+ """A completed request, with everything provenance needs to explain it."""
94
+
95
+ url: str # final URL after redirects, redacted
96
+ request_url: str # as requested, redacted
97
+ status: int
98
+ content: bytes
99
+ content_type: str
100
+ headers: Dict[str, str]
101
+ elapsed: float
102
+ from_cache: bool = False
103
+
104
+ @property
105
+ def ok(self) -> bool:
106
+ return 200 <= self.status < 300
107
+
108
+ @property
109
+ def text(self) -> str:
110
+ return self.content.decode("utf-8", errors="replace")
111
+
112
+ def json(self):
113
+ """Parse as JSON. Raises ValueError on anything that is not.
114
+
115
+ Deliberately not silently returning {}: a source that asked for JSON and
116
+ got an HTML error page served as 200 needs to know, because that is
117
+ exactly the failure this package exists to catch.
118
+ """
119
+ import json as _json
120
+
121
+ return _json.loads(self.text)
122
+
123
+ def json_or(self, default):
124
+ try:
125
+ return self.json()
126
+ except ValueError:
127
+ return default
128
+
129
+
130
+ @dataclass
131
+ class Download:
132
+ """The outcome of a size-capped streaming GET written straight to disk.
133
+
134
+ `path` is set only on a complete, under-cap write, so `ok` is the single
135
+ question a caller has to ask. Everything else exists so the supplementary
136
+ manifest can say *why* a file is not there -- "refused, 4.2 GB" and "nothing
137
+ was offered" are different facts and must not collapse into one.
138
+ """
139
+
140
+ url: str # final URL after redirects, redacted
141
+ request_url: str # as requested, redacted
142
+ status: int
143
+ content_type: str = ""
144
+ bytes_written: int = 0
145
+ sha256: str = "" # of the bytes actually written
146
+ path: Optional[str] = None
147
+ outcome: str = "ok" # ok | too-large | http-error | empty | unreachable
148
+ declared_length: Optional[int] = None # Content-Length as served, when sent
149
+ elapsed: float = 0.0
150
+ detail: str = ""
151
+
152
+ @property
153
+ def ok(self) -> bool:
154
+ return self.path is not None
155
+
156
+
157
+ class HttpClient:
158
+ """Rate-limited, retrying, redacting HTTP for the retrieval path."""
159
+
160
+ #: Transport-level failures worth another attempt. HTTP 4xx/5xx are not
161
+ #: retried here -- a 403 from a publisher TDM link is an answer, not a blip.
162
+ _TRANSIENT = (SSLError, _ReqConnErr, Timeout, ChunkedEncodingError)
163
+
164
+ def __init__(self, limiter: HostRateLimiter, email: Optional[str] = None,
165
+ verbose: bool = False, timeout: int = 30):
166
+ self.limiter = limiter
167
+ self.email = email or _DEFAULT_EMAIL
168
+ self.verbose = verbose
169
+ self.timeout = timeout
170
+ self.session = requests.Session()
171
+ self.session.headers.update({"User-Agent": USER_AGENT})
172
+ self.call_count = 0
173
+
174
+ # -- politeness ---------------------------------------------------------
175
+
176
+ def polite_params(self, host: str, params: Optional[dict] = None) -> dict:
177
+ """Add each API's required or rate-limit-lifting identification."""
178
+ params = dict(params or {})
179
+ if not self.email:
180
+ return params
181
+ if host == "api.crossref.org":
182
+ params.setdefault("mailto", self.email)
183
+ elif host.endswith("ncbi.nlm.nih.gov"):
184
+ params.setdefault("tool", "fetchpdf")
185
+ params.setdefault("email", self.email)
186
+ api_key = os.getenv("ENTREZ_EUTILS_API_KEY")
187
+ if api_key:
188
+ params.setdefault("api_key", api_key)
189
+ elif host == "api.unpaywall.org":
190
+ params.setdefault("email", self.email)
191
+ elif host == "www.ebi.ac.uk":
192
+ params.setdefault("email", self.email)
193
+ return params
194
+
195
+ def polite_headers(self, host: str, headers: Optional[dict] = None) -> dict:
196
+ headers = dict(headers or {})
197
+ if host == "api.semanticscholar.org":
198
+ key = os.getenv("SEMANTIC_SCHOLAR_API_KEY")
199
+ if key:
200
+ headers.setdefault("x-api-key", key)
201
+ elif host == "api.openalex.org":
202
+ key = os.getenv("OPENALEXAPIKEY")
203
+ if key:
204
+ headers.setdefault("Authorization", f"Bearer {key}")
205
+ elif host == "api.core.ac.uk":
206
+ key = os.getenv("COREAPIKEY")
207
+ if key:
208
+ headers.setdefault("Authorization", f"Bearer {key}")
209
+ return headers
210
+
211
+ # -- the request --------------------------------------------------------
212
+
213
+ def get(self, url, params=None, headers=None, timeout=None, retries=3,
214
+ allow_redirects=True, polite=True, stream_limit=None) -> Response:
215
+ """GET with rate limiting, backoff and redaction.
216
+
217
+ Never raises for an HTTP status: sources decide what a status means.
218
+ Transport failures after `retries` attempts surface as a synthetic
219
+ status 0 response, so a dead host demotes the source instead of killing
220
+ the record.
221
+ """
222
+ host = urlparse(url).netloc
223
+ if polite:
224
+ params = self.polite_params(host, params)
225
+ headers = self.polite_headers(host, headers)
226
+
227
+ backoff = 0.5
228
+ last_error = ""
229
+ for attempt in range(max(1, retries)):
230
+ self.limiter.acquire(host)
231
+ started = time.monotonic()
232
+ try:
233
+ r = self.session.get(
234
+ url,
235
+ params=params,
236
+ headers=headers,
237
+ timeout=timeout or self.timeout,
238
+ allow_redirects=allow_redirects,
239
+ )
240
+ self.call_count += 1
241
+ elapsed = time.monotonic() - started
242
+
243
+ # A key we sent and the host rejected is WORSE than no key:
244
+ # unkeyed, these APIs answer at a lower rate limit; keyed with a
245
+ # dead credential they answer 401/403 and the source drops out
246
+ # of the chain entirely. Measured 2026-08-17: an expired
247
+ # SEMANTIC_SCHOLAR_API_KEY turned every Semantic Scholar call --
248
+ # step 5 of the default resolution chain -- into a 403, while
249
+ # the same request with no key at all returned 200. Nothing
250
+ # said so, because a dead source just looks like a source that
251
+ # had nothing. Retry once without the credential and say which
252
+ # key is bad, so the failure is a warning rather than a silent
253
+ # capability loss.
254
+ if r.status_code in (401, 403) and polite:
255
+ stripped = {k: v for k, v in (headers or {}).items()
256
+ if k.lower() not in _CREDENTIAL_HEADERS}
257
+ if stripped != (headers or {}):
258
+ bad = _credential_env_for(host) or "its API key"
259
+ # Once per host per process. The keyed hosts sit in the
260
+ # per-record resolution chain, so a per-call warning
261
+ # prints once per paper and buries the run log under
262
+ # thousands of copies of one fact.
263
+ if host not in _WARNED_CREDENTIALS:
264
+ _WARNED_CREDENTIALS.add(host)
265
+ print(f" ⚠ {host} rejected {bad} ({r.status_code}); "
266
+ f"retrying unkeyed at the lower rate limit for "
267
+ f"the rest of this run. Refresh or unset it to "
268
+ f"silence this.")
269
+ headers = stripped
270
+ polite = False # do not re-add it on the next pass
271
+ continue
272
+
273
+ # 429/503 are worth waiting out; they mean "later", not "no".
274
+ if r.status_code in (429, 503) and attempt < retries - 1:
275
+ wait = _retry_after(r) or backoff * (2 ** attempt)
276
+ self.limiter.penalize(host, wait)
277
+ if self.verbose:
278
+ print(f" {host} {r.status_code}; backing off {wait:.1f}s")
279
+ time.sleep(wait)
280
+ continue
281
+
282
+ content = r.content
283
+ if stream_limit and len(content) > stream_limit:
284
+ content = content[:stream_limit]
285
+ return Response(
286
+ url=redact(r.url),
287
+ request_url=redact(url),
288
+ status=r.status_code,
289
+ content=content,
290
+ content_type=r.headers.get("content-type", "").split(";")[0].strip().lower(),
291
+ headers={
292
+ k: ("REDACTED" if k.lower() in _SECRET_HEADERS else v)
293
+ for k, v in r.headers.items()
294
+ },
295
+ elapsed=elapsed,
296
+ )
297
+ except self._TRANSIENT as e:
298
+ last_error = str(e)[:200]
299
+ if attempt < retries - 1:
300
+ time.sleep(backoff * (2 ** attempt))
301
+ continue
302
+ except Exception as e: # malformed URL, bad encoding, ...
303
+ last_error = str(e)[:200]
304
+ break
305
+
306
+ if self.verbose:
307
+ print(f" {host} unreachable: {last_error}")
308
+ return Response(
309
+ url=redact(url),
310
+ request_url=redact(url),
311
+ status=0,
312
+ content=b"",
313
+ content_type="",
314
+ headers={},
315
+ elapsed=0.0,
316
+ )
317
+
318
+ # -- the JSON request ---------------------------------------------------
319
+
320
+ def post_json(self, url, payload, headers=None, timeout=None, retries=2,
321
+ polite=False) -> Response:
322
+ """POST a JSON body, with the same rate limiting, backoff and redaction.
323
+
324
+ Added for the retrieval agent's OpenRouter backend, and routed through
325
+ here rather than through `requests` directly for one reason: `redact`
326
+ and `_SECRET_HEADERS`. A bearer token that reaches a log line reaches
327
+ the manifest and the sidecar too, and those are files a corpus gets
328
+ shared as.
329
+
330
+ Fewer retries than `get` by default. A model call is expensive and
331
+ slow, so a repeat is not the cheap insurance it is for a metadata
332
+ lookup, and an inference endpoint that 429s wants a real wait rather
333
+ than three quick attempts.
334
+
335
+ Never raises for an HTTP status, exactly as `get` does not -- the
336
+ caller decides what a status means, and a dead endpoint must degrade
337
+ the agent rather than kill the record.
338
+ """
339
+ host = urlparse(url).netloc
340
+ if polite:
341
+ headers = self.polite_headers(host, headers)
342
+
343
+ backoff = 1.0
344
+ last_error = ""
345
+ for attempt in range(max(1, retries)):
346
+ self.limiter.acquire(host)
347
+ started = time.monotonic()
348
+ try:
349
+ r = self.session.post(
350
+ url,
351
+ json=payload,
352
+ headers=headers,
353
+ timeout=timeout or self.timeout,
354
+ )
355
+ self.call_count += 1
356
+ elapsed = time.monotonic() - started
357
+
358
+ if r.status_code in (429, 503) and attempt < retries - 1:
359
+ wait = _retry_after(r) or backoff * (2 ** attempt)
360
+ self.limiter.penalize(host, wait)
361
+ if self.verbose:
362
+ print(f" {host} {r.status_code}; backing off {wait:.1f}s")
363
+ time.sleep(wait)
364
+ continue
365
+
366
+ return Response(
367
+ url=redact(r.url),
368
+ request_url=redact(url),
369
+ status=r.status_code,
370
+ content=r.content,
371
+ content_type=r.headers.get("content-type", "").split(";")[0].strip().lower(),
372
+ headers={
373
+ k: ("REDACTED" if k.lower() in _SECRET_HEADERS else v)
374
+ for k, v in r.headers.items()
375
+ },
376
+ elapsed=elapsed,
377
+ )
378
+ except self._TRANSIENT as e:
379
+ last_error = str(e)[:200]
380
+ if attempt < retries - 1:
381
+ time.sleep(backoff * (2 ** attempt))
382
+ continue
383
+ except Exception as e:
384
+ last_error = str(e)[:200]
385
+ break
386
+
387
+ if self.verbose:
388
+ print(f" {host} unreachable: {last_error}")
389
+ return Response(url=redact(url), request_url=redact(url), status=0,
390
+ content=b"", content_type="", headers={}, elapsed=0.0)
391
+
392
+ # -- the size-capped transfer -------------------------------------------
393
+
394
+ def download(self, url, dest, max_bytes, params=None, headers=None, timeout=None,
395
+ retries=2, allow_redirects=True, polite=True,
396
+ chunk_size=65536) -> Download:
397
+ """Stream a URL to `dest`, aborting cleanly above `max_bytes`.
398
+
399
+ Unlike get(), nothing is buffered in memory and nothing is ever
400
+ truncated. get()'s stream_limit keeps the first N bytes of an oversized
401
+ body, which is right for sniffing a landing page and catastrophic for a
402
+ file: a 300 MB prefix of a 400 MB .xlsx is a zip with a corrupt central
403
+ directory that some readers open far enough to yield wrong numbers.
404
+ Here an over-cap body is abandoned and its partial removed, so `dest` is
405
+ complete or absent -- never partial.
406
+
407
+ Never raises for an HTTP status, and never raises for a transport
408
+ failure: like get(), a dead host comes back as a synthetic status 0 so
409
+ one bad URL demotes a file rather than killing the record.
410
+ """
411
+ if str(url).startswith("file://"):
412
+ # A provider that had to fetch the bytes itself hands them back as a
413
+ # local path. supplement_atypon does this because its URLs only work
414
+ # from inside the browser session that clicked them, so there is
415
+ # nothing here to re-request -- but the file still has to go through
416
+ # the same cap, hashing, sniffing and manifesting as everything else.
417
+ return _adopt_local_file_impl(url, dest, max_bytes)
418
+
419
+ host = urlparse(url).netloc
420
+ if polite:
421
+ params = self.polite_params(host, params)
422
+ headers = self.polite_headers(host, headers)
423
+
424
+ directory = os.path.dirname(os.path.abspath(dest)) or "."
425
+ backoff = 0.5
426
+ last_error = ""
427
+ for attempt in range(max(1, retries)):
428
+ self.limiter.acquire(host)
429
+ started = time.monotonic()
430
+ temp_path = None
431
+ try:
432
+ r = self.session.get(
433
+ url,
434
+ params=params,
435
+ headers=headers,
436
+ timeout=timeout or self.timeout,
437
+ allow_redirects=allow_redirects,
438
+ stream=True,
439
+ )
440
+ self.call_count += 1
441
+ with r:
442
+ declared = _content_length(r)
443
+
444
+ if r.status_code in (429, 503) and attempt < retries - 1:
445
+ wait = _retry_after(r) or backoff * (2 ** attempt)
446
+ self.limiter.penalize(host, wait)
447
+ if self.verbose:
448
+ print(f" {host} {r.status_code}; backing off {wait:.1f}s")
449
+ time.sleep(wait)
450
+ continue
451
+
452
+ outcome = _Outcome(r, declared, url)
453
+ if not (200 <= r.status_code < 300):
454
+ return outcome.failure("http-error", f"HTTP {r.status_code}")
455
+
456
+ # The cheap refusal: a declared length over the cap costs no
457
+ # bandwidth at all. Deliberately read off the streaming
458
+ # response rather than from a separate HEAD -- plenty of
459
+ # repository file hosts 405 HEAD, and Europe PMC answers it
460
+ # with content-length: 0 while GET returns real bytes.
461
+ if declared is not None and declared > max_bytes:
462
+ return outcome.failure(
463
+ "too-large", f"declared {declared} > cap {max_bytes}"
464
+ )
465
+
466
+ os.makedirs(directory, exist_ok=True)
467
+ handle, temp_path = tempfile.mkstemp(
468
+ prefix=".fetchpdf-dl-", dir=directory
469
+ )
470
+ hasher = hashlib.sha256()
471
+ written = 0
472
+ over_cap = False
473
+ with os.fdopen(handle, "wb") as f:
474
+ for chunk in r.iter_content(chunk_size):
475
+ if not chunk:
476
+ continue
477
+ written += len(chunk)
478
+ # Servers lie, and chunked responses declare nothing.
479
+ # This is the case the Content-Length check above
480
+ # cannot catch, and the one that makes truncating
481
+ # tempting.
482
+ if written > max_bytes:
483
+ over_cap = True
484
+ break
485
+ hasher.update(chunk)
486
+ f.write(chunk)
487
+
488
+ if over_cap:
489
+ _unlink(temp_path)
490
+ return outcome.failure(
491
+ "too-large", f"exceeded cap {max_bytes} mid-stream"
492
+ )
493
+ if written == 0:
494
+ _unlink(temp_path)
495
+ return outcome.failure("empty", "no bytes served")
496
+
497
+ apply_default_mode(temp_path)
498
+ os.replace(temp_path, dest)
499
+ return Download(
500
+ url=redact(r.url),
501
+ request_url=redact(url),
502
+ status=r.status_code,
503
+ content_type=_content_type(r),
504
+ bytes_written=written,
505
+ sha256=hasher.hexdigest(),
506
+ path=dest,
507
+ outcome="ok",
508
+ declared_length=declared,
509
+ elapsed=time.monotonic() - started,
510
+ )
511
+ except self._TRANSIENT as e:
512
+ # Retry from scratch, never resume: a resumed partial with a
513
+ # mismatched offset is silent corruption of exactly the kind the
514
+ # no-truncation rule above exists to prevent.
515
+ _unlink(temp_path)
516
+ last_error = str(e)[:200]
517
+ if attempt < retries - 1:
518
+ time.sleep(backoff * (2 ** attempt))
519
+ continue
520
+ except BaseException as e:
521
+ _unlink(temp_path)
522
+ if isinstance(e, Exception):
523
+ last_error = str(e)[:200]
524
+ break
525
+ raise
526
+
527
+ if self.verbose:
528
+ print(f" {host} download failed: {last_error}")
529
+ return Download(
530
+ url=redact(url),
531
+ request_url=redact(url),
532
+ status=0,
533
+ outcome="unreachable",
534
+ detail=last_error,
535
+ )
536
+
537
+
538
+ def _adopt_local_file_impl(url, dest, max_bytes) -> Download:
539
+ """Bring a provider-fetched local file into the pipeline under the same rules.
540
+
541
+ Same cap, same hash, same "complete or absent" guarantee as a network
542
+ download -- the only difference is where the bytes came from. Copied rather
543
+ than moved so the provider's scratch dir stays consistent and a retry is
544
+ still possible.
545
+ """
546
+ source = url[len("file://"):]
547
+ try:
548
+ size = os.path.getsize(source)
549
+ except OSError as error:
550
+ return Download(url=redact(url), request_url=redact(url), status=0,
551
+ outcome="unreachable", detail=str(error)[:120])
552
+ if size == 0:
553
+ return Download(url=redact(url), request_url=redact(url), status=200,
554
+ outcome="empty", declared_length=0)
555
+ if size > max_bytes:
556
+ return Download(url=redact(url), request_url=redact(url), status=200,
557
+ outcome="too-large", declared_length=size,
558
+ detail=f"{size} bytes over the {max_bytes} cap")
559
+
560
+ hasher = hashlib.sha256()
561
+ written = 0
562
+ try:
563
+ with open(source, "rb") as src, open(dest, "wb") as out:
564
+ while True:
565
+ chunk = src.read(65536)
566
+ if not chunk:
567
+ break
568
+ hasher.update(chunk)
569
+ out.write(chunk)
570
+ written += len(chunk)
571
+ except OSError as error:
572
+ try:
573
+ os.unlink(dest)
574
+ except OSError:
575
+ pass
576
+ return Download(url=redact(url), request_url=redact(url), status=0,
577
+ outcome="unreachable", detail=str(error)[:120])
578
+
579
+ return Download(url=redact(url), request_url=redact(url), status=200,
580
+ content_type="", bytes_written=written,
581
+ sha256=hasher.hexdigest(), path=dest, outcome="ok",
582
+ declared_length=size)
583
+
584
+
585
+ class _Outcome:
586
+ """Builds a failed Download that still carries the response metadata."""
587
+
588
+ def __init__(self, response, declared, request_url):
589
+ self.url = redact(response.url)
590
+ self.request_url = redact(request_url)
591
+ self.status = response.status_code
592
+ self.content_type = _content_type(response)
593
+ self.declared = declared
594
+
595
+ def failure(self, outcome: str, detail: str) -> Download:
596
+ return Download(
597
+ url=self.url,
598
+ request_url=self.request_url,
599
+ status=self.status,
600
+ content_type=self.content_type,
601
+ outcome=outcome,
602
+ declared_length=self.declared,
603
+ detail=detail,
604
+ )
605
+
606
+
607
+ def _content_type(response) -> str:
608
+ return response.headers.get("content-type", "").split(";")[0].strip().lower()
609
+
610
+
611
+ def _content_length(response) -> Optional[int]:
612
+ raw = response.headers.get("Content-Length")
613
+ if raw is None:
614
+ return None
615
+ try:
616
+ value = int(raw)
617
+ except (TypeError, ValueError):
618
+ return None
619
+ return value if value >= 0 else None
620
+
621
+
622
+ def _default_file_mode() -> int:
623
+ """The mode an ordinary open(..., "wb") would produce here.
624
+
625
+ Read once, at import, because reading a umask means temporarily setting it:
626
+ doing that later would race every other thread in batch_fetch_pdfs's pool,
627
+ any of which could create a file during the window when the umask is 0.
628
+ """
629
+ try:
630
+ current = os.umask(0)
631
+ os.umask(current)
632
+ return 0o666 & ~current
633
+ except OSError:
634
+ return 0o644
635
+
636
+
637
+ DELIVERED_FILE_MODE = _default_file_mode()
638
+
639
+
640
+ def apply_default_mode(path: str) -> None:
641
+ """Give a delivered file the permissions an ordinary write would have had.
642
+
643
+ tempfile.mkstemp creates 0600, which is right for a staging file and wrong for
644
+ a delivered one: supplementary files would end up less readable than the PDF
645
+ sitting beside them, which anyone sharing an output directory hits at once.
646
+ """
647
+ try:
648
+ os.chmod(path, DELIVERED_FILE_MODE)
649
+ except OSError:
650
+ pass
651
+
652
+
653
+ def _unlink(path: Optional[str]) -> None:
654
+ if not path:
655
+ return
656
+ try:
657
+ os.unlink(path)
658
+ except OSError:
659
+ pass
660
+
661
+
662
+ def _retry_after(response) -> Optional[float]:
663
+ raw = response.headers.get("Retry-After")
664
+ if not raw:
665
+ return None
666
+ try:
667
+ return min(float(raw), 60.0)
668
+ except (TypeError, ValueError):
669
+ return None