pyzotero 1.6.15__tar.gz → 1.6.17__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. {pyzotero-1.6.15 → pyzotero-1.6.17}/PKG-INFO +10 -8
  2. {pyzotero-1.6.15 → pyzotero-1.6.17}/README.md +9 -7
  3. {pyzotero-1.6.15 → pyzotero-1.6.17}/doc/index.rst +1 -1
  4. {pyzotero-1.6.15 → pyzotero-1.6.17}/pyproject.toml +1 -1
  5. {pyzotero-1.6.15 → pyzotero-1.6.17}/src/pyzotero/zotero.py +23 -19
  6. {pyzotero-1.6.15 → pyzotero-1.6.17}/tests/test_zotero.py +75 -10
  7. {pyzotero-1.6.15 → pyzotero-1.6.17}/LICENSE.md +0 -0
  8. {pyzotero-1.6.15 → pyzotero-1.6.17}/doc/Makefile +0 -0
  9. {pyzotero-1.6.15 → pyzotero-1.6.17}/doc/_templates/layout.html +0 -0
  10. {pyzotero-1.6.15 → pyzotero-1.6.17}/doc/cat.png +0 -0
  11. {pyzotero-1.6.15 → pyzotero-1.6.17}/doc/conf.py +0 -0
  12. {pyzotero-1.6.15 → pyzotero-1.6.17}/src/pyzotero/__init__.py +0 -0
  13. {pyzotero-1.6.15 → pyzotero-1.6.17}/src/pyzotero/filetransport.py +0 -0
  14. {pyzotero-1.6.15 → pyzotero-1.6.17}/src/pyzotero/zotero_errors.py +0 -0
  15. {pyzotero-1.6.15 → pyzotero-1.6.17}/tests/api_responses/__init__.py +0 -0
  16. {pyzotero-1.6.15 → pyzotero-1.6.17}/tests/api_responses/attachments_doc.json +0 -0
  17. {pyzotero-1.6.15 → pyzotero-1.6.17}/tests/api_responses/citation_doc.xml +0 -0
  18. {pyzotero-1.6.15 → pyzotero-1.6.17}/tests/api_responses/collection_doc.json +0 -0
  19. {pyzotero-1.6.15 → pyzotero-1.6.17}/tests/api_responses/collection_tags.json +0 -0
  20. {pyzotero-1.6.15 → pyzotero-1.6.17}/tests/api_responses/collection_versions.json +0 -0
  21. {pyzotero-1.6.15 → pyzotero-1.6.17}/tests/api_responses/collections_doc.json +0 -0
  22. {pyzotero-1.6.15 → pyzotero-1.6.17}/tests/api_responses/creation_doc.json +0 -0
  23. {pyzotero-1.6.15 → pyzotero-1.6.17}/tests/api_responses/groups_doc.json +0 -0
  24. {pyzotero-1.6.15 → pyzotero-1.6.17}/tests/api_responses/item_doc.json +0 -0
  25. {pyzotero-1.6.15 → pyzotero-1.6.17}/tests/api_responses/item_fields.json +0 -0
  26. {pyzotero-1.6.15 → pyzotero-1.6.17}/tests/api_responses/item_file.pdf +0 -0
  27. {pyzotero-1.6.15 → pyzotero-1.6.17}/tests/api_responses/item_template.json +0 -0
  28. {pyzotero-1.6.15 → pyzotero-1.6.17}/tests/api_responses/item_types.json +0 -0
  29. {pyzotero-1.6.15 → pyzotero-1.6.17}/tests/api_responses/item_versions.json +0 -0
  30. {pyzotero-1.6.15 → pyzotero-1.6.17}/tests/api_responses/items_doc.json +0 -0
  31. {pyzotero-1.6.15 → pyzotero-1.6.17}/tests/api_responses/keys_doc.txt +0 -0
  32. {pyzotero-1.6.15 → pyzotero-1.6.17}/tests/api_responses/tags_doc.json +0 -0
  33. {pyzotero-1.6.15 → pyzotero-1.6.17}/tests/test_async.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: pyzotero
