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.
Files changed (24) hide show
  1. {bibcite_cli-0.4.1 → bibcite_cli-0.5.0}/PKG-INFO +1 -1
  2. {bibcite_cli-0.4.1 → bibcite_cli-0.5.0}/pyproject.toml +1 -1
  3. {bibcite_cli-0.4.1 → bibcite_cli-0.5.0}/src/bibcite/__init__.py +1 -1
  4. {bibcite_cli-0.4.1 → bibcite_cli-0.5.0}/src/bibcite/cli.py +22 -15
  5. {bibcite_cli-0.4.1 → bibcite_cli-0.5.0}/src/bibcite/resolve.py +24 -4
  6. {bibcite_cli-0.4.1 → bibcite_cli-0.5.0}/src/bibcite/sources.py +73 -17
  7. bibcite_cli-0.5.0/tests/test_status_semantics.py +78 -0
  8. {bibcite_cli-0.4.1 → bibcite_cli-0.5.0}/uv.lock +1 -1
  9. {bibcite_cli-0.4.1 → bibcite_cli-0.5.0}/.gitignore +0 -0
  10. {bibcite_cli-0.4.1 → bibcite_cli-0.5.0}/LICENSE +0 -0
  11. {bibcite_cli-0.4.1 → bibcite_cli-0.5.0}/Readme.md +0 -0
  12. {bibcite_cli-0.4.1 → bibcite_cli-0.5.0}/src/bibcite/bibfile.py +0 -0
  13. {bibcite_cli-0.4.1 → bibcite_cli-0.5.0}/src/bibcite/cache.py +0 -0
  14. {bibcite_cli-0.4.1 → bibcite_cli-0.5.0}/src/bibcite/data/strings.bib +0 -0
  15. {bibcite_cli-0.4.1 → bibcite_cli-0.5.0}/src/bibcite/normalize.py +0 -0
  16. {bibcite_cli-0.4.1 → bibcite_cli-0.5.0}/src/bibcite/venues.py +0 -0
  17. {bibcite_cli-0.4.1 → bibcite_cli-0.5.0}/tests/test_bibfile.py +0 -0
  18. {bibcite_cli-0.4.1 → bibcite_cli-0.5.0}/tests/test_bugfixes.py +0 -0
  19. {bibcite_cli-0.4.1 → bibcite_cli-0.5.0}/tests/test_entry_types.py +0 -0
  20. {bibcite_cli-0.4.1 → bibcite_cli-0.5.0}/tests/test_normalize.py +0 -0
  21. {bibcite_cli-0.4.1 → bibcite_cli-0.5.0}/tests/test_round2.py +0 -0
  22. {bibcite_cli-0.4.1 → bibcite_cli-0.5.0}/tests/test_round3.py +0 -0
  23. {bibcite_cli-0.4.1 → bibcite_cli-0.5.0}/tests/test_strings_override.py +0 -0
  24. {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.4.1
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "bibcite-cli"
3
- version = "0.4.1"
3
+ version = "0.5.0"
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"
@@ -1,3 +1,3 @@
1
1
  """bibcite: canonical BibTeX resolution for papers (arXiv id / DOI / title)."""
2
2
 
3
- __version__ = "0.4.1"
3
+ __version__ = "0.5.0"
@@ -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
- _emit(
84
- {
85
- "action": "resolved",
86
- "key": res.entry["ID"],
87
- "title": res.entry.get("title", ""),
88
- "venue": res.venue or "arXiv (preprint, no published venue found)",
89
- "published": res.published,
90
- "source": res.source,
91
- "bibtex": res.bibtex,
92
- },
93
- args.json,
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 a trustworthy miss; "sources_unavailable"
316
- # means the sources were down — do not conclude anything.
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
- "sources_unavailable" if status == "unavailable" else "no_published_version"
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
- _log("[bibcite] no published version found; using arXiv preprint entry")
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
- _log("[bibcite] no published version found; using arXiv preprint entry")
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 = c.get(
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
- br = c.get(info["url"] + ".bib")
186
- if br.status_code == 200:
187
- bibtex = br.text
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 = c.get(
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
- br = c.get(info["url"] + ".bib")
250
- if br.status_code == 200:
251
- bibtex = br.text
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). status distinguishes a trustworthy miss from an
669
- outage: "found" | "not_found" (>=1 source answered cleanly with no hit) |
670
- "unavailable" (every source was disabled or errored — do NOT conclude the
671
- paper is unpublished).
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
- return None, ("not_found" if clean_misses else "unavailable")
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")
@@ -18,7 +18,7 @@ wheels = [
18
18
 
19
19
  [[package]]
20
20
  name = "bibcite-cli"
21
- version = "0.4.1"
21
+ version = "0.5.0"
22
22
  source = { editable = "." }
23
23
  dependencies = [
24
24
  { name = "bibtexparser" },
File without changes
File without changes
File without changes