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.
- {scholarcli-1.23 → scholarcli-1.24}/PKG-INFO +1 -1
- {scholarcli-1.23 → scholarcli-1.24}/pyproject.toml +1 -1
- {scholarcli-1.23 → scholarcli-1.24}/src/scholar/providers.py +257 -143
- {scholarcli-1.23 → scholarcli-1.24}/src/scholarcli.egg-info/PKG-INFO +1 -1
- {scholarcli-1.23 → scholarcli-1.24}/tests/test_providers.py +251 -55
- {scholarcli-1.23 → scholarcli-1.24}/LICENSE +0 -0
- {scholarcli-1.23 → scholarcli-1.24}/README.md +0 -0
- {scholarcli-1.23 → scholarcli-1.24}/setup.cfg +0 -0
- {scholarcli-1.23 → scholarcli-1.24}/src/scholar/__init__.py +0 -0
- {scholarcli-1.23 → scholarcli-1.24}/src/scholar/__main__.py +0 -0
- {scholarcli-1.23 → scholarcli-1.24}/src/scholar/cache.py +0 -0
- {scholarcli-1.23 → scholarcli-1.24}/src/scholar/cli.py +0 -0
- {scholarcli-1.23 → scholarcli-1.24}/src/scholar/crossref.py +0 -0
- {scholarcli-1.23 → scholarcli-1.24}/src/scholar/enrich.py +0 -0
- {scholarcli-1.23 → scholarcli-1.24}/src/scholar/llm_review.py +0 -0
- {scholarcli-1.23 → scholarcli-1.24}/src/scholar/notes.py +0 -0
- {scholarcli-1.23 → scholarcli-1.24}/src/scholar/pdf.py +0 -0
- {scholarcli-1.23 → scholarcli-1.24}/src/scholar/questionary.py +0 -0
- {scholarcli-1.23 → scholarcli-1.24}/src/scholar/review.py +0 -0
- {scholarcli-1.23 → scholarcli-1.24}/src/scholar/scholar.py +0 -0
- {scholarcli-1.23 → scholarcli-1.24}/src/scholar/tui.py +0 -0
- {scholarcli-1.23 → scholarcli-1.24}/src/scholar/utils.py +0 -0
- {scholarcli-1.23 → scholarcli-1.24}/src/scholarcli.egg-info/SOURCES.txt +0 -0
- {scholarcli-1.23 → scholarcli-1.24}/src/scholarcli.egg-info/dependency_links.txt +0 -0
- {scholarcli-1.23 → scholarcli-1.24}/src/scholarcli.egg-info/entry_points.txt +0 -0
- {scholarcli-1.23 → scholarcli-1.24}/src/scholarcli.egg-info/requires.txt +0 -0
- {scholarcli-1.23 → scholarcli-1.24}/src/scholarcli.egg-info/top_level.txt +0 -0
- {scholarcli-1.23 → scholarcli-1.24}/src/snowball/__init__.py +0 -0
- {scholarcli-1.23 → scholarcli-1.24}/src/snowball/apis/__init__.py +0 -0
- {scholarcli-1.23 → scholarcli-1.24}/src/snowball/apis/aggregator.py +0 -0
- {scholarcli-1.23 → scholarcli-1.24}/src/snowball/apis/arxiv.py +0 -0
- {scholarcli-1.23 → scholarcli-1.24}/src/snowball/apis/base.py +0 -0
- {scholarcli-1.23 → scholarcli-1.24}/src/snowball/apis/crossref.py +0 -0
- {scholarcli-1.23 → scholarcli-1.24}/src/snowball/apis/google_scholar.py +0 -0
- {scholarcli-1.23 → scholarcli-1.24}/src/snowball/apis/openalex.py +0 -0
- {scholarcli-1.23 → scholarcli-1.24}/src/snowball/apis/opencitations.py +0 -0
- {scholarcli-1.23 → scholarcli-1.24}/src/snowball/apis/semantic_scholar.py +0 -0
- {scholarcli-1.23 → scholarcli-1.24}/src/snowball/cli.py +0 -0
- {scholarcli-1.23 → scholarcli-1.24}/src/snowball/exporters/__init__.py +0 -0
- {scholarcli-1.23 → scholarcli-1.24}/src/snowball/exporters/bibtex.py +0 -0
- {scholarcli-1.23 → scholarcli-1.24}/src/snowball/exporters/csv_exporter.py +0 -0
- {scholarcli-1.23 → scholarcli-1.24}/src/snowball/exporters/tikz.py +0 -0
- {scholarcli-1.23 → scholarcli-1.24}/src/snowball/filters/__init__.py +0 -0
- {scholarcli-1.23 → scholarcli-1.24}/src/snowball/filters/filter_engine.py +0 -0
- {scholarcli-1.23 → scholarcli-1.24}/src/snowball/models.py +0 -0
- {scholarcli-1.23 → scholarcli-1.24}/src/snowball/paper_utils.py +0 -0
- {scholarcli-1.23 → scholarcli-1.24}/src/snowball/parsers/__init__.py +0 -0
- {scholarcli-1.23 → scholarcli-1.24}/src/snowball/parsers/pdf_parser.py +0 -0
- {scholarcli-1.23 → scholarcli-1.24}/src/snowball/scoring/__init__.py +0 -0
- {scholarcli-1.23 → scholarcli-1.24}/src/snowball/scoring/base.py +0 -0
- {scholarcli-1.23 → scholarcli-1.24}/src/snowball/scoring/llm_scorer.py +0 -0
- {scholarcli-1.23 → scholarcli-1.24}/src/snowball/scoring/tfidf_scorer.py +0 -0
- {scholarcli-1.23 → scholarcli-1.24}/src/snowball/services.py +0 -0
- {scholarcli-1.23 → scholarcli-1.24}/src/snowball/snowballing.py +0 -0
- {scholarcli-1.23 → scholarcli-1.24}/src/snowball/storage/__init__.py +0 -0
- {scholarcli-1.23 → scholarcli-1.24}/src/snowball/storage/json_storage.py +0 -0
- {scholarcli-1.23 → scholarcli-1.24}/src/snowball/tui/__init__.py +0 -0
- {scholarcli-1.23 → scholarcli-1.24}/src/snowball/tui/app.py +0 -0
- {scholarcli-1.23 → scholarcli-1.24}/src/snowball/tui/dialogs.py +0 -0
- {scholarcli-1.23 → scholarcli-1.24}/src/snowball/tui/setup.py +0 -0
- {scholarcli-1.23 → scholarcli-1.24}/src/snowball/visualization.py +0 -0
- {scholarcli-1.23 → scholarcli-1.24}/src/tuxedo/__init__.py +0 -0
- {scholarcli-1.23 → scholarcli-1.24}/src/tuxedo/analysis.py +0 -0
- {scholarcli-1.23 → scholarcli-1.24}/src/tuxedo/cli.py +0 -0
- {scholarcli-1.23 → scholarcli-1.24}/src/tuxedo/clustering.py +0 -0
- {scholarcli-1.23 → scholarcli-1.24}/src/tuxedo/database.py +0 -0
- {scholarcli-1.23 → scholarcli-1.24}/src/tuxedo/grobid.py +0 -0
- {scholarcli-1.23 → scholarcli-1.24}/src/tuxedo/logging.py +0 -0
- {scholarcli-1.23 → scholarcli-1.24}/src/tuxedo/models.py +0 -0
- {scholarcli-1.23 → scholarcli-1.24}/src/tuxedo/project.py +0 -0
- {scholarcli-1.23 → scholarcli-1.24}/src/tuxedo/tui.py +0 -0
- {scholarcli-1.23 → scholarcli-1.24}/tests/test_cache.py +0 -0
- {scholarcli-1.23 → scholarcli-1.24}/tests/test_cli.py +0 -0
- {scholarcli-1.23 → scholarcli-1.24}/tests/test_crossref.py +0 -0
- {scholarcli-1.23 → scholarcli-1.24}/tests/test_enrich.py +0 -0
- {scholarcli-1.23 → scholarcli-1.24}/tests/test_llm_review.py +0 -0
- {scholarcli-1.23 → scholarcli-1.24}/tests/test_notes.py +0 -0
- {scholarcli-1.23 → scholarcli-1.24}/tests/test_pdf.py +0 -0
- {scholarcli-1.23 → scholarcli-1.24}/tests/test_review.py +0 -0
- {scholarcli-1.23 → scholarcli-1.24}/tests/test_scholar.py +0 -0
- {scholarcli-1.23 → scholarcli-1.24}/tests/test_tui.py +0 -0
- {scholarcli-1.23 → scholarcli-1.24}/tests/test_utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "scholarcli"
|
|
3
|
-
version = "1.
|
|
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
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
597
|
+
response = self._request(
|
|
554
598
|
url,
|
|
555
|
-
|
|
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.
|
|
707
|
+
response = self._request(
|
|
667
708
|
url,
|
|
668
|
-
|
|
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,
|
|
1223
|
+
page_size = min(limit, self.MAX_LIMIT)
|
|
1098
1224
|
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
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
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
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
|
-
|
|
1122
|
-
|
|
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
|
-
|
|
1126
|
-
|
|
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
|
-
|
|
1145
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
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
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
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
|
-
|
|
1172
|
-
|
|
1283
|
+
time.sleep(delay)
|
|
1284
|
+
continue
|
|
1173
1285
|
|
|
1174
|
-
|
|
1175
|
-
if not hits:
|
|
1176
|
-
# No more results available
|
|
1177
|
-
break
|
|
1286
|
+
response.raise_for_status()
|
|
1178
1287
|
|
|
1179
|
-
|
|
1180
|
-
|
|
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
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
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
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
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."""
|
|
@@ -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
|
-
"""
|
|
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
|
-
|
|
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
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|