3
- Version: 1.6.15
3
+ Version: 1.6.17
4
4
  Summary: Python wrapper for the Zotero API
5
5
  Keywords: Zotero,DH
6
6
  Author: Stephan Hügel
@@ -86,7 +86,7 @@ items = zot.top(limit=5)
86
86
  # we've retrieved the latest five top-level items in our library
87
87
  # we can print each item's item type and ID
88
88
  for item in items:
89
- print('Item: %s | Key: %s' % (item['data']['itemType'], item['data']['key']))
89
+ print(f"Item: {item['data']['itemType']} | Key: {item['data']['key']}")
90
90
  ```
91
91
 
92
92
  # Documentation
@@ -95,8 +95,9 @@ Full documentation of available Pyzotero methods, code examples, and sample outp
95
95
 
96
96
  # Installation
97
97
 
98
- * Using [pip][10]: `pip install pyzotero` (it's available as a wheel, and is tested on Python 3.7 and up)
99
- * Using Anaconda:`conda config --add channels conda-forge && conda install pyzotero`
98
+ * Using [uv][11]: `uv add pyzotero`
99
+ * Using [pip][10]: `pip install pyzotero`
100
+ * Using Anaconda:`conda install conda-forge::pyzotero`
100
101
  * From a local clone, if you wish to install Pyzotero from a specific branch:
101
102
 
102
103
  Example:
@@ -105,12 +106,13 @@ Example:
105
106
  git clone git://github.com/urschrei/pyzotero.git
106
107
  cd pyzotero
107
108
  git checkout main
108
- pip install .
109
+ # specify --dev if you're planning on running tests
110
+ uv sync
109
111
  ```
110
112
 
111
113
  ## Testing
112
114
 
113
- Run `pytest .` from the top-level directory.
115
+ Run `pytest .` from the top-level directory. This requires the `dev` dependency group to be installed: `uv sync --dev` / `pip install --group dev`
114
116
 
115
117
  ## Issues
116
118
 
@@ -118,14 +120,13 @@ The latest commits can be found on the [main branch][9], although new features a
118
120
 
119
121
  ## Pull Requests
120
122
 
121
- Pull requests are welcomed. Please read the [contribution guidelines](CONTRIBUTING.md). In particular, please **base your PR on the `dev` branch**.
123
+ Pull requests are welcomed. Please read the [contribution guidelines](CONTRIBUTING.md). In particular, please **base your PR on the `main` branch**.
122
124
 
123
125
  ## Versioning
124
126
 
