Coverage for src/local_deep_research/journal_quality/data_sources/_openalex_common.py: 98%

114 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-22 21:20 +0000

1"""Shared helpers for the two OpenAlex snapshot fetchers. 

2 

3Sources and institutions both pull from the OpenAlex S3 bucket, translate 

4``s3://`` URLs to the public HTTPS gateway, and defend-in-depth against 

5a compromised manifest by allowlisting the ``s3://openalex/`` prefix. 

6This file owns those three shared symbols so ``openalex.py`` and 

7``institutions.py`` don't duplicate them (and can't drift). It also 

8owns the per-partition streaming helper they both use to iterate 

9records with consistent malformed-line suppression and tmp-file 

10lifecycle. 

11""" 

12 

13from __future__ import annotations 

14 

15import gzip 

16import hashlib 

17import json 

18from pathlib import Path 

19from typing import Callable, Iterator, Tuple 

20 

21from loguru import logger 

22 

23# Public OpenAlex snapshot — CC0, no auth, no rate limits. 

24# Manifest format documented at: 

25# https://docs.openalex.org/download-all-data/snapshot-data-format 

26# Each entry in ``manifest["files"]`` (``manifest["entries"]`` before the 

27# 2026-06 standard-format snapshot) has ``url`` (s3://...) and 

28# ``meta.content_length`` / ``meta.record_count``. We translate s3:// to 

29# the public HTTPS gateway so we don't need boto3. 

30OPENALEX_S3_BASE = "https://openalex.s3.amazonaws.com" 

31 

32# Only fetch parts hosted under the OpenAlex public S3 bucket — defense 

33# in depth on top of safe_get's private-IP block. A compromised or 

34# malformed manifest could otherwise list arbitrary attacker-controlled 

35# URLs. 

36OPENALEX_MANIFEST_ALLOWED_PREFIX = "s3://openalex/" 

37 

38 

39def s3_to_https(s3_url: str) -> str: 

40 """Translate ``s3://openalex/...`` to the public HTTPS gateway.""" 

41 return s3_url.replace( 

42 OPENALEX_MANIFEST_ALLOWED_PREFIX, OPENALEX_S3_BASE + "/", 1 

43 ) 

44 

45 

46def validate_manifest_entries(entries: list[dict], label: str) -> None: 

47 """Refuse to fetch if any manifest entry escapes the S3 allowlist. 

48 

49 Defense-in-depth: a compromised or tampered manifest could list 

50 URLs outside the OpenAlex bucket. Refusing the whole fetch rather 

51 than fetching some-and-not-others keeps failure modes simple. 

52 """ 

53 for entry in entries: 

54 raw = entry.get("url", "") 

55 if not raw.startswith(OPENALEX_MANIFEST_ALLOWED_PREFIX): 

56 raise ValueError( 

57 f"{label} manifest contains disallowed URL " 

58 f"(must start with {OPENALEX_MANIFEST_ALLOWED_PREFIX!r}): " 

59 f"{raw!r}" 

60 ) 

61 

62 

63# Per-partition retry budget. The default ``safe_get_with_retries`` 

64# budget (3 retries, 1-2-4 s backoff = ~7 s total) is sized for small 

65# request bodies and trips on a sustained mid-stream S3 hiccup: every 

66# retry of a ~5–10 MB partition that lands inside the same bad window 

67# fails the same way, exhausts the budget in seconds, and aborts the 

68# whole 30-partition pull. The release-gate workflow saw this twice in 

69# a row on 2026-04-26. 

70# 

71# 5 retries with 2-5-10-20-40 s backoff rides out a ~75 s S3 blip 

72# instead, while still bounding total wall-clock per partition at 

73# roughly ``timeout * 6 + 77 s`` — well inside the 45 min job timeout 

74# even if every partition needed all retries. 

75_PARTITION_MAX_RETRIES = 5 

76_PARTITION_BACKOFF_SECONDS = (2, 5, 10, 20, 40) 

