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.
- {bibcite_cli-0.6.6 → bibcite_cli-0.6.7}/PKG-INFO +1 -1
- {bibcite_cli-0.6.6 → bibcite_cli-0.6.7}/pyproject.toml +1 -1
- {bibcite_cli-0.6.6 → bibcite_cli-0.6.7}/src/bibcite/sources.py +158 -70
- {bibcite_cli-0.6.6 → bibcite_cli-0.6.7}/tests/test_public_service.py +18 -6
- bibcite_cli-0.6.7/tests/test_s2_pacing.py +91 -0
- {bibcite_cli-0.6.6 → bibcite_cli-0.6.7}/tests/test_source_retries.py +5 -5
- {bibcite_cli-0.6.6 → bibcite_cli-0.6.7}/tests/test_status_semantics.py +41 -0
- {bibcite_cli-0.6.6 → bibcite_cli-0.6.7}/uv.lock +1 -1
- {bibcite_cli-0.6.6 → bibcite_cli-0.6.7}/.github/workflows/ci.yml +0 -0
- {bibcite_cli-0.6.6 → bibcite_cli-0.6.7}/.github/workflows/publish.yml +0 -0
- {bibcite_cli-0.6.6 → bibcite_cli-0.6.7}/.gitignore +0 -0
- {bibcite_cli-0.6.6 → bibcite_cli-0.6.7}/LICENSE +0 -0
- {bibcite_cli-0.6.6 → bibcite_cli-0.6.7}/Readme.md +0 -0
- {bibcite_cli-0.6.6 → bibcite_cli-0.6.7}/assets/bibcite.svg +0 -0
- {bibcite_cli-0.6.6 → bibcite_cli-0.6.7}/skills/bibcite/SKILL.md +0 -0
- {bibcite_cli-0.6.6 → bibcite_cli-0.6.7}/src/bibcite/__init__.py +0 -0
- {bibcite_cli-0.6.6 → bibcite_cli-0.6.7}/src/bibcite/bibfile.py +0 -0
- {bibcite_cli-0.6.6 → bibcite_cli-0.6.7}/src/bibcite/cache.py +0 -0
- {bibcite_cli-0.6.6 → bibcite_cli-0.6.7}/src/bibcite/cli.py +0 -0
- {bibcite_cli-0.6.6 → bibcite_cli-0.6.7}/src/bibcite/data/strings.bib +0 -0
- {bibcite_cli-0.6.6 → bibcite_cli-0.6.7}/src/bibcite/normalize.py +0 -0
- {bibcite_cli-0.6.6 → bibcite_cli-0.6.7}/src/bibcite/resolve.py +0 -0
- {bibcite_cli-0.6.6 → bibcite_cli-0.6.7}/src/bibcite/venues.py +0 -0
- {bibcite_cli-0.6.6 → bibcite_cli-0.6.7}/tests/test_bibfile.py +0 -0
- {bibcite_cli-0.6.6 → bibcite_cli-0.6.7}/tests/test_bugfixes.py +0 -0
- {bibcite_cli-0.6.6 → bibcite_cli-0.6.7}/tests/test_cli_status.py +0 -0
- {bibcite_cli-0.6.6 → bibcite_cli-0.6.7}/tests/test_dblp.py +0 -0
- {bibcite_cli-0.6.6 → bibcite_cli-0.6.7}/tests/test_entry_types.py +0 -0
- {bibcite_cli-0.6.6 → bibcite_cli-0.6.7}/tests/test_no_google_scholar.py +0 -0
- {bibcite_cli-0.6.6 → bibcite_cli-0.6.7}/tests/test_normalize.py +0 -0
- {bibcite_cli-0.6.6 → bibcite_cli-0.6.7}/tests/test_round2.py +0 -0
- {bibcite_cli-0.6.6 → bibcite_cli-0.6.7}/tests/test_round3.py +0 -0
- {bibcite_cli-0.6.6 → bibcite_cli-0.6.7}/tests/test_strings_override.py +0 -0
- {bibcite_cli-0.6.6 → bibcite_cli-0.6.7}/tests/test_venues.py +0 -0
- {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.
|
|
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
|
|
@@ -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
|
-
#
|
|
312
|
-
#
|
|
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
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
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")
|
|
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 = [
|
|
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
|
-
|
|
1134
|
-
|
|
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(
|
|
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(
|
|
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 = {
|
|
1204
|
-
|
|
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.
|
|
137
|
+
sources._s2_get(
|
|
137
138
|
client,
|
|
138
139
|
"https://api.semanticscholar.org/graph/v1/paper/search",
|
|
139
|
-
|
|
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.
|
|
156
|
+
sources._s2_get(
|
|
157
157
|
client,
|
|
158
158
|
"https://api.semanticscholar.org/graph/v1/paper/search",
|
|
159
|
-
|
|
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
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
assert
|
|
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", {})
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|