scholarcli 1.23__tar.gz → 1.24__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 (82) hide show
  1. {scholarcli-1.23 → scholarcli-1.24}/PKG-INFO +1 -1
  2. {scholarcli-1.23 → scholarcli-1.24}/pyproject.toml +1 -1
  3. {scholarcli-1.23 → scholarcli-1.24}/src/scholar/providers.py +257 -143
  4. {scholarcli-1.23 → scholarcli-1.24}/src/scholarcli.egg-info/PKG-INFO +1 -1
  5. {scholarcli-1.23 → scholarcli-1.24}/tests/test_providers.py +251 -55
  6. {scholarcli-1.23 → scholarcli-1.24}/LICENSE +0 -0
  7. {scholarcli-1.23 → scholarcli-1.24}/README.md +0 -0
  8. {scholarcli-1.23 → scholarcli-1.24}/setup.cfg +0 -0
  9. {scholarcli-1.23 → scholarcli-1.24}/src/scholar/__init__.py +0 -0
  10. {scholarcli-1.23 → scholarcli-1.24}/src/scholar/__main__.py +0 -0
  11. {scholarcli-1.23 → scholarcli-1.24}/src/scholar/cache.py +0 -0
  12. {scholarcli-1.23 → scholarcli-1.24}/src/scholar/cli.py +0 -0
  13. {scholarcli-1.23 → scholarcli-1.24}/src/scholar/crossref.py +0 -0
  14. {scholarcli-1.23 → scholarcli-1.24}/src/scholar/enrich.py +0 -0
  15. {scholarcli-1.23 → scholarcli-1.24}/src/scholar/llm_review.py +0 -0
  16. {scholarcli-1.23 → scholarcli-1.24}/src/scholar/notes.py +0 -0
  17. {scholarcli-1.23 → scholarcli-1.24}/src/scholar/pdf.py +0 -0
  18. {scholarcli-1.23 → scholarcli-1.24}/src/scholar/questionary.py +0 -0
  19. {scholarcli-1.23 → scholarcli-1.24}/src/scholar/review.py +0 -0
  20. {scholarcli-1.23 → scholarcli-1.24}/src/scholar/scholar.py +0 -0
  21. {scholarcli-1.23 → scholarcli-1.24}/src/scholar/tui.py +0 -0
  22. {scholarcli-1.23 → scholarcli-1.24}/src/scholar/utils.py +0 -0
  23. {scholarcli-1.23 → scholarcli-1.24}/src/scholarcli.egg-info/SOURCES.txt +0 -0
  24. {scholarcli-1.23 → scholarcli-1.24}/src/scholarcli.egg-info/dependency_links.txt +0 -0
  25. {scholarcli-1.23 → scholarcli-1.24}/src/scholarcli.egg-info/entry_points.txt +0 -0
  26. {scholarcli-1.23 → scholarcli-1.24}/src/scholarcli.egg-info/requires.txt +0 -0
  27. {scholarcli-1.23 → scholarcli-1.24}/src/scholarcli.egg-info/top_level.txt +0 -0
  28. {scholarcli-1.23 → scholarcli-1.24}/src/snowball/__init__.py +0 -0
  29. {scholarcli-1.23 → scholarcli-1.24}/src/snowball/apis/__init__.py +0 -0
  30. {scholarcli-1.23 → scholarcli-1.24}/src/snowball/apis/aggregator.py +0 -0
  31. {scholarcli-1.23 → scholarcli-1.24}/src/snowball/apis/arxiv.py +0 -0
  32. {scholarcli-1.23 → scholarcli-1.24}/src/snowball/apis/base.py +0 -0
  33. {scholarcli-1.23 → scholarcli-1.24}/src/snowball/apis/crossref.py +0 -0
  34. {scholarcli-1.23 → scholarcli-1.24}/src/snowball/apis/google_scholar.py +0 -0
  35. {scholarcli-1.23 → scholarcli-1.24}/src/snowball/apis/openalex.py +0 -0
  36. {scholarcli-1.23 → scholarcli-1.24}/src/snowball/apis/opencitations.py +0 -0
  37. {scholarcli-1.23 → scholarcli-1.24}/src/snowball/apis/semantic_scholar.py +0 -0
  38. {scholarcli-1.23 → scholarcli-1.24}/src/snowball/cli.py +0 -0
  39. {scholarcli-1.23 → scholarcli-1.24}/src/snowball/exporters/__init__.py +0 -0
  40. {scholarcli-1.23 → scholarcli-1.24}/src/snowball/exporters/bibtex.py +0 -0
  41. {scholarcli-1.23 → scholarcli-1.24}/src/snowball/exporters/csv_exporter.py +0 -0
  42. {scholarcli-1.23 → scholarcli-1.24}/src/snowball/exporters/tikz.py +0 -0
  43. {scholarcli-1.23 → scholarcli-1.24}/src/snowball/filters/__init__.py +0 -0
  44. {scholarcli-1.23 → scholarcli-1.24}/src/snowball/filters/filter_engine.py +0 -0
  45. {scholarcli-1.23 → scholarcli-1.24}/src/snowball/models.py +0 -0
  46. {scholarcli-1.23 → scholarcli-1.24}/src/snowball/paper_utils.py +0 -0
  47. {scholarcli-1.23 → scholarcli-1.24}/src/snowball/parsers/__init__.py +0 -0
  48. {scholarcli-1.23 → scholarcli-1.24}/src/snowball/parsers/pdf_parser.py +0 -0
  49. {scholarcli-1.23 → scholarcli-1.24}/src/snowball/scoring/__init__.py +0 -0
  50. {scholarcli-1.23 → scholarcli-1.24}/src/snowball/scoring/base.py +0 -0
  51. {scholarcli-1.23 → scholarcli-1.24}/src/snowball/scoring/llm_scorer.py +0 -0
  52. {scholarcli-1.23 → scholarcli-1.24}/src/snowball/scoring/tfidf_scorer.py +0 -0
  53. {scholarcli-1.23 → scholarcli-1.24}/src/snowball/services.py +0 -0
  54. {scholarcli-1.23 → scholarcli-1.24}/src/snowball/snowballing.py +0 -0
  55. {scholarcli-1.23 → scholarcli-1.24}/src/snowball/storage/__init__.py +0 -0
  56. {scholarcli-1.23 → scholarcli-1.24}/src/snowball/storage/json_storage.py +0 -0
  57. {scholarcli-1.23 → scholarcli-1.24}/src/snowball/tui/__init__.py +0 -0
  58. {scholarcli-1.23 → scholarcli-1.24}/src/snowball/tui/app.py +0 -0
  59. {scholarcli-1.23 → scholarcli-1.24}/src/snowball/tui/dialogs.py +0 -0
  60. {scholarcli-1.23 → scholarcli-1.24}/src/snowball/tui/setup.py +0 -0
  61. {scholarcli-1.23 → scholarcli-1.24}/src/snowball/visualization.py +0 -0
  62. {scholarcli-1.23 → scholarcli-1.24}/src/tuxedo/__init__.py +0 -0
  63. {scholarcli-1.23 → scholarcli-1.24}/src/tuxedo/analysis.py +0 -0
  64. {scholarcli-1.23 → scholarcli-1.24}/src/tuxedo/cli.py +0 -0
  65. {scholarcli-1.23 → scholarcli-1.24}/src/tuxedo/clustering.py +0 -0
  66. {scholarcli-1.23 → scholarcli-1.24}/src/tuxedo/database.py +0 -0
  67. {scholarcli-1.23 → scholarcli-1.24}/src/tuxedo/grobid.py +0 -0
  68. {scholarcli-1.23 → scholarcli-1.24}/src/tuxedo/logging.py +0 -0
  69. {scholarcli-1.23 → scholarcli-1.24}/src/tuxedo/models.py +0 -0
  70. {scholarcli-1.23 → scholarcli-1.24}/src/tuxedo/project.py +0 -0
  71. {scholarcli-1.23 → scholarcli-1.24}/src/tuxedo/tui.py +0 -0
  72. {scholarcli-1.23 → scholarcli-1.24}/tests/test_cache.py +0 -0
  73. {scholarcli-1.23 → scholarcli-1.24}/tests/test_cli.py +0 -0
  74. {scholarcli-1.23 → scholarcli-1.24}/tests/test_crossref.py +0 -0
  75. {scholarcli-1.23 → scholarcli-1.24}/tests/test_enrich.py +0 -0
  76. {scholarcli-1.23 → scholarcli-1.24}/tests/test_llm_review.py +0 -0
  77. {scholarcli-1.23 → scholarcli-1.24}/tests/test_notes.py +0 -0
  78. {scholarcli-1.23 → scholarcli-1.24}/tests/test_pdf.py +0 -0
  79. {scholarcli-1.23 → scholarcli-1.24}/tests/test_review.py +0 -0
  80. {scholarcli-1.23 → scholarcli-1.24}/tests/test_scholar.py +0 -0
  81. {scholarcli-1.23 → scholarcli-1.24}/tests/test_tui.py +0 -0
  82. {scholarcli-1.23 → scholarcli-1.24}/tests/test_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scholarcli
