bibcite-cli 0.4.1__tar.gz → 0.5.0__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.4.1 → bibcite_cli-0.5.0}/PKG-INFO +1 -1
- {bibcite_cli-0.4.1 → bibcite_cli-0.5.0}/pyproject.toml +1 -1
- {bibcite_cli-0.4.1 → bibcite_cli-0.5.0}/src/bibcite/__init__.py +1 -1
- {bibcite_cli-0.4.1 → bibcite_cli-0.5.0}/src/bibcite/cli.py +22 -15
- {bibcite_cli-0.4.1 → bibcite_cli-0.5.0}/src/bibcite/resolve.py +24 -4
- {bibcite_cli-0.4.1 → bibcite_cli-0.5.0}/src/bibcite/sources.py +73 -17
- bibcite_cli-0.5.0/tests/test_status_semantics.py +78 -0
- {bibcite_cli-0.4.1 → bibcite_cli-0.5.0}/uv.lock +1 -1
- {bibcite_cli-0.4.1 → bibcite_cli-0.5.0}/.gitignore +0 -0
- {bibcite_cli-0.4.1 → bibcite_cli-0.5.0}/LICENSE +0 -0
- {bibcite_cli-0.4.1 → bibcite_cli-0.5.0}/Readme.md +0 -0
- {bibcite_cli-0.4.1 → bibcite_cli-0.5.0}/src/bibcite/bibfile.py +0 -0
- {bibcite_cli-0.4.1 → bibcite_cli-0.5.0}/src/bibcite/cache.py +0 -0
- {bibcite_cli-0.4.1 → bibcite_cli-0.5.0}/src/bibcite/data/strings.bib +0 -0
- {bibcite_cli-0.4.1 → bibcite_cli-0.5.0}/src/bibcite/normalize.py +0 -0
- {bibcite_cli-0.4.1 → bibcite_cli-0.5.0}/src/bibcite/venues.py +0 -0
- {bibcite_cli-0.4.1 → bibcite_cli-0.5.0}/tests/test_bibfile.py +0 -0
- {bibcite_cli-0.4.1 → bibcite_cli-0.5.0}/tests/test_bugfixes.py +0 -0
- {bibcite_cli-0.4.1 → bibcite_cli-0.5.0}/tests/test_entry_types.py +0 -0
- {bibcite_cli-0.4.1 → bibcite_cli-0.5.0}/tests/test_normalize.py +0 -0
- {bibcite_cli-0.4.1 → bibcite_cli-0.5.0}/tests/test_round2.py +0 -0
- {bibcite_cli-0.4.1 → bibcite_cli-0.5.0}/tests/test_round3.py +0 -0
- {bibcite_cli-0.4.1 → bibcite_cli-0.5.0}/tests/test_strings_override.py +0 -0
- {bibcite_cli-0.4.1 → bibcite_cli-0.5.0}/tests/test_venues.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: bibcite-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
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
|
|
@@ -80,18 +80,18 @@ def cmd_get(args) -> int:
|
|
|
80
80
|
res, code = _resolve_or_none(query, args.require_published)
|
|
81
81
|
if res is None:
|
|
82
82
|
return code
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
)
|
|
83
|
+
payload = {
|
|
84
|
+
"action": "resolved",
|
|
85
|
+
"key": res.entry["ID"],
|
|
86
|
+
"title": res.entry.get("title", ""),
|
|
87
|
+
"venue": res.venue or "arXiv (preprint, no published venue found)",
|
|
88
|
+
"published": res.published,
|
|
89
|
+
"source": res.source,
|
|
90
|
+
"bibtex": res.bibtex,
|
|
91
|
+
}
|
|
92
|
+
if not res.published:
|
|
93
|
+
payload["published_check"] = res.check
|
|
94
|
+
_emit(payload, args.json)
|
|
95
95
|
return 0
|
|
96
96
|
|
|
97
97
|
|
|
@@ -256,6 +256,10 @@ def cmd_add(args) -> int:
|
|
|
256
256
|
"published": res.published,
|
|
257
257
|
"source": res.source,
|
|
258
258
|
}
|
|
259
|
+
if not res.published:
|
|
260
|
+
# "incomplete" = the check ran while core sources were down; the
|
|
261
|
+
# paper may well be published — retry later.
|
|
262
|
+
result["published_check"] = res.check
|
|
259
263
|
if warning:
|
|
260
264
|
result["warning"] = warning
|
|
261
265
|
results.append(result)
|
|
@@ -312,10 +316,13 @@ def _upgrade_entries(path: Path, dry_run: bool) -> dict:
|
|
|
312
316
|
)
|
|
313
317
|
match, status = find_published(title, entry.get("year", ""), aid, hint)
|
|
314
318
|
if not match:
|
|
315
|
-
# "no_published_version" is
|
|
316
|
-
#
|
|
319
|
+
# "no_published_version" is trustworthy ONLY when every core
|
|
320
|
+
# source answered; a batch that tripped DBLP's rate limit gets
|
|
321
|
+
# "sources_unavailable" so nobody believes a poisoned verdict.
|
|
317
322
|
reason = (
|
|
318
|
-
"
|
|
323
|
+
"no_published_version"
|
|
324
|
+
if status == "not_found"
|
|
325
|
+
else "sources_unavailable"
|
|
319
326
|
)
|
|
320
327
|
report.append(
|
|
321
328
|
{"key": entry["ID"], "title": title, "matched": False, "reason": reason}
|
|
@@ -72,6 +72,12 @@ class Resolved:
|
|
|
72
72
|
source: str # where the publication info came from
|
|
73
73
|
venue: str # final venue string ("" if preprint)
|
|
74
74
|
published: bool
|
|
75
|
+
# For preprint fallbacks: was the publication check trustworthy?
|
|
76
|
+
# "complete" — every core source answered; the paper really has no
|
|
77
|
+
# published version (as of now)
|
|
78
|
+
# "incomplete" — core sources were rate-limited/down; retry later before
|
|
79
|
+
# believing the preprint status
|
|
80
|
+
check: str = "complete"
|
|
75
81
|
|
|
76
82
|
@property
|
|
77
83
|
def bibtex(self) -> str:
|
|
@@ -220,9 +226,16 @@ def resolve(query: str, require_published: bool = False) -> Resolved:
|
|
|
220
226
|
f"Could not check publication status for arXiv:{value} (sources down)"
|
|
221
227
|
)
|
|
222
228
|
raise NotFound(f"No published version found for arXiv:{value}")
|
|
223
|
-
|
|
229
|
+
check = "complete" if status == "not_found" else "incomplete"
|
|
230
|
+
if check == "incomplete":
|
|
231
|
+
_log(
|
|
232
|
+
"[bibcite] preprint fallback with INCOMPLETE publication check "
|
|
233
|
+
"(core sources were unavailable) — retry later"
|
|
234
|
+
)
|
|
235
|
+
else:
|
|
236
|
+
_log("[bibcite] no published version found; using arXiv preprint entry")
|
|
224
237
|
entry = _arxiv_only_entry(meta)
|
|
225
|
-
return Resolved(_finalize(entry, meta), "arxiv", "", False)
|
|
238
|
+
return Resolved(_finalize(entry, meta), "arxiv", "", False, check)
|
|
226
239
|
|
|
227
240
|
if kind == "doi":
|
|
228
241
|
match = crossref_by_doi(value)
|
|
@@ -256,9 +269,16 @@ def resolve(query: str, require_published: bool = False) -> Resolved:
|
|
|
256
269
|
if meta and meta.arxiv_id:
|
|
257
270
|
if require_published:
|
|
258
271
|
raise NotFound(f"Only an arXiv preprint was found for: {value}")
|
|
259
|
-
|
|
272
|
+
check = "complete" if status == "not_found" else "incomplete"
|
|
273
|
+
if check == "incomplete":
|
|
274
|
+
_log(
|
|
275
|
+
"[bibcite] preprint fallback with INCOMPLETE publication check "
|
|
276
|
+
"(core sources were unavailable) — retry later"
|
|
277
|
+
)
|
|
278
|
+
else:
|
|
279
|
+
_log("[bibcite] no published version found; using arXiv preprint entry")
|
|
260
280
|
entry = _arxiv_only_entry(meta)
|
|
261
|
-
return Resolved(_finalize(entry, meta), "arxiv", "", False)
|
|
281
|
+
return Resolved(_finalize(entry, meta), "arxiv", "", False, check)
|
|
262
282
|
if status == "unavailable":
|
|
263
283
|
raise SourcesUnavailable(
|
|
264
284
|
f"All sources were rate-limited or down while resolving: {value}"
|
|
@@ -147,6 +147,42 @@ def arxiv_metadata(arxiv_id: str) -> ArxivMeta:
|
|
|
147
147
|
# DBLP
|
|
148
148
|
# ---------------------------------------------------------------------------
|
|
149
149
|
|
|
150
|
+
# DBLP throttles at roughly 1-2 req/s and escalates to temporary IP bans when
|
|
151
|
+
# hammered. Client-side pacing prevents the 429 in the first place; on a 429
|
|
152
|
+
# we back off and retry (honoring Retry-After) instead of instantly poisoning
|
|
153
|
+
# the rest of a batch run — only repeated failure disables the source.
|
|
154
|
+
_DBLP_MIN_INTERVAL = 0.8
|
|
155
|
+
_dblp_last_request = 0.0
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _dblp_get(c: httpx.Client, url: str, params: dict | None = None) -> httpx.Response:
|
|
159
|
+
global _dblp_last_request
|
|
160
|
+
for attempt in range(3):
|
|
161
|
+
wait = _DBLP_MIN_INTERVAL - (time.monotonic() - _dblp_last_request)
|
|
162
|
+
if wait > 0:
|
|
163
|
+
time.sleep(wait)
|
|
164
|
+
_dblp_last_request = time.monotonic()
|
|
165
|
+
try:
|
|
166
|
+
r = c.get(url, params=params)
|
|
167
|
+
except httpx.HTTPError as e: # TCP reset = temporary ban; retrying fast makes it worse
|
|
168
|
+
if attempt < 2:
|
|
169
|
+
time.sleep(5 * (attempt + 1))
|
|
170
|
+
continue
|
|
171
|
+
raise SourceUnavailable(f"DBLP unreachable ({type(e).__name__})")
|
|
172
|
+
if r.status_code == 429:
|
|
173
|
+
retry_after = int(r.headers.get("Retry-After") or 0)
|
|
174
|
+
if retry_after > 30:
|
|
175
|
+
raise SourceUnavailable(f"DBLP rate-limited (Retry-After {retry_after}s)")
|
|
176
|
+
if attempt < 2:
|
|
177
|
+
delay = max(retry_after, 4 * (attempt + 1))
|
|
178
|
+
_log(f"[dblp] 429 — backing off {delay}s")
|
|
179
|
+
time.sleep(delay)
|
|
180
|
+
continue
|
|
181
|
+
raise SourceUnavailable("DBLP rate-limited (429) after backoff retries")
|
|
182
|
+
return r
|
|
183
|
+
raise SourceUnavailable("DBLP unavailable")
|
|
184
|
+
|
|
185
|
+
|
|
150
186
|
def try_dblp(title: str, author_hint: str = "") -> Match | None:
|
|
151
187
|
"""DBLP search. Generic titles ("X is all you need") drown in DBLP's
|
|
152
188
|
ranking, so when we know the first author we query with their last name
|
|
@@ -157,12 +193,11 @@ def try_dblp(title: str, author_hint: str = "") -> Match | None:
|
|
|
157
193
|
queries.append(title)
|
|
158
194
|
with _client() as c:
|
|
159
195
|
for q in queries:
|
|
160
|
-
r =
|
|
196
|
+
r = _dblp_get(
|
|
197
|
+
c,
|
|
161
198
|
"https://dblp.org/search/publ/api",
|
|
162
199
|
params={"q": q, "format": "json", "h": 100},
|
|
163
200
|
)
|
|
164
|
-
if r.status_code == 429:
|
|
165
|
-
raise SourceUnavailable("DBLP rate-limited (429)")
|
|
166
201
|
r.raise_for_status()
|
|
167
202
|
hits = (
|
|
168
203
|
r.json().get("result", {}).get("hits", {}).get("hit", []) or []
|
|
@@ -182,9 +217,12 @@ def try_dblp(title: str, author_hint: str = "") -> Match | None:
|
|
|
182
217
|
venue = venue[0]
|
|
183
218
|
bibtex = ""
|
|
184
219
|
if info.get("url"):
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
220
|
+
try:
|
|
221
|
+
br = _dblp_get(c, info["url"] + ".bib")
|
|
222
|
+
if br.status_code == 200:
|
|
223
|
+
bibtex = br.text
|
|
224
|
+
except SourceUnavailable:
|
|
225
|
+
pass # keep the match; construct from fields
|
|
188
226
|
_log(f"[dblp] match: {venue} {info.get('year', '')}")
|
|
189
227
|
return Match(
|
|
190
228
|
source="dblp",
|
|
@@ -219,12 +257,11 @@ def try_dblp_fuzzy(title: str, author_hint: str, year: str = "") -> Match | None
|
|
|
219
257
|
return None
|
|
220
258
|
q = " ".join([author_hint] + tokens)
|
|
221
259
|
with _client() as c:
|
|
222
|
-
r =
|
|
260
|
+
r = _dblp_get(
|
|
261
|
+
c,
|
|
223
262
|
"https://dblp.org/search/publ/api",
|
|
224
263
|
params={"q": q, "format": "json", "h": 100},
|
|
225
264
|
)
|
|
226
|
-
if r.status_code == 429:
|
|
227
|
-
raise SourceUnavailable("DBLP rate-limited (429)")
|
|
228
265
|
r.raise_for_status()
|
|
229
266
|
hits = r.json().get("result", {}).get("hits", {}).get("hit", []) or []
|
|
230
267
|
hits.sort(key=lambda h: int(h.get("info", {}).get("year", 9999)))
|
|
@@ -246,9 +283,12 @@ def try_dblp_fuzzy(title: str, author_hint: str, year: str = "") -> Match | None
|
|
|
246
283
|
venue = venue[0]
|
|
247
284
|
bibtex = ""
|
|
248
285
|
if info.get("url"):
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
286
|
+
try:
|
|
287
|
+
br = _dblp_get(c, info["url"] + ".bib")
|
|
288
|
+
if br.status_code == 200:
|
|
289
|
+
bibtex = br.text
|
|
290
|
+
except SourceUnavailable:
|
|
291
|
+
pass # keep the match; construct from fields
|
|
252
292
|
_log(
|
|
253
293
|
f"[dblp-fuzzy] match with title drift: '{hit_title}' "
|
|
254
294
|
f"@ {venue} {info.get('year', '')}"
|
|
@@ -659,16 +699,24 @@ CASCADE = (
|
|
|
659
699
|
# (PaperMemory's DISABLE_MATCH, ported).
|
|
660
700
|
_DISABLED: dict[str, str] = {}
|
|
661
701
|
|
|
702
|
+
# Only these sources are authoritative enough that losing one taints a miss
|
|
703
|
+
# into "incomplete". Google Scholar captchas and Unpaywall flakiness are
|
|
704
|
+
# routine and must not stop "not_found" from ever being trustworthy.
|
|
705
|
+
CORE_SOURCES = frozenset({"dblp", "semanticscholar", "crossref", "openalex"})
|
|
706
|
+
|
|
662
707
|
|
|
663
708
|
def find_published(
|
|
664
709
|
title: str, year: str = "", arxiv_id: str = "", author_hint: str = ""
|
|
665
710
|
) -> tuple[Match | None, str]:
|
|
666
711
|
"""Try each source in order; first verified hit wins.
|
|
667
712
|
|
|
668
|
-
Returns (match, status)
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
713
|
+
Returns (match, status):
|
|
714
|
+
"found" — verified publication match
|
|
715
|
+
"not_found" — EVERY source answered cleanly with no hit; trustworthy
|
|
716
|
+
"incomplete" — some sources answered (no hit) but others were
|
|
717
|
+
disabled/erroring; a batch run that tripped DBLP's rate
|
|
718
|
+
limit lands here — do NOT conclude "unpublished"
|
|
719
|
+
"unavailable" — no source answered at all
|
|
672
720
|
"""
|
|
673
721
|
from . import cache
|
|
674
722
|
|
|
@@ -679,6 +727,8 @@ def find_published(
|
|
|
679
727
|
return Match(**cached), "found"
|
|
680
728
|
|
|
681
729
|
clean_misses = 0
|
|
730
|
+
# Core sources lost earlier in this run taint this query's verdict too.
|
|
731
|
+
incomplete = any(n in CORE_SOURCES for n in _DISABLED)
|
|
682
732
|
for name, fn in CASCADE:
|
|
683
733
|
if name in _DISABLED:
|
|
684
734
|
continue
|
|
@@ -691,8 +741,10 @@ def find_published(
|
|
|
691
741
|
_log(f"[{name}] no publication found")
|
|
692
742
|
except SourceUnavailable as e:
|
|
693
743
|
_DISABLED[name] = str(e)
|
|
744
|
+
incomplete = incomplete or name in CORE_SOURCES
|
|
694
745
|
_log(f"[{name}] disabled for the rest of this run: {e}")
|
|
695
746
|
except Exception as e: # network hiccup on one source must not kill the run
|
|
747
|
+
incomplete = incomplete or name in CORE_SOURCES
|
|
696
748
|
_log(f"[{name}] error: {type(e).__name__}: {e}")
|
|
697
749
|
|
|
698
750
|
# Exact-title search missed everywhere. Before concluding "no published
|
|
@@ -707,6 +759,10 @@ def find_published(
|
|
|
707
759
|
clean_misses += 1
|
|
708
760
|
except SourceUnavailable as e:
|
|
709
761
|
_DISABLED["dblp"] = str(e)
|
|
762
|
+
incomplete = True
|
|
710
763
|
except Exception as e:
|
|
764
|
+
incomplete = True
|
|
711
765
|
_log(f"[dblp-fuzzy] error: {type(e).__name__}: {e}")
|
|
712
|
-
|
|
766
|
+
if not clean_misses:
|
|
767
|
+
return None, "unavailable"
|
|
768
|
+
return None, ("incomplete" if incomplete else "not_found")
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""The batch-429 poisoning scenario: a disabled core source must taint the
|
|
2
|
+
verdict ('incomplete'), never masquerade as a trustworthy 'not_found'."""
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
import bibcite.sources as sources
|
|
7
|
+
from bibcite import cache
|
|
8
|
+
from bibcite.sources import SourceUnavailable, find_published
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@pytest.fixture(autouse=True)
|
|
12
|
+
def isolated(monkeypatch, tmp_path):
|
|
13
|
+
monkeypatch.setattr(cache, "DISABLED", True)
|
|
14
|
+
monkeypatch.setattr(sources, "_DISABLED", {})
|
|
15
|
+
# No fuzzy fallback network calls in these tests.
|
|
16
|
+
monkeypatch.setattr(sources, "try_dblp_fuzzy", lambda *a, **k: None)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _cascade(**outcomes):
|
|
20
|
+
"""Build a fake CASCADE; outcome per source: None=clean miss, 'raise'=429."""
|
|
21
|
+
|
|
22
|
+
def make(o):
|
|
23
|
+
def fn(t, y, a, au):
|
|
24
|
+
if o == "raise":
|
|
25
|
+
raise SourceUnavailable("simulated 429")
|
|
26
|
+
return o
|
|
27
|
+
|
|
28
|
+
return fn
|
|
29
|
+
|
|
30
|
+
return tuple((name, make(o)) for name, o in outcomes.items())
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_all_clean_misses_is_trustworthy(monkeypatch):
|
|
34
|
+
monkeypatch.setattr(
|
|
35
|
+
sources, "CASCADE", _cascade(dblp=None, semanticscholar=None, crossref=None)
|
|
36
|
+
)
|
|
37
|
+
match, status = find_published("Some Title", author_hint="smith")
|
|
38
|
+
assert (match, status) == (None, "not_found")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_core_source_429_taints_verdict(monkeypatch):
|
|
42
|
+
# DBLP 429s, others answer cleanly — the user's exact failure chain.
|
|
43
|
+
monkeypatch.setattr(
|
|
44
|
+
sources,
|
|
45
|
+
"CASCADE",
|
|
46
|
+
_cascade(dblp="raise", googlescholar=None, crossref=None),
|
|
47
|
+
)
|
|
48
|
+
match, status = find_published("Some Title", author_hint="smith")
|
|
49
|
+
assert (match, status) == (None, "incomplete")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_previously_disabled_core_source_taints_next_queries(monkeypatch):
|
|
53
|
+
# Query N tripped DBLP; queries N+1... in the same run inherit the taint.
|
|
54
|
+
monkeypatch.setattr(sources, "_DISABLED", {"dblp": "429"})
|
|
55
|
+
monkeypatch.setattr(
|
|
56
|
+
sources, "CASCADE", _cascade(dblp=None, crossref=None) # dblp skipped anyway
|
|
57
|
+
)
|
|
58
|
+
match, status = find_published("Another Title", author_hint="smith")
|
|
59
|
+
assert (match, status) == (None, "incomplete")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_noncore_outage_does_not_taint(monkeypatch):
|
|
63
|
+
# Google Scholar captcha is routine; a miss stays trustworthy.
|
|
64
|
+
monkeypatch.setattr(
|
|
65
|
+
sources,
|
|
66
|
+
"CASCADE",
|
|
67
|
+
_cascade(dblp=None, googlescholar="raise", crossref=None),
|
|
68
|
+
)
|
|
69
|
+
match, status = find_published("Some Title", author_hint="smith")
|
|
70
|
+
assert (match, status) == (None, "not_found")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_everything_down_is_unavailable(monkeypatch):
|
|
74
|
+
monkeypatch.setattr(
|
|
75
|
+
sources, "CASCADE", _cascade(dblp="raise", semanticscholar="raise")
|
|
76
|
+
)
|
|
77
|
+
match, status = find_published("Some Title", author_hint="smith")
|
|
78
|
+
assert (match, status) == (None, "unavailable")
|
|
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
|