pyzotero 1.6.7__py3-none-any.whl → 1.6.9__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/zotero.py CHANGED
@@ -1,8 +1,4 @@
1
- # pylint: disable=R0904
2
- """
3
- zotero.py
4
-
5
- Created by Stephan Hügel on 2011-02-28
1
+ """Created by Stephan Hügel on 2011-02-28.
6
2
 
7
3
  This file is part of Pyzotero.
8
4
  """
@@ -16,7 +12,6 @@ import hashlib
16
12
  import io
17
13
  import json
18
14
  import mimetypes
19
- import os
20
15
  import re
21
16
  import threading
22
17
  import time
@@ -40,22 +35,27 @@ import feedparser
40
35
  import httpx
41
36
  import pytz
42
37
  from httpx import Request
43
- from httpx_file import Client as File_Client
44
38
 
45
39
  import pyzotero as pz
46
40
 
47
41
  from . import zotero_errors as ze
42
+ from .filetransport import Client as File_Client
48
43
 
49
44
  # Avoid hanging the application if there's no server response
50
45
  timeout = 30
51
46
 
47
+ NOT_MODIFIED = 304
48
+ ONE_HOUR = 3600
49
+ DEFAULT_NUM_ITEMS = 50
50
+ TOO_MANY_REQUESTS = 429
51
+
52
52
 
53
53
  def build_url(base_url, path, args_dict=None):
54
54
  """Build a valid URL so we don't have to worry about string concatenation errors and
55
55
  leading / trailing slashes etc.
56
- Returns a list in the structure of urlparse.ParseResult"""
57
- if base_url.endswith("/"):
58
- base_url = base_url[:-1]
56
+ Returns a list in the structure of urlparse.ParseResult
57
+ """
58
+ base_url = base_url.removesuffix("/")
59
59
  url_parts = list(urlparse(base_url))
60
60
  url_parts[2] += path
61
61
  if args_dict:
@@ -64,8 +64,9 @@ def build_url(base_url, path, args_dict=None):
64
64
 
65
65
 
66
66
  def merge_params(url, params):
67
- """This function strips query parameters, extracting them into a dict, then merging it with
68
- the "params" dict, returning the truncated url and merged query params dict"""
67
+ """Strip query parameters, extracting them into a dict, then merging it with
68
+ the "params" dict, returning the truncated url and merged query params dict
69
+ """
69
70
  parsed = urlparse(url)
70
71
  # Extract query parameters from URL
71
72
  incoming = parse_qs(parsed.query)
@@ -86,7 +87,7 @@ def token():
86
87
 
87
88
 
88
89
  def cleanwrap(func):
89
- """Wrapper for Zotero._cleanup"""
90
+ """Wrap for Zotero._cleanup"""
90
91
 
91
92
  def enc(self, *args, **kwargs):
92
93
  """Send each item to _cleanup()"""
@@ -106,7 +107,7 @@ def tcache(func):
106
107
 
107
108
  @wraps(func)
108
109
  def wrapped_f(self, *args, **kwargs):
109
- """Calls the decorated function to get query string and params,
110
+ """Call the decorated function to get query string and params,
110
111
  builds URL, retrieves template, caches result, and returns template
111
112
  """
112
113
  query_string, params = func(self, *args, **kwargs)
@@ -124,7 +125,9 @@ def tcache(func):
124
125
  # construct cache key
125
126
  cachekey = f"{result.path}_{result.query}"
126
127
  if self.templates.get(cachekey) and not self._updated(
127
- query_string, self.templates[cachekey], cachekey
128
+ query_string,
129
+ self.templates[cachekey],
130
+ cachekey,
128
131
  ):
129
132
  return self.templates[cachekey]["tmplt"]
130
133
  # otherwise perform a normal request and cache the response
@@ -135,8 +138,7 @@ def tcache(func):
135
138
 
136
139
 
137
140
  def backoff_check(func):
138
- """
139
- Perform backoff processing
141
+ """Perform backoff processing
140
142
  func must return a Requests GET / POST / PUT / PATCH / DELETE etc
141
143
  This is is intercepted: we first check for an active backoff
142
144
  and wait if need be.
@@ -166,15 +168,13 @@ def backoff_check(func):
166
168
 
167
169
 
168
170
  def retrieve(func):
169
- """
170
- Decorator for Zotero read API methods; calls _retrieve_data() and passes
171
+ """Call _retrieve_data() and passes
171
172
  the result to the correct processor, based on a lookup
172
173
  """
173
174
 
174
175
  @wraps(func)
175
176
  def wrapped_f(self, *args, **kwargs):
176
- """
177
- Returns result of _retrieve_data()
177
+ """Return result of _retrieve_data()
178
178
 
179
179
  func's return value is part of a URI, and it's this
180
180
  which is intercepted and passed to _retrieve_data:
@@ -189,8 +189,7 @@ def retrieve(func):
189
189
  content = (
190
190
  self.content.search(str(self.request.url))
191
191
  and self.content.search(str(self.request.url)).group(0)
192
- or "bib"
193
- )
192
+ ) or "bib"
194
193
  # select format, or assume JSON
195
194
  content_type_header = self.request.headers["Content-Type"].lower() + ";"