3
- Version: 1.23
3
+ Version: 1.24
4
4
  Summary: A tool for structured literature searches across bibliographic databases
5
5
  Author-email: Daniel Bosk <dbosk@kth.se>, Ric Glassey <glassey@kth.se>
6
6
  License-Expression: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "scholarcli"
3
- version = "1.23"
3
+ version = "1.24"
4
4
  description = "A tool for structured literature searches across bibliographic databases"
5
5
  authors = [{ name = "Daniel Bosk", email = "dbosk@kth.se" },
6
6
  { name = "Ric Glassey", email = "glassey@kth.se" }]
@@ -37,6 +37,9 @@ logger = logging.getLogger(__name__)
37
37
  # Provider constants
38
38
  DEFAULT_LIMIT = 100
39
39
  DBLP_API_URL = "https://dblp.org/search/publ/api"
40
+ DBLP_USER_AGENT = "scholarcli (https://github.com/dbosk/scholar)"
41
+ DBLP_MAX_RETRIES = 3 # extra attempts on HTTP 429 before giving up
42
+ DBLP_RETRY_BACKOFF = 2.0 # base seconds for the exponential 429 back-off
40
43
  WOS_STARTER_API_URL = (
41
44
  "https://api.clarivate.com/apis/wos-starter/v1/documents"
42
45
  )
@@ -344,6 +347,7 @@ class SemanticScholarProvider:
344
347
  self._cache: dict = load_cache(self.name)
345
348
  register_cache(self.name, self._cache)
346
349
  self._last_request_time: float = 0.0
350
+ self._key_rejected: bool = False
347
351
  self._session = self._create_session()
348
352
 
349
353
  def is_available(self) -> bool:
@@ -353,7 +357,7 @@ class SemanticScholarProvider:
353
357
  @property
354
358
  def _min_request_interval(self) -> float:
355
359
  """Return minimum interval based on whether API key is set."""
356
- if self.api_key:
360
+ if self.api_key and not self._key_rejected:
357
361
  return self.RATE_LIMIT_WITH_KEY
358
362
  return self.RATE_LIMIT_WITHOUT_KEY
359
363
 
@@ -377,14 +381,46 @@ class SemanticScholarProvider:
377
381
  time.sleep(self._min_request_interval - elapsed)
378
382
  self._last_request_time = time.time()
379
383
 
