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/_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
+ ]