bibcite-cli 0.5.0__tar.gz → 0.5.2__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.5.0 → bibcite_cli-0.5.2}/PKG-INFO +11 -1
  2. {bibcite_cli-0.5.0 → bibcite_cli-0.5.2}/Readme.md +10 -0
  3. {bibcite_cli-0.5.0 → bibcite_cli-0.5.2}/pyproject.toml +1 -1
  4. {bibcite_cli-0.5.0 → bibcite_cli-0.5.2}/src/bibcite/__init__.py +1 -1
  5. {bibcite_cli-0.5.0 → bibcite_cli-0.5.2}/src/bibcite/sources.py +87 -34
  6. {bibcite_cli-0.5.0 → bibcite_cli-0.5.2}/uv.lock +1 -1
  7. {bibcite_cli-0.5.0 → bibcite_cli-0.5.2}/.gitignore +0 -0
  8. {bibcite_cli-0.5.0 → bibcite_cli-0.5.2}/LICENSE +0 -0
  9. {bibcite_cli-0.5.0 → bibcite_cli-0.5.2}/src/bibcite/bibfile.py +0 -0
  10. {bibcite_cli-0.5.0 → bibcite_cli-0.5.2}/src/bibcite/cache.py +0 -0
  11. {bibcite_cli-0.5.0 → bibcite_cli-0.5.2}/src/bibcite/cli.py +0 -0
  12. {bibcite_cli-0.5.0 → bibcite_cli-0.5.2}/src/bibcite/data/strings.bib +0 -0
  13. {bibcite_cli-0.5.0 → bibcite_cli-0.5.2}/src/bibcite/normalize.py +0 -0
  14. {bibcite_cli-0.5.0 → bibcite_cli-0.5.2}/src/bibcite/resolve.py +0 -0
  15. {bibcite_cli-0.5.0 → bibcite_cli-0.5.2}/src/bibcite/venues.py +0 -0
  16. {bibcite_cli-0.5.0 → bibcite_cli-0.5.2}/tests/test_bibfile.py +0 -0
  17. {bibcite_cli-0.5.0 → bibcite_cli-0.5.2}/tests/test_bugfixes.py +0 -0
  18. {bibcite_cli-0.5.0 → bibcite_cli-0.5.2}/tests/test_entry_types.py +0 -0
  19. {bibcite_cli-0.5.0 → bibcite_cli-0.5.2}/tests/test_normalize.py +0 -0
  20. {bibcite_cli-0.5.0 → bibcite_cli-0.5.2}/tests/test_round2.py +0 -0
  21. {bibcite_cli-0.5.0 → bibcite_cli-0.5.2}/tests/test_round3.py +0 -0
  22. {bibcite_cli-0.5.0 → bibcite_cli-0.5.2}/tests/test_status_semantics.py +0 -0
  23. {bibcite_cli-0.5.0 → bibcite_cli-0.5.2}/tests/test_strings_override.py +0 -0
  24. {bibcite_cli-0.5.0 → bibcite_cli-0.5.2}/tests/test_venues.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bibcite-cli
3
- Version: 0.5.0
3
+ Version: 0.5.2
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
@@ -44,6 +44,16 @@ npm install -g bibtex-tidy
44
44
 
45
45
  To use your own venue table instead of the vendored one, set `BIBCITE_STRINGS=/path/to/strings.bib` or place it at `~/.config/bibcite/strings.bib`.
46
46
 
47
+ Environment variables that make the sources faster/more reliable:
48
+
49
+ | Variable | Effect |
50
+ |---|---|
51
+ | `OPENALEX_API_KEY` | OpenAlex refuses anonymous search with 503 under load; a free key makes it dependable |
52
+ | `S2_API_KEY` | Semantic Scholar private quota (~1 req/s) instead of the shared global pool |
53
+ | `BIBCITE_MAILTO` | Your contact email for the CrossRef/OpenAlex/Unpaywall polite pools |
54
+ | `BIBCITE_CORE_SOURCES` | Override which sources count as "core" for `published_check` verdicts (default `dblp,semanticscholar,crossref,openalex`) |
55
+ | `BIBCITE_NO_CACHE=1` | Disable the local match cache |
56
+
47
57
  ## Usage
48
58
 