77 

78# Absolute line cap: a real OpenAlex JSONL record is a few KB; even the 

79# largest institution records stay far below 10 MB. A line longer than 

80# this is hostile padding, not data — skipped as malformed without 

81# being materialized. 

82_MAX_JSONL_LINE_CHARS = 10_000_000 

83 

84# Absolute ceiling on a partition's decompressed stream, in characters. 

85# Doubles as the fallback for entries whose manifest entry carries no 

86# meta.content_length: the largest partition in the live fleet (an 

87# institutions one, 176,715,737 compressed bytes) decompresses to 

88# 1,979,631,761 bytes — 1.84 GiB — so 4 GiB keeps ~2.2x over today's 

89# snapshot while still bounding a gzip ratio bomb. The manifest itself 

90# is untrusted, so an anchored cap can never exceed it. 

91_DEFAULT_MAX_PARTITION_DECOMPRESSED_BYTES = 4 * 1024**3 

92 

93# Manifest ``meta.content_length`` counts COMPRESSED gz bytes; the 

94# enforced bound counts DECOMPRESSED characters — so the declaration 

95# must be scaled by an expansion allowance, and the allowance has to 

96# clear the whole live fleet or a real snapshot aborts as a "bomb". 

97# Census of every live partition on 2026-09-24, read from each file's 

98# gzip ISIZE trailer (decompressed bytes, an upper bound on the 

99# characters this bound counts): the 196 sources partitions expand at 

100# a median 14.23x and a maximum 18.69x — files[0], 560,290 compressed 

101# bytes, decompresses to 10,470,101 bytes / 10,461,631 characters — 

102# and the 80 institutions partitions top out at 12.17x. 32 leaves 

103# ~1.7x over that worst live ratio while still catching gzip ratio 

104# bombs (which expand ~1032:1). 

105_COMPRESSED_BYTES_TO_DECOMPRESSED_CHARS = 32 

106 

107# Slack added to an anchored cap, in characters, so format drift on 

108# small partitions is not false-flagged. 

109_PARTITION_CAP_SLACK_CHARS = 1024**2 

110 

111 

112def _partition_decompressed_cap(entry: dict) -> int: 

113 """Upper bound for one partition's decompressed stream, in characters. 

114 

115 Anchored to the manifest's declared ``meta.content_length`` (the 

116 compressed gz size) scaled by the expansion allowance plus slack: 

117 a stream expanding far past what the manifest declared is a 

118 decompression bomb, not a snapshot. The anchor is clamped to the 

119 absolute ceiling — a hostile manifest can declare any size, so a 

120 huge declaration must not buy a bigger bomb budget than a 

121 meta-less entry gets. 

122 """ 

123 meta = entry.get("meta") or {} 

124 declared = meta.get("content_length") 

125 # ``bool`` is an ``int``: a declaration of ``True`` would otherwise 

126 # anchor the cap at 1 * 32 + 1 MiB and abort every real partition. 

127 # The transport-side content_length check excludes bool the same 

128 # way, so a non-integer declaration is no declaration at all. 

129 if ( 

130 isinstance(declared, int) 

131 and not isinstance(declared, bool) 

132 and declared > 0 

133 ): 

134 return min( 

135 _DEFAULT_MAX_PARTITION_DECOMPRESSED_BYTES, 

136 declared * _COMPRESSED_BYTES_TO_DECOMPRESSED_CHARS 

137 + _PARTITION_CAP_SLACK_CHARS, 

138 ) 

139 return _DEFAULT_MAX_PARTITION_DECOMPRESSED_BYTES 

140 

141 

142_DIGEST_HEX_LENGTH = 32 

143_HEX_DIGIT_CHARS = frozenset("0123456789abcdefABCDEF") 

144 

145 

146def _normalize_digest(declared: object) -> str | None: 

