pyzotero 1.6.8__py3-none-any.whl → 1.6.10__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.
- _version.py +9 -4
- pyzotero/filetransport.py +3 -3
- pyzotero/zotero.py +317 -393
- pyzotero/zotero_errors.py +17 -17
- {pyzotero-1.6.8.dist-info → pyzotero-1.6.10.dist-info}/METADATA +5 -2
- pyzotero-1.6.10.dist-info/RECORD +11 -0
- {pyzotero-1.6.8.dist-info → pyzotero-1.6.10.dist-info}/WHEEL +1 -1
- pyzotero-1.6.8.dist-info/RECORD +0 -11
- {pyzotero-1.6.8.dist-info → pyzotero-1.6.10.dist-info/licenses}/AUTHORS +0 -0
- {pyzotero-1.6.8.dist-info → pyzotero-1.6.10.dist-info/licenses}/LICENSE.md +0 -0
- {pyzotero-1.6.8.dist-info → pyzotero-1.6.10.dist-info}/top_level.txt +0 -0
pyzotero/zotero.py
CHANGED
|
@@ -1,8 +1,4 @@
|
|
|
1
|
-
|
|
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
|
|
@@ -49,13 +44,18 @@ from .filetransport import Client as File_Client
|
|
|
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
|
-
|
|
58
|
-
|
|
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
|
-
"""
|
|
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
|
-
"""
|
|
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
|
-
"""
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
236
|
+
if fmt != "json":
|
|
237
237
|
return retrieved.content
|
|
238
238
|
# no need to do anything special, return JSON
|
|
239
|
-
|
|
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
|
-
"""
|
|
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
|
-
|
|
286
|
-
|
|
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(),
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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"),
|
|
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
|
-
|
|
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.
|
|
559
|
-
|
|
548
|
+
datetime.datetime.now(tz=datetime.timezone.utc).replace(
|
|
549
|
+
tzinfo=pytz.timezone("GMT"),
|
|
550
|
+
)
|
|
551
|
+
- self.templates[template]["updated"],
|
|
560
552
|
).seconds
|
|
561
|
-
>
|
|
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 ==
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
633
|
-
|
|
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 = "/{
|
|
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/{
|
|
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 =
|
|
684
|
-
|
|
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
|
-
"/{
|
|
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 = "/{
|
|
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),
|
|
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 = "/{
|
|
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 =
|
|
803
|
-
|
|
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
|
|
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 =
|
|
828
|
-
|
|
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 = "/{
|
|
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 = "/{
|
|
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 = "/{
|
|
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 =
|
|
860
|
-
|
|
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
|
-
"""
|
|
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 = "/{
|
|
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 =
|
|
928
|
-
|
|
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
|
-
|
|
942
|
-
|
|
943
|
-
return
|
|
914
|
+
return self._striplocal(n)
|
|
915
|
+
return None
|
|
944
916
|
|
|
945
917
|
def iterfollow(self):
|
|
946
|
-
"""
|
|
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) >
|
|
983
|
-
|
|
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
|
-
|
|
1017
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
"/{
|
|
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
|
-
"/{
|
|
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
|
-
|
|
1173
|
-
|
|
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
|
-
|
|
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,
|
|
1163
|
+
query_string,
|
|
1164
|
+
self.templates[cachekey],
|
|
1165
|
+
cachekey,
|
|
1203
1166
|
):
|
|
1204
|
-
template =
|
|
1167
|
+
template = {t["field"] for t in self.templates[cachekey]["tmplt"]}
|
|
1205
1168
|
else:
|
|
1206
|
-
template =
|
|
1169
|
+
template = {t["field"] for t in self.item_fields()}
|
|
1207
1170
|
# add fields we know to be OK
|
|
1208
|
-
template
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
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) ==
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
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
|
-
|
|
1251
|
-
|
|
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
|
-
|
|
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(
|
|
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) >
|
|
1309
|
-
|
|
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 =
|
|
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
|
-
"/{
|
|
1284
|
+
f"/{self.library_type}/{self.library_id}/items",
|
|
1320
1285
|
),
|
|
1321
|
-
|
|
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
|
-
"/{
|
|
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
|
-
|
|
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
|
-
"/{
|
|
1356
|
+
f"/{self.library_type}/{self.library_id}/collections",
|
|
1394
1357
|
),
|
|
1395
1358
|
headers=headers,
|
|
1396
|
-
|
|
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
|
-
"/{
|
|
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
|
-
|
|
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"] =
|
|
1401
|
+
tmplt["title"] = Path(files[idx]).name
|
|
1443
1402
|
tmplt["filename"] = files[idx]
|
|
1444
1403
|
if parentid:
|
|
1445
1404
|
return self._attachment(to_add, parentid)
|
|
1446
|
-
|
|
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
|
-
|
|
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
|
-
"/{
|
|
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
|
-
|
|
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,
|
|
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
|
-
"/{
|
|
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,
|
|
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
|
-
"/{
|
|
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
|
-
"/{
|
|
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
|
-
"/{
|
|
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) >
|
|
1602
|
-
|
|
1603
|
-
|
|
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[
|
|
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
|
-
"/{
|
|
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
|
-
"/{
|
|
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
|
-
"/{
|
|
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
|
-
"/{
|
|
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
|
-
"/{
|
|
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.
|
|
1691
|
-
401: ze.
|
|
1692
|
-
403: ze.
|
|
1693
|
-
404: ze.
|
|
1694
|
-
409: ze.
|
|
1695
|
-
412: ze.
|
|
1696
|
-
413: ze.
|
|
1697
|
-
428: ze.
|
|
1698
|
-
429: ze.
|
|
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: {
|
|
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 ==
|
|
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
|
-
|
|
1712
|
-
|
|
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
|
-
|
|
1715
|
-
|
|
1651
|
+
zot._set_backoff(delay)
|
|
1652
|
+
elif not exc:
|
|
1653
|
+
raise error_codes.get(req.status_code)(err_msg(req))
|
|
1716
1654
|
else:
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
1872
|
-
|
|
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
|
-
|
|
1876
|
-
|
|
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 =
|
|
1884
|
-
|
|
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
|
-
|
|
1888
|
-
|
|
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(
|
|
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.
|
|
1852
|
+
raise ze.ParamNotPassedError
|
|
1918
1853
|
for templt in payload:
|
|
1919
|
-
if
|
|
1854
|
+
if Path(str(self.basedir.joinpath(templt["filename"]))).is_file():
|
|
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
|
|
1925
|
-
|
|
1926
|
-
|
|
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
|
-
|
|
1931
|
-
|
|
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
|
-
|
|
1942
|
-
|
|
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,
|
|
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":
|
|
1994
|
-
"filesize":
|
|
1995
|
-
"mtime": str(int(
|
|
1928
|
+
"filename": Path(attachment).name,
|
|
1929
|
+
"filesize": Path(attachment).stat().st_size,
|
|
1930
|
+
"mtime": str(int(Path(attachment).stat().st_mtime * 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
|
-
"/{
|
|
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
|
-
|
|
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
|
-
"/{
|
|
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
|