49
59
  ```bash
@@ -31,6 +31,16 @@ npm install -g bibtex-tidy
31
31
 
32
32
  To use your own venue table instead of the vendored one, set `BIBCITE_STRINGS=/path/to/strings.bib` or place it at `~/.config/bibcite/strings.bib`.
33
33
 
34
+ Environment variables that make the sources faster/more reliable:
35
+
36
+ | Variable | Effect |
37
+ |---|---|
38
+ | `OPENALEX_API_KEY` | OpenAlex refuses anonymous search with 503 under load; a free key makes it dependable |
39
+ | `S2_API_KEY` | Semantic Scholar private quota (~1 req/s) instead of the shared global pool |
40
+ | `BIBCITE_MAILTO` | Your contact email for the CrossRef/OpenAlex/Unpaywall polite pools |
41
+ | `BIBCITE_CORE_SOURCES` | Override which sources count as "core" for `published_check` verdicts (default `dblp,semanticscholar,crossref,openalex`) |
42
+ | `BIBCITE_NO_CACHE=1` | Disable the local match cache |
43
+
34
44
  ## Usage
35
45
 
36
46
  ```bash
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "bibcite-cli"
3
- version = "0.5.0"
3
+ version = "0.5.2"
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.5.0"
3
+ __version__ = "0.5.2"
@@ -18,7 +18,7 @@ import httpx
18
18
 
19
19
  from .normalize import clean_title, mini_hash, norm_title, sig_tokens, titles_similar
20
20
 
21
- UA = "bibcite/0.1 (https://github.com/leonardo/bibcite; mailto:bibcite@gmail.com)"
21
+ UA = "bibcite/0.5 (https://github.com/leo1oel/bibcite)"
22
22
  BROWSER_UA = (
23
23
  "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
24
24
  "(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"
@@ -53,6 +53,22 @@ def _s2_headers() -> dict:
53
53
  return {"x-api-key": key} if key else {}
54
54
 
55
55
 
56
+ def _mailto() -> str:
57
+ """Contact email for the polite pools (CrossRef/OpenAlex/Unpaywall).
58
+ Set BIBCITE_MAILTO to use your own."""
59
+ return os.environ.get("BIBCITE_MAILTO") or "bibcite@gmail.com"
60
+
61
+
62
+ def _openalex_params(extra: dict) -> dict:
63
+ """OpenAlex rejects ANONYMOUS search with 503 under heavy load ("use a
64
+ free API key for uninterrupted access"); OPENALEX_API_KEY unlocks it."""
65
+ params = {**extra, "mailto": _mailto()}
66
+ key = os.environ.get("OPENALEX_API_KEY")
67
+ if key:
68
+ params["api_key"] = key
69
+ return params
70
+
71
+
56
72
  @dataclass
57
73
  class Match:
58
74
  source: str
@@ -147,40 +163,50 @@ def arxiv_metadata(arxiv_id: str) -> ArxivMeta:
147
163
  # DBLP
148
164
  # ---------------------------------------------------------------------------
149
165
 
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
166
+ # Client-side pacing + backoff-retry, shared by the throttle-prone sources.
167
+ # Pacing prevents the 429 in the first place; on a 429 we back off (honoring
168
+ # Retry-After) instead of instantly poisoning the rest of a batch run — only
169
+ # repeated failure raises SourceUnavailable (which disables the source).
170
+ _LAST_REQUEST: dict[str, float] = {}
171
+
172
+
173
+ def _paced_get(
174
+ c: httpx.Client,
175
+ url: str,
176
+ source: str,
177
+ min_interval: float,
178
+ params: dict | None = None,
179
+ headers: dict | None = None,
180
+ ) -> httpx.Response:
160
181
  for attempt in range(3):
161
- wait = _DBLP_MIN_INTERVAL - (time.monotonic() - _dblp_last_request)
182
+ wait = min_interval - (time.monotonic() - _LAST_REQUEST.get(source, 0.0))
162
183
  if wait > 0:
163
184
  time.sleep(wait)
164
- _dblp_last_request = time.monotonic()
185
+ _LAST_REQUEST[source] = time.monotonic()
165
186
  try:
166
- r = c.get(url, params=params)
187
+ r = c.get(url, params=params, headers=headers)
167
188
  except httpx.HTTPError as e: # TCP reset = temporary ban; retrying fast makes it worse
168
189
  if attempt < 2:
169
190
  time.sleep(5 * (attempt + 1))
170
191
  continue
171
- raise SourceUnavailable(f"DBLP unreachable ({type(e).__name__})")
192
+ raise SourceUnavailable(f"{source} unreachable ({type(e).__name__})")
172
193
  if r.status_code == 429:
173
194
  retry_after = int(r.headers.get("Retry-After") or 0)
174
195
  if retry_after > 30:
175
- raise SourceUnavailable(f"DBLP rate-limited (Retry-After {retry_after}s)")
196
+ raise SourceUnavailable(f"{source} rate-limited (Retry-After {retry_after}s)")
176
197
  if attempt < 2:
177
198
  delay = max(retry_after, 4 * (attempt + 1))
178
- _log(f"[dblp] 429 — backing off {delay}s")
199
+ _log(f"[{source}] 429 — backing off {delay}s")
179
200
  time.sleep(delay)
180
201
  continue
181
- raise SourceUnavailable("DBLP rate-limited (429) after backoff retries")
202
+ raise SourceUnavailable(f"{source} rate-limited (429) after backoff retries")
182
203
  return r
183
- raise SourceUnavailable("DBLP unavailable")
204
+ raise SourceUnavailable(f"{source} unavailable")
205
+
206
+
207
+ # DBLP throttles at roughly 1-2 req/s and escalates to temporary IP bans.
208
+ def _dblp_get(c: httpx.Client, url: str, params: dict | None = None) -> httpx.Response:
209
+ return _paced_get(c, url, "dblp", 0.8, params=params)
184
210
 
185
211
 
186
212
  def try_dblp(title: str, author_hint: str = "") -> Match | None:
@@ -369,14 +395,22 @@ def arxiv_abs_metadata(arxiv_id: str) -> ArxivMeta | None:
369
395
  )