380
- @cachedmethod(
381
- lambda self: self._cache,
382
- key=lambda self, query, limit=100, filters=None: (
383
- query,
384
- limit,
385
- filters.cache_key() if filters else "",
386
- ),
387
- )
384
+ def _build_headers(self) -> dict:
385
+ """Return request headers, omitting the API key once it was rejected."""
386
+ headers = {}
387
+ if self.api_key and not self._key_rejected:
388
+ headers["x-api-key"] = self.api_key
389
+ return headers
390
+
391
+ def _request(self, url: str, params: dict) -> requests.Response:
392
+ """GET ``url`` with rate limiting and stale-key fallback.
393
+
394
+ On a 403 while authenticated, warn once, stop sending the key for the
395
+ rest of the session, and retry the same request anonymously. A 403 that
396
+ persists without a key is logged at error level; the response is returned
397
+ so the caller degrades to an empty result rather than raising.
398
+ """
399
+ self._wait_for_rate_limit()
400
+ headers = self._build_headers()
401
+ response = self._session.get(
402
+ url, params=params, headers=headers, timeout=30
403
+ )
404
+
405
+ if response.status_code == 403 and "x-api-key" in headers:
406
+ logger.warning(
407
+ "s2: API key rejected (403) -- check or replace S2_API_KEY; "
408
+ "falling back to anonymous access"
409
+ )
410
+ self._key_rejected = True
411
+ self._wait_for_rate_limit()
412
+ response = self._session.get(
413
+ url, params=params, headers=self._build_headers(), timeout=30
414
+ )
415
+
416
+ if response.status_code == 403:
417
+ logger.error(
418
+ "s2: Forbidden (403) even without an API key; "
419
+ "returning no results for this request"
420
+ )
421
+
422
+ return response
423
+
388
424
  def search(
389
425
  self,
390
426
  query: str,
@@ -397,19 +433,33 @@ class SemanticScholarProvider:
397
433
  Automatically fetches multiple pages if the requested limit exceeds
398
434
  the API's per-request maximum (100 results per page).
399
435
  """
436
+ cache_key = (query, limit, filters.cache_key() if filters else "")
437
+ if cache_key in self._cache:
438
+ return self._cache[cache_key]
439
+
440
+ papers, cacheable = self._search_uncached(query, limit, filters)
441
+ if cacheable:
442
+ self._cache[cache_key] = papers
443
+ return papers
444
+
445
+ def _search_uncached(
446
+ self,
447
+ query: str,
448
+ limit: int,
449
+ filters: SearchFilters | None,
450
+ ) -> tuple[list[Paper], bool]:
451
+ """Run the paged search, returning ``(papers, cacheable)``.
452
+
453
+ ``cacheable`` is False when a transient failure (rate limit, rejected
454
+ key, HTTP or parse error) truncated the search, so the caller can avoid
455
+ freezing a partial or empty result into the cache.
456
+ """
400
457
  all_papers: list[Paper] = []
401
458
  offset = 0
402
459
  page_size = min(limit, 100) # S2 API returns max 100 per request
403
460
 
404
461
  try:
405
462
  while len(all_papers) < limit:
406
- self._wait_for_rate_limit()
407
-
408
- headers = {}
409
- if self.api_key:
410
- headers["x-api-key"] = self.api_key
411
- logger.debug("semantic_scholar: Using API key")
412
-
413
463
  # Calculate how many results we still need
414
464
  remaining = limit - len(all_papers)
415
465
  current_page_size = min(page_size, remaining)
@@ -467,12 +517,7 @@ class SemanticScholarProvider:
467
517
  if s2_types:
468
518
  params["publicationTypes"] = ",".join(s2_types)
469
519
 
470
- response = self._session.get(
471
- self.API_URL,
472
- params=params,
473
- headers=headers,
474
- timeout=30,
475
- )
520
+ response = self._request(self.API_URL, params)
476
521
 
477
522
  if response.status_code == 429:
478
523
  logger.warning(
@@ -481,7 +526,12 @@ class SemanticScholarProvider:
481
526
  "https://www.semanticscholar.org/product/api#api-key-form "
482
527
  "and set S2_API_KEY environment variable."
483
528
  )
484
- break
529
+ return all_papers, False
530
+
531
+ if response.status_code == 403:
532
+ # Key rejected and anonymous access also forbidden; the
533
+ # helper already logged the error. Don't cache the empty.
534
+ return all_papers, False
485
535
 
486
536
  response.raise_for_status()
487
537
  data = response.json()
@@ -502,7 +552,7 @@ class SemanticScholarProvider:
502
552
  logger.debug(
503
553
  f"semantic_scholar: Retrieved {len(all_papers)} papers"
504
554
  )
505
- return all_papers
555
+ return all_papers, True
506
556
  except requests.exceptions.RetryError:
507
557
  logger.warning(
508
558
  "s2: Rate limited after retries. "
@@ -510,13 +560,13 @@ class SemanticScholarProvider:
510
560
  "https://www.semanticscholar.org/product/api#api-key-form "
511
561
  "and set S2_API_KEY environment variable."
512
562
  )
513
- return all_papers
563
+ return all_papers, False
514
564
  except requests.exceptions.HTTPError as e:
515
565
  logger.warning("s2: HTTP error: %s", e)
516
- return all_papers
566
+ return all_papers, False
517
567
  except Exception as e:
518
568
  logger.warning("s2: %s", e)
519
- return all_papers
569
+ return all_papers, False
520
570
 
521
571
  def get_paper_by_doi(self, doi: str | None) -> Paper | None:
522
572
  """
@@ -542,21 +592,13 @@ class SemanticScholarProvider:
542
592
  )
543
593
 
544
594
  try:
545
- self._wait_for_rate_limit()
546
-
547
- headers = {}
548
- if self.api_key:
549
- headers["x-api-key"] = self.api_key
550
-
551
595
  # Use the paper endpoint with DOI: prefix
552
596
  url = f"https://api.semanticscholar.org/graph/v1/paper/DOI:{doi}"