196
195
  fmt = self.formats.get(
@@ -229,21 +228,21 @@ def retrieve(func):
229
228
  self.snapshot = True
230
229
  if fmt == "bibtex":
231
230
  parser = bibtexparser.bparser.BibTexParser(
232
- common_strings=True, ignore_nonstandard_types=False
231
+ common_strings=True,
232
+ ignore_nonstandard_types=False,
233
233
  )
234
234
  return parser.parse(retrieved.text)
235
235
  # it's binary, so return raw content
236
- elif fmt != "json":
236
+ if fmt != "json":
237
237
  return retrieved.content
238
238
  # no need to do anything special, return JSON
239
- else:
240
- return retrieved.json()
239
+ return retrieved.json()
241
240
 
242
241
  return wrapped_f
243
242
 
244
243
 
245
244
  def ss_wrap(func):
246
- """ensure that a SavedSearch object exists"""
245
+ """Ensure that a SavedSearch object exists"""
247
246
 
248
247
  def wrapper(self, *args, **kwargs):
249
248
  if not self.savedsearch:
@@ -254,8 +253,7 @@ def ss_wrap(func):
254
253
 
255
254
 
256
255
  class Zotero:
257
- """
258
- Zotero API methods
256
+ """Zotero API methods
259
257
  A full list of methods can be found here:
260
258
  http://www.zotero.org/support/dev/server_api
261
259
  """
@@ -282,9 +280,8 @@ class Zotero:
282
280
  # library_type determines whether query begins w. /users or /groups
283
281
  self.library_type = library_type + "s"
284
282
  else:
285
- raise ze.MissingCredentials(
286
- "Please provide both the library ID and the library type"
287
- )
283
+ err = "Please provide both the library ID and the library type"
284
+ raise ze.MissingCredentialsError(err)
288
285
  # api_key is not required for public individual or group libraries
289
286
  self.api_key = api_key
290
287
  self.preserve_json_order = preserve_json_order
@@ -294,10 +291,11 @@ class Zotero:
294
291
  self.request = None
295
292
  self.snapshot = False
296
293
  self.client = httpx.Client(
297
- headers=self.default_headers(), follow_redirects=True
294
+ headers=self.default_headers(),
295
+ follow_redirects=True,
298
296
  )
299
297
  # these aren't valid item fields, so never send them to the server
300
- self.temp_keys = set(["key", "etag", "group_id", "updated"])
298
+ self.temp_keys = {"key", "etag", "group_id", "updated"}
301
299
  # determine which processor to use for the parsed content
302
300
  self.fmt = re.compile(r"(?<=format=)\w+")
303
301
  self.content = re.compile(r"(?<=content=)\w+")
@@ -358,15 +356,14 @@ class Zotero:
358
356
  self.backoff_duration = 0.0
359
357
 
360
358
  def __del__(self):
359
+ """Remove client before cleanup"""
361
360
  # this isn't guaranteed to run, but that's OK
362
361
  if c := self.client:
363
362
  c.close()
364
363
 
365
364
  def _check_for_component(self, url, component):
366
365
  """Check a url path query fragment for a specific query parameter"""
367
- if parse_qs(url).get(component):
368
- return True
369
- return False
366
+ return bool(parse_qs(url).get(component))
370
367
 
371
368
  def _striplocal(self, url):
372
369
  """We need to remve the leading "/api" substring from urls if we're running in local mode"""
@@ -379,8 +376,7 @@ class Zotero:
379
376
  return url
380
377
 
381
378
  def _set_backoff(self, duration):
382
- """
383
- Set a backoff
379
+ """Set a backoff
384
380
  Spins up a timer in a background thread which resets the backoff logic
385
381
  when it expires, then sets the time at which the backoff will expire.
386
382
  The latter step is required so that other calls can check whether there's
@@ -397,8 +393,7 @@ class Zotero:
397
393
  self.backoff_duration = 0.0
398
394
 
399
395
  def _check_backoff(self):
400
- """
401
- Before an API call is made, we check whether there's an active backoff.
396
+ """Before an API call is made, we check whether there's an active backoff.
402
397
  If there is, we check whether there's any time left on the backoff.
403
398
  If there is, we sleep for the remainder before returning
404
399
  """
@@ -408,9 +403,7 @@ class Zotero:
408
403
  time.sleep(remainder)
409
404
 
410
405
  def default_headers(self):
411
- """
412
- It's always OK to include these headers
413
- """
406
+ """It's always OK to include these headers"""
414
407
  _headers = {
415
408
  "User-Agent": f"Pyzotero/{pz.__version__}",
416
409
  "Zotero-API-Version": f"{__api_version__}",
@@ -420,19 +413,18 @@ class Zotero:
420
413
  return _headers
421
414
 
422
415
  def _cache(self, response, key):
423
- """
424
- Add a retrieved template to the cache for 304 checking
416
+ """Add a retrieved template to the cache for 304 checking
425
417
  accepts a dict and key name, adds the retrieval time, and adds both
426
418
  to self.templates as a new dict using the specified key
427
419
  """
428
420
  # cache template and retrieval time for subsequent calls
429
421
  try:
430
422
  thetime = datetime.datetime.now(datetime.UTC).replace(
431
- tzinfo=pytz.timezone("GMT")
423
+ tzinfo=pytz.timezone("GMT"),
432
424
  )
433
425
  except AttributeError:
434
- thetime = thetime = datetime.datetime.utcnow().replace(
435
- tzinfo=pytz.timezone("GMT")
426
+ thetime = datetime.datetime.now(tz=datetime.timezone.utc).replace(
427
+ tzinfo=pytz.timezone("GMT"),
436
428
  )
437
429
  self.templates[key] = {"tmplt": response.json(), "updated": thetime}
438
430
  return copy.deepcopy(response.json())
@@ -449,12 +441,11 @@ class Zotero:
449
441
  [k, v]
450
442
  for k, v in list(to_clean.items())
451
443
  if (k in allow or k not in self.temp_keys)
452
- ]
444
+ ],
453
445
  )
454
446
 
455
447
  def _retrieve_data(self, request=None, params=None):
456
- """
457
- Retrieve Zotero items via the API
448
+ """Retrieve Zotero items via the API
458
449
  Combine endpoint and request to access the specific resource
459
450
  Returns a JSON document
460
451
  """
@@ -466,7 +457,8 @@ class Zotero:
466
457
  # don't set locale if the url already contains it
467
458
  # we always add a locale if it's a "standalone" or first call
468
459
  needs_locale = not self.links or not self._check_for_component(
469
- self.links.get("next"), "locale"
460
+ self.links.get("next"),
461
+ "locale",
470
462
  )
471
463
  if needs_locale:
472
464
  if params:
@@ -510,17 +502,15 @@ class Zotero:
510
502
  except httpx.HTTPError as exc:
511
503
  error_handler(self, self.request, exc)
512
504
  backoff = self.request.headers.get("backoff") or self.request.headers.get(
513
- "retry-after"
505
+ "retry-after",
514
506
  )
515
507
  if backoff:
516
508
  self._set_backoff(backoff)
517
509
  return self.request
518
510
 
519
511
  def _extract_links(self):
520
- """
521
- Extract self, first, next, last links from a request response
522
- """
523
- extracted = dict()
512
+ """Extract self, first, next, last links from a request response"""
513
+ extracted = {}
524
514
  try:
525
515
  for key, value in self.request.links.items():
526
516
  parsed = urlparse(value["url"])
@@ -530,21 +520,21 @@ class Zotero:
530
520
  parsed = list(urlparse(self.self_link))
531
521
  # strip 'format' query parameter
532
522
  stripped = "&".join(
533
- ["=".join(p) for p in parse_qsl(parsed[4])[:2] if p[0] != "format"]
523
+ ["=".join(p) for p in parse_qsl(parsed[4])[:2] if p[0] != "format"],
534
524
  )
535
525
  # rebuild url fragment
536
526
  # this is a death march
537
527
  extracted["self"] = urlunparse(
538
- [parsed[0], parsed[1], parsed[2], parsed[3], stripped, parsed[5]]
528
+ [parsed[0], parsed[1], parsed[2], parsed[3], stripped, parsed[5]],
539
529
  )
540
- return extracted
541
530
  except KeyError:
542
531
  # No links present, because it's a single item
543
532
  return None
533
+ else:
534
+ return extracted
544
535
 
545
536
  def _updated(self, url, payload, template=None):
546
- """
547
- Generic call to see if a template request returns 304
537
+ """Call to see if a template request returns 304
548
538
  accepts:
549
539
  - a string to combine with the API endpoint
550
540
  - a dict of format values, in case they're required by 'url'
@@ -555,10 +545,12 @@ class Zotero:
555
545
  # If the template is more than an hour old, try a 304
556
546
  if (
557
547
  abs(
558
- datetime.datetime.utcnow().replace(tzinfo=pytz.timezone("GMT"))
559
- - self.templates[template]["updated"]
548
+ datetime.datetime.now(tz=datetime.timezone.utc).replace(
549
+ tzinfo=pytz.timezone("GMT"),
550
+ )
551
+ - self.templates[template]["updated"],
560
552
  ).seconds
561
- > 3600
553
+ > ONE_HOUR
562
554
  ):
563
555
  query = build_url(
564
556
  self.endpoint,
@@ -566,8 +558,8 @@ class Zotero:
566
558
  )
567
559
  headers = {
568
560
  "If-Modified-Since": payload["updated"].strftime(
569
- "%a, %d %b %Y %H:%M:%S %Z"
570
- )
561
+ "%a, %d %b %Y %H:%M:%S %Z",
562
+ ),
571
563
  }
572
564
  # perform the request, and check whether the response returns 304
573
565
  self._check_backoff()
@@ -577,17 +569,17 @@ class Zotero:
577
569
  except httpx.HTTPError as exc:
578
570
  error_handler(self, req, exc)
579
571
  backoff = self.request.headers.get("backoff") or self.request.headers.get(
580
- "retry-after"
572
+ "retry-after",
581
573
  )
582
574
  if backoff:
583
575
  self._set_backoff(backoff)
584
- return req.status_code == 304
576
+ return req.status_code == NOT_MODIFIED
585
577
  # Still plenty of life left in't
586
578
  return False
587
579
 
588
580
  def add_parameters(self, **params):
589
- """
590
- Add URL parameters
581
+ """Add URL parameters.
582
+
591
583
  Also ensure that only valid format/content combinations are requested
592
584
  """
593
585
  self.url_params = None
@@ -611,26 +603,26 @@ class Zotero:
611
603
  self.url_params = params
612
604
 
613
605
  def _build_query(self, query_string, no_params=False):
614
- """
615
- Set request parameters. Will always add the user ID if it hasn't
606
+ """Set request parameters. Will always add the user ID if it hasn't
616
607
  been specifically set by an API method
617
608
  """
618
609
  try:
619
610
  query = quote(query_string.format(u=self.library_id, t=self.library_type))
620
611
  except KeyError as err:
621
- raise ze.ParamNotPassed(f"There's a request parameter missing: {err}")
612
+ errmsg = f"There's a request parameter missing: {err}"
613
+ raise ze.ParamNotPassedError(errmsg) from None
622
614
  # Add the URL parameters and the user key, if necessary
623
- if no_params is False:
624
- if not self.url_params:
625
- self.add_parameters()
615
+ if no_params is False and not self.url_params:
616
+ self.add_parameters()
626
617
  return query
627
618
 
628
619
  @retrieve
629
620
  def publications(self):
630
- """Return the contents of My Publications"""
621
+ """Return the contents of My Publications."""
631
622
  if self.library_type != "users":
632
- raise ze.CallDoesNotExist(
633
- "This API call does not exist for group libraries"
623
+ msg = "This API call does not exist for group libraries"
624
+ raise ze.CallDoesNotExistError(
625
+ msg,
634
626
  )
635
627
  query_string = "/{t}/{u}/publications/items"
636
628
  return self._build_query(query_string)
@@ -648,9 +640,7 @@ class Zotero:
648
640
 
649
641
  def num_collectionitems(self, collection):
650
642
  """Return the total number of items in the specified collection"""
651
- query = "/{t}/{u}/collections/{c}/items".format(
652
- u=self.library_id, t=self.library_type, c=collection.upper()
653
- )
643
+ query = f"/{self.library_type}/{self.library_id}/collections/{collection.upper()}/items"
654
644
  return self._totals(query)
655
645
 
656
646
  def _totals(self, query):
@@ -664,11 +654,10 @@ class Zotero:
664
654
 
665
655
  @retrieve
666
656
  def key_info(self, **kwargs):
667
- """
668
- Retrieve info about the permissions associated with the
657
+ """Retrieve info about the permissions associated with the
669
658
  key associated to the given Zotero instance
670
659
  """
671
- query_string = "/keys/{k}".format(k=self.api_key)
660
+ query_string = f"/keys/{self.api_key}"
672
661
  return self._build_query(query_string)
673
662
 
674
663
  @retrieve
@@ -677,18 +666,23 @@ class Zotero:
677
666
  query_string = "/{t}/{u}/items"
678
667
  return self._build_query(query_string)
679
668
 
669
+ @retrieve
670
+ def settings(self, **kwargs):
671
+ """Get synced user settings"""
672
+ query_string = "/{t}/{u}/settings"
673
+ return self._build_query(query_string)
674
+
680
675
  @retrieve
681
676
  def fulltext_item(self, itemkey, **kwargs):
682
677
  """Get full-text content for an item"""
683
- query_string = "/{t}/{u}/items/{itemkey}/fulltext".format(
684
- t=self.library_type, u=self.library_id, itemkey=itemkey
678
+ query_string = (
679
+ f"/{self.library_type}/{self.library_id}/items/{itemkey}/fulltext"
685
680
  )
686
681
  return self._build_query(query_string)
687
682
 
688
683
  @backoff_check
689
684
  def set_fulltext(self, itemkey, payload):
690
- """
691
- Set full-text data for an item
685
+ """Set full-text data for an item
692
686
  <itemkey> should correspond to an existing attachment item.
693
687
  payload should be a dict containing three keys:
694
688
  'content': the full-text content and either
@@ -700,42 +694,38 @@ class Zotero:
700
694
  return self.client.put(
701
695
  url=build_url(
702
696
  self.endpoint,
703
- "/{t}/{u}/items/{k}/fulltext".format(
704
- t=self.library_type, u=self.library_id, k=itemkey
705
- ),
697
+ f"/{self.library_type}/{self.library_id}/items/{itemkey}/fulltext",
706
698
  ),
707
699
  headers=headers,
708
700
  data=json.dumps(payload),
709
701
  )
710
702
 
711
703
  def new_fulltext(self, since):
712
- """
713
- Retrieve list of full-text content items and versions which are newer
704
+ """Retrieve list of full-text content items and versions which are newer
714
705
  than <since>
715
706
  """
716
- query_string = "/{t}/{u}/fulltext".format(
717
- t=self.library_type, u=self.library_id
718
- )
707
+ query_string = f"/{self.library_type}/{self.library_id}/fulltext"
719
708
  headers = {}
720
709
  params = {"since": since}
721
710
  self._check_backoff()
722
711
  resp = self.client.get(
723
- build_url(self.endpoint, query_string), params=params, headers=headers
712
+ build_url(self.endpoint, query_string),
713
+ params=params,
714
+ headers=headers,
724
715
  )
725
716
  try:
726
717
  resp.raise_for_status()
727
718
  except httpx.HTTPError as exc:
728
719
  error_handler(self, resp, exc)
729
720
  backoff = self.request.headers.get("backoff") or self.request.headers.get(
730
- "retry-after"
721
+ "retry-after",
731
722
  )
732
723
  if backoff:
733
724
  self._set_backoff(backoff)
734
725
  return resp.json()
735
726
 
736
727
  def item_versions(self, **kwargs):
737
- """
738
- Returns dict associating items keys (all no limit by default) to versions.
728
+ """Return dict associating items keys (all no limit by default) to versions.
739
729
  Accepts a since= parameter in kwargs to limit the data to those updated since since=
740
730
  """
741
731
  if "limit" not in kwargs:
@@ -744,8 +734,7 @@ class Zotero:
744
734
  return self.items(**kwargs)
745
735
 
746
736
  def collection_versions(self, **kwargs):
747
- """
748
- Returns dict associating collection keys (all no limit by default) to versions.
737
+ """Return dict associating collection keys (all no limit by default) to versions.
749
738
  Accepts a since= parameter in kwargs to limit the data to those updated since since=
750
739
  """
751
740
  if "limit" not in kwargs:
@@ -791,73 +780,60 @@ class Zotero:
791
780
  @retrieve
792
781
  def item(self, item, **kwargs):
793
782
  """Get a specific item"""
794
- query_string = "/{t}/{u}/items/{i}".format(
795
- u=self.library_id, t=self.library_type, i=item.upper()
796
- )
783
+ query_string = f"/{self.library_type}/{self.library_id}/items/{item.upper()}"
797
784
  return self._build_query(query_string)
798
785
 
799
786
  @retrieve
800
787
  def file(self, item, **kwargs):
801
788
  """Get the file from a specific item"""
802
- query_string = "/{t}/{u}/items/{i}/file".format(
803
- u=self.library_id, t=self.library_type, i=item.upper()
789
+ query_string = (
790
+ f"/{self.library_type}/{self.library_id}/items/{item.upper()}/file"
804
791
  )
805
792
  return self._build_query(query_string, no_params=True)
806
793
 
807
794
  def dump(self, itemkey, filename=None, path=None):
808
- """
809
- Dump a file attachment to disk, with optional filename and path
810
- """
795
+ """Dump a file attachment to disk, with optional filename and path"""
811
796
  if not filename:
812
797
  filename = self.item(itemkey)["data"]["filename"]
813
- if path:
814
- pth = os.path.join(path, filename)
815
- else:
816
- pth = filename
798
+ pth = Path(path) / filename if path else filename
817
799
  file = self.file(itemkey)
818
800
  if self.snapshot:
819
801
  self.snapshot = False
820
- pth = pth + ".zip"
821
- with open(pth, "wb") as f:
802
+ pth += ".zip"
803
+ with Path.open(pth, "wb") as f:
822
804
  f.write(file)
823
805
 
824
806
  @retrieve
825
807
  def children(self, item, **kwargs):
826
808
  """Get a specific item's child items"""
827
- query_string = "/{t}/{u}/items/{i}/children".format(
828
- u=self.library_id, t=self.library_type, i=item.upper()
809
+ query_string = (
810
+ f"/{self.library_type}/{self.library_id}/items/{item.upper()}/children"
829
811
  )
830
812
  return self._build_query(query_string)
831
813
 
832
814
  @retrieve
833
815
  def collection_items(self, collection, **kwargs):
834
816
  """Get a specific collection's items"""
835
- query_string = "/{t}/{u}/collections/{c}/items".format(
836
- u=self.library_id, t=self.library_type, c=collection.upper()
837
- )
817
+ query_string = f"/{self.library_type}/{self.library_id}/collections/{collection.upper()}/items"
838
818
  return self._build_query(query_string)
839
819
 
840
820
  @retrieve
841
821
  def collection_items_top(self, collection, **kwargs):
842
822
  """Get a specific collection's top-level items"""
843
- query_string = "/{t}/{u}/collections/{c}/items/top".format(
844
- u=self.library_id, t=self.library_type, c=collection.upper()
845
- )
823
+ query_string = f"/{self.library_type}/{self.library_id}/collections/{collection.upper()}/items/top"
846
824
  return self._build_query(query_string)
847
825
 
848
826
  @retrieve
849
827
  def collection_tags(self, collection, **kwargs):
850
828
  """Get a specific collection's tags"""
851
- query_string = "/{t}/{u}/collections/{c}/tags".format(
852
- u=self.library_id, t=self.library_type, c=collection.upper()
853
- )
829
+ query_string = f"/{self.library_type}/{self.library_id}/collections/{collection.upper()}/tags"
854
830
  return self._build_query(query_string)
855
831
 
856
832
  @retrieve
857
833
  def collection(self, collection, **kwargs):
858
834
  """Get user collection"""
859
- query_string = "/{t}/{u}/collections/{c}".format(
860
- u=self.library_id, t=self.library_type, c=collection.upper()
835
+ query_string = (
836
+ f"/{self.library_type}/{self.library_id}/collections/{collection.upper()}"
861
837
  )
862
838
  return self._build_query(query_string)
863
839
 
@@ -868,14 +844,13 @@ class Zotero:
868
844
  return self._build_query(query_string)
869
845
 
870
846
  def all_collections(self, collid=None):
871
- """
872
- Retrieve all collections and subcollections. Works for top-level collections
847
+ """Retrieve all collections and subcollections. Works for top-level collections
873
848
  or for a specific collection. Works at all collection depths.
874
849
  """
875
850
  all_collections = []
876
851
 
877
852
  def subcoll(clct):
878
- """recursively add collections to a flat master list"""
853
+ """Recursively add collections to a flat master list"""
879
854
  all_collections.append(clct)
880
855
  if clct["meta"].get("numCollections", 0) > 0:
881
856
  # add collection to master list & recur with all child
@@ -903,9 +878,7 @@ class Zotero:
903
878
  @retrieve
904
879
  def collections_sub(self, collection, **kwargs):
905
880
  """Get subcollections for a specific collection"""
906
- query_string = "/{t}/{u}/collections/{c}/collections".format(
907
- u=self.library_id, t=self.library_type, c=collection.upper()
908
- )
881
+ query_string = f"/{self.library_type}/{self.library_id}/collections/{collection.upper()}/collections"
909
882
  return self._build_query(query_string)
910
883
 
911
884
  @retrieve
@@ -924,8 +897,8 @@ class Zotero:
924
897
  @retrieve
925
898
  def item_tags(self, item, **kwargs):
926
899
  """Get tags for a specific item"""
927
- query_string = "/{t}/{u}/items/{i}/tags".format(
928
- u=self.library_id, t=self.library_type, i=item.upper()
900
+ query_string = (
901
+ f"/{self.library_type}/{self.library_id}/items/{item.upper()}/tags"
929
902
  )
930
903
  self.tag_data = True
931
904
  return self._build_query(query_string)
@@ -938,12 +911,11 @@ class Zotero:
938
911
  def follow(self):
939
912
  """Return the result of the call to the URL in the 'Next' link"""
940
913
  if n := self.links.get("next"):
941
- newurl = self._striplocal(n)
942
- return newurl
943
- return
914
+ return self._striplocal(n)
915
+ return None
944
916
 
945
917
  def iterfollow(self):
946
- """Generator for self.follow()"""
918
+ """Return generator for self.follow()"""
947
919
  # use same criterion as self.follow()
948
920
  while True:
949
921
  if self.links.get("next"):
@@ -958,8 +930,7 @@ class Zotero:
958
930
  return self.iterfollow()
959
931
 
960
932
  def everything(self, query):
961
- """
962
- Retrieve all items in the library for a particular query
933
+ """Retrieve all items in the library for a particular query
963
934
  This method will override the 'limit' parameter if it's been set
964
935
  """
965
936
  try:
@@ -975,12 +946,12 @@ class Zotero:
975
946
  return items
976
947
 
977
948
  def get_subset(self, subset):
978
- """
979
- Retrieve a subset of items
949
+ """Retrieve a subset of items
980
950
  Accepts a single argument: a list of item IDs
981
951
  """
982
- if len(subset) > 50:
983
- raise ze.TooManyItems("You may only retrieve 50 items per call")
952
+ if len(subset) > DEFAULT_NUM_ITEMS:
953
+ err = f"You may only retrieve {DEFAULT_NUM_ITEMS} items per call"
954
+ raise ze.TooManyItemsError(err)
984
955
  # remember any url parameters that have been set
985
956
  params = self.url_params
986
957
  retr = []
@@ -1013,24 +984,22 @@ class Zotero:
1013
984
  json_kwargs = {}
1014
985
  if self.preserve_json_order:
1015
986
  json_kwargs["object_pairs_hook"] = OrderedDict
1016
- for csl in retrieved.entries:
1017
- items.append(json.loads(csl["content"][0]["value"], **json_kwargs))
987
+ items = [
988
+ json.loads(entry["content"][0]["value"], **json_kwargs)
989
+ for entry in retrieved.entries
990
+ ]
1018
991
  self.url_params = None
1019
992
  return items
1020
993
 
1021
994
  def _bib_processor(self, retrieved):
1022
995
  """Return a list of strings formatted as HTML bibliography entries"""
1023
- items = []
1024
- for bib in retrieved.entries:
1025
- items.append(bib["content"][0]["value"])
996
+ items = [bib["content"][0]["value"] for bib in retrieved.entries]
1026
997
  self.url_params = None
1027
998
  return items
1028
999
 
1029
1000
  def _citation_processor(self, retrieved):
1030
1001
  """Return a list of strings formatted as HTML citation entries"""
1031
- items = []
1032
- for cit in retrieved.entries:
1033
- items.append(cit["content"][0]["value"])
1002
+ items = [cit["content"][0]["value"] for cit in retrieved.entries]
1034
1003
  self.url_params = None
1035
1004
  return items
1036
1005
 
@@ -1051,7 +1020,9 @@ class Zotero:
1051
1020
  self.add_parameters(**params)
1052
1021
  query_string = "/items/new"
1053
1022
  if self.templates.get(template_name) and not self._updated(
1054
- query_string, self.templates[template_name], template_name
1023
+ query_string,
1024
+ self.templates[template_name],
1025
+ template_name,
1055
1026
  ):
1056
1027
  return copy.deepcopy(self.templates[template_name]["tmplt"])
1057
1028
  # otherwise perform a normal request and cache the response
@@ -1059,8 +1030,7 @@ class Zotero:
1059
1030
  return self._cache(retrieved, template_name)
1060
1031
 
1061
1032
  def _attachment_template(self, attachment_type):
1062
- """
1063
- Return a new attachment template of the required type:
1033
+ """Return a new attachment template of the required type:
1064
1034
  imported_file
1065
1035
  imported_url
1066
1036
  linked_file
@@ -1069,15 +1039,13 @@ class Zotero:
1069
1039
  return self.item_template("attachment", linkmode=attachment_type)
1070
1040
 
1071
1041
  def _attachment(self, payload, parentid=None):
1072
- """
1073
- Create attachments
1042
+ """Create attachments
1074
1043
  accepts a list of one or more attachment template dicts
1075
1044
  and an optional parent Item ID. If this is specified,
1076
1045
  attachments are created under this ID
1077
1046
  """
1078
1047
  attachment = Zupload(self, payload, parentid)
1079
- res = attachment.upload()
1080
- return res
1048
+ return attachment.upload()
1081
1049
 
1082
1050
  @ss_wrap
1083
1051
  def show_operators(self):
@@ -1095,10 +1063,7 @@ class Zotero:
1095
1063
  # dict keys of allowed operators for the current condition
1096
1064
  permitted_operators = self.savedsearch.conditions_operators.get(condition)
1097
1065
  # transform these into values
1098
- permitted_operators_list = set(
1099
- [self.savedsearch.operators.get(op) for op in permitted_operators]
1100
- )
1101
- return permitted_operators_list
1066
+ return {self.savedsearch.operators.get(op) for op in permitted_operators}
1102
1067
 
1103
1068
  @ss_wrap
1104
1069
  def saved_search(self, name, conditions):
@@ -1113,7 +1078,7 @@ class Zotero:
1113
1078
  req = self.client.post(
1114
1079
  url=build_url(
1115
1080
  self.endpoint,
1116
- "/{t}/{u}/searches".format(t=self.library_type, u=self.library_id),
1081
+ f"/{self.library_type}/{self.library_id}/searches",
1117
1082
  ),
1118
1083
  headers=headers,
1119
1084
  data=json.dumps(payload),
@@ -1124,7 +1089,7 @@ class Zotero:
1124
1089
  except httpx.HTTPError as exc:
1125
1090
  error_handler(self, req, exc)
1126
1091
  backoff = self.request.headers.get("backoff") or self.request.headers.get(
1127
- "retry-after"
1092
+ "retry-after",
1128
1093
  )
1129
1094
  if backoff:
1130
1095
  self._set_backoff(backoff)
@@ -1140,7 +1105,7 @@ class Zotero:
1140
1105
  req = self.client.delete(
1141
1106
  url=build_url(
1142
1107
  self.endpoint,
1143
- "/{t}/{u}/searches".format(t=self.library_type, u=self.library_id),
1108
+ f"/{self.library_type}/{self.library_id}/searches",
1144
1109
  ),
1145
1110
  headers=headers,
1146
1111
  params={"searchKey": ",".join(keys)},
@@ -1151,7 +1116,7 @@ class Zotero:
1151
1116
  except httpx.HTTPError as exc:
1152
1117
  error_handler(self, req, exc)
1153
1118
  backoff = self.request.headers.get("backoff") or self.request.headers.get(
1154
- "retry-after"
1119
+ "retry-after",
1155
1120
  )
1156
1121
  if backoff:
1157
1122
  self._set_backoff(backoff)
@@ -1162,26 +1127,22 @@ class Zotero:
1162
1127
  return Zupload(self, attachments, parentid, basedir=basedir).upload()
1163
1128
 
1164
1129
  def add_tags(self, item, *tags):
1165
- """
1166
- Add one or more tags to a retrieved item,
1130
+ """Add one or more tags to a retrieved item,
1167
1131
  then update it on the server
1168
1132
  Accepts a dict, and one or more tags to add to it
1169
1133
  Returns the updated item from the server
1170
1134
  """
1171
1135
  # Make sure there's a tags field, or add one
1172
- try:
1173
- assert item["data"]["tags"]
1174
- except AssertionError:
1175
- item["data"]["tags"] = list()
1136
+ if not item.get("data", {}).get("tags"):
1137
+ item["data"]["tags"] = []
1176
1138
  for tag in tags:
1177
1139
  item["data"]["tags"].append({"tag": f"{tag}"})
1178
1140
  # make sure everything's OK
1179
- assert self.check_items([item])
1141
+ self.check_items([item])
1180
1142
  return self.update_item(item)
1181
1143
 
1182
1144
  def check_items(self, items):
1183
- """
1184
- Check that items to be created contain no invalid dict keys
1145
+ """Check that items to be created contain no invalid dict keys
1185
1146
  Accepts a single argument: a list of one or more dicts
1186
1147
  The retrieved fields are cached and re-used until a 304 call fails
1187
1148
  """
@@ -1199,58 +1160,62 @@ class Zotero:
1199
1160
  # construct cache key
1200
1161
  cachekey = result.path + "_" + result.query
1201
1162
  if self.templates.get(cachekey) and not self._updated(
1202
- query_string, self.templates[cachekey], cachekey
1163
+ query_string,
1164
+ self.templates[cachekey],
1165
+ cachekey,
1203
1166
  ):
1204
- template = set(t["field"] for t in self.templates[cachekey]["tmplt"])
1167
+ template = {t["field"] for t in self.templates[cachekey]["tmplt"]}
1205
1168
  else:
1206
- template = set(t["field"] for t in self.item_fields())
1169
+ template = {t["field"] for t in self.item_fields()}
1207
1170
  # add fields we know to be OK
1208
- template = template | set(
1209
- [
1210
- "path",
1211
- "tags",
1212
- "notes",
1213
- "itemType",
1214
- "creators",
1215
- "mimeType",
1216
- "linkMode",
1217
- "note",
1218
- "charset",
1219
- "dateAdded",
1220
- "version",
1221
- "collections",
1222
- "dateModified",
1223
- "relations",
1224
- # attachment items
1225
- "parentItem",
1226
- "mtime",
1227
- "contentType",
1228
- "md5",
1229
- "filename",
1230
- "inPublications",
1231
- # annotation fields
1232
- "annotationText",
1233
- "annotationColor",
1234
- "annotationType",
1235
- "annotationPageLabel",
1236
- "annotationPosition",
1237
- "annotationSortIndex",
1238
- "annotationComment",
1239
- "annotationAuthorName",
1240
- ]
1241
- )
1242
- template = template | set(self.temp_keys)
1171
+ template |= {
1172
+ "path",
1173
+ "tags",
1174
+ "notes",
1175
+ "itemType",
1176
+ "creators",
1177
+ "mimeType",
1178
+ "linkMode",
1179
+ "note",
1180
+ "charset",
1181
+ "dateAdded",
1182
+ "version",
1183
+ "collections",
1184
+ "dateModified",
1185
+ "relations",
1186
+ # attachment items
1187
+ "parentItem",
1188
+ "mtime",
1189
+ "contentType",
1190
+ "md5",
1191
+ "filename",
1192
+ "inPublications",
1193
+ # annotation fields
1194
+ "annotationText",
1195
+ "annotationColor",
1196
+ "annotationType",
1197
+ "annotationPageLabel",
1198
+ "annotationPosition",
1199
+ "annotationSortIndex",
1200
+ "annotationComment",
1201
+ "annotationAuthorName",
1202
+ }
1203
+ template |= set(self.temp_keys)
1204
+ processed_items = []
1243
1205
  for pos, item in enumerate(items):
1244
- if set(item) == set(["links", "library", "version", "meta", "key", "data"]):
1245
- # we have an item that was retrieved from the API
1246
- item = item["data"]
1247
- to_check = set(i for i in list(item.keys()))
1206
+ if set(item) == {"links", "library", "version", "meta", "key", "data"}:
1207
+ itm = item["data"]
1208
+ else:
1209
+ itm = item
1210
+ to_check = set(itm.keys())
1248
1211
  difference = to_check.difference(template)
1249
1212
  if difference:
1250
- raise ze.InvalidItemFields(
1251
- f"Invalid keys present in item {pos + 1}: {' '.join(i for i in difference)}"
1213
+ err = f"Invalid keys present in item {pos + 1}: {' '.join(i for i in difference)}"
1214
+ raise ze.InvalidItemFieldsError(
1215
+ err,
1252
1216
  )
1253
- return items
1217
+ processed_items.append(itm)
1218
+ return processed_items
1254
1219
 
1255
1220
  @tcache
1256
1221
  def item_types(self):
@@ -1290,7 +1255,7 @@ class Zotero:
1290
1255
  query_string = "/itemFields"
1291
1256
  return query_string, params
1292
1257
 
1293
- def item_attachment_link_modes(self):
1258
+ def item_attachment_link_modes():
1294
1259
  """Get all available link mode types.
1295
1260
  Note: No viable REST API route was found for this, so I tested and built a list from documentation found
1296
1261
  here - https://www.zotero.org/support/dev/web_api/json
@@ -1298,27 +1263,27 @@ class Zotero:
1298
1263
  return ["imported_file", "imported_url", "linked_file", "linked_url"]
1299
1264
 
1300
1265
  def create_items(self, payload, parentid=None, last_modified=None):
1301
- """
1302
- Create new Zotero items
1266
+ """Create new Zotero items
1303
1267
  Accepts two arguments:
1304
1268
  a list containing one or more item dicts
1305
1269
  an optional parent item ID.
1306
1270
  Note that this can also be used to update existing items
1307
1271
  """
1308
- if len(payload) > 50:
1309
- raise ze.TooManyItems("You may only create up to 50 items per call")
1272
+ if len(payload) > DEFAULT_NUM_ITEMS:
1273
+ msg = f"You may only create up to {DEFAULT_NUM_ITEMS} items per call"
1274
+ raise ze.TooManyItemsError(msg)
1310
1275
  # TODO: strip extra data if it's an existing item
1311
1276
  headers = {"Zotero-Write-Token": token(), "Content-Type": "application/json"}
1312
1277
  if last_modified is not None:
1313
1278
  headers["If-Unmodified-Since-Version"] = str(last_modified)
1314
- to_send = json.dumps([i for i in self._cleanup(*payload, allow=("key"))])
1279
+ to_send = list(self._cleanup(*payload, allow=("key")))
1315
1280
  self._check_backoff()
1316
1281
  req = self.client.post(
1317
1282
  url=build_url(
1318
1283
  self.endpoint,
1319
- "/{t}/{u}/items".format(t=self.library_type, u=self.library_id),
1284
+ f"/{self.library_type}/{self.library_id}/items",
1320
1285
  ),
1321
- data=to_send,
1286
+ content=json.dumps(to_send),
1322
1287
  headers=dict(headers),
1323
1288
  )
1324
1289
  self.request = req
@@ -1328,7 +1293,7 @@ class Zotero:
1328
1293
  error_handler(self, req, exc)
1329
1294
  resp = req.json()
1330
1295
  backoff = self.request.headers.get("backoff") or self.request.headers.get(
1331
- "retry-after"
1296
+ "retry-after",
1332
1297
  )
1333
1298
  if backoff:
1334
1299
  self._set_backoff(backoff)
@@ -1337,7 +1302,7 @@ class Zotero:
1337
1302
  # TODO: handle possibility of item creation + failed parent
1338
1303
  # attachment
1339
1304
  uheaders = {
1340
- "If-Unmodified-Since-Version": req.headers["last-modified-version"]
1305
+ "If-Unmodified-Since-Version": req.headers["last-modified-version"],
1341
1306
  }
1342
1307
  for value in resp["success"].values():
1343
1308
  payload = json.dumps({"parentItem": parentid})
@@ -1345,9 +1310,7 @@ class Zotero:
1345
1310
  presp = self.client.patch(
1346
1311
  url=build_url(
1347
1312
  self.endpoint,
1348
- "/{t}/{u}/items/{v}".format(
1349
- t=self.library_type, u=self.library_id, v=value
1350
- ),
1313
+ f"/{self.library_type}/{self.library_id}/items/{value}",
1351
1314
  ),
1352
1315
  data=payload,
1353
1316
  headers=dict(uheaders),
@@ -1358,7 +1321,7 @@ class Zotero:
1358
1321
  except httpx.HTTPError as exc:
1359
1322
  error_handler(self, presp, exc)
1360
1323
  backoff = presp.headers.get("backoff") or presp.headers.get(
1361
- "retry-after"
1324
+ "retry-after",
1362
1325
  )
1363
1326
  if backoff:
1364
1327
  self._set_backoff(backoff)
@@ -1369,8 +1332,7 @@ class Zotero:
1369
1332
  return self.create_collections(payload, last_modified)
1370
1333
 
1371
1334
  def create_collections(self, payload, last_modified=None):
1372
- """
1373
- Create new Zotero collections
1335
+ """Create new Zotero collections
1374
1336
  Accepts one argument, a list of dicts containing the following keys:
1375
1337
 
1376
1338
  'name': the name of the collection
@@ -1379,7 +1341,8 @@ class Zotero:
1379
1341
  # no point in proceeding if there's no 'name' key
1380
1342
  for item in payload:
1381
1343
  if "name" not in item:
1382
- raise ze.ParamNotPassed("The dict you pass must include a 'name' key")
1344
+ msg = "The dict you pass must include a 'name' key"
1345
+ raise ze.ParamNotPassedError(msg)
1383
1346
  # add a blank 'parentCollection' key if it hasn't been passed
1384
1347
  if "parentCollection" not in item:
1385
1348
  item["parentCollection"] = ""
@@ -1390,10 +1353,10 @@ class Zotero:
1390
1353
  req = self.client.post(
1391
1354
  url=build_url(
1392
1355
  self.endpoint,
1393
- "/{t}/{u}/collections".format(t=self.library_type, u=self.library_id),
1356
+ f"/{self.library_type}/{self.library_id}/collections",
1394
1357
  ),
1395
1358
  headers=headers,
1396
- data=json.dumps(payload),
1359
+ content=json.dumps(payload),
1397
1360
  )
1398
1361
  self.request = req
1399
1362
  try:
@@ -1407,8 +1370,7 @@ class Zotero:
1407
1370
 
1408
1371
  @backoff_check
1409
1372
  def update_collection(self, payload, last_modified=None):
1410
- """
1411
- Update a Zotero collection property such as 'name'
1373
+ """Update a Zotero collection property such as 'name'
1412
1374
  Accepts one argument, a dict containing collection data retrieved
1413
1375
  using e.g. 'collections()'
1414
1376
  """
@@ -1421,17 +1383,14 @@ class Zotero:
1421
1383
  return self.client.put(
1422
1384
  url=build_url(
1423
1385
  self.endpoint,
1424
- "/{t}/{u}/collections/{c}".format(
1425
- t=self.library_type, u=self.library_id, c=key
1426
- ),
1386
+ f"/{self.library_type}/{self.library_id}/collections/{key}",
1427
1387
  ),
1428
1388
  headers=headers,
1429
- data=json.dumps(payload),
1389
+ content=json.dumps(payload),
1430
1390
  )
1431
1391
 
1432
1392
  def attachment_simple(self, files, parentid=None):
1433
- """
1434
- Add attachments using filenames as title
1393
+ """Add attachments using filenames as title
1435
1394
  Arguments:
1436
1395
  One or more file paths to add as attachments:
1437
1396
  An optional Item ID, which will create child attachments
@@ -1439,16 +1398,14 @@ class Zotero:
1439
1398
  orig = self._attachment_template("imported_file")
1440
1399
  to_add = [orig.copy() for fls in files]
1441
1400
  for idx, tmplt in enumerate(to_add):
1442
- tmplt["title"] = os.path.basename(files[idx])
1401
+ tmplt["title"] = Path.name(files[idx])
1443
1402
  tmplt["filename"] = files[idx]
1444
1403
  if parentid:
1445
1404
  return self._attachment(to_add, parentid)
1446
- else:
1447
- return self._attachment(to_add)
1405
+ return self._attachment(to_add)
1448
1406
 
1449
1407
  def attachment_both(self, files, parentid=None):
1450
- """
1451
- Add child attachments using title, filename
1408
+ """Add child attachments using title, filename
1452
1409
  Arguments:
1453
1410
  One or more lists or tuples containing title, file path
1454
1411
  An optional Item ID, which will create child attachments
@@ -1460,47 +1417,39 @@ class Zotero:
1460
1417
  tmplt["filename"] = files[idx][1]
1461
1418
  if parentid:
1462
1419
  return self._attachment(to_add, parentid)
1463
- else:
1464
- return self._attachment(to_add)
1420
+ return self._attachment(to_add)
1465
1421
 
1466
1422
  @backoff_check
1467
1423
  def update_item(self, payload, last_modified=None):
1468
- """
1469
- Update an existing item
1424
+ """Update an existing item
1470
1425
  Accepts one argument, a dict containing Item data
1471
1426
  """
1472
1427
  to_send = self.check_items([payload])[0]
1473
- if last_modified is None:
1474
- modified = payload["version"]
1475
- else:
1476
- modified = last_modified
1428
+ modified = payload["version"] if last_modified is None else last_modified
1477
1429
  ident = payload["key"]
1478
1430
  headers = {"If-Unmodified-Since-Version": str(modified)}
1479
1431
  return self.client.patch(
1480
1432
  url=build_url(
1481
1433
  self.endpoint,
1482
- "/{t}/{u}/items/{id}".format(
1483
- t=self.library_type, u=self.library_id, id=ident
1484
- ),
1434
+ f"/{self.library_type}/{self.library_id}/items/{ident}",
1485
1435
  ),
1486
1436
  headers=headers,
1487
- data=json.dumps(to_send),
1437
+ content=json.dumps(to_send),
1488
1438
  )
1489
1439
 
1490
1440
  def update_items(self, payload):
1491
- """
1492
- Update existing items
1441
+ """Update existing items
1493
1442
  Accepts one argument, a list of dicts containing Item data
1494
1443
  """
1495
1444
  to_send = [self.check_items([p])[0] for p in payload]
1496
1445
  # the API only accepts 50 items at a time, so we have to split
1497
1446
  # anything longer
1498
- for chunk in chunks(to_send, 50):
1447
+ for chunk in chunks(to_send, DEFAULT_NUM_ITEMS):
1499
1448
  self._check_backoff()
1500
1449
  req = self.client.post(
1501
1450
  url=build_url(
1502
1451
  self.endpoint,
1503
- "/{t}/{u}/items/".format(t=self.library_type, u=self.library_id),
1452
+ f"/{self.library_type}/{self.library_id}/items/",
1504
1453
  ),
1505
1454
  data=json.dumps(chunk),
1506
1455
  )
@@ -1515,21 +1464,18 @@ class Zotero:
1515
1464
  return True
1516
1465
 
1517
1466
  def update_collections(self, payload):
1518
- """
1519
- Update existing collections
1467
+ """Update existing collections
1520
1468
  Accepts one argument, a list of dicts containing Collection data
1521
1469
  """
1522
1470
  to_send = [self.check_items([p])[0] for p in payload]
1523
1471
  # the API only accepts 50 items at a time, so we have to split
1524
1472
  # anything longer
1525
- for chunk in chunks(to_send, 50):
1473
+ for chunk in chunks(to_send, DEFAULT_NUM_ITEMS):
1526
1474
  self._check_backoff()
1527
1475
  req = self.client.post(
1528
1476
  url=build_url(
1529
1477
  self.endpoint,
1530
- "/{t}/{u}/collections/".format(
1531
- t=self.library_type, u=self.library_id
1532
- ),
1478
+ f"/{self.library_type}/{self.library_id}/collections/",
1533
1479
  ),
1534
1480
  data=json.dumps(chunk),
1535
1481
  )
@@ -1545,8 +1491,7 @@ class Zotero:
1545
1491
 
1546
1492
  @backoff_check
1547
1493
  def addto_collection(self, collection, payload):
1548
- """
1549
- Add one or more items to a collection
1494
+ """Add one or more items to a collection
1550
1495
  Accepts two arguments:
1551
1496
  The collection ID, and an item dict
1552
1497
  """
@@ -1558,9 +1503,7 @@ class Zotero:
1558
1503
  return self.client.patch(
1559
1504
  url=build_url(
1560
1505
  self.endpoint,
1561
- "/{t}/{u}/items/{i}".format(
1562
- t=self.library_type, u=self.library_id, i=ident
1563
- ),
1506
+ f"/{self.library_type}/{self.library_id}/items/{ident}",
1564
1507
  ),
1565
1508
  data=json.dumps({"collections": modified_collections}),
1566
1509
  headers=headers,
@@ -1568,8 +1511,7 @@ class Zotero:
1568
1511
 
1569
1512
  @backoff_check
1570
1513
  def deletefrom_collection(self, collection, payload):
1571
- """
1572
- Delete an item from a collection
1514
+ """Delete an item from a collection
1573
1515
  Accepts two arguments:
1574
1516
  The collection ID, and and an item dict
1575
1517
  """
@@ -1583,9 +1525,7 @@ class Zotero:
1583
1525
  return self.client.patch(
1584
1526
  url=build_url(
1585
1527
  self.endpoint,
1586
- "/{t}/{u}/items/{i}".format(
1587
- t=self.library_type, u=self.library_id, i=ident
1588
- ),
1528
+ f"/{self.library_type}/{self.library_id}/items/{ident}",
1589
1529
  ),
1590
1530
  data=json.dumps({"collections": modified_collections}),
1591
1531
  headers=headers,
@@ -1593,23 +1533,25 @@ class Zotero:
1593
1533
 
1594
1534
  @backoff_check
1595
1535
  def delete_tags(self, *payload):
1596
- """
1597
- Delete a group of tags
1536
+ """Delete a group of tags
1598
1537
  pass in up to 50 tags, or use *[tags]
1599
1538
 
1600
1539
  """
1601
- if len(payload) > 50:
1602
- raise ze.TooManyItems("Only 50 tags or fewer may be deleted")
1603
- modified_tags = " || ".join([tag for tag in payload])
1540
+ if len(payload) > DEFAULT_NUM_ITEMS:
1541
+ msg = f"Only {DEFAULT_NUM_ITEMS} tags or fewer may be deleted"
1542
+ raise ze.TooManyItemsError(msg)
1543
+ modified_tags = " || ".join(list(payload))
1604
1544
  # first, get version data by getting one tag
1605
1545
  self.tags(limit=1)
1606
1546
  headers = {
1607
- "If-Unmodified-Since-Version": self.request.headers["last-modified-version"]
1547
+ "If-Unmodified-Since-Version": self.request.headers[
1548
+ "last-modified-version"
1549
+ ],
1608
1550
  }
1609
1551
  return self.client.delete(
1610
1552
  url=build_url(
1611
1553
  self.endpoint,
1612
- "/{t}/{u}/tags".format(t=self.library_type, u=self.library_id),
1554
+ f"/{self.library_type}/{self.library_id}/tags",
1613
1555
  ),
1614
1556
  params={"tag": modified_tags},
1615
1557
  headers=headers,
@@ -1617,8 +1559,7 @@ class Zotero:
1617
1559
 
1618
1560
  @backoff_check
1619
1561
  def delete_item(self, payload, last_modified=None):
1620
- """
1621
- Delete Items from a Zotero library
1562
+ """Delete Items from a Zotero library
1622
1563
  Accepts a single argument:
1623
1564
  a dict containing item data
1624
1565
  OR a list of dicts containing item data
@@ -1632,7 +1573,7 @@ class Zotero:
1632
1573
  modified = payload[0]["version"]
1633
1574
  url = build_url(
1634
1575
  self.endpoint,
1635
- "/{t}/{u}/items".format(t=self.library_type, u=self.library_id),
1576
+ f"/{self.library_type}/{self.library_id}/items",
1636
1577
  )
1637
1578
  else:
1638
1579
  ident = payload["key"]
@@ -1642,17 +1583,14 @@ class Zotero:
1642
1583
  modified = payload["version"]
1643
1584
  url = build_url(
1644
1585
  self.endpoint,
1645
- "/{t}/{u}/items/{c}".format(
1646
- t=self.library_type, u=self.library_id, c=ident
1647
- ),
1586
+ f"/{self.library_type}/{self.library_id}/items/{ident}",
1648
1587
  )
1649
1588
  headers = {"If-Unmodified-Since-Version": str(modified)}
1650
1589
  return self.client.delete(url=url, params=params, headers=headers)
1651
1590
 
1652
1591
  @backoff_check
1653
1592
  def delete_collection(self, payload, last_modified=None):
1654
- """
1655
- Delete a Collection from a Zotero library
1593
+ """Delete a Collection from a Zotero library
1656
1594
  Accepts a single argument:
1657
1595
  a dict containing item data
1658
1596
  OR a list of dicts containing item data
@@ -1666,7 +1604,7 @@ class Zotero:
1666
1604
  modified = payload[0]["version"]
1667
1605
  url = build_url(
1668
1606
  self.endpoint,
1669
- "/{t}/{u}/collections".format(t=self.library_type, u=self.library_id),
1607
+ f"/{self.library_type}/{self.library_id}/collections",
1670
1608
  )
1671
1609
  else:
1672
1610
  ident = payload["key"]
@@ -1676,9 +1614,7 @@ class Zotero:
1676
1614
  modified = payload["version"]
1677
1615
  url = build_url(
1678
1616
  self.endpoint,
1679
- "/{t}/{u}/collections/{c}".format(
1680
- t=self.library_type, u=self.library_id, c=ident
1681
- ),
1617
+ f"/{self.library_type}/{self.library_id}/collections/{ident}",
1682
1618
  )
1683
1619
  headers = {"If-Unmodified-Since-Version": str(modified)}
1684
1620
  return self.client.delete(url=url, params=params, headers=headers)
@@ -1687,42 +1623,40 @@ class Zotero:
1687
1623
  def error_handler(zot, req, exc=None):
1688
1624
  """Error handler for HTTP requests"""
1689
1625
  error_codes = {
1690
- 400: ze.UnsupportedParams,
1691
- 401: ze.UserNotAuthorised,
1692
- 403: ze.UserNotAuthorised,
1693
- 404: ze.ResourceNotFound,
1694
- 409: ze.Conflict,
1695
- 412: ze.PreConditionFailed,
1696
- 413: ze.RequestEntityTooLarge,
1697
- 428: ze.PreConditionRequired,
1698
- 429: ze.TooManyRequests,
1626
+ 400: ze.UnsupportedParamsError,
1627
+ 401: ze.UserNotAuthorisedError,
1628
+ 403: ze.UserNotAuthorisedError,
1629
+ 404: ze.ResourceNotFoundError,
1630
+ 409: ze.ConflictError,
1631
+ 412: ze.PreConditionFailedError,
1632
+ 413: ze.RequestEntityTooLargeError,
1633
+ 428: ze.PreConditionRequiredError,
1634
+ 429: ze.TooManyRequestsError,
1699
1635
  }
1700
1636
 
1701
1637
  def err_msg(req):
1702
1638
  """Return a nicely-formatted error message"""
1703
- return f"\nCode: {req.status_code}\nURL: {str(req.url)}\nMethod: {req.request.method}\nResponse: {req.text}"
1639
+ return f"\nCode: {req.status_code}\nURL: {req.url!s}\nMethod: {req.request.method}\nResponse: {req.text}"
1704
1640
 
1705
1641
  if error_codes.get(req.status_code):
1706
1642
  # check to see whether its 429
1707
- if req.status_code == 429:
1643
+ if req.status_code == TOO_MANY_REQUESTS:
1708
1644
  # try to get backoff or delay duration
1709
1645
  delay = req.headers.get("backoff") or req.headers.get("retry-after")
1710
1646
  if not delay:
1711
- raise ze.TooManyRetries(
1712
- "You are being rate-limited and no backoff or retry duration has been received from the server. Try again later"
1647
+ msg = "You are being rate-limited and no backoff or retry duration has been received from the server. Try again later"
1648
+ raise ze.TooManyRetriesError(
1649
+ msg,
1713
1650
  )
1714
- else:
1715
- zot._set_backoff(delay)
1651
+ zot._set_backoff(delay)
1652
+ elif not exc:
1653
+ raise error_codes.get(req.status_code)(err_msg(req))
1716
1654
  else:
1717
- if not exc:
1718
- raise error_codes.get(req.status_code)(err_msg(req))
1719
- else:
1720
- raise error_codes.get(req.status_code)(err_msg(req)) from exc
1655
+ raise error_codes.get(req.status_code)(err_msg(req)) from exc
1656
+ elif not exc:
1657
+ raise ze.HTTPError(err_msg(req))
1721
1658
  else:
1722
- if not exc:
1723
- raise ze.HTTPError(err_msg(req))
1724
- else:
1725
- raise ze.HTTPError(err_msg(req)) from exc
1659
+ raise ze.HTTPError(err_msg(req)) from exc
1726
1660
 
1727
1661
 
1728
1662
  class SavedSearch:
@@ -1731,7 +1665,7 @@ class SavedSearch:
1731
1665
  """
1732
1666
 
1733
1667
  def __init__(self, zinstance):
1734
- super(SavedSearch, self).__init__()
1668
+ super().__init__()
1735
1669
  self.zinstance = zinstance
1736
1670
  self.searchkeys = ("condition", "operator", "value")
1737
1671
  # always exclude these fields from zotero.item_keys()
@@ -1868,78 +1802,80 @@ class SavedSearch:
1868
1802
  operators_set = set(self.operators.keys())
1869
1803
  for condition in conditions:
1870
1804
  if set(condition.keys()) != allowed_keys:
1871
- raise ze.ParamNotPassed(
1872
- f"Keys must be all of: {', '.join(self.searchkeys)}"
1805
+ msg = f"Keys must be all of: {', '.join(self.searchkeys)}"
1806
+ raise ze.ParamNotPassedError(
1807
+ msg,
1873
1808
  )
1874
1809
  if condition.get("operator") not in operators_set:
1875
- raise ze.ParamNotPassed(
1876
- f"You have specified an unknown operator: {condition.get('operator')}"
1810
+ msg = f"You have specified an unknown operator: {condition.get('operator')}"
1811
+ raise ze.ParamNotPassedError(
1812
+ msg,
1877
1813
  )
1878
1814
  # dict keys of allowed operators for the current condition
1879
1815
  permitted_operators = self.conditions_operators.get(
1880
- condition.get("condition")
1816
+ condition.get("condition"),
1881
1817
  )
1882
1818
  # transform these into values
1883
- permitted_operators_list = set(
1884
- [self.operators.get(op) for op in permitted_operators]
1885
- )
1819
+ permitted_operators_list = {
1820
+ self.operators.get(op) for op in permitted_operators
1821
+ }
1886
1822
  if condition.get("operator") not in permitted_operators_list:
1887
- raise ze.ParamNotPassed(
1888
- f"You may not use the '{condition.get('operator')}' operator when selecting the '{condition.get('condition')}' condition. \nAllowed operators: {', '.join(list(permitted_operators_list))}"
1823
+ msg = f"You may not use the '{condition.get('operator')}' operator when selecting the '{condition.get('condition')}' condition. \nAllowed operators: {', '.join(list(permitted_operators_list))}"
1824
+ raise ze.ParamNotPassedError(
1825
+ msg,
1889
1826
  )
1890
1827
 
1891
1828
 
1892
1829
  class Zupload:
1893
- """
1894
- Zotero file attachment helper
1830
+ """Zotero file attachment helper
1895
1831
  Receives a Zotero instance, file(s) to upload, and optional parent ID
1896
1832
 
1897
1833
  """
1898
1834
 
1899
1835
  def __init__(self, zinstance, payload, parentid=None, basedir=None):
1900
- super(Zupload, self).__init__()
1836
+ super().__init__()
1901
1837
  self.zinstance = zinstance
1902
1838
  self.payload = payload
1903
1839
  self.parentid = parentid
1904
1840
  if basedir is None:
1905
- self.basedir = Path("")
1841
+ self.basedir = Path()
1906
1842
  elif isinstance(basedir, Path):
1907
1843
  self.basedir = basedir
1908
1844
  else:
1909
1845
  self.basedir = Path(basedir)
1910
1846
 
1911
1847
  def _verify(self, payload):
1912
- """
1913
- ensure that all files to be attached exist
1848
+ """Ensure that all files to be attached exist
1914
1849
  open()'s better than exists(), cos it avoids a race condition
1915
1850
  """
1916
1851
  if not payload: # Check payload has nonzero length
1917
- raise ze.ParamNotPassed
1852
+ raise ze.ParamNotPassedError
1918
1853
  for templt in payload:
1919
- if os.path.isfile(str(self.basedir.joinpath(templt["filename"]))):
1854
+ if Path.is_file(str(self.basedir.joinpath(templt["filename"]))):
1920
1855
  try:
1921
1856
  # if it is a file, try to open it, and catch the error
1922
- with open(str(self.basedir.joinpath(templt["filename"]))):
1857
+ with Path.open(str(self.basedir.joinpath(templt["filename"]))):
1923
1858
  pass
1924
- except IOError:
1925
- raise ze.FileDoesNotExist(
1926
- f"The file at {str(self.basedir.joinpath(templt['filename']))} couldn't be opened or found."
1927
- )
1859
+ except OSError:
1860
+ msg = f"The file at {self.basedir.joinpath(templt['filename'])!s} couldn't be opened or found."
1861
+ raise ze.FileDoesNotExistError(
1862
+ msg,
1863
+ ) from None
1928
1864
  # no point in continuing if the file isn't a file
1929
1865
  else:
1930
- raise ze.FileDoesNotExist(
1931
- f"The file at {str(self.basedir.joinpath(templt['filename']))} couldn't be opened or found."
1866
+ msg = f"The file at {self.basedir.joinpath(templt['filename'])!s} couldn't be opened or found."
1867
+ raise ze.FileDoesNotExistError(
1868
+ msg,
1932
1869
  )
1933
1870
 
1934
1871
  def _create_prelim(self):
1935
- """
1936
- Step 0: Register intent to upload files
1937
- """
1872
+ """Step 0: Register intent to upload files"""
1938
1873
  self._verify(self.payload)
1939
1874
  if "key" in self.payload[0] and self.payload[0]["key"]:
1940
1875
  if next((i for i in self.payload if "key" not in i), False):
1941
- raise ze.UnsupportedParams(
1942
- "Can't pass payload entries with and without keys to Zupload"
1876
+ msg = "Can't pass payload entries with and without keys to Zupload"
1877
+ raise ze.UnsupportedParamsError(
1878
+ msg,
1943
1879
  )
1944
1880
  return None # Don't do anything if payload comes with keys
1945
1881
  liblevel = "/{t}/{u}/items"
@@ -1955,7 +1891,8 @@ class Zupload:
1955
1891
  url=build_url(
1956
1892
  self.zinstance.endpoint,
1957
1893
  liblevel.format(
1958
- t=self.zinstance.library_type, u=self.zinstance.library_id
1894
+ t=self.zinstance.library_type,
1895
+ u=self.zinstance.library_id,
1959
1896
  ),
1960
1897
  ),
1961
1898
  data=to_send,
@@ -1974,12 +1911,10 @@ class Zupload:
1974
1911
  return data
1975
1912
 
1976
1913
  def _get_auth(self, attachment, reg_key, md5=None):
1977
- """
1978
- Step 1: get upload authorisation for a file
1979
- """
1914
+ """Step 1: get upload authorisation for a file"""
1980
1915
  mtypes = mimetypes.guess_type(attachment)
1981
- digest = hashlib.md5()
1982
- with open(attachment, "rb") as att:
1916
+ digest = hashlib.md5() # noqa: S324
1917
+ with Path.open(attachment, "rb") as att:
1983
1918
  for chunk in iter(lambda: att.read(8192), b""):
1984
1919
  digest.update(chunk)
1985
1920
  auth_headers = {"Content-Type": "application/x-www-form-urlencoded"}
@@ -1990,9 +1925,9 @@ class Zupload:
1990
1925
  auth_headers["If-Match"] = md5
1991
1926
  data = {
1992
1927
  "md5": digest.hexdigest(),
1993
- "filename": os.path.basename(attachment),
1994
- "filesize": os.path.getsize(attachment),
1995
- "mtime": str(int(os.path.getmtime(attachment) * 1000)),
1928
+ "filename": Path.name(attachment),
1929
+ "filesize": Path.stat().st_size(attachment),
1930
+ "mtime": str(int(Path.stat().st_mtime(attachment) * 1000)),
1996
1931
  "contentType": mtypes[0] or "application/octet-stream",
1997
1932
  "charset": mtypes[1],
1998
1933
  "params": 1,
@@ -2001,11 +1936,7 @@ class Zupload:
2001
1936
  auth_req = self.zinstance.client.post(
2002
1937
  url=build_url(
2003
1938
  self.zinstance.endpoint,
2004
- "/{t}/{u}/items/{i}/file".format(
2005
- t=self.zinstance.library_type,
2006
- u=self.zinstance.library_id,
2007
- i=reg_key,
2008
- ),
1939
+ f"/{self.zinstance.library_type}/{self.zinstance.library_id}/items/{reg_key}/file",
2009
1940
  ),
2010
1941
  data=data,
2011
1942
  headers=auth_headers,
@@ -2020,8 +1951,7 @@ class Zupload:
2020
1951
  return auth_req.json()
2021
1952
 
2022
1953
  def _upload_file(self, authdata, attachment, reg_key):
2023
- """
2024
- Step 2: auth successful, and file not on server
1954
+ """Step 2: auth successful, and file not on server
2025
1955
  zotero.org/support/dev/server_api/file_upload#a_full_upload
2026
1956
 
2027
1957
  reg_key isn't used, but we need to pass it through to Step 3
@@ -2031,7 +1961,7 @@ class Zupload:
2031
1961
  upload_list = [("key", upload_dict.pop("key"))]
2032
1962
  for key, value in upload_dict.items():
2033
1963
  upload_list.append((key, value))
2034
- upload_list.append(("file", open(attachment, "rb").read()))
1964
+ upload_list.append(("file", Path.open(attachment, "rb").read()))
2035
1965
  upload_pairs = tuple(upload_list)
2036
1966
  try:
2037
1967
  self.zinstance._check_backoff()
@@ -2043,7 +1973,8 @@ class Zupload:
2043
1973
  headers={"User-Agent": f"Pyzotero/{pz.__version__}"},
2044
1974
  )
2045
1975
  except httpx.ConnectionError:
2046
- raise ze.UploadError("ConnectionError")
1976
+ msg = "ConnectionError"
1977
+ raise ze.UploadError(msg) from None
2047
1978
  try:
2048
1979
  upload.raise_for_status()
2049
1980
  except httpx.HTTPError as exc:
@@ -2055,9 +1986,7 @@ class Zupload:
2055
1986
  return self._register_upload(authdata, reg_key)
2056
1987
 
2057
1988
  def _register_upload(self, authdata, reg_key):
2058
- """
2059
- Step 3: upload successful, so register it
2060
- """
1989
+ """Step 3: upload successful, so register it"""
2061
1990
  reg_headers = {
2062
1991
  "Content-Type": "application/x-www-form-urlencoded",
2063
1992
  "If-None-Match": "*",
@@ -2067,11 +1996,7 @@ class Zupload:
2067
1996
  upload_reg = self.zinstance.client.post(
2068
1997
  url=build_url(
2069
1998
  self.zinstance.endpoint,
2070
- "/{t}/{u}/items/{i}/file".format(
2071
- t=self.zinstance.library_type,
2072
- u=self.zinstance.library_id,
2073
- i=reg_key,
2074
- ),
1999
+ f"/{self.zinstance.library_type}/{self.zinstance.library_id}/items/{reg_key}/file",
2075
2000
  ),
2076
2001
  data=reg_data,
2077
2002
  headers=dict(reg_headers),
@@ -2081,14 +2006,13 @@ class Zupload:
2081
2006
  except httpx.HTTPError as exc:
2082
2007
  error_handler(self.zinstance, upload_reg, exc)
2083
2008
  backoff = upload_reg.headers.get("backoff") or upload_reg.headers.get(
2084
- "retry-after"
2009
+ "retry-after",
2085
2010
  )
2086
2011
  if backoff:
2087
2012
  self._set_backoff(backoff)
2088
2013
 
2089
2014
  def upload(self):
2090
- """
2091
- File upload functionality
2015
+ """File upload functionality
2092
2016
 
2093
2017
  Goes through upload steps 0 - 3 (private class methods), and returns
2094
2018
  a dict noting success, failure, or unchanged