370
396
 
371
397
 
398
+ def _s2_get(c: httpx.Client, url: str, params: dict) -> httpx.Response:
399
+ # With an API key S2 allows ~1 req/s on a private quota; unauthenticated
400
+ # requests share a global pool where backoff still beats instant defeat.
401
+ return _paced_get(
402
+ c, url, "semanticscholar", 1.0, params=params, headers=_s2_headers()
403
+ )
404
+
405
+
372
406
  def s2_arxiv_metadata(arxiv_id: str) -> ArxivMeta | None:
373
407
  """Metadata (title/authors/year) for an arXiv id via Semantic Scholar —
374
408
  the fallback when export.arxiv.org itself is throttled."""
375
409
  with _client() as c:
376
- r = c.get(
410
+ r = _s2_get(
411
+ c,
377
412
  f"https://api.semanticscholar.org/graph/v1/paper/arXiv:{arxiv_id}",
378
413
  params={"fields": "title,year,authors"},
379
- headers=_s2_headers(),
380
414
  )
381
415
  if r.status_code != 200:
382
416
  return None
@@ -398,24 +432,20 @@ def try_semantic_scholar(
398
432
  with _client() as c:
399
433
  # Direct id lookup first: unambiguous, no title-search needed.
400
434
  if arxiv_id:
401
- r = c.get(
435
+ r = _s2_get(
436
+ c,
402
437
  f"https://api.semanticscholar.org/graph/v1/paper/arXiv:{arxiv_id}",
403
438
  params={"fields": S2_FIELDS},
404
- headers=_s2_headers(),
405
439
  )
406
- if r.status_code == 429:
407
- raise SourceUnavailable("Semantic Scholar rate-limited (429)")
408
440
  if r.status_code == 200:
409
441
  m = _s2_to_match(r.json(), title, year)
410
442
  if m:
411
443
  return m
412
- r = c.get(
444
+ r = _s2_get(
445
+ c,
413
446
  "https://api.semanticscholar.org/graph/v1/paper/search",
414
447
  params={"query": title, "fields": S2_FIELDS, "limit": 5},
415
- headers=_s2_headers(),
416
448
  )
417
- if r.status_code == 429:
418
- raise SourceUnavailable("Semantic Scholar rate-limited (429)")
419
449
  r.raise_for_status()
420
450
  for item in r.json().get("data") or []:
421
451
  m = _s2_to_match(item, title, year)
@@ -491,11 +521,16 @@ def try_crossref(title: str) -> Match | None:
491
521
  "rows": 3,
492
522
  "query.title": title,
493
523
  "select": "title,event,container-title,DOI,issued",
494
- "mailto": "bibcite@gmail.com",
524
+ "mailto": _mailto(),
495
525
  },
496
526
  )
497
527
  if r.status_code == 429:
498
528
  raise SourceUnavailable("CrossRef rate-limited (429)")
529
+ if r.status_code >= 500:
530
+ # A dead endpoint (Unpaywall search 500s for days at a time)
531
+ # gets benched for the run instead of adding latency + noise
532
+ # to every remaining query.
533
+ raise SourceUnavailable(f"CrossRef server error ({r.status_code})")
499
534
  r.raise_for_status()
500
535
  payload = r.json()
501
536
  if payload.get("status") != "ok":
@@ -544,10 +579,15 @@ def try_unpaywall(title: str) -> Match | None:
544
579
  with _client() as c:
545
580
  r = c.get(
546
581
  "https://api.unpaywall.org/v2/search",
547
- params={"query": title, "is_oa": "true", "email": "bibcite@gmail.com"},
582
+ params={"query": title, "is_oa": "true", "email": _mailto()},
548
583
  )
549
584
  if r.status_code == 429:
550
585
  raise SourceUnavailable("Unpaywall rate-limited (429)")
586
+ if r.status_code >= 500:
587
+ # A dead endpoint (Unpaywall search 500s for days at a time)
588
+ # gets benched for the run instead of adding latency + noise
589
+ # to every remaining query.
590
+ raise SourceUnavailable(f"Unpaywall server error ({r.status_code})")
551
591
  r.raise_for_status()
552
592
  ref = norm_title(title)
553
593
  for res in r.json().get("results") or []:
@@ -586,10 +626,15 @@ def openalex_search(title: str) -> dict | None:
586
626
  with _client() as c:
587
627
  r = c.get(
588
628
  "https://api.openalex.org/works",
589
- params={"search": title, "per-page": 5, "mailto": "bibcite@gmail.com"},
629
+ params=_openalex_params({"search": title, "per-page": 5}),
590
630
  )
591
631
  if r.status_code == 429:
592
632
  raise SourceUnavailable("OpenAlex rate-limited (429)")
633
+ if r.status_code >= 500:
634
+ # A dead endpoint (Unpaywall search 500s for days at a time)
635
+ # gets benched for the run instead of adding latency + noise
636
+ # to every remaining query.
637
+ raise SourceUnavailable(f"OpenAlex server error ({r.status_code})")
593
638
  r.raise_for_status()
594
639
  ref = norm_title(title)
595
640
  for w in r.json().get("results") or []:
@@ -651,7 +696,7 @@ def try_openalex(title: str) -> Match | None:
651
696
 
652
697
  def crossref_by_doi(doi: str) -> Match | None:
653
698
  with _client() as c:
654
- r = c.get(f"https://api.crossref.org/works/{doi}", params={"mailto": "bibcite@gmail.com"})
699
+ r = c.get(f"https://api.crossref.org/works/{doi}", params={"mailto": _mailto()})
655
700
  if r.status_code != 200:
656
701
  return None
657
702
  data = r.json().get("message", {})
@@ -702,7 +747,15 @@ _DISABLED: dict[str, str] = {}
702
747
  # Only these sources are authoritative enough that losing one taints a miss
703
748
  # into "incomplete". Google Scholar captchas and Unpaywall flakiness are
704
749
  # routine and must not stop "not_found" from ever being trustworthy.
705
- CORE_SOURCES = frozenset({"dblp", "semanticscholar", "crossref", "openalex"})
750
+ # Override with BIBCITE_CORE_SOURCES="dblp,semanticscholar" if one of these
751
+ # is down for days and keeps every verdict incomplete.
752
+ CORE_SOURCES = frozenset(
753
+ s.strip()
754
+ for s in (
755
+ os.environ.get("BIBCITE_CORE_SOURCES") or "dblp,semanticscholar,crossref,openalex"
756
+ ).split(",")
757
+ if s.strip()
758
+ )
706
759
 
707
760
 
708
761
  def find_published(
@@ -18,7 +18,7 @@ wheels = [
18
18
 
19
19
  [[package]]
20
20
  name = "bibcite-cli"
21
- version = "0.5.0"
21
+ version = "0.5.2"
22
22
  source = { editable = "." }
23
23
  dependencies = [
24
24
  { name = "bibtexparser" },
File without changes
File without changes