147 """Return a lowercase 32-hex digest from a manifest ``meta.md5`` value. # DevSkim: ignore DS126858 

148 

149 Accepts a bare hex digest or the same digest wrapped in one matching 

150 pair of double quotes (the S3 ETag wire form for a non-multipart 

151 object). Returns ``None`` for anything else — a multipart ETag 

152 (``<hex>-<n>``), a base64 digest, the wrong length, non-hex 

153 characters, or a non-string value — so the caller can skip 

154 verification for this partition instead of comparing bytes against 

155 a value that was never a valid digest. 

156 """ 

157 if not isinstance(declared, str): 

158 return None 

159 candidate = declared.strip() 

160 if ( 

161 len(candidate) == _DIGEST_HEX_LENGTH + 2 

162 and candidate[0] == '"' 

163 and candidate[-1] == '"' 

164 ): 

165 candidate = candidate[1:-1] 

166 if len(candidate) == _DIGEST_HEX_LENGTH and all( 

167 c in _HEX_DIGIT_CHARS for c in candidate 

168 ): 

169 return candidate.lower() 

170 return None 

171 

172 

173def iter_partitions( 

174 entries: list[dict], 

175 data_dir: Path, 

176 *, 

177 file_prefix: str, 

178 label: str, 

179 safe_get: Callable, 

180 timeout: int = 120, 

181 max_retries: int = _PARTITION_MAX_RETRIES, 

182 backoff_times: tuple = _PARTITION_BACKOFF_SECONDS, 

183 max_line_chars: int = _MAX_JSONL_LINE_CHARS, 

184) -> Iterator[Tuple[int, int, Iterator[dict]]]: 

185 """Download each partition, yielding ``(idx, total_parts, records)``. 

186 

187 Shared between ``openalex.py`` and ``institutions.py`` so the 

188 tmp-file lifecycle and malformed-JSON suppression (first-10 

189 warnings + one "further suppressed" notice) are defined once. 

190 

191 The caller iterates ``records`` for per-record work and is 

192 responsible for per-partition progress logging and ``progress_cb`` 

193 invocations — those need caller-specific state (running record 

194 count, schema-drift counters) that doesn't belong in the helper. 

195 

196 ``records`` decodes lazily and is valid only until this generator 

197 is advanced — the gzip handle closes and the tmp file is removed 

198 when the next partition is requested, so consume it in place. 

199 Deferring it raises ``ValueError: I/O operation on closed file``. 

200 

201 Args: 

202 entries: ``manifest["files"]`` — each dict has ``url`` 

203 starting with ``s3://openalex/``. 

204 data_dir: Directory used for the transient ``.<prefix>_part_<n>.gz`` 

205 files. Cleaned up even on exception. 

206 file_prefix: Leaf prefix for tmp files 

207 (e.g. ``openalex_sources`` / ``openalex_institutions``). 

208 label: Human-readable label used in log messages 

209 (e.g. ``"OpenAlex sources"`` / ``"Institutions"``). 

210 safe_get: Dependency-injected HTTP getter (lets the caller 

211 pick ``safe_get_with_retries`` without forcing a global 

212 import at module load). Must accept ``consume_body=True`` 

213 so body-stream transients (``ChunkedEncodingError``, 

214 ``ReadTimeout``) raised during ``resp.content`` are 

215 retried inside the wrapper, not propagated to abort the 

216 whole multi-partition pull. Must also accept 

217 ``require_https=True``: partitions are fetched with the 

218 scheme pinned so no redirect hop can move them to cleartext. 

219 timeout: Per-partition HTTP timeout (seconds). 

220 max_retries: Per-partition retry budget. Defaults higher than 

221 ``safe_get_with_retries``' generic 3 because partition 

222 bodies are MB-sized and a mid-stream IncompleteRead aborts 

223 the whole multi-partition pull on exhaustion. 

224 backoff_times: Per-attempt sleep schedule. Defaults to a 

225 longer schedule than the generic ``safe_get_with_retries`` 

226 (1, 2, 4) so we ride out a sustained S3 blip instead of 

227 burning all retries inside the same bad window. 

228 

229 Raises: 

230 ValueError: If a partition's declared ``meta.content_length`` 

231 doesn't match the number of bytes actually received, or 

232 its declared ``meta.md5`` (once normalized to a bare # DevSkim: ignore DS126858 

233 digest) doesn't match the digest of the bytes received — 

234 either aborts before anything is written to disk, so the 

235 previous snapshot is left in place. Also if a partition's 

236 decompressed stream runs past the bound 

237 ``_partition_decompressed_cap`` derives from its manifest 

238 entry: that one fires later, while the caller consumes the 

239 yielded records, so the compressed body has already been 

240 written to the temp file — which the ``finally`` still 

241 removes. 

242 """ 

