pyzotero 1.7.6__py3-none-any.whl → 1.8.0__py3-none-any.whl
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.
- pyzotero/__init__.py +60 -0
- pyzotero/_client.py +1402 -0
- pyzotero/_decorators.py +195 -0
- pyzotero/_search.py +190 -0
- pyzotero/_upload.py +241 -0
- pyzotero/_utils.py +86 -0
- pyzotero/cli.py +420 -1
- pyzotero/errors.py +185 -0
- pyzotero/filetransport.py +2 -2
- pyzotero/semantic_scholar.py +441 -0
- pyzotero/zotero.py +62 -2035
- pyzotero/zotero_errors.py +53 -136
- {pyzotero-1.7.6.dist-info → pyzotero-1.8.0.dist-info}/METADATA +3 -3
- pyzotero-1.8.0.dist-info/RECORD +16 -0
- pyzotero-1.7.6.dist-info/RECORD +0 -9
- {pyzotero-1.7.6.dist-info → pyzotero-1.8.0.dist-info}/WHEEL +0 -0
- {pyzotero-1.7.6.dist-info → pyzotero-1.8.0.dist-info}/entry_points.txt +0 -0
pyzotero/_utils.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Utility functions for Pyzotero.
|
|
2
|
+
|
|
3
|
+
This module contains helper functions used throughout the library.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import uuid
|
|
9
|
+
from collections.abc import Iterator
|
|
10
|
+
from pathlib import PurePosixPath
|
|
11
|
+
from typing import TypeVar
|
|
12
|
+
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
|
|
13
|
+
|
|
14
|
+
# Avoid hanging the application if there's no server response
|
|
15
|
+
DEFAULT_TIMEOUT = 30
|
|
16
|
+
|
|
17
|
+
ONE_HOUR = 3600
|
|
18
|
+
DEFAULT_NUM_ITEMS = 50
|
|
19
|
+
DEFAULT_ITEM_LIMIT = 100
|
|
20
|
+
|
|
21
|
+
T = TypeVar("T")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def build_url(base_url: str, path: str, args_dict: dict | None = None) -> str:
|
|
25
|
+
"""Build a valid URL from base, path, and optional query parameters.
|
|
26
|
+
|
|
27
|
+
This avoids string concatenation errors and leading/trailing slash issues.
|
|
28
|
+
"""
|
|
29
|
+
base_url = base_url.removesuffix("/")
|
|
30
|
+
parsed = urlparse(base_url)
|
|
31
|
+
new_path = str(PurePosixPath(parsed.path) / path.removeprefix("/"))
|
|
32
|
+
if args_dict:
|
|
33
|
+
return urlunparse(parsed._replace(path=new_path, query=urlencode(args_dict)))
|
|
34
|
+
return urlunparse(parsed._replace(path=new_path))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def merge_params(url: str, params: dict) -> tuple[str, dict]:
|
|
38
|
+
"""Strip query parameters from URL and merge with provided params.
|
|
39
|
+
|
|
40
|
+
Returns a tuple of (base_url, merged_params).
|
|
41
|
+
"""
|
|
42
|
+
parsed = urlparse(url)
|
|
43
|
+
# Extract query parameters from URL
|
|
44
|
+
incoming = parse_qs(parsed.query)
|
|
45
|
+
incoming = {k: v[0] for k, v in incoming.items()}
|
|
46
|
+
|
|
47
|
+
# Create new params dict by merging
|
|
48
|
+
merged = {**incoming, **params}
|
|
49
|
+
|
|
50
|
+
# Get base URL by zeroing out the query component
|
|
51
|
+
base_url = urlunparse(parsed._replace(query=""))
|
|
52
|
+
|
|
53
|
+
return base_url, merged
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def token() -> str:
|
|
57
|
+
"""Return a unique 32-char write-token."""
|
|
58
|
+
return str(uuid.uuid4().hex)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def chunks(iterable: list[T], n: int) -> Iterator[list[T]]:
|
|
62
|
+
"""Yield successive n-sized chunks from an iterable."""
|
|
63
|
+
for i in range(0, len(iterable), n):
|
|
64
|
+
yield iterable[i : i + n]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def get_backoff_duration(headers) -> str | None:
|
|
68
|
+
"""Extract backoff duration from response headers.
|
|
69
|
+
|
|
70
|
+
The Zotero API may return backoff instructions via either the
|
|
71
|
+
'Backoff' or 'Retry-After' header.
|
|
72
|
+
"""
|
|
73
|
+
return headers.get("backoff") or headers.get("retry-after")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
__all__ = [
|
|
77
|
+
"DEFAULT_ITEM_LIMIT",
|
|
78
|
+
"DEFAULT_NUM_ITEMS",
|
|
79
|
+
"DEFAULT_TIMEOUT",
|
|
80
|
+
"ONE_HOUR",
|
|
81
|
+
"build_url",
|
|
82
|
+
"chunks",
|
|
83
|
+
"get_backoff_duration",
|
|
84
|
+
"merge_params",
|
|
85
|
+
"token",
|
|
86
|
+
]
|
pyzotero/cli.py
CHANGED
|
@@ -3,10 +3,20 @@
|
|
|
3
3
|
import json
|
|
4
4
|
import sys
|
|
5
5
|
|
|
6
|
-
import click
|
|
6
|
+
import click # ty:ignore[unresolved-import]
|
|
7
7
|
import httpx
|
|
8
8
|
|
|
9
9
|
from pyzotero import __version__, zotero
|
|
10
|
+
from pyzotero.semantic_scholar import (
|
|
11
|
+
PaperNotFoundError,
|
|
12
|
+
RateLimitError,
|
|
13
|
+
SemanticScholarError,
|
|
14
|
+
filter_by_citations,
|
|
15
|
+
get_citations,
|
|
16
|
+
get_recommendations,
|
|
17
|
+
get_references,
|
|
18
|
+
search_papers,
|
|
19
|
+
)
|
|
10
20
|
from pyzotero.zotero import chunks
|
|
11
21
|
|
|
12
22
|
|
|
@@ -513,5 +523,414 @@ def alldoi(ctx, dois, output_json): # noqa: PLR0912
|
|
|
513
523
|
sys.exit(1)
|
|
514
524
|
|
|
515
525
|
|
|
526
|
+
def _build_doi_index(zot):
|
|
527
|
+
"""Build a mapping of normalised DOIs to Zotero item keys.
|
|
528
|
+
|
|
529
|
+
Returns:
|
|
530
|
+
Dict mapping normalised DOIs to item keys
|
|
531
|
+
|
|
532
|
+
"""
|
|
533
|
+
doi_map = {}
|
|
534
|
+
all_items = zot.everything(zot.items())
|
|
535
|
+
|
|
536
|
+
for item in all_items:
|
|
537
|
+
data = item.get("data", {})
|
|
538
|
+
item_doi = data.get("DOI", "")
|
|
539
|
+
|
|
540
|
+
if item_doi:
|
|
541
|
+
normalised_doi = _normalize_doi(item_doi)
|
|
542
|
+
item_key = data.get("key", "")
|
|
543
|
+
|
|
544
|
+
if normalised_doi and item_key:
|
|
545
|
+
doi_map[normalised_doi] = item_key
|
|
546
|
+
|
|
547
|
+
return doi_map
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def _format_s2_paper(paper, in_library=None):
|
|
551
|
+
"""Format a Semantic Scholar paper for output.
|
|
552
|
+
|
|
553
|
+
Args:
|
|
554
|
+
paper: Normalised paper dict from semantic_scholar module
|
|
555
|
+
in_library: Boolean indicating if paper is in local Zotero
|
|
556
|
+
|
|
557
|
+
Returns:
|
|
558
|
+
Formatted dict for output
|
|
559
|
+
|
|
560
|
+
"""
|
|
561
|
+
result = {
|
|
562
|
+
"paperId": paper.get("paperId"),
|
|
563
|
+
"doi": paper.get("doi"),
|
|
564
|
+
"title": paper.get("title"),
|
|
565
|
+
"authors": [a.get("name") for a in (paper.get("authors") or [])],
|
|
566
|
+
"year": paper.get("year"),
|
|
567
|
+
"venue": paper.get("venue"),
|
|
568
|
+
"citationCount": paper.get("citationCount"),
|
|
569
|
+
"referenceCount": paper.get("referenceCount"),
|
|
570
|
+
"isOpenAccess": paper.get("isOpenAccess"),
|
|
571
|
+
"openAccessPdfUrl": paper.get("openAccessPdfUrl"),
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
if in_library is not None:
|
|
575
|
+
result["inLibrary"] = in_library
|
|
576
|
+
|
|
577
|
+
return result
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def _annotate_with_library(papers, doi_map):
|
|
581
|
+
"""Annotate papers with in_library status based on DOI matching.
|
|
582
|
+
|
|
583
|
+
Args:
|
|
584
|
+
papers: List of normalised paper dicts
|
|
585
|
+
doi_map: Dict mapping normalised DOIs to Zotero item keys
|
|
586
|
+
|
|
587
|
+
Returns:
|
|
588
|
+
List of formatted paper dicts with inLibrary field
|
|
589
|
+
|
|
590
|
+
"""
|
|
591
|
+
results = []
|
|
592
|
+
for paper in papers:
|
|
593
|
+
doi = paper.get("doi")
|
|
594
|
+
in_library = False
|
|
595
|
+
if doi:
|
|
596
|
+
normalised = _normalize_doi(doi)
|
|
597
|
+
in_library = normalised in doi_map
|
|
598
|
+
results.append(_format_s2_paper(paper, in_library))
|
|
599
|
+
return results
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
@main.command()
|
|
603
|
+
@click.option(
|
|
604
|
+
"--doi",
|
|
605
|
+
required=True,
|
|
606
|
+
help="DOI of the paper to find related papers for",
|
|
607
|
+
)
|
|
608
|
+
@click.option(
|
|
609
|
+
"--limit",
|
|
610
|
+
type=int,
|
|
611
|
+
default=20,
|
|
612
|
+
help="Maximum number of results to return (default: 20, max: 500)",
|
|
613
|
+
)
|
|
614
|
+
@click.option(
|
|
615
|
+
"--min-citations",
|
|
616
|
+
type=int,
|
|
617
|
+
default=0,
|
|
618
|
+
help="Minimum citation count filter (default: 0)",
|
|
619
|
+
)
|
|
620
|
+
@click.option(
|
|
621
|
+
"--check-library/--no-check-library",
|
|
622
|
+
default=True,
|
|
623
|
+
help="Check if papers exist in local Zotero (default: True)",
|
|
624
|
+
)
|
|
625
|
+
@click.pass_context
|
|
626
|
+
def related(ctx, doi, limit, min_citations, check_library):
|
|
627
|
+
"""Find papers related to a given paper using Semantic Scholar.
|
|
628
|
+
|
|
629
|
+
Uses SPECTER2 embeddings to find semantically similar papers.
|
|
630
|
+
|
|
631
|
+
Examples:
|
|
632
|
+
pyzotero related --doi "10.1038/nature12373"
|
|
633
|
+
|
|
634
|
+
pyzotero related --doi "10.1038/nature12373" --limit 50
|
|
635
|
+
|
|
636
|
+
pyzotero related --doi "10.1038/nature12373" --min-citations 100
|
|
637
|
+
|
|
638
|
+
"""
|
|
639
|
+
try:
|
|
640
|
+
# Get recommendations from Semantic Scholar
|
|
641
|
+
click.echo(f"Fetching related papers for DOI: {doi}...", err=True)
|
|
642
|
+
result = get_recommendations(doi, id_type="doi", limit=limit)
|
|
643
|
+
papers = result.get("papers", [])
|
|
644
|
+
|
|
645
|
+
# Apply citation filter
|
|
646
|
+
if min_citations > 0:
|
|
647
|
+
papers = filter_by_citations(papers, min_citations)
|
|
648
|
+
|
|
649
|
+
if not papers:
|
|
650
|
+
click.echo(json.dumps({"count": 0, "papers": []}))
|
|
651
|
+
return
|
|
652
|
+
|
|
653
|
+
# Optionally annotate with library status
|
|
654
|
+
if check_library:
|
|
655
|
+
click.echo("Checking local Zotero library...", err=True)
|
|
656
|
+
locale = ctx.obj.get("locale", "en-US")
|
|
657
|
+
zot = _get_zotero_client(locale)
|
|
658
|
+
doi_map = _build_doi_index(zot)
|
|
659
|
+
output_papers = _annotate_with_library(papers, doi_map)
|
|
660
|
+
else:
|
|
661
|
+
output_papers = [_format_s2_paper(p) for p in papers]
|
|
662
|
+
|
|
663
|
+
click.echo(
|
|
664
|
+
json.dumps({"count": len(output_papers), "papers": output_papers}, indent=2)
|
|
665
|
+
)
|
|
666
|
+
|
|
667
|
+
except PaperNotFoundError:
|
|
668
|
+
click.echo("Error: Paper not found in Semantic Scholar.", err=True)
|
|
669
|
+
sys.exit(1)
|
|
670
|
+
except RateLimitError:
|
|
671
|
+
click.echo("Error: Rate limit exceeded. Please wait and try again.", err=True)
|
|
672
|
+
sys.exit(1)
|
|
673
|
+
except SemanticScholarError as e:
|
|
674
|
+
click.echo(f"Error: {e!s}", err=True)
|
|
675
|
+
sys.exit(1)
|
|
676
|
+
except Exception as e:
|
|
677
|
+
click.echo(f"Error: {e!s}", err=True)
|
|
678
|
+
sys.exit(1)
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
@main.command()
|
|
682
|
+
@click.option(
|
|
683
|
+
"--doi",
|
|
684
|
+
required=True,
|
|
685
|
+
help="DOI of the paper to find citations for",
|
|
686
|
+
)
|
|
687
|
+
@click.option(
|
|
688
|
+
"--limit",
|
|
689
|
+
type=int,
|
|
690
|
+
default=100,
|
|
691
|
+
help="Maximum number of results to return (default: 100, max: 1000)",
|
|
692
|
+
)
|
|
693
|
+
@click.option(
|
|
694
|
+
"--min-citations",
|
|
695
|
+
type=int,
|
|
696
|
+
default=0,
|
|
697
|
+
help="Minimum citation count filter (default: 0)",
|
|
698
|
+
)
|
|
699
|
+
@click.option(
|
|
700
|
+
"--check-library/--no-check-library",
|
|
701
|
+
default=True,
|
|
702
|
+
help="Check if papers exist in local Zotero (default: True)",
|
|
703
|
+
)
|
|
704
|
+
@click.pass_context
|
|
705
|
+
def citations(ctx, doi, limit, min_citations, check_library):
|
|
706
|
+
"""Find papers that cite a given paper using Semantic Scholar.
|
|
707
|
+
|
|
708
|
+
Examples:
|
|
709
|
+
pyzotero citations --doi "10.1038/nature12373"
|
|
710
|
+
|
|
711
|
+
pyzotero citations --doi "10.1038/nature12373" --limit 50
|
|
712
|
+
|
|
713
|
+
pyzotero citations --doi "10.1038/nature12373" --min-citations 50
|
|
714
|
+
|
|
715
|
+
"""
|
|
716
|
+
try:
|
|
717
|
+
# Get citations from Semantic Scholar
|
|
718
|
+
click.echo(f"Fetching citations for DOI: {doi}...", err=True)
|
|
719
|
+
result = get_citations(doi, id_type="doi", limit=limit)
|
|
720
|
+
papers = result.get("papers", [])
|
|
721
|
+
|
|
722
|
+
# Apply citation filter
|
|
723
|
+
if min_citations > 0:
|
|
724
|
+
papers = filter_by_citations(papers, min_citations)
|
|
725
|
+
|
|
726
|
+
if not papers:
|
|
727
|
+
click.echo(json.dumps({"count": 0, "papers": []}))
|
|
728
|
+
return
|
|
729
|
+
|
|
730
|
+
# Optionally annotate with library status
|
|
731
|
+
if check_library:
|
|
732
|
+
click.echo("Checking local Zotero library...", err=True)
|
|
733
|
+
locale = ctx.obj.get("locale", "en-US")
|
|
734
|
+
zot = _get_zotero_client(locale)
|
|
735
|
+
doi_map = _build_doi_index(zot)
|
|
736
|
+
output_papers = _annotate_with_library(papers, doi_map)
|
|
737
|
+
else:
|
|
738
|
+
output_papers = [_format_s2_paper(p) for p in papers]
|
|
739
|
+
|
|
740
|
+
click.echo(
|
|
741
|
+
json.dumps({"count": len(output_papers), "papers": output_papers}, indent=2)
|
|
742
|
+
)
|
|
743
|
+
|
|
744
|
+
except PaperNotFoundError:
|
|
745
|
+
click.echo("Error: Paper not found in Semantic Scholar.", err=True)
|
|
746
|
+
sys.exit(1)
|
|
747
|
+
except RateLimitError:
|
|
748
|
+
click.echo("Error: Rate limit exceeded. Please wait and try again.", err=True)
|
|
749
|
+
sys.exit(1)
|
|
750
|
+
except SemanticScholarError as e:
|
|
751
|
+
click.echo(f"Error: {e!s}", err=True)
|
|
752
|
+
sys.exit(1)
|
|
753
|
+
except Exception as e:
|
|
754
|
+
click.echo(f"Error: {e!s}", err=True)
|
|
755
|
+
sys.exit(1)
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
@main.command()
|
|
759
|
+
@click.option(
|
|
760
|
+
"--doi",
|
|
761
|
+
required=True,
|
|
762
|
+
help="DOI of the paper to find references for",
|
|
763
|
+
)
|
|
764
|
+
@click.option(
|
|
765
|
+
"--limit",
|
|
766
|
+
type=int,
|
|
767
|
+
default=100,
|
|
768
|
+
help="Maximum number of results to return (default: 100, max: 1000)",
|
|
769
|
+
)
|
|
770
|
+
@click.option(
|
|
771
|
+
"--min-citations",
|
|
772
|
+
type=int,
|
|
773
|
+
default=0,
|
|
774
|
+
help="Minimum citation count filter (default: 0)",
|
|
775
|
+
)
|
|
776
|
+
@click.option(
|
|
777
|
+
"--check-library/--no-check-library",
|
|
778
|
+
default=True,
|
|
779
|
+
help="Check if papers exist in local Zotero (default: True)",
|
|
780
|
+
)
|
|
781
|
+
@click.pass_context
|
|
782
|
+
def references(ctx, doi, limit, min_citations, check_library):
|
|
783
|
+
"""Find papers referenced by a given paper using Semantic Scholar.
|
|
784
|
+
|
|
785
|
+
Examples:
|
|
786
|
+
pyzotero references --doi "10.1038/nature12373"
|
|
787
|
+
|
|
788
|
+
pyzotero references --doi "10.1038/nature12373" --limit 50
|
|
789
|
+
|
|
790
|
+
pyzotero references --doi "10.1038/nature12373" --min-citations 100
|
|
791
|
+
|
|
792
|
+
"""
|
|
793
|
+
try:
|
|
794
|
+
# Get references from Semantic Scholar
|
|
795
|
+
click.echo(f"Fetching references for DOI: {doi}...", err=True)
|
|
796
|
+
result = get_references(doi, id_type="doi", limit=limit)
|
|
797
|
+
papers = result.get("papers", [])
|
|
798
|
+
|
|
799
|
+
# Apply citation filter
|
|
800
|
+
if min_citations > 0:
|
|
801
|
+
papers = filter_by_citations(papers, min_citations)
|
|
802
|
+
|
|
803
|
+
if not papers:
|
|
804
|
+
click.echo(json.dumps({"count": 0, "papers": []}))
|
|
805
|
+
return
|
|
806
|
+
|
|
807
|
+
# Optionally annotate with library status
|
|
808
|
+
if check_library:
|
|
809
|
+
click.echo("Checking local Zotero library...", err=True)
|
|
810
|
+
locale = ctx.obj.get("locale", "en-US")
|
|
811
|
+
zot = _get_zotero_client(locale)
|
|
812
|
+
doi_map = _build_doi_index(zot)
|
|
813
|
+
output_papers = _annotate_with_library(papers, doi_map)
|
|
814
|
+
else:
|
|
815
|
+
output_papers = [_format_s2_paper(p) for p in papers]
|
|
816
|
+
|
|
817
|
+
click.echo(
|
|
818
|
+
json.dumps({"count": len(output_papers), "papers": output_papers}, indent=2)
|
|
819
|
+
)
|
|
820
|
+
|
|
821
|
+
except PaperNotFoundError:
|
|
822
|
+
click.echo("Error: Paper not found in Semantic Scholar.", err=True)
|
|
823
|
+
sys.exit(1)
|
|
824
|
+
except RateLimitError:
|
|
825
|
+
click.echo("Error: Rate limit exceeded. Please wait and try again.", err=True)
|
|
826
|
+
sys.exit(1)
|
|
827
|
+
except SemanticScholarError as e:
|
|
828
|
+
click.echo(f"Error: {e!s}", err=True)
|
|
829
|
+
sys.exit(1)
|
|
830
|
+
except Exception as e:
|
|
831
|
+
click.echo(f"Error: {e!s}", err=True)
|
|
832
|
+
sys.exit(1)
|
|
833
|
+
|
|
834
|
+
|
|
835
|
+
@main.command()
|
|
836
|
+
@click.option(
|
|
837
|
+
"-q",
|
|
838
|
+
"--query",
|
|
839
|
+
required=True,
|
|
840
|
+
help="Search query string",
|
|
841
|
+
)
|
|
842
|
+
@click.option(
|
|
843
|
+
"--limit",
|
|
844
|
+
type=int,
|
|
845
|
+
default=20,
|
|
846
|
+
help="Maximum number of results to return (default: 20, max: 100)",
|
|
847
|
+
)
|
|
848
|
+
@click.option(
|
|
849
|
+
"--year",
|
|
850
|
+
help="Year filter (e.g., '2020', '2018-2022', '2020-')",
|
|
851
|
+
)
|
|
852
|
+
@click.option(
|
|
853
|
+
"--open-access/--no-open-access",
|
|
854
|
+
default=False,
|
|
855
|
+
help="Only return open access papers (default: False)",
|
|
856
|
+
)
|
|
857
|
+
@click.option(
|
|
858
|
+
"--sort",
|
|
859
|
+
type=click.Choice(["citations", "year"], case_sensitive=False),
|
|
860
|
+
help="Sort results by citation count or year (descending)",
|
|
861
|
+
)
|
|
862
|
+
@click.option(
|
|
863
|
+
"--min-citations",
|
|
864
|
+
type=int,
|
|
865
|
+
default=0,
|
|
866
|
+
help="Minimum citation count filter (default: 0)",
|
|
867
|
+
)
|
|
868
|
+
@click.option(
|
|
869
|
+
"--check-library/--no-check-library",
|
|
870
|
+
default=True,
|
|
871
|
+
help="Check if papers exist in local Zotero (default: True)",
|
|
872
|
+
)
|
|
873
|
+
@click.pass_context
|
|
874
|
+
def s2search(ctx, query, limit, year, open_access, sort, min_citations, check_library):
|
|
875
|
+
"""Search for papers on Semantic Scholar.
|
|
876
|
+
|
|
877
|
+
Search across Semantic Scholar's index of over 200M papers.
|
|
878
|
+
|
|
879
|
+
Examples:
|
|
880
|
+
pyzotero s2search -q "climate adaptation"
|
|
881
|
+
|
|
882
|
+
pyzotero s2search -q "machine learning" --year 2020-2024
|
|
883
|
+
|
|
884
|
+
pyzotero s2search -q "neural networks" --open-access --limit 50
|
|
885
|
+
|
|
886
|
+
pyzotero s2search -q "deep learning" --sort citations --min-citations 100
|
|
887
|
+
|
|
888
|
+
"""
|
|
889
|
+
try:
|
|
890
|
+
# Search Semantic Scholar
|
|
891
|
+
click.echo(f'Searching Semantic Scholar for: "{query}"...', err=True)
|
|
892
|
+
result = search_papers(
|
|
893
|
+
query,
|
|
894
|
+
limit=limit,
|
|
895
|
+
year=year,
|
|
896
|
+
open_access_only=open_access,
|
|
897
|
+
sort=sort,
|
|
898
|
+
min_citations=min_citations,
|
|
899
|
+
)
|
|
900
|
+
papers = result.get("papers", [])
|
|
901
|
+
total = result.get("total", len(papers))
|
|
902
|
+
|
|
903
|
+
if not papers:
|
|
904
|
+
click.echo(json.dumps({"count": 0, "total": total, "papers": []}))
|
|
905
|
+
return
|
|
906
|
+
|
|
907
|
+
# Optionally annotate with library status
|
|
908
|
+
if check_library:
|
|
909
|
+
click.echo("Checking local Zotero library...", err=True)
|
|
910
|
+
locale = ctx.obj.get("locale", "en-US")
|
|
911
|
+
zot = _get_zotero_client(locale)
|
|
912
|
+
doi_map = _build_doi_index(zot)
|
|
913
|
+
output_papers = _annotate_with_library(papers, doi_map)
|
|
914
|
+
else:
|
|
915
|
+
output_papers = [_format_s2_paper(p) for p in papers]
|
|
916
|
+
|
|
917
|
+
click.echo(
|
|
918
|
+
json.dumps(
|
|
919
|
+
{"count": len(output_papers), "total": total, "papers": output_papers},
|
|
920
|
+
indent=2,
|
|
921
|
+
)
|
|
922
|
+
)
|
|
923
|
+
|
|
924
|
+
except RateLimitError:
|
|
925
|
+
click.echo("Error: Rate limit exceeded. Please wait and try again.", err=True)
|
|
926
|
+
sys.exit(1)
|
|
927
|
+
except SemanticScholarError as e:
|
|
928
|
+
click.echo(f"Error: {e!s}", err=True)
|
|
929
|
+
sys.exit(1)
|
|
930
|
+
except Exception as e:
|
|
931
|
+
click.echo(f"Error: {e!s}", err=True)
|
|
932
|
+
sys.exit(1)
|
|
933
|
+
|
|
934
|
+
|
|
516
935
|
if __name__ == "__main__":
|
|
517
936
|
main()
|
pyzotero/errors.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Exception classes and error handling for Pyzotero.
|
|
2
|
+
|
|
3
|
+
This module defines all custom exceptions used by the library
|
|
4
|
+
and the error_handler function for processing HTTP errors.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from ._utils import get_backoff_duration
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class PyZoteroError(Exception):
|
|
20
|
+
"""Generic parent exception for all Pyzotero errors."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ParamNotPassedError(PyZoteroError):
|
|
24
|
+
"""Raised if a parameter which is required isn't passed."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class CallDoesNotExistError(PyZoteroError):
|
|
28
|
+
"""Raised if the specified API call doesn't exist."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class UnsupportedParamsError(PyZoteroError):
|
|
32
|
+
"""Raised when unsupported parameters are passed."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class UserNotAuthorisedError(PyZoteroError):
|
|
36
|
+
"""Raised when the user is not allowed to retrieve the resource."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class TooManyItemsError(PyZoteroError):
|
|
40
|
+
"""Raised when too many items are passed to a Write API method."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class MissingCredentialsError(PyZoteroError):
|
|
44
|
+
"""Raised when an attempt is made to create a Zotero instance
|
|
45
|
+
without providing both the user ID and the user key.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class InvalidItemFieldsError(PyZoteroError):
|
|
50
|
+
"""Raised when an attempt is made to create/update items w/invalid fields."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class ResourceNotFoundError(PyZoteroError):
|
|
54
|
+
"""Raised when a resource (item, collection etc.) could not be found."""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class HTTPError(PyZoteroError):
|
|
58
|
+
"""Raised for miscellaneous HTTP errors."""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class CouldNotReachURLError(PyZoteroError):
|
|
62
|
+
"""Raised when we can't reach a URL."""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class ConflictError(PyZoteroError):
|
|
66
|
+
"""409 - Raised when the target library is locked."""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class PreConditionFailedError(PyZoteroError):
|
|
70
|
+
"""412 - Raised when the provided X-Zotero-Write-Token has already been
|
|
71
|
+
submitted.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class RequestEntityTooLargeError(PyZoteroError):
|
|
76
|
+
"""413 - The upload would exceed the storage quota of the library owner."""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class PreConditionRequiredError(PyZoteroError):
|
|
80
|
+
"""428 - Raised when If-Match or If-None-Match was not provided."""
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class TooManyRequestsError(PyZoteroError):
|
|
84
|
+
"""429 - Raised when there are too many unfinished uploads.
|
|
85
|
+
Try again after the number of seconds specified in the Retry-After header.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class FileDoesNotExistError(PyZoteroError):
|
|
90
|
+
"""Raised when a file path to be attached can't be opened (or doesn't exist)."""
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class TooManyRetriesError(PyZoteroError):
|
|
94
|
+
"""Raise after the backoff period for new requests exceeds 32s."""
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class UploadError(PyZoteroError):
|
|
98
|
+
"""Raise if the connection drops during upload or some other non-HTTP error
|
|
99
|
+
code is returned.
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# Mapping of HTTP status codes to exception classes
|
|
104
|
+
ERROR_CODES: dict[int, type[PyZoteroError]] = {
|
|
105
|
+
400: UnsupportedParamsError,
|
|
106
|
+
401: UserNotAuthorisedError,
|
|
107
|
+
403: UserNotAuthorisedError,
|
|
108
|
+
404: ResourceNotFoundError,
|
|
109
|
+
409: ConflictError,
|
|
110
|
+
412: PreConditionFailedError,
|
|
111
|
+
413: RequestEntityTooLargeError,
|
|
112
|
+
428: PreConditionRequiredError,
|
|
113
|
+
429: TooManyRequestsError,
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def error_handler(
|
|
118
|
+
zot: Any, req: httpx.Response, exc: BaseException | None = None
|
|
119
|
+
) -> None:
|
|
120
|
+
"""Error handler for HTTP requests.
|
|
121
|
+
|
|
122
|
+
Raises appropriate exceptions based on HTTP status codes and handles
|
|
123
|
+
rate limiting with backoff.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
zot: A Zotero instance (or any object with _set_backoff method)
|
|
127
|
+
req: The HTTP response object
|
|
128
|
+
exc: Optional exception that triggered this handler
|
|
129
|
+
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
def err_msg(req: httpx.Response) -> str:
|
|
133
|
+
"""Return a nicely-formatted error message."""
|
|
134
|
+
return (
|
|
135
|
+
f"\nCode: {req.status_code}\n"
|
|
136
|
+
f"URL: {req.url!s}\n"
|
|
137
|
+
f"Method: {req.request.method}\n"
|
|
138
|
+
f"Response: {req.text}"
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
if ERROR_CODES.get(req.status_code):
|
|
142
|
+
# check to see whether its 429
|
|
143
|
+
if req.status_code == httpx.codes.TOO_MANY_REQUESTS:
|
|
144
|
+
# try to get backoff or delay duration
|
|
145
|
+
delay = get_backoff_duration(req.headers)
|
|
146
|
+
if not delay:
|
|
147
|
+
msg = (
|
|
148
|
+
"You are being rate-limited and no backoff or retry duration "
|
|
149
|
+
"has been received from the server. Try again later"
|
|
150
|
+
)
|
|
151
|
+
raise TooManyRetriesError(msg)
|
|
152
|
+
zot._set_backoff(delay)
|
|
153
|
+
elif not exc:
|
|
154
|
+
raise ERROR_CODES[req.status_code](err_msg(req))
|
|
155
|
+
else:
|
|
156
|
+
raise ERROR_CODES[req.status_code](err_msg(req)) from exc
|
|
157
|
+
elif not exc:
|
|
158
|
+
raise HTTPError(err_msg(req))
|
|
159
|
+
else:
|
|
160
|
+
raise HTTPError(err_msg(req)) from exc
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
__all__ = [
|
|
164
|
+
"ERROR_CODES",
|
|
165
|
+
"CallDoesNotExistError",
|
|
166
|
+
"ConflictError",
|
|
167
|
+
"CouldNotReachURLError",
|
|
168
|
+
"FileDoesNotExistError",
|
|
169
|
+
"HTTPError",
|
|
170
|
+
"InvalidItemFieldsError",
|
|
171
|
+
"MissingCredentialsError",
|
|
172
|
+
"ParamNotPassedError",
|
|
173
|
+
"PreConditionFailedError",
|
|
174
|
+
"PreConditionRequiredError",
|
|
175
|
+
"PyZoteroError",
|
|
176
|
+
"RequestEntityTooLargeError",
|
|
177
|
+
"ResourceNotFoundError",
|
|
178
|
+
"TooManyItemsError",
|
|
179
|
+
"TooManyRequestsError",
|
|
180
|
+
"TooManyRetriesError",
|
|
181
|
+
"UnsupportedParamsError",
|
|
182
|
+
"UploadError",
|
|
183
|
+
"UserNotAuthorisedError",
|
|
184
|
+
"error_handler",
|
|
185
|
+
]
|