125
127
  As of v1.0.0, Pyzotero is versioned according to [Semver](http://semver.org); version increments are performed as follows:
126
128
 
127
129
 
128
-
129
130
  1. MAJOR version will increment with incompatible API changes,
130
131
  2. MINOR version will increment when functionality is added in a backwards-compatible manner, and
131
132
  3. PATCH version will increment with backwards-compatible bug fixes.
@@ -149,5 +150,6 @@ Pyzotero is licensed under the [Blue Oak Model Licence 1.0.0][8]. See [LICENSE.m
149
150
  [8]: https://opensource.org/license/blue-oak-model-license
150
151
  [9]: https://github.com/urschrei/pyzotero/tree/main
151
152
  [10]: http://www.pip-installer.org/en/latest/index.html
153
+ [11]: https://docs.astral.sh/uv
152
154
  † This isn't strictly true: you only need an API key for personal libraries and non-public group libraries.
153
155
 
@@ -21,7 +21,7 @@ items = zot.top(limit=5)
21
21
  # we've retrieved the latest five top-level items in our library
22
22
  # we can print each item's item type and ID
23
23
  for item in items:
24
- print('Item: %s | Key: %s' % (item['data']['itemType'], item['data']['key']))
24
+ print(f"Item: {item['data']['itemType']} | Key: {item['data']['key']}")
25
25
  ```
26
26
 
27
27
  # Documentation
@@ -30,8 +30,9 @@ Full documentation of available Pyzotero methods, code examples, and sample outp
30
30
 
31
31
  # Installation
32
32
 
33
- * Using [pip][10]: `pip install pyzotero` (it's available as a wheel, and is tested on Python 3.7 and up)
34
- * Using Anaconda:`conda config --add channels conda-forge && conda install pyzotero`
33
+ * Using [uv][11]: `uv add pyzotero`
34
+ * Using [pip][10]: `pip install pyzotero`
35
+ * Using Anaconda:`conda install conda-forge::pyzotero`
35
36
  * From a local clone, if you wish to install Pyzotero from a specific branch:
36
37
 
37
38
  Example:
@@ -40,12 +41,13 @@ Example:
40
41
  git clone git://github.com/urschrei/pyzotero.git
41
42
  cd pyzotero
42
43
  git checkout main
43
- pip install .
44
+ # specify --dev if you're planning on running tests
45
+ uv sync
44
46
  ```
45
47
 
46
48
  ## Testing
47
49
 
48
- Run `pytest .` from the top-level directory.
50
+ Run `pytest .` from the top-level directory. This requires the `dev` dependency group to be installed: `uv sync --dev` / `pip install --group dev`
49
51
 
50
52
  ## Issues
51
53
 
@@ -53,14 +55,13 @@ The latest commits can be found on the [main branch][9], although new features a
53
55
 
54
56
  ## Pull Requests
55
57
 
56
- Pull requests are welcomed. Please read the [contribution guidelines](CONTRIBUTING.md). In particular, please **base your PR on the `dev` branch**.
58
+ Pull requests are welcomed. Please read the [contribution guidelines](CONTRIBUTING.md). In particular, please **base your PR on the `main` branch**.
57
59
 
58
60
  ## Versioning
59
61
 
60
62
  As of v1.0.0, Pyzotero is versioned according to [Semver](http://semver.org); version increments are performed as follows:
61
63
 
62
64
 
63
-
64
65
  1. MAJOR version will increment with incompatible API changes,
65
66
  2. MINOR version will increment when functionality is added in a backwards-compatible manner, and
66
67
  3. PATCH version will increment with backwards-compatible bug fixes.
@@ -84,5 +85,6 @@ Pyzotero is licensed under the [Blue Oak Model Licence 1.0.0][8]. See [LICENSE.m
84
85
  [8]: https://opensource.org/license/blue-oak-model-license
85
86
  [9]: https://github.com/urschrei/pyzotero/tree/main
86
87
  [10]: http://www.pip-installer.org/en/latest/index.html
88
+ [11]: https://docs.astral.sh/uv
87
89
  † This isn't strictly true: you only need an API key for personal libraries and non-public group libraries.
88
90
 
@@ -37,7 +37,7 @@ Getting started (short version)
37
37
  # we've retrieved the latest five top-level items in our library
38
38
  # we can print each item's item type and ID
39
39
  for item in items:
40
- print('Item Type: %s | Key: %s' % (item['data']['itemType'], item['data']['key']))
40
+ print(f"Item: {item['data']['itemType']} | Key: {item['data']['key']}")
41
41
 
42
42
  Refer to the :ref:`read` and :ref:`write`.
43
43
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pyzotero"
3
- version = "1.6.15"
3
+ version = "1.6.17"
4
4
  description = "Python wrapper for the Zotero API"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.9"
@@ -43,10 +43,9 @@ from .filetransport import Client as File_Client
43
43
  # Avoid hanging the application if there's no server response
44
44
  timeout = 30
45
45
 
46
- NOT_MODIFIED = 304
47
46
  ONE_HOUR = 3600
48
47
  DEFAULT_NUM_ITEMS = 50
49
- TOO_MANY_REQUESTS = 429
48
+ DEFAULT_ITEM_LIMIT = 100
50
49
 
51
50
 
52
51
  def build_url(base_url, path, args_dict=None):
@@ -450,8 +449,6 @@ class Zotero:
450
449
  Returns a JSON document
451
450
  """
452
451
  full_url = build_url(self.endpoint, request)
453
- # The API doesn't return this any more, so we have to cheat
454
- self.self_link = request
455
452
  # ensure that we wait if there's an active backoff
456
453
  self._check_backoff()
457
454
  # don't set locale if the url already contains it
@@ -470,7 +467,7 @@ class Zotero:
470
467
  params = {}
471
468
  if not self.url_params:
472
469
  self.url_params = {}
473
- merged_params = {**params, **self.url_params}
470
+ merged_params = {**self.url_params, **params}
474
471
  # our incoming url might be from the "links" dict, in which case it will contain url parameters.
475
472
  # Unfortunately, httpx doesn't like to merge query paramaters in the url string and passed params
476
473
  # so we strip the url params, combining them with our existing url_params
@@ -484,6 +481,8 @@ class Zotero:
484
481
  timeout=timeout,
485
482
  )
486
483
  self.request.encoding = "utf-8"
484
+ # The API doesn't return this any more, so we have to cheat
485
+ self.self_link = self.request.url
487
486
  except httpx.UnsupportedProtocol:
488
487
  # File URI handler logic
489
488
  fc = File_Client()
@@ -517,15 +516,12 @@ class Zotero:
517
516
  fragment = f"{parsed[2]}?{parsed[4]}"
518
517
  extracted[key] = fragment
519
518
  # add a 'self' link
520
- parsed = list(urlparse(self.self_link))
521
- # strip 'format' query parameter
522
- stripped = "&".join(
523
- ["=".join(p) for p in parse_qsl(parsed[4])[:2] if p[0] != "format"],
524
- )
525
- # rebuild url fragment
526
- # this is a death march
519
+ parsed = urlparse(str(self.self_link))
520
+ # strip 'format' query parameter and rebuild query string
521
+ query_params = [(k, v) for k, v in parse_qsl(parsed.query) if k != "format"]
522
+ # rebuild url fragment with just path and query (consistent with other links)
527
523
  extracted["self"] = urlunparse(
528
- [parsed[0], parsed[1], parsed[2], parsed[3], stripped, parsed[5]],
524
+ ("", "", parsed.path, "", urlencode(query_params), "")
529
525
  )
530
526
  except KeyError:
531
527
  # No links present, because it's a single item
@@ -571,7 +567,7 @@ class Zotero:
571
567
  )
572
568
  if backoff:
573
569
  self._set_backoff(backoff)
574
- return req.status_code == NOT_MODIFIED
570
+ return req.status_code == httpx.codes.NOT_MODIFIED
575
571
  # Still plenty of life left in't
576
572
  return False
577
573
 
@@ -580,7 +576,13 @@ class Zotero:
580
576
 
581
577
  Also ensure that only valid format/content combinations are requested
582
578
  """
583
- self.url_params = None
579
+ # Preserve constructor-level parameters (like locale) while allowing method-level overrides
580
+ if self.url_params is None:
581
+ self.url_params = {}
582
+
583
+ # Store existing params to preserve things like locale
584
+ preserved_params = self.url_params.copy()
585
+
584
586
  # we want JSON by default
585
587
  if not params.get("format"):
586
588
  params["format"] = "json"
@@ -589,7 +591,7 @@ class Zotero:
589
591
  params["format"] = "atom"
590
592
  # TODO: rewrite format=atom, content=json request
591
593
  if "limit" not in params or params.get("limit") == 0:
592
- params["limit"] = 100
594
+ params["limit"] = DEFAULT_ITEM_LIMIT
593
595
  # Need ability to request arbitrary number of results for version
594
596
  # response
595
597
  # -1 value is hack that works with current version
@@ -597,8 +599,10 @@ class Zotero:
597
599
  del params["limit"]
598
600
  # bib format can't have a limit
599
601
  if params.get("format") == "bib":
600
- del params["limit"]
601
- self.url_params = params
602
+ params.pop("limit", None)
603
+
604
+ # Merge preserved params with new params (new params override existing ones)
605
+ self.url_params = {**preserved_params, **params}
602
606
 
603
607
  def _build_query(self, query_string, no_params=False):
604
608
  """Set request parameters. Will always add the user ID if it hasn't
@@ -1638,7 +1642,7 @@ def error_handler(zot, req, exc=None):
1638
1642
 
1639
1643
  if error_codes.get(req.status_code):
1640
1644
  # check to see whether its 429
1641
- if req.status_code == TOO_MANY_REQUESTS:
1645
+ if req.status_code == httpx.codes.TOO_MANY_REQUESTS:
1642
1646
  # try to get backoff or delay duration
1643
1647
  delay = req.headers.get("backoff") or req.headers.get("retry-after")
1644
1648
  if not delay:
@@ -21,9 +21,10 @@ from httpretty import HTTPretty
21
21
 
22
22
  try:
23
23
  from pyzotero.pyzotero import zotero as z
24
+ from pyzotero.pyzotero.zotero import DEFAULT_ITEM_LIMIT
24
25
  except ModuleNotFoundError:
25
26
  from pyzotero import zotero as z
26
-
27
+ from pyzotero.zotero import DEFAULT_ITEM_LIMIT
27
28
  from urllib.parse import urlencode
28
29
 
29
30
 
@@ -88,7 +89,7 @@ class ZoteroTests(unittest.TestCase):
88
89
  zot = z.Zotero("myuserID", "user", "myuserkey")
89
90
  zot.add_parameters(limit=0, start=7)
90
91
  self.assertEqual(
91
- parse_qs("start=7&limit=100&format=json"),
92
+ parse_qs(f"start=7&limit={DEFAULT_ITEM_LIMIT}&format=json"),
92
93
  parse_qs(urlencode(zot.url_params, doseq=True)),
93
94
  )
94
95
 
@@ -104,7 +105,26 @@ class ZoteroTests(unittest.TestCase):
104
105
  zot = z.Zotero("myuserID", "user", "myuserkey")
105
106
  _ = zot.items()
106
107
  req = zot.request
107
- self.assertEqual(str(req.url).find("locale"), 44)
108
+ self.assertIn("locale=en-US", str(req.url))
109
+
110
+ @httpretty.activate
111
+ def testLocalePreservedWithMethodParams(self):
112
+ """Should preserve locale when methods provide their own parameters"""
113
+ HTTPretty.register_uri(
114
+ HTTPretty.GET,
115
+ "https://api.zotero.org/users/myuserID/items/top",
116
+ content_type="application/json",
117
+ body=self.items_doc,
118
+ )
119
+ # Test with non-default locale
120
+ zot = z.Zotero("myuserID", "user", "myuserkey", locale="de-DE")
121
+ # Call top() with limit which internally adds parameters
122
+ _ = zot.top(limit=1)
123
+ req = zot.request
124
+ # Check that locale is preserved in the URL
125
+ self.assertIn("locale=de-DE", str(req.url))
126
+ # Also verify the method parameter is present
127
+ self.assertIn("limit=1", str(req.url))
108
128
 
109
129
  @httpretty.activate
110
130
  def testRequestBuilderLimitNone(self):
@@ -368,9 +388,13 @@ class ZoteroTests(unittest.TestCase):
368
388
  body=self.tags_doc,
369
389
  )
370
390
  _ = zot.tags(limit=1)
371
- self.assertEqual(
372
- "https://api.zotero.org/users/myuserID/tags?locale=en-US&limit=1&format=json",
373
- zot.request.url,
391
+ # Check that all expected parameters are present
392
+ url_str = str(zot.request.url)
393
+ self.assertIn("locale=en-US", url_str)
394
+ self.assertIn("limit=1", url_str)
395
+ self.assertIn("format=json", url_str)
396
+ self.assertTrue(
397
+ url_str.startswith("https://api.zotero.org/users/myuserID/tags?")
374
398
  )
375
399
 
376
400
  @httpretty.activate
@@ -391,6 +415,27 @@ class ZoteroTests(unittest.TestCase):
391
415
  self.assertEqual(zot.links["last"], "/users/436/items/top?limit=1&start=2319")
392
416
  self.assertEqual(zot.links["alternate"], "/users/436/items/top?")
393
417
 
418
+ @httpretty.activate
419
+ def testParseLinkHeadersPreservesAllParameters(self):
420
+ """Test that the self link preserves all parameters, not just the first 2"""
421
+ zot = z.Zotero("myuserID", "user", "myuserkey")
422
+ HTTPretty.register_uri(
423
+ HTTPretty.GET,
424
+ "https://api.zotero.org/users/myuserID/items/top",
425
+ content_type="application/json",
426
+ body=self.items_doc,
427
+ adding_headers={
428
+ "Link": '<https://api.zotero.org/users/myuserID/items/top?start=1>; rel="next"'
429
+ },
430
+ )
431
+ # Call with multiple parameters including limit
432
+ zot.top(limit=1)
433
+ # The self link should preserve all parameters except format
434
+ self.assertIn("limit=1", zot.links["self"])
435
+ self.assertIn("locale=", zot.links["self"])
436
+ # format should be stripped
437
+ self.assertNotIn("format=", zot.links["self"])
438
+
394
439
  @httpretty.activate
395
440
  def testParseGroupsJSONDoc(self):
396
441
  """Should successfully return a list of group dicts, ID should match
@@ -408,15 +453,14 @@ class ZoteroTests(unittest.TestCase):
408
453
  self.assertEqual("smart_cities", groups_data[0]["data"]["name"])
409
454
 
410
455
  def testParamsReset(self):
411
- """Should successfully reset URL parameters after a query string
412
- is built
413
- """
456
+ """Should preserve existing URL parameters when add_parameters is called multiple times"""
414
457
  zot = z.Zotero("myuserID", "user", "myuserkey")
415
458
  zot.add_parameters(start=5, limit=10)
416
459
  zot._build_query("/whatever")
417
460
  zot.add_parameters(start=2)
461
+ # Should get default limit=100 since no limit specified in second call
418
462
  self.assertEqual(
419
- parse_qs("start=2&format=json&limit=100"),
463
+ parse_qs(f"start=2&format=json&limit={DEFAULT_ITEM_LIMIT}"),
420
464
  parse_qs(urlencode(zot.url_params, doseq=True)),
421
465
  )
422
466
 
@@ -1591,6 +1635,27 @@ class ZoteroTests(unittest.TestCase):
1591
1635
  # Verify the mock was called
1592
1636
  mock_iterfollow.assert_called_once()
1593
1637
 
1638
+ def test_makeiter_preserves_limit_parameter(self):
1639
+ """Test that makeiter preserves the limit parameter in the self link"""
1640
+ zot = z.Zotero("myuserID", "user", "myuserkey")
1641
+
1642
+ # Simulate a self link with multiple parameters including limit
1643
+ # This mimics what _extract_links() creates
1644
+ test_self_link = "/users/myuserID/items/top?limit=1&locale=en-US"
1645
+ zot.links = {
1646
+ "self": test_self_link,
1647
+ "next": "/users/myuserID/items/top?start=1",
1648
+ }
1649
+
1650
+ # Call makeiter (with a dummy function since we're testing link manipulation)
1651
+ with patch.object(zot, "iterfollow"):
1652
+ zot.makeiter(lambda: None)
1653
+
1654
+ # Verify that the 'next' link was set to 'self' and still contains limit parameter
1655
+ self.assertEqual(zot.links["next"], test_self_link)
1656
+ self.assertIn("limit=1", zot.links["next"])
1657
+ self.assertIn("locale=en-US", zot.links["next"])
1658
+
1594
1659
  @httpretty.activate
1595
1660
  def test_publications_user(self):
1596
1661
  """Test the publications method for user libraries"""
File without changes
File without changes
File without changes
File without changes
File without changes