243 malformed_total = 0 

244 total_parts = len(entries) 

245 

246 def decode(fh, idx: int, max_chars: int, max_bytes: int) -> Iterator[dict]: 

247 nonlocal malformed_total 

248 

249 def bomb_error() -> ValueError: 

250 return ValueError( 

251 f"{label} partition {idx}: decompressed stream exceeded " 

252 f"its bound ({max_bytes} characters) — refusing a " 

253 "possible decompression bomb" 

254 ) 

255 

256 seen_chars = 0 

257 while True: 

258 # readline(size) never buffers more than size characters, so 

259 # an over-long line is detected — and its remainder discarded 

260 # in bounded chunks — without ever being materialized whole. 

261 # Every chunk read, kept or discarded, counts toward the 

262 # bound so an all-overlong-line bomb cannot expand past it 

263 # uncounted. 

264 line = fh.readline(max_chars + 1) 

265 if not line: 

266 return 

267 seen_chars += len(line) 

268 if seen_chars > max_bytes: 

269 raise bomb_error() 

270 if len(line) > max_chars and not line.endswith("\n"): 

271 while True: 

272 rest = fh.readline(max_chars + 1) 

273 if not rest: 273 ↛ 274line 273 didn't jump to line 274 because the condition on line 273 was never true

274 break 

275 seen_chars += len(rest) 

276 if seen_chars > max_bytes: 

277 raise bomb_error() 

278 if rest.endswith("\n"): 

279 break 

280 malformed_total += 1 

281 if malformed_total <= 10: 

282 logger.warning( 

283 f"{label} partition {idx}: skipping over-long line" 

284 ) 

285 elif malformed_total == 11: 

286 logger.warning( 

287 f"{label} partition {idx}: further " 

288 "malformed lines suppressed" 

289 ) 

290 continue 

291 line = line.strip() 

292 if not line: 292 ↛ 293line 292 didn't jump to line 293 because the condition on line 292 was never true

293 continue 

294 try: 

295 rec = json.loads(line) 

296 except (json.JSONDecodeError, ValueError): 

297 malformed_total += 1 

298 if malformed_total <= 10: 

299 logger.warning( 

300 f"{label} partition {idx}: skipping malformed JSON line" 

301 ) 

302 elif malformed_total == 11: 

303 logger.warning( 

304 f"{label} partition {idx}: further " 

305 "malformed lines suppressed" 

306 ) 

307 continue 

308 yield rec 

309 

310 for idx, entry in enumerate(entries): 

311 part_url = s3_to_https(entry["url"]) 

312 tmp_part = data_dir / f".{file_prefix}_part_{idx}.gz" 

313 

314 try: 

315 # consume_body=True: an OpenAlex S3 partition is ~10 MB 

316 # gzipped. A mid-stream ChunkedEncodingError / 

317 # IncompleteRead would otherwise abort the whole 30+ 

318 # partition pull. With consume_body, safe_get_with_retries 

319 # reads resp.content inside its retry loop and retries 

320 # body-stream transients the same way it retries 

321 # header-stage failures. 

322 resp = safe_get( 

323 part_url, 

324 timeout=timeout, 

325 consume_body=True, 

326 max_retries=max_retries, 

327 backoff_times=backoff_times, 

328 require_https=True, 

329 ) 