553
- response = self._session.get(
597
+ response = self._request(
554
598
  url,
555
- params={
599
+ {
556
600
  "fields": "title,authors,year,abstract,venue,externalIds,url,openAccessPdf,fieldsOfStudy",
557
601
  },
558
- headers=headers,
559
- timeout=30,
560
602
  )
561
603
 
562
604
  if response.status_code == 404:
@@ -568,6 +610,11 @@ class SemanticScholarProvider:
568
610
  logger.warning("s2: Rate limited during DOI lookup")
569
611
  return None
570
612
 
613
+ if response.status_code == 403:
614
+ # Key rejected and anonymous access also forbidden (logged by
615
+ # the helper). Do not cache; a later call may succeed.
616
+ return None
617
+
571
618
  response.raise_for_status()
572
619
  paper_data = response.json()
573
620
 
@@ -656,22 +703,14 @@ class SemanticScholarProvider:
656
703
 
657
704
  try:
658
705
  while len(all_papers) < limit:
659
- self._wait_for_rate_limit()
660
-
661
- headers = {}
662
- if self.api_key:
663
- headers["x-api-key"] = self.api_key
664
-
665
706
  url = f"https://api.semanticscholar.org/graph/v1/paper/{paper_id}/{relation}"
666
- response = self._session.get(
707
+ response = self._request(
667
708
  url,
668
- params={
709
+ {
669
710
  "fields": "title,authors,year,abstract,venue,externalIds,url,openAccessPdf,fieldsOfStudy",
670
711
  "limit": page_size,
671
712
  "offset": offset,
672
713
  },
673
- headers=headers,
674
- timeout=30,
675
714
  )
676
715
 
677
716
  if response.status_code == 404:
@@ -684,6 +723,11 @@ class SemanticScholarProvider:
684
723
  )
685
724
  break
686
725
 
726
+ if response.status_code == 403:
727
+ # Key rejected and anonymous access also forbidden (logged
728
+ # by the helper). Stop with what we have.
729
+ break
730
+
687
731
  response.raise_for_status()
688
732
  data = response.json()
689
733
 
@@ -1057,6 +1101,14 @@ class OpenAlexProvider:
1057
1101
  register_provider(OpenAlexProvider())
1058
1102
 
1059
1103
 
1104
+ class _DBLPUnavailable(Exception):
1105
+ """Raised internally when a DBLP request fails transiently.
1106
+
1107
+ Used to distinguish a throttled or malformed response (do not cache, allow
1108
+ a retry later) from a genuine zero-result answer (safe to cache).
1109
+ """
1110
+
1111
+
1060
1112
  class DBLPProvider:
1061
1113
  """Search provider for DBLP."""
1062
1114
 
@@ -1072,14 +1124,6 @@ class DBLPProvider:
1072
1124
  """DBLP is always available (no API key needed)."""
1073
1125
  return True
1074
1126
 
1075
- @cachedmethod(
1076
- lambda self: self._cache,
1077
- key=lambda self, query, limit=100, filters=None: (
1078
- query,
1079
- limit,
1080
- filters.cache_key() if filters else "",
1081
- ),
1082
- )
1083
1127
  def search(
1084
1128
  self,
1085
1129
  query: str,
@@ -1092,109 +1136,179 @@ class DBLPProvider:
1092
1136
  Automatically fetches multiple pages if the requested limit exceeds
1093
1137
  the API's per-request maximum (1000 results per page).
1094
1138
  """
1139
+ cache_key = (query, limit, filters.cache_key() if filters else "")
1140
+ if cache_key in self._cache:
1141
+ return self._cache[cache_key]
1142
+
1143
+ try:
1144
+ papers = self._fetch_pages(query, limit, filters)
1145
+ except Exception as e:
1146
+ # Transient API failure (throttling, malformed body, HTTP error) or
1147
+ # an unexpected parsing error: return empty but do NOT cache, so a
1148
+ # later call can retry instead of replaying a frozen empty result.
1149
+ logger.warning("dblp: %s", e)
1150
+ return []
1151
+
1152
+ self._cache[cache_key] = papers
1153
+ return papers
1154
+
1155
+ def _fetch_pages(
1156
+ self,
1157
+ query: str,
1158
+ limit: int,
1159
+ filters: SearchFilters | None,
1160
+ ) -> list[Paper]:
1161
+ """Fetch successive pages until the limit or the result set is exhausted.
1162
+
1163
+ Raises ``_DBLPUnavailable`` on a transient API failure so the caller can
1164
+ avoid caching an incomplete result.
1165
+ """
1166
+ effective_query = query
1167
+ if filters:
1168
+ # Year filter: DBLP uses "year:YYYY" or "year:YYYY:YYYY" syntax
1169
+ if filters.year:
1170
+ start, end = filters.year_range()
1171
+ if start and end:
1172
+ if start == end:
1173
+ effective_query += f" year:{start}"
1174
+ else:
1175
+ effective_query += f" year:{start}:{end}"
1176
+ elif start:
1177
+ # Open end: DBLP doesn't support this well, use current year
1178
+ import datetime
1179
+
1180
+ current_year = datetime.datetime.now().year
1181
+ effective_query += f" year:{start}:{current_year}"
1182
+ elif end:
1183
+ # Open start: use 1900 as earliest
1184
+ effective_query += f" year:1900:{end}"
1185
+
1186
+ # Venue filter
1187
+ if filters.venue:
1188
+ effective_query += f" venue:{filters.venue}"
1189
+
1190
+ # Publication types: map to DBLP type syntax
1191
+ if filters.pub_types:
1192
+ dblp_types = []
1193
+ type_mapping = {
1194
+ "article": "Journal_Articles",
1195
+ "conference": "Conference_and_Workshop_Papers",
1196
+ "book": "Books_and_Theses",
1197
+ }
1198
+ for pt in filters.pub_types:
1199
+ if pt.lower() in type_mapping:
1200
+ dblp_types.append(type_mapping[pt.lower()])
1201
+ else:
1202
+ logger.warning(
1203
+ "dblp: Publication type '%s' not supported, ignoring",
1204
+ pt,
1205
+ )
1206
+ if dblp_types:
1207
+ effective_query += f" type:{dblp_types[0]}" # DBLP only supports one type
1208
+
1209
+ # Warn about unsupported filters
1210
+ if filters.open_access:
1211
+ logger.warning(
1212
+ "dblp: Open access filter not supported, ignoring"
1213
+ )
1214
+ if filters.min_citations is not None:
1215
+ logger.warning(
1216
+ "dblp: Citation count filter not supported, ignoring"
1217
+ )
1218
+
1219
+ logger.debug(f"dblp: Searching for '{query}' with limit={limit}")
1220
+
1095
1221
  all_papers: list[Paper] = []
1096
1222
  offset = 0
1097
- page_size = min(limit, 1000) # DBLP API caps at 1000 per request
1223
+ page_size = min(limit, self.MAX_LIMIT)
1098
1224
 
1099
- try:
1100
- # Build query with embedded filters
1101
- effective_query = query
1102
- if filters:
1103
- # Year filter: DBLP uses "year:YYYY" or "year:YYYY:YYYY" syntax
1104
- if filters.year:
1105
- start, end = filters.year_range()
1106
- if start and end:
1107
- if start == end:
1108
- effective_query += f" year:{start}"
1109
- else:
1110
- effective_query += f" year:{start}:{end}"
1111
- elif start:
1112
- # Open end: DBLP doesn't support this well, use current year
1113
- import datetime
1225
+ while len(all_papers) < limit:
1226
+ remaining = limit - len(all_papers)
1227
+ current_page_size = min(page_size, remaining)
1114
1228
 
1115
- current_year = datetime.datetime.now().year
1116
- effective_query += f" year:{start}:{current_year}"
1117
- elif end:
1118
- # Open start: use 1900 as earliest
1119
- effective_query += f" year:1900:{end}"
1229
+ hits, total = self._fetch_one_page(
1230
+ effective_query, current_page_size, offset
1231
+ )
1232
+ if not hits:
1233
+ # No more results available
1234
+ break
1120
1235
 
1121
- # Venue filter
1122
- if filters.venue:
1123
- effective_query += f" venue:{filters.venue}"
1236
+ all_papers.extend(self._convert_hit(hit) for hit in hits)
1237
+ offset += len(hits)
1124
1238
 
1125
- # Publication types: map to DBLP type syntax
1126
- if filters.pub_types:
1127
- dblp_types = []
1128
- type_mapping = {
1129
- "article": "Journal_Articles",
1130
- "conference": "Conference_and_Workshop_Papers",
1131
- "book": "Books_and_Theses",
1132
- }
1133
- for pt in filters.pub_types:
1134
- if pt.lower() in type_mapping:
1135
- dblp_types.append(type_mapping[pt.lower()])
1136
- else:
1137
- logger.warning(
1138
- "dblp: Publication type '%s' not supported, ignoring",
1139
- pt,
1140
- )
1141
- if dblp_types:
1142
- effective_query += f" type:{dblp_types[0]}" # DBLP only supports one type
1239
+ if offset >= total:
1240
+ break
1143
1241
 
1144
- # Warn about unsupported filters
1145
- if filters.open_access:
1146
- logger.warning(
1147
- "dblp: Open access filter not supported, ignoring"
1148
- )
1149
- if filters.min_citations is not None:
1150
- logger.warning(
1151
- "dblp: Citation count filter not supported, ignoring"
1152
- )
1242
+ logger.debug(f"dblp: Retrieved {len(all_papers)} papers")
1243
+ return all_papers
1153
1244
 
1154
- logger.debug(f"dblp: Searching for '{query}' with limit={limit}")
1245
+ def _fetch_one_page(
1246
+ self,
1247
+ effective_query: str,
1248
+ page_size: int,
1249
+ offset: int,
1250
+ ) -> tuple[list, int]:
1251
+ """Fetch one page of hits, returning ``(hits, total)``.
1155
1252
 
1156
- while len(all_papers) < limit:
1157
- # Calculate how many results we still need
1158
- remaining = limit - len(all_papers)
1159
- current_page_size = min(page_size, remaining)
1253
+ ``hits`` is always a list, even when DBLP collapses a single result into a
1254
+ bare object. Raises ``_DBLPUnavailable`` on a non-JSON body or after
1255
+ exhausting HTTP 429 retries.
1256
+ """
1257
+ for attempt in range(DBLP_MAX_RETRIES + 1):
1258
+ response = requests.get(
1259
+ DBLP_API_URL,
1260
+ params={
1261
+ "q": effective_query,
1262
+ "format": "json",
1263
+ "h": page_size,
1264
+ "f": offset,
1265
+ },
1266
+ headers={"User-Agent": DBLP_USER_AGENT},
1267
+ timeout=30,
1268
+ )
1160
1269
 
1161
- response = requests.get(
1162
- DBLP_API_URL,
1163
- params={
1164
- "q": effective_query,
1165
- "format": "json",
1166
- "h": current_page_size,
1167
- "f": offset,
1168
- },
1169
- timeout=30,
1270
+ if response.status_code == 429:
1271
+ if attempt >= DBLP_MAX_RETRIES:
1272
+ break
1273
+ delay = DBLP_RETRY_BACKOFF * (2**attempt)
1274
+ retry_after = response.headers.get("Retry-After")
1275
+ if retry_after:
1276
+ try:
1277
+ delay = max(delay, float(retry_after))
1278
+ except ValueError:
1279
+ pass
1280
+ logger.warning(
1281
+ "dblp: rate limited (429), retrying in %.1fs", delay
1170
1282
  )
1171
- response.raise_for_status()
1172
- data = response.json()
1283
+ time.sleep(delay)
1284
+ continue
1173
1285
 
1174
- hits = data.get("result", {}).get("hits", {}).get("hit", [])
1175
- if not hits:
1176
- # No more results available
1177
- break
1286
+ response.raise_for_status()
1178
1287
 
1179
- all_papers.extend(self._convert_hit(hit) for hit in hits)
1180
- offset += len(hits)
1288
+ try:
1289
+ data = response.json()
1290
+ except ValueError as e:
1291
+ raise _DBLPUnavailable(
1292
+ f"non-JSON response (HTTP {response.status_code})"
1293
+ ) from e
1181
1294
 
1182
- # Check if we've fetched all available results
1183
- total_str = (
1184
- data.get("result", {}).get("hits", {}).get("@total", "0")
1185
- )
1186
- try:
1187
- total = int(total_str)
1188
- except (ValueError, TypeError):
1189
- total = 0
1190
- if offset >= total:
1191
- break
1295
+ hits = ensure_list(
1296
+ safe_get_nested(data, "result", "hits", "hit", default=[])
1297
+ )
1298
+ total = self._parse_total(data)
1299
+ return hits, total
1192
1300
 
1193
- logger.debug(f"dblp: Retrieved {len(all_papers)} papers")
1194
- return all_papers
1195
- except Exception as e:
1196
- logger.warning("dblp: %s", e)
1197
- return all_papers
1301
+ raise _DBLPUnavailable("rate limited (HTTP 429) after retries")
1302
+
1303
+ def _parse_total(self, data: dict) -> int:
1304
+ """Return the ``@total`` hit count, or 0 when absent or unparseable."""
1305
+ total_str = safe_get_nested(
1306
+ data, "result", "hits", "@total", default="0"
1307
+ )
1308
+ try:
1309
+ return int(total_str)
1310
+ except (ValueError, TypeError):
1311
+ return 0
1198
1312
 
1199
1313
  def _convert_hit(self, hit: dict) -> Paper:
1200
1314
  """Convert a DBLP API hit to our Paper type."""
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scholarcli
3
- Version: 1.23
3
+ Version: 1.24
4
4
  Summary: A tool for structured literature searches across bibliographic databases
5
5
  Author-email: Daniel Bosk <dbosk@kth.se>, Ric Glassey <glassey@kth.se>
6
6
  License-Expression: MIT
@@ -212,6 +212,57 @@ class TestSemanticScholarProvider:
212
212
  results = provider.search("test")
213
213
  assert results == []
214
214
 
215
+ def test_search_caches_successful_results(self):
216
+ """A successful search is cached; the second call skips the API."""
217
+ provider = SemanticScholarProvider()
218
+
219
+ mock_response = Mock()
220
+ mock_response.status_code = 200
221
+ mock_response.json.return_value = {
222
+ "total": 1,
223
+ "data": [{"title": "Cached Paper", "authors": []}],
224
+ }
225
+
226
+ with patch.object(
227
+ provider._session, "get", return_value=mock_response
228
+ ) as mock_get:
229
+ first = provider.search("test")
230
+ second = provider.search("test")
231
+
232
+ assert len(first) == 1
233
+ assert len(second) == 1
234
+ assert mock_get.call_count == 1 # second call served from cache
235
+
236
+ def test_rate_limited_result_is_not_cached(self):
237
+ """A 429 returns empty without caching, so a later call retries.
238
+
239
+ Unlike a successful search, a rate-limited (transient) result must
240
+ not be frozen into the persistent cache, or every later call with the
241
+ same query would replay the empty result until the cache is cleared.
242
+ """
243
+ provider = SemanticScholarProvider()
244
+
245
+ throttled = Mock()
246
+ throttled.status_code = 429
247
+
248
+ recovered = Mock()
249
+ recovered.status_code = 200
250
+ recovered.json.return_value = {
251
+ "total": 1,
252
+ "data": [{"title": "Recovered Paper", "authors": []}],
253
+ }
254
+
255
+ with patch.object(
256
+ provider._session, "get", side_effect=[throttled, recovered]
257
+ ) as mock_get:
258
+ first = provider.search("test")
259
+ assert first == []
260
+
261
+ second = provider.search("test")
262
+ assert len(second) == 1
263
+ assert second[0].title == "Recovered Paper"
264
+ assert mock_get.call_count == 2 # not served from cache
265
+
215
266
  def test_rate_limiting_with_api_key(self):
216
267
  """Provider uses faster rate limit when API key is set."""
217
268
  provider = SemanticScholarProvider(api_key="test-key")
@@ -395,20 +446,27 @@ class TestSemanticScholarIntegration:
395
446
  """Integration test for the SemanticScholar provider."""
396
447
 
397
448
  @pytest.mark.integration
398
- @pytest.mark.skip(reason="Skipped by default due to API rate limits")
399
449
  def test_real_api_call(self):
400
- """Make one real API call to verify the provider works."""
450
+ """Anonymous search returns real results.
451
+
452
+ We force the key off *after* construction (the constructor falls back
453
+ to the ``S2_API_KEY`` environment variable, so ``api_key=None`` alone
454
+ would still pick up a stale key). This exercises the pure anonymous
455
+ path in one request, unmasked by any environment key. An empty result
456
+ fails the test rather than skipping, so a regression is visible.
457
+ """
458
+ provider = SemanticScholarProvider(api_key=None)
459
+ provider.api_key = None # ignore any S2_API_KEY in the environment
401
460
  try:
402
- provider = SemanticScholarProvider()
403
461
  papers = provider.search("machine learning", limit=1)
404
- assert len(papers) >= 1, "Provider returned no results"
405
-
406
- paper = papers[0]
407
- assert isinstance(paper, Paper)
408
- assert paper.title
409
- assert paper.sources == ["s2"]
410
462
  except Exception as e:
411
463
  pytest.skip(f"Semantic Scholar API unavailable: {e}")
464
+
465
+ assert len(papers) >= 1, "Provider returned no results"
466
+ paper = papers[0]
467
+ assert isinstance(paper, Paper)
468
+ assert paper.title
469
+ assert paper.sources == ["s2"]
412
470
  class TestOpenAlexProvider:
413
471
  """Tests for the OpenAlex provider."""
414
472
 
@@ -616,22 +674,20 @@ class TestOpenAlexIntegration:
616
674
  """Integration test for the OpenAlex provider."""
617
675
 
618
676
  @pytest.mark.integration
619
- @pytest.mark.skip(reason="Skipped by default to avoid API calls")
620
677
  def test_real_api_call(self):
621
678
  """Make one real API call to verify the provider works."""
622
679
  provider = OpenAlexProvider()
623
680
 
624
681
  try:
625
682
  papers = provider.search("machine learning", limit=1)
626
-
627
- assert len(papers) >= 1, "Provider returned no results"
628
-
629
- paper = papers[0]
630
- assert isinstance(paper, Paper)
631
- assert paper.title
632
- assert paper.sources == ["openalex"]
633
683
  except Exception as e:
634
684
  pytest.skip(f"OpenAlex API unavailable: {e}")
685
+
686
+ assert len(papers) >= 1, "Provider returned no results"
687
+ paper = papers[0]
688
+ assert isinstance(paper, Paper)
689
+ assert paper.title
690
+ assert paper.sources == ["openalex"]
635
691
  class TestDBLPProvider:
636
692
  """Tests for the DBLP provider."""
637
693
 
@@ -711,6 +767,39 @@ class TestDBLPProvider:
711
767
  assert len(results) == 1
712
768
  assert results[0].authors == ["Solo Author"]
713
769
 
770
+ def test_search_handles_single_hit_as_dict(self):
771
+ """search() handles a lone hit returned as a dict, not a list.
772
+
773
+ DBLP collapses ``result.hits.hit`` to a bare object when exactly one
774
+ paper matches. Iterating that object used to crash and silently cache
775
+ an empty result; ``ensure_list`` must wrap it into a one-element list.
776
+ """
777
+ provider = DBLPProvider()
778
+
779
+ mock_response = Mock()
780
+ mock_response.json.return_value = {
781
+ "result": {
782
+ "hits": {
783
+ "@total": "1",
784
+ "hit": {
785
+ "info": {
786
+ "title": "The Only Match",
787
+ "year": "2024",
788
+ }
789
+ },
790
+ }
791
+ }
792
+ }
793
+ mock_response.raise_for_status = Mock()
794
+
795
+ with patch("requests.get") as mock_get:
796
+ mock_get.return_value = mock_response
797
+
798
+ results = provider.search("very specific query")
799
+
800
+ assert len(results) == 1
801
+ assert results[0].title == "The Only Match"
802
+
714
803
  def test_search_handles_empty_results(self):
715
804
  """search() returns empty list when no results."""
716
805
  provider = DBLPProvider()
@@ -737,6 +826,94 @@ class TestDBLPProvider:
737
826
  results = provider.search("test")
738
827
  assert results == []
739
828
 
829
+ def test_search_sends_user_agent(self):
830
+ """search() identifies itself with a descriptive User-Agent.
831
+
832
+ DBLP throttles anonymous default-agent traffic more readily, so we
833
+ always send our own User-Agent header.
834
+ """
835
+ provider = DBLPProvider()
836
+
837
+ mock_response = Mock()
838
+ mock_response.json.return_value = {
839
+ "result": {"hits": {"hit": [], "@total": "0"}}
840
+ }
841
+ mock_response.raise_for_status = Mock()
842
+
843
+ with patch("requests.get") as mock_get:
844
+ mock_get.return_value = mock_response
845
+
846
+ provider.search("test")
847
+
848
+ headers = mock_get.call_args[1]["headers"]
849
+ assert headers["User-Agent"] == DBLP_USER_AGENT
850
+
851
+ def test_transient_failure_is_not_cached(self):
852
+ """A non-JSON (throttled) body returns empty without caching it.
853
+
854
+ Caching a transient outage would freeze the query into a permanently
855
+ empty result. The second call must therefore reach the API again, and
856
+ once it succeeds the real results come through.
857
+ """
858
+ provider = DBLPProvider()
859
+
860
+ bad_response = Mock()
861
+ bad_response.status_code = 200
862
+ bad_response.raise_for_status = Mock()
863
+ bad_response.json.side_effect = ValueError("Expecting value")
864
+
865
+ good_response = Mock()
866
+ good_response.status_code = 200
867
+ good_response.raise_for_status = Mock()
868
+ good_response.json.return_value = {
869
+ "result": {
870
+ "hits": {
871
+ "@total": "1",
872
+ "hit": [{"info": {"title": "Recovered Paper"}}],
873
+ }
874
+ }
875
+ }
876
+
877
+ with patch(
878
+ "requests.get", side_effect=[bad_response, good_response]
879
+ ) as mock_get:
880
+ first = provider.search("test")
881
+ assert first == []
882
+
883
+ second = provider.search("test")
884
+ assert len(second) == 1
885
+ assert second[0].title == "Recovered Paper"
886
+ assert mock_get.call_count == 2
887
+
888
+ def test_retries_on_rate_limit(self):
889
+ """search() retries after an HTTP 429 before succeeding."""
890
+ provider = DBLPProvider()
891
+
892
+ throttled = Mock()
893
+ throttled.status_code = 429
894
+ throttled.headers = {}
895
+
896
+ ok = Mock()
897
+ ok.status_code = 200
898
+ ok.raise_for_status = Mock()
899
+ ok.json.return_value = {
900
+ "result": {
901
+ "hits": {
902
+ "@total": "1",
903
+ "hit": [{"info": {"title": "After Backoff"}}],
904
+ }
905
+ }
906
+ }
907
+
908
+ with patch("requests.get", side_effect=[throttled, ok]) as mock_get:
909
+ with patch("time.sleep") as mock_sleep:
910
+ results = provider.search("test")
911
+
912
+ assert len(results) == 1
913
+ assert results[0].title == "After Backoff"
914
+ assert mock_get.call_count == 2
915
+ mock_sleep.assert_called_once()
916
+
740
917
  def test_handles_missing_fields(self):
741
918
  """search() handles publications with missing optional fields."""
742
919
  provider = DBLPProvider()
@@ -847,22 +1024,44 @@ class TestDBLPIntegration:
847
1024
  """Integration test for the DBLP provider."""
848
1025
 
849
1026
  @pytest.mark.integration
850
- @pytest.mark.skip(reason="Skipped by default to avoid API calls")
851
1027
  def test_real_api_call(self):
852
1028
  """Make one real API call to verify the provider works."""
853
1029
  provider = DBLPProvider()
854
1030
 
855
1031
  try:
856
1032
  papers = provider.search("machine learning", limit=1)
1033
+ except Exception as e:
1034
+ pytest.skip(f"DBLP API unavailable: {e}")
857
1035
 
858
- assert len(papers) >= 1, "Provider returned no results"
1036
+ assert len(papers) >= 1, "Provider returned no results"
1037
+ paper = papers[0]
1038
+ assert isinstance(paper, Paper)
1039
+ assert paper.title
1040
+ assert paper.sources == ["dblp"]
859
1041
 
860
- paper = papers[0]
861
- assert isinstance(paper, Paper)
862
- assert paper.title
863
- assert paper.sources == ["dblp"]
1042
+ @pytest.mark.integration
1043
+ def test_real_specific_query(self):
1044
+ """A long, specific multi-term query still returns results.
1045
+
1046
+ Narrow queries that match only a handful of papers are exactly the
1047
+ case that used to break: DBLP returns a lone hit as a bare object, and
1048
+ the swallowed crash cached an empty result. This guards that the
1049
+ ``ensure_list`` normalisation survives a real near-single-hit response.
1050
+ (The exact bare-object case is covered deterministically by
1051
+ ``test_search_handles_single_hit_as_dict``.)
1052
+ """
1053
+ provider = DBLPProvider()
1054
+
1055
+ title = (
1056
+ "Dynamic Searchable Encryption in Very-Large Databases: "
1057
+ "Data Structures and Implementation"
1058
+ )
1059
+ try:
1060
+ papers = provider.search(title, limit=30)
864
1061
  except Exception as e:
865
1062
  pytest.skip(f"DBLP API unavailable: {e}")
1063
+
1064
+ assert len(papers) >= 1, "specific query returned no results"
866
1065
  class TestWebOfScienceProvider:
867
1066
  """Tests for the Web of Science provider."""
868
1067
 
@@ -1673,7 +1872,6 @@ class TestWebOfScienceIntegration:
1673
1872
  """Integration test for the Web of Science provider."""
1674
1873
 
1675
1874
  @pytest.mark.integration
1676
- @pytest.mark.skip(reason="Skipped by default; requires WOS API key")
1677
1875
  def test_real_api_call(self):
1678
1876
  """Make one real API call to verify the provider works."""
1679
1877
  import os
@@ -1693,15 +1891,14 @@ class TestWebOfScienceIntegration:
1693
1891
 
1694
1892
  try:
1695
1893
  papers = provider.search("machine learning", limit=1)
1696
-
1697
- assert len(papers) >= 1, "Provider returned no results"
1698
-
1699
- paper = papers[0]
1700
- assert isinstance(paper, Paper)
1701
- assert paper.title
1702
- assert paper.sources == ["wos"]
1703
1894
  except Exception as e:
1704
1895
  pytest.skip(f"Web of Science API unavailable: {e}")
1896
+
1897
+ assert len(papers) >= 1, "Provider returned no results"
1898
+ paper = papers[0]
1899
+ assert isinstance(paper, Paper)
1900
+ assert paper.title
1901
+ assert paper.sources == ["wos"]
1705
1902
  class TestIEEEXploreProvider:
1706
1903
  """Tests for the IEEE Xplore provider."""
1707
1904
 
@@ -2028,7 +2225,6 @@ class TestIEEEXploreIntegration:
2028
2225
  """Integration test for the IEEE Xplore provider."""
2029
2226
 
2030
2227
  @pytest.mark.integration
2031
- @pytest.mark.skip(reason="Skipped by default; requires IEEE_API_KEY")
2032
2228
  def test_real_api_call(self):
2033
2229
  """Make one real API call to verify the provider works."""
2034
2230
  import os
@@ -2041,15 +2237,14 @@ class TestIEEEXploreIntegration:
2041
2237
 
2042
2238
  try:
2043
2239
  papers = provider.search("machine learning", limit=1)
2044
-
2045
- assert len(papers) >= 1, "Provider returned no results"
2046
-
2047
- paper = papers[0]
2048
- assert isinstance(paper, Paper)
2049
- assert paper.title
2050
- assert paper.sources == ["ieee"]
2051
2240
  except Exception as e:
2052
2241
  pytest.skip(f"IEEE Xplore API unavailable: {e}")
2242
+
2243
+ assert len(papers) >= 1, "Provider returned no results"
2244
+ paper = papers[0]
2245
+ assert isinstance(paper, Paper)
2246
+ assert paper.title
2247
+ assert paper.sources == ["ieee"]
2053
2248
  class TestArxivProvider:
2054
2249
  """Tests for the arXiv provider."""
2055
2250
 
@@ -2275,22 +2470,20 @@ class TestArxivIntegration:
2275
2470
  """Integration test for the arXiv provider."""
2276
2471
 
2277
2472
  @pytest.mark.integration
2278
- @pytest.mark.skip(reason="Skipped by default to avoid API calls")
2279
2473
  def test_real_api_call(self):
2280
2474
  """Make one real API call to verify the provider works."""
2281
2475
  provider = ArxivProvider()
2282
2476
 
2283
2477
  try:
2284
2478
  papers = provider.search("machine learning", limit=1)
2285
-
2286
- assert len(papers) >= 1, "Provider returned no results"
2287
-
2288
- paper = papers[0]
2289
- assert isinstance(paper, Paper)
2290
- assert paper.title
2291
- assert paper.sources == ["arxiv"]
2292
2479
  except Exception as e:
2293
2480
  pytest.skip(f"arXiv API unavailable: {e}")
2481
+
2482
+ assert len(papers) >= 1, "Provider returned no results"
2483
+ paper = papers[0]
2484
+ assert isinstance(paper, Paper)
2485
+ assert paper.title
2486
+ assert paper.sources == ["arxiv"]
2294
2487
  class TestScopusProvider:
2295
2488
  """Tests for the Scopus provider."""
2296
2489
 
@@ -2520,23 +2713,26 @@ class TestScopusIntegration:
2520
2713
  """Integration test for the Scopus provider."""
2521
2714
 
2522
2715
  @pytest.mark.integration
2523
- @pytest.mark.skip(
2524
- reason="Skipped to avoid API calls",
2525
- )
2526
2716
  def test_real_api_call(self):
2527
2717
  """Make one real API call to verify."""
2718
+ import os
2719
+
2720
+ if not os.environ.get("SCOPUS_API_KEY"):
2721
+ pytest.skip("SCOPUS_API_KEY environment variable not set")
2722
+
2528
2723
  provider = ScopusProvider()
2529
2724
 
2530
2725
  try:
2531
2726
  papers = provider.search(
2532
2727
  "machine learning", limit=1,
2533
2728
  )
2534
- assert len(papers) >= 1
2535
- paper = papers[0]
2536
- assert isinstance(paper, Paper)
2537
- assert paper.title
2538
- assert paper.sources == ["scopus"]
2539
2729
  except Exception as e:
2540
2730
  pytest.skip(
2541
2731
  f"Scopus API unavailable: {e}"
2542
2732
  )
2733
+
2734
+ assert len(papers) >= 1
2735
+ paper = papers[0]
2736
+ assert isinstance(paper, Paper)
2737
+ assert paper.title
2738
+ assert paper.sources == ["scopus"]
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