bibcite-cli 0.6.6__tar.gz → 0.6.7__tar.gz

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 (35) hide show
  1. {bibcite_cli-0.6.6 → bibcite_cli-0.6.7}/PKG-INFO +1 -1
  2. {bibcite_cli-0.6.6 → bibcite_cli-0.6.7}/pyproject.toml +1 -1
  3. {bibcite_cli-0.6.6 → bibcite_cli-0.6.7}/src/bibcite/sources.py +158 -70
  4. {bibcite_cli-0.6.6 → bibcite_cli-0.6.7}/tests/test_public_service.py +18 -6
  5. bibcite_cli-0.6.7/tests/test_s2_pacing.py +91 -0
  6. {bibcite_cli-0.6.6 → bibcite_cli-0.6.7}/tests/test_source_retries.py +5 -5
  7. {bibcite_cli-0.6.6 → bibcite_cli-0.6.7}/tests/test_status_semantics.py +41 -0
  8. {bibcite_cli-0.6.6 → bibcite_cli-0.6.7}/uv.lock +1 -1
  9. {bibcite_cli-0.6.6 → bibcite_cli-0.6.7}/.github/workflows/ci.yml +0 -0
  10. {bibcite_cli-0.6.6 → bibcite_cli-0.6.7}/.github/workflows/publish.yml +0 -0
  11. {bibcite_cli-0.6.6 → bibcite_cli-0.6.7}/.gitignore +0 -0
  12. {bibcite_cli-0.6.6 → bibcite_cli-0.6.7}/LICENSE +0 -0
  13. {bibcite_cli-0.6.6 → bibcite_cli-0.6.7}/Readme.md +0 -0
  14. {bibcite_cli-0.6.6 → bibcite_cli-0.6.7}/assets/bibcite.svg +0 -0
  15. {bibcite_cli-0.6.6 → bibcite_cli-0.6.7}/skills/bibcite/SKILL.md +0 -0
  16. {bibcite_cli-0.6.6 → bibcite_cli-0.6.7}/src/bibcite/__init__.py +0 -0
  17. {bibcite_cli-0.6.6 → bibcite_cli-0.6.7}/src/bibcite/bibfile.py +0 -0
  18. {bibcite_cli-0.6.6 → bibcite_cli-0.6.7}/src/bibcite/cache.py +0 -0
  19. {bibcite_cli-0.6.6 → bibcite_cli-0.6.7}/src/bibcite/cli.py +0 -0
  20. {bibcite_cli-0.6.6 → bibcite_cli-0.6.7}/src/bibcite/data/strings.bib +0 -0
  21. {bibcite_cli-0.6.6 → bibcite_cli-0.6.7}/src/bibcite/normalize.py +0 -0
  22. {bibcite_cli-0.6.6 → bibcite_cli-0.6.7}/src/bibcite/resolve.py +0 -0
  23. {bibcite_cli-0.6.6 → bibcite_cli-0.6.7}/src/bibcite/venues.py +0 -0
  24. {bibcite_cli-0.6.6 → bibcite_cli-0.6.7}/tests/test_bibfile.py +0 -0
  25. {bibcite_cli-0.6.6 → bibcite_cli-0.6.7}/tests/test_bugfixes.py +0 -0
  26. {bibcite_cli-0.6.6 → bibcite_cli-0.6.7}/tests/test_cli_status.py +0 -0
  27. {bibcite_cli-0.6.6 → bibcite_cli-0.6.7}/tests/test_dblp.py +0 -0
  28. {bibcite_cli-0.6.6 → bibcite_cli-0.6.7}/tests/test_entry_types.py +0 -0
  29. {bibcite_cli-0.6.6 → bibcite_cli-0.6.7}/tests/test_no_google_scholar.py +0 -0
  30. {bibcite_cli-0.6.6 → bibcite_cli-0.6.7}/tests/test_normalize.py +0 -0
  31. {bibcite_cli-0.6.6 → bibcite_cli-0.6.7}/tests/test_round2.py +0 -0
  32. {bibcite_cli-0.6.6 → bibcite_cli-0.6.7}/tests/test_round3.py +0 -0
  33. {bibcite_cli-0.6.6 → bibcite_cli-0.6.7}/tests/test_strings_override.py +0 -0
  34. {bibcite_cli-0.6.6 → bibcite_cli-0.6.7}/tests/test_venues.py +0 -0
  35. {bibcite_cli-0.6.6 → bibcite_cli-0.6.7}/tests/test_webpages.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: bibcite-cli