330 resp.raise_for_status() 

331 meta = entry.get("meta") or {} 

332 

333 # Transport-corruption check when the manifest declares an 

334 # expected size: compressed bytes whose length doesn't match 

335 # meta.content_length were truncated or corrupted in 

336 # transit — refuse before writing them to disk. This is not 

337 # an authentication check: the declared length comes from 

338 # the same manifest fetch as the bytes it describes, so it 

339 # can't detect a hostile origin, only a garbled download. A 

340 # declared value that isn't a plain integer (a string, 

341 # float, or bool) is logged and skipped rather than treated 

342 # as a mismatch. 

343 declared_length = meta.get("content_length") 

344 if declared_length is not None: 

345 if isinstance(declared_length, int) and not isinstance( 

346 declared_length, bool 

347 ): 

348 actual_length = len(resp.content) 

349 if actual_length != declared_length: 

350 raise ValueError( 

351 f"{label} partition {idx}: content_length " 

352 f"mismatch — manifest declares " 

353 f"{declared_length} bytes but received " 

354 f"{actual_length} bytes; " 

355 "refusing possibly corrupted or truncated " 

356 "partition" 

357 ) 

358 else: 

359 shape = ( 

360 f"{len(declared_length)}-character string" 

361 if isinstance(declared_length, str) 

362 else type(declared_length).__name__ 

363 ) 

364 logger.warning( 

365 f"{label} partition {idx}: manifest " 

366 f"content_length is not an integer ({shape}); " 

367 "skipping the size check for this partition" 

368 ) 

369 

370 # Transport-corruption check when the manifest declares a 

371 # digest: bytes that hash differently than meta.md5 were 

372 # altered or corrupted in transit — refuse before writing 

373 # them to disk. Like the content_length check above, this 

374 # is not an authentication check: the digest comes from the 

375 # same manifest fetch as the bytes it certifies, so a party 

376 # able to substitute the partition can substitute the 

377 # declared md5 too. A declared value that isn't a 32-hex 

378 # digest (plain or ETag-quoted) is logged and skipped 

379 # rather than treated as a mismatch. 

380 declared_digest = meta.get("md5") # DevSkim: ignore DS126858 

381 if declared_digest is not None: 

382 normalized_digest = _normalize_digest(declared_digest) 

383 if normalized_digest is None: 

384 shape = ( 

385 f"{len(declared_digest)}-character string" 

386 if isinstance(declared_digest, str) 

387 else type(declared_digest).__name__ 

388 ) 

389 logger.warning( 

390 f"{label} partition {idx}: manifest md5 is not " # DevSkim: ignore DS126858 

391 f"a 32-character hex digest ({shape}); skipping " 

392 "the integrity check for this partition" 

393 ) 

394 else: 

395 actual_digest = hashlib.md5( # DevSkim: ignore DS126858 

396 resp.content, usedforsecurity=False 

397 ).hexdigest() 

398 if actual_digest != normalized_digest: 

399 raise ValueError( 

400 f"{label} partition {idx}: md5 mismatch — " # DevSkim: ignore DS126858 

401 f"manifest declares {normalized_digest!r} but " 

402 f"received {actual_digest!r}; refusing possibly " 

403 "corrupted partition" 

404 ) 

405 

406 tmp_part.write_bytes(resp.content) 

407 # Unpin the compressed body; the suspended frame would 

408 # otherwise hold it until the caller drains the partition. 

409 del resp 

410 

411 with gzip.open(tmp_part, "rt", encoding="utf-8") as fh: 

412 cap = _partition_decompressed_cap(entry) 

413 yield idx, total_parts, decode(fh, idx, max_line_chars, cap) 

414 finally: 

415 tmp_part.unlink(missing_ok=True) 

416 

417 if malformed_total: 

418 logger.warning( 

419 f"{label}: {malformed_total:,} malformed lines skipped across " 

420 f"{total_parts} partitions" 

421 )