3
- Version: 0.6.6
3
+ Version: 0.6.7
4
4
  Summary: Resolve papers (arXiv id / DOI / title) to canonical, normalized BibTeX for agents and humans
5
5
  Project-URL: Repository, https://github.com/leo1oel/bibcite
6
6
  License-Expression: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "bibcite-cli"
3
- version = "0.6.6"
3
+ version = "0.6.7"
4
4
  description = "Resolve papers (arXiv id / DOI / title) to canonical, normalized BibTeX for agents and humans"
5
5
  readme = "Readme.md"
6
6
  license = "MIT"
@@ -7,15 +7,18 @@ preprint venues (arXiv / CoRR / bioRxiv / ...).
7
7
  """
8
8
 
9
9
  import html
10
+ import hashlib
10
11
  import json
11
12
  import os
12
13
  import re
14
+ import sqlite3
13
15
  import sys
14
16
  import threading
15
17
  import time
16
18
  import xml.etree.ElementTree as ET
17
19
  from concurrent.futures import ThreadPoolExecutor, as_completed
18
20
  from dataclasses import dataclass, field
21
+ from pathlib import Path
19
22
  from urllib.parse import urlsplit
20
23
 
21
24
  import httpx
@@ -104,8 +107,7 @@ def _get(
104
107
  personal_access = {
105
108
  "openalex": bool(os.environ.get("OPENALEX_API_KEY")),
106
109
  "semanticscholar": bool(
107
- os.environ.get("S2_API_KEY")
108
- or os.environ.get("SEMANTIC_SCHOLAR_API_KEY")
110
+ os.environ.get("S2_API_KEY") or os.environ.get("SEMANTIC_SCHOLAR_API_KEY")
109
111
  ),
110
112
  "crossref": bool(os.environ.get("BIBCITE_MAILTO")),
111
113
  }
@@ -147,6 +149,16 @@ def _get(
147
149
  if response.status_code == 404:
148
150
  return response
149
151
  if response.status_code == 429:
152
+ try:
153
+ code = response.json().get("code")
154
+ except (ValueError, AttributeError):
155
+ code = None
156
+ if isinstance(code, str) and code in {
157
+ "queue_busy",
158
+ "daily_quota",
159
+ "upstream_rate_limit",
160
+ }:
161
+ raise SourceUnavailable(f"public literature service {code}")
150
162
  raise SourceUnavailable("public literature service rate-limited (429)")
151
163
  if response.is_error:
152
164
  raise SourceUnavailable(
@@ -197,6 +209,11 @@ def _s2_headers() -> dict:
197
209
  return {"x-api-key": key} if key else {}
198
210
 
199
211
 
212
+ def _s2_batch_status() -> str | None:
213
+ status = os.environ.get("BIBCITE_S2_BATCH_STATUS")
214
+ return status if status in {"checked", "unavailable"} else None
215
+
216
+
200
217
  def _mailto() -> str:
201
218
  """Contact email for the polite pools (CrossRef/OpenAlex).
202
219
  Set BIBCITE_MAILTO to use your own."""
@@ -308,51 +325,11 @@ def arxiv_metadata(arxiv_id: str) -> ArxivMeta:
308
325
  # DBLP
309
326
  # ---------------------------------------------------------------------------
310
327
 
311
- # Client-side pacing + backoff-retry, shared by the throttle-prone sources.
312
- # Pacing prevents the 429 in the first place; on a 429 we back off (honoring
313
- # Retry-After) instead of instantly poisoning the rest of a batch run — only
314
- # repeated failure raises SourceUnavailable (which disables the source).
328
+ # Process-local pacing for DBLP. Semantic Scholar uses a cross-process SQLite
329
+ # dispatch gate below, shared with the Rust batch client.
315
330
  _LAST_REQUEST: dict[str, float] = {}
316
331
 
317
332
 
318
- def _paced_get(
319
- c: httpx.Client,
320
- url: str,
321
- source: str,
322
- min_interval: float,
323
- params: dict | None = None,
324
- headers: dict | None = None,
325
- ) -> httpx.Response:
326
- for attempt in range(2):
327
- wait = min_interval - (time.monotonic() - _LAST_REQUEST.get(source, 0.0))
328
- if wait > 0:
329
- _sleep(wait)
330
- _LAST_REQUEST[source] = time.monotonic()
331
- try:
332
- r = _get(c, url, params=params, headers=headers)
333
- except httpx.HTTPError as e: # Retry transport errors once before failing.
334
- if attempt < 1:
335
- _sleep(1)
336
- continue
337
- raise TransientSourceError(
338
- f"{source} unreachable ({type(e).__name__})"
339
- ) from e
340
- if r.status_code == 429:
341
- # A 429 is usually a persistent rate-limit (e.g. the shared
342
- # unauthenticated Semantic Scholar pool), not a transient blip, so a
343
- # long client-side backoff rarely clears it and just stalls an
344
- # interactive resolve. Take at most one quick retry when the server
345
- # asks for a short wait, then give up and let the circuit breaker
346
- # skip this source for the rest of the run.
347
- retry_after = int(r.headers.get("Retry-After") or 0)
348
- if attempt < 1 and retry_after <= 2:
349
- _sleep(max(retry_after, 1))
350
- continue
351
- raise SourceUnavailable(f"{source} rate-limited (429)")
352
- return r
353
- raise SourceUnavailable(f"{source} unavailable")
354
-
355
-
356
333
  # DBLP throttles at roughly 1-2 req/s and escalates to temporary IP bans.
357
334
  def _dblp_get(c: httpx.Client, url: str, params: dict | None = None) -> httpx.Response:
358
335
  wait = 0.8 - (time.monotonic() - _LAST_REQUEST.get("dblp", 0.0))
@@ -673,9 +650,7 @@ def arxiv_abs_metadata(arxiv_id: str) -> ArxivMeta | None:
673
650
  def metas(name: str) -> list[str]:
674
651
  return [
675
652
  html.unescape(m)
676
- for m in re.findall(
677
- rf'<meta\s+name="{name}"\s+content="([^"]*)"', page
678
- )
653
+ for m in re.findall(rf'<meta\s+name="{name}"\s+content="([^"]*)"', page)
679
654
  ]
680
655
 
681
656
  titles = metas("citation_title")
@@ -691,12 +666,96 @@ def arxiv_abs_metadata(arxiv_id: str) -> ArxivMeta | None:
691
666
  )
692
667
 
693
668
 
669
+ def _s2_pacing_path() -> Path:
670
+ cache_home = os.environ.get("XDG_CACHE_HOME")
671
+ root = Path(cache_home) if cache_home else Path.home() / ".cache"
672
+ return root / "bibcite" / "requests.sqlite3"
673
+
674
+
675
+ def _s2_gate(key: str) -> None:
676
+ """Reserve one S2 dispatch slot using the Rust-compatible SQLite protocol."""
677
+ started = time.monotonic()
678
+ publication_deadline = getattr(_REQUEST_DEADLINE, "value", None)
679
+ gate_deadline = started + 2.2
680
+ if publication_deadline is not None:
681
+ gate_deadline = min(gate_deadline, publication_deadline)
682
+ remaining = gate_deadline - time.monotonic()
683
+ if remaining <= 0:
684
+ raise PublicationTimeout("publication lookup timed out before S2 pacing")
685
+
686
+ path = _s2_pacing_path()
687
+ connection = None
688
+ in_transaction = False
689
+ try:
690
+ path.parent.mkdir(parents=True, exist_ok=True)
691
+ connection = sqlite3.connect(path, timeout=remaining)
692
+ busy_ms = max(1, min(2200, int(remaining * 1000)))
693
+ connection.execute(f"PRAGMA busy_timeout = {busy_ms}")
694
+ connection.execute(
695
+ "CREATE TABLE IF NOT EXISTS s2_pacing "
696
+ "(key TEXT PRIMARY KEY, last_dispatch INTEGER NOT NULL)"
697
+ )
698
+ remaining = gate_deadline - time.monotonic()
699
+ if remaining <= 0:
700
+ raise SourceUnavailable("Semantic Scholar pacing deadline exhausted")
701
+ connection.execute(f"PRAGMA busy_timeout = {max(1, int(remaining * 1000))}")
702
+ connection.execute("BEGIN IMMEDIATE")
703
+ in_transaction = True
704
+ row = connection.execute(
705
+ "SELECT last_dispatch FROM s2_pacing WHERE key = ?", (key,)
706
+ ).fetchone()
707
+ now_ms = int(time.time() * 1000)
708
+ wait = max(0.0, ((row[0] + 1100) - now_ms) / 1000) if row else 0.0
709
+ if time.monotonic() + wait > gate_deadline:
710
+ raise SourceUnavailable("Semantic Scholar pacing deadline exhausted")
711
+ if wait:
712
+ time.sleep(wait)
713
+ if time.monotonic() > gate_deadline:
714
+ raise SourceUnavailable("Semantic Scholar pacing deadline exhausted")
715
+ dispatch_ms = int(time.time() * 1000)
716
+ connection.execute(
717
+ "INSERT INTO s2_pacing(key, last_dispatch) VALUES (?, ?) "
718
+ "ON CONFLICT(key) DO UPDATE SET last_dispatch = excluded.last_dispatch",
719
+ (key, dispatch_ms),
720
+ )
721
+ connection.commit()
722
+ in_transaction = False
723
+ except PublicationTimeout:
724
+ raise
725
+ except (OSError, sqlite3.Error) as e:
726
+ raise SourceUnavailable(
727
+ f"Semantic Scholar pacing unavailable ({type(e).__name__})"
728
+ ) from e
729
+ finally:
730
+ if connection is not None:
731
+ if in_transaction:
732
+ connection.rollback()
733
+ connection.close()
734
+
735
+
694
736
  def _s2_get(c: httpx.Client, url: str, params: dict) -> httpx.Response:
695
- # With an API key S2 allows ~1 req/s on a private quota; unauthenticated
696
- # requests share a global pool where backoff still beats instant defeat.
697
- return _paced_get(
698
- c, url, "semanticscholar", 1.0, params=params, headers=_s2_headers()
699
- )
737
+ if _s2_batch_status() is not None:
738
+ raise SourceUnavailable("Semantic Scholar batch status already supplied")
739
+
740
+ key = os.environ.get("S2_API_KEY") or os.environ.get("SEMANTIC_SCHOLAR_API_KEY")
741
+ # A keyless public-service request is paced by the service itself. Personal
742
+ # and direct anonymous traffic coordinate with the Rust app by key hash.
743
+ if key or not os.environ.get("BIBCITE_PUBLIC_SERVICE_URL"):
744
+ pacing_key = hashlib.sha256(key.encode()).hexdigest() if key else "anonymous"
745
+ _s2_gate(pacing_key)
746
+ try:
747
+ response = _get(c, url, params=params, headers=_s2_headers())
748
+ except httpx.HTTPError as e:
749
+ raise TransientSourceError(
750
+ f"semanticscholar unreachable ({type(e).__name__})"
751
+ ) from e
752
+ if response.status_code == 429:
753
+ raise SourceUnavailable("semanticscholar rate-limited (429)")
754
+ if response.status_code >= 500:
755
+ raise TransientSourceError(
756
+ f"semanticscholar server error ({response.status_code})"
757
+ )
758
+ return response
700
759
 
701
760
 
702
761
  def s2_arxiv_metadata(arxiv_id: str) -> ArxivMeta | None:
@@ -754,6 +813,7 @@ def try_semantic_scholar(
754
813
  # CrossRef
755
814
  # ---------------------------------------------------------------------------
756
815
 
816
+
757
817
  def try_crossref(title: str) -> Match | None:
758
818
  with _client() as c:
759
819
  r = _get(
@@ -818,6 +878,7 @@ def try_crossref(title: str) -> Match | None:
818
878
  # it doubles as the metadata fallback when the arXiv API / S2 are throttled)
819
879
  # ---------------------------------------------------------------------------
820
880
 
881
+
821
882
  def openalex_search(title: str) -> dict | None:
822
883
  """OpenAlex work with an exactly-matching normalized title, or None."""
823
884
  with _client() as c:
@@ -843,9 +904,7 @@ def openalex_search(title: str) -> dict | None:
843
904
  def openalex_arxiv_id(work: dict) -> str:
844
905
  for loc in work.get("locations") or []:
845
906
  for f in ("landing_page_url", "pdf_url"):
846
- m = re.search(
847
- r"arxiv\.org/(?:abs|pdf)/(\d{4}\.\d{4,5})", loc.get(f) or ""
848
- )
907
+ m = re.search(r"arxiv\.org/(?:abs|pdf)/(\d{4}\.\d{4,5})", loc.get(f) or "")
849
908
  if m:
850
909
  return m.group(1)
851
910
  return ""
@@ -891,6 +950,7 @@ def try_openalex(title: str) -> Match | None:
891
950
  # CrossRef by DOI (for `bibcite add file 10.xxxx/yyy`)
892
951
  # ---------------------------------------------------------------------------
893
952
 
953
+
894
954
  def crossref_by_doi(doi: str) -> Match | None:
895
955
  with _client() as c:
896
956
  r = _get(
@@ -951,7 +1011,8 @@ _DISABLED: dict[str, str] = {}
951
1011
  CORE_SOURCES = frozenset(
952
1012
  s.strip()
953
1013
  for s in (
954
- os.environ.get("BIBCITE_CORE_SOURCES") or "dblp,semanticscholar,crossref,openalex"
1014
+ os.environ.get("BIBCITE_CORE_SOURCES")
1015
+ or "dblp,semanticscholar,crossref,openalex"
955
1016
  ).split(",")
956
1017
  if s.strip()
957
1018
  )
@@ -980,6 +1041,13 @@ def find_published(
980
1041
 
981
1042
  # Core sources lost earlier in this run taint this query's verdict too.
982
1043
  incomplete = any(n in CORE_SOURCES for n in _DISABLED)
1044
+ batch_status = _s2_batch_status()
1045
+ if batch_status == "checked":
1046
+ _log("[semanticscholar] batch result reused")
1047
+ incomplete = True
1048
+ elif batch_status == "unavailable":
1049
+ _log("[semanticscholar] batch unavailable")
1050
+ incomplete = True
983
1051
  # Query every still-viable source concurrently. A preprint with no published
984
1052
  # version (the common case) misses everywhere, and used to pay the *sum* of
985
1053
  # each source's latency; now the wall-clock is the slowest single source.
@@ -987,7 +1055,11 @@ def find_published(
987
1055
  started = time.monotonic()
988
1056
  deadline = started + PUBLICATION_TIMEOUT
989
1057
  dblp_deadline = min(deadline, started + 4.0)
990
- active = [(name, fn) for name, fn in CASCADE if name not in _DISABLED]
1058
+ active = [
1059
+ (name, fn)
1060
+ for name, fn in CASCADE
1061
+ if name not in _DISABLED and not (name == "semanticscholar" and batch_status)
1062
+ ]
991
1063
  outcomes: dict[str, tuple] = {}
992
1064
  if active:
993
1065
  with ThreadPoolExecutor(max_workers=len(active)) as pool:
@@ -1042,11 +1114,7 @@ def find_published(
1042
1114
  # failure has already spent its chance for this entry, and retrying the
1043
1115
  # fuzzy form was doubling the worst-case interactive latency.
1044
1116
  dblp_outcome = outcomes.get("dblp")
1045
- if (
1046
- author_hint
1047
- and dblp_outcome is not None
1048
- and dblp_outcome[0] == "miss"
1049
- ):
1117
+ if author_hint and dblp_outcome is not None and dblp_outcome[0] == "miss":
1050
1118
  if time.monotonic() >= dblp_deadline:
1051
1119
  incomplete = True
1052
1120
  _log("[dblp-fuzzy] skipped: DBLP lookup budget exhausted")
@@ -1129,14 +1197,15 @@ def fetch_web_page(url: str) -> WebPage:
1129
1197
  except httpx.HTTPError as e:
1130
1198
  raise SourceUnavailable(f"could not fetch {url}: {e}") from e
1131
1199
 
1132
- title = (
1133
- _meta_content(body, "citation_title", "DC.title", "og:title", "twitter:title")
1134
- or _title_tag(body)
1135
- )
1200
+ title = _meta_content(
1201
+ body, "citation_title", "DC.title", "og:title", "twitter:title"
1202
+ ) or _title_tag(body)
1136
1203
  authors = [
1137
1204
  author
1138
1205
  for author in (
1139
- _meta_content(body, "citation_author", "DC.creator", "author", "article:author"),
1206
+ _meta_content(
1207
+ body, "citation_author", "DC.creator", "author", "article:author"
1208
+ ),
1140
1209
  )
1141
1210
  if author
1142
1211
  ]
@@ -1151,7 +1220,9 @@ def fetch_web_page(url: str) -> WebPage:
1151
1220
  )
1152
1221
  year = _year_in(date) or _year_in_path(url)
1153
1222
  site = _meta_content(body, "og:site_name") or _site_author(_host(url))
1154
- return WebPage(url=str(response.url), title=title, authors=authors, year=year, site=site)
1223
+ return WebPage(
1224
+ url=str(response.url), title=title, authors=authors, year=year, site=site
1225
+ )
1155
1226
 
1156
1227
 
1157
1228
  def _year_in(text: str) -> str:
@@ -1200,7 +1271,24 @@ def _site_author(host: str) -> str:
1200
1271
  labels = [label for label in host.lower().split(".") if label]
1201
1272
  if not labels:
1202
1273
  return host
1203
- generic = {"github", "io", "com", "org", "net", "edu", "gov", "ai", "dev",
1204
- "co", "uk", "cn", "blog", "www", "docs", "pages", "medium"}
1274
+ generic = {
1275
+ "github",
1276
+ "io",
1277
+ "com",
1278
+ "org",
1279
+ "net",
1280
+ "edu",
1281
+ "gov",
1282
+ "ai",
1283
+ "dev",
1284
+ "co",
1285
+ "uk",
1286
+ "cn",
1287
+ "blog",
1288
+ "www",
1289
+ "docs",
1290
+ "pages",
1291
+ "medium",
1292
+ }
1205
1293
  named = [label for label in labels if label not in generic]
1206
1294
  return (named[0] if named else labels[0]).capitalize()
@@ -14,6 +14,7 @@ def clean_environment(monkeypatch):
14
14
  "OPENALEX_API_KEY",
15
15
  "S2_API_KEY",
16
16
  "SEMANTIC_SCHOLAR_API_KEY",
17
+ "BIBCITE_S2_BATCH_STATUS",
17
18
  ):
18
19
  monkeypatch.delenv(name, raising=False)
19
20
  monkeypatch.setattr(sources, "_LAST_REQUEST", {})
@@ -133,11 +134,10 @@ def test_public_service_http_failure_is_not_retried(status, monkeypatch):
133
134
  return httpx.Response(status)
134
135
 
135
136
  with _client(handler) as client, pytest.raises(sources.SourceUnavailable):
136
- sources._paced_get(
137
+ sources._s2_get(
137
138
  client,
138
139
  "https://api.semanticscholar.org/graph/v1/paper/search",
139
- "semanticscholar",
140
- 0,
140
+ {},
141
141
  )
142
142
 
143
143
  assert calls == 1
@@ -153,11 +153,10 @@ def test_public_service_timeout_is_not_retried(monkeypatch):
153
153
  raise httpx.ReadTimeout("timed out", request=request)
154
154
 
155
155
  with _client(handler) as client, pytest.raises(sources.SourceUnavailable):
156
- sources._paced_get(
156
+ sources._s2_get(
157
157
  client,
158
158
  "https://api.semanticscholar.org/graph/v1/paper/search",
159
- "semanticscholar",
160
- 0,
159
+ {},
161
160
  )
162
161
 
163
162
  assert calls == 1
@@ -172,6 +171,19 @@ def test_public_service_404_is_preserved(monkeypatch):
172
171
  assert response.status_code == 404
173
172
 
174
173
 
174
+ @pytest.mark.parametrize("code", ["queue_busy", "daily_quota", "upstream_rate_limit"])
175
+ def test_public_limit_reasons_are_preserved_without_retry(monkeypatch, code):
176
+ monkeypatch.setenv("BIBCITE_PUBLIC_SERVICE_URL", "https://literature.test/v1/query")
177
+ with _client(
178
+ lambda request: httpx.Response(429, json={"code": code, "error": "secret"})
179
+ ) as client:
180
+ with pytest.raises(sources.SourceUnavailable, match=code) as error:
181
+ sources._get(
182
+ client, "https://api.semanticscholar.org/graph/v1/paper/search"
183
+ )
184
+ assert "secret" not in str(error.value)
185
+
186
+
175
187
  @pytest.mark.parametrize(
176
188
  "value",
177
189
  (
@@ -0,0 +1,91 @@
1
+ import os
2
+ import subprocess
3
+ import sys
4
+ import threading
5
+ import time
6
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
7
+
8
+
9
+ def _run_server(handler):
10
+ server = ThreadingHTTPServer(("127.0.0.1", 0), handler)
11
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
12
+ thread.start()
13
+ return server, thread
14
+
15
+
16
+ def test_independent_provider_processes_share_s2_dispatch_gate(tmp_path):
17
+ arrivals = []
18
+
19
+ class Handler(BaseHTTPRequestHandler):
20
+ def do_GET(self):
21
+ arrivals.append(time.monotonic())
22
+ self.send_response(200)
23
+ self.send_header("Content-Type", "application/json")
24
+ self.end_headers()
25
+ self.wfile.write(b"{}")
26
+
27
+ def log_message(self, format, *args):
28
+ pass
29
+
30
+ server, thread = _run_server(Handler)
31
+ url = f"http://127.0.0.1:{server.server_port}/paper"
32
+ script = (
33
+ "import httpx; from bibcite.sources import _s2_get; "
34
+ f"c=httpx.Client(); _s2_get(c, {url!r}, {{}}); c.close()"
35
+ )
36
+ env = {
37
+ **os.environ,
38
+ "XDG_CACHE_HOME": str(tmp_path),
39
+ "S2_API_KEY": "shared-test-key",
40
+ }
41
+ command = ["uv", "run", sys.executable, "-c", script]
42
+ processes = [
43
+ subprocess.Popen(command, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
44
+ for _ in range(2)
45
+ ]
46
+ try:
47
+ results = [process.communicate(timeout=10) for process in processes]
48
+ finally:
49
+ server.shutdown()
50
+ thread.join()
51
+
52
+ assert [process.returncode for process in processes] == [0, 0], results
53
+ assert len(arrivals) == 2
54
+ assert abs(arrivals[1] - arrivals[0]) >= 1.09
55
+
56
+
57
+ def test_provider_process_does_not_retry_real_429(tmp_path):
58
+ calls = 0
59
+
60
+ class Handler(BaseHTTPRequestHandler):
61
+ def do_GET(self):
62
+ nonlocal calls
63
+ calls += 1
64
+ self.send_response(429)
65
+ self.send_header("Retry-After", "1")
66
+ self.end_headers()
67
+
68
+ def log_message(self, format, *args):
69
+ pass
70
+
71
+ server, thread = _run_server(Handler)
72
+ url = f"http://127.0.0.1:{server.server_port}/paper"
73
+ script = (
74
+ "import httpx; from bibcite.sources import _s2_get; "
75
+ f"c=httpx.Client(); _s2_get(c, {url!r}, {{}})"
76
+ )
77
+ env = {**os.environ, "XDG_CACHE_HOME": str(tmp_path), "S2_API_KEY": "test-key"}
78
+ try:
79
+ result = subprocess.run(
80
+ ["uv", "run", sys.executable, "-c", script],
81
+ env=env,
82
+ capture_output=True,
83
+ timeout=10,
84
+ check=False,
85
+ )
86
+ finally:
87
+ server.shutdown()
88
+ thread.join()
89
+
90
+ assert result.returncode != 0
91
+ assert calls == 1
@@ -35,13 +35,13 @@ class _ReadErrorClient:
35
35
  return httpx.Response(200, request=request, json={})
36
36
 
37
37
 
38
- def test_paced_get_retries_a_single_read_error_for_other_sources():
38
+ def test_s2_get_never_retries_a_read_error(monkeypatch):
39
39
  client = _ReadErrorClient(failures=1)
40
+ monkeypatch.setattr(sources, "_s2_gate", lambda key: None)
40
41
 
41
- response = sources._paced_get(client, "https://dblp.org/test", "dblp", 0)
42
-
43
- assert response.status_code == 200
44
- assert client.calls == 2
42
+ with pytest.raises(TransientSourceError):
43
+ sources._s2_get(client, "https://api.semanticscholar.org/test", {})
44
+ assert client.calls == 1
45
45
 
46
46
 
47
47
  @pytest.mark.parametrize("failure", ["timeout", 500, 429])
@@ -65,3 +65,44 @@ def test_everything_down_is_unavailable(monkeypatch):
65
65
  )
66
66
  match, status = find_published("Some Title", author_hint="smith")
67
67
  assert (match, status) == (None, "unavailable")
68
+
69
+
70
+ @pytest.mark.parametrize(
71
+ ("batch_status", "message"),
72
+ (("checked", "batch result reused"), ("unavailable", "batch unavailable")),
73
+ )
74
+ def test_batch_status_skips_s2_but_keeps_other_sources_conservative(
75
+ batch_status, message, monkeypatch, capsys
76
+ ):
77
+ calls = []
78
+
79
+ def source(name):
80
+ def run(*args):
81
+ calls.append(name)
82
+ return None
83
+
84
+ return run
85
+
86
+ monkeypatch.setenv("BIBCITE_S2_BATCH_STATUS", batch_status)
87
+ monkeypatch.setattr(
88
+ sources,
89
+ "CASCADE",
90
+ (("semanticscholar", source("s2")), ("crossref", source("crossref"))),
91
+ )
92
+
93
+ match, status = find_published("Batch checked title")
94
+
95
+ assert (match, status) == (None, "incomplete")
96
+ assert calls == ["crossref"]
97
+ assert f"[semanticscholar] {message}" in capsys.readouterr().err
98
+
99
+
100
+ def test_batch_status_suppresses_s2_metadata_request(monkeypatch):
101
+ monkeypatch.setenv("BIBCITE_S2_BATCH_STATUS", "checked")
102
+
103
+ class Client:
104
+ def get(self, *args, **kwargs):
105
+ raise AssertionError("network request must be suppressed")
106
+
107
+ with pytest.raises(SourceUnavailable):
108
+ sources._s2_get(Client(), "https://api.semanticscholar.org/test", {})
@@ -18,7 +18,7 @@ wheels = [
18
18
 
19
19
  [[package]]
20
20
  name = "bibcite-cli"
21
- version = "0.6.6"
21
+ version = "0.6.7"
22
22
  source = { editable = "." }
23
23
  dependencies = [
24
24
  { name = "bibtexparser" },
File without changes
File without changes
File without changes