pyzotero 1.6.16__tar.gz → 1.7.0__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 (34) hide show
  1. {pyzotero-1.6.16 → pyzotero-1.7.0}/PKG-INFO +66 -1
  2. {pyzotero-1.6.16 → pyzotero-1.7.0}/README.md +63 -0
  3. {pyzotero-1.6.16 → pyzotero-1.7.0}/doc/index.rst +82 -0
  4. {pyzotero-1.6.16 → pyzotero-1.7.0}/pyproject.toml +7 -1
  5. pyzotero-1.7.0/src/pyzotero/cli.py +310 -0
  6. {pyzotero-1.6.16 → pyzotero-1.7.0}/src/pyzotero/zotero.py +14 -20
  7. {pyzotero-1.6.16 → pyzotero-1.7.0}/tests/test_zotero.py +43 -1
  8. {pyzotero-1.6.16 → pyzotero-1.7.0}/LICENSE.md +0 -0
  9. {pyzotero-1.6.16 → pyzotero-1.7.0}/doc/Makefile +0 -0
  10. {pyzotero-1.6.16 → pyzotero-1.7.0}/doc/_templates/layout.html +0 -0
  11. {pyzotero-1.6.16 → pyzotero-1.7.0}/doc/cat.png +0 -0
  12. {pyzotero-1.6.16 → pyzotero-1.7.0}/doc/conf.py +0 -0
  13. {pyzotero-1.6.16 → pyzotero-1.7.0}/src/pyzotero/__init__.py +0 -0
  14. {pyzotero-1.6.16 → pyzotero-1.7.0}/src/pyzotero/filetransport.py +0 -0
  15. {pyzotero-1.6.16 → pyzotero-1.7.0}/src/pyzotero/zotero_errors.py +0 -0
  16. {pyzotero-1.6.16 → pyzotero-1.7.0}/tests/api_responses/__init__.py +0 -0
  17. {pyzotero-1.6.16 → pyzotero-1.7.0}/tests/api_responses/attachments_doc.json +0 -0
  18. {pyzotero-1.6.16 → pyzotero-1.7.0}/tests/api_responses/citation_doc.xml +0 -0
  19. {pyzotero-1.6.16 → pyzotero-1.7.0}/tests/api_responses/collection_doc.json +0 -0
  20. {pyzotero-1.6.16 → pyzotero-1.7.0}/tests/api_responses/collection_tags.json +0 -0
  21. {pyzotero-1.6.16 → pyzotero-1.7.0}/tests/api_responses/collection_versions.json +0 -0
  22. {pyzotero-1.6.16 → pyzotero-1.7.0}/tests/api_responses/collections_doc.json +0 -0
  23. {pyzotero-1.6.16 → pyzotero-1.7.0}/tests/api_responses/creation_doc.json +0 -0
  24. {pyzotero-1.6.16 → pyzotero-1.7.0}/tests/api_responses/groups_doc.json +0 -0
  25. {pyzotero-1.6.16 → pyzotero-1.7.0}/tests/api_responses/item_doc.json +0 -0
  26. {pyzotero-1.6.16 → pyzotero-1.7.0}/tests/api_responses/item_fields.json +0 -0
  27. {pyzotero-1.6.16 → pyzotero-1.7.0}/tests/api_responses/item_file.pdf +0 -0
  28. {pyzotero-1.6.16 → pyzotero-1.7.0}/tests/api_responses/item_template.json +0 -0
  29. {pyzotero-1.6.16 → pyzotero-1.7.0}/tests/api_responses/item_types.json +0 -0
  30. {pyzotero-1.6.16 → pyzotero-1.7.0}/tests/api_responses/item_versions.json +0 -0
  31. {pyzotero-1.6.16 → pyzotero-1.7.0}/tests/api_responses/items_doc.json +0 -0
  32. {pyzotero-1.6.16 → pyzotero-1.7.0}/tests/api_responses/keys_doc.txt +0 -0
  33. {pyzotero-1.6.16 → pyzotero-1.7.0}/tests/api_responses/tags_doc.json +0 -0
  34. {pyzotero-1.6.16 → pyzotero-1.7.0}/tests/test_async.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: pyzotero
3
- Version: 1.6.16
3
+ Version: 1.7.0
4
4
  Summary: Python wrapper for the Zotero API
5
5
  Keywords: Zotero,DH
6
6
  Author: Stephan Hügel
@@ -57,10 +57,12 @@ Requires-Dist: feedparser>=6.0.12
57
57
  Requires-Dist: bibtexparser>=1.4.3,<2.0.0
58
58
  Requires-Dist: httpx>=0.28.1
59
59
  Requires-Dist: whenever>=0.8.8
60
+ Requires-Dist: click>=8.0.0 ; extra == 'cli'
60
61
  Requires-Python: >=3.9
61
62
  Project-URL: Repository, https://github.com/urschrei/pyzotero
62
63
  Project-URL: Tracker, https://github.com/urschrei/pyzotero/issues
63
64
  Project-URL: documentation, https://pyzotero.readthedocs.org
65
+ Provides-Extra: cli
64
66
  Description-Content-Type: text/markdown
65
67
 
66
68
  [![Supported Python versions](https://img.shields.io/pypi/pyversions/Pyzotero.svg?style=flat)](https://pypi.python.org/pypi/Pyzotero/) [![Docs](https://readthedocs.org/projects/pyzotero/badge/?version=latest)](http://pyzotero.readthedocs.org/en/latest/?badge=latest) [![PyPI Version](https://img.shields.io/pypi/v/Pyzotero.svg)](https://pypi.python.org/pypi/Pyzotero) [![Anaconda-Server Badge](https://anaconda.org/conda-forge/pyzotero/badges/version.svg)](https://anaconda.org/conda-forge/pyzotero) [![Downloads](https://pepy.tech/badge/pyzotero)](https://pepy.tech/project/pyzotero)
@@ -93,11 +95,74 @@ for item in items:
93
95
 
94
96
  Full documentation of available Pyzotero methods, code examples, and sample output is available on [Read The Docs][3].
95
97
 
98
+ # Command-Line Interface
99
+
100
+ Pyzotero includes an optional command-line interface for searching and querying your local Zotero library. The CLI must be installed separately (see [Installation](#optional-command-line-interface)).
101
+
102
+ ## Basic Usage
103
+
104
+ The CLI connects to your local Zotero installation and allows you to search your library, list collections, and view item types:
105
+
106
+ ```bash
107
+ # Search for top-level items
108
+ pyzotero search -q "machine learning"
109
+
110
+ # Search with full-text mode
111
+ pyzotero search -q "climate change" --fulltext
112
+
113
+ # Filter by item type
114
+ pyzotero search -q "methodology" --itemtype book --itemtype journalArticle
115
+
116
+ # Search for top-level items within a collection
117
+ pyzotero search --collection ABC123 -q "test"
118
+
119
+ # Output as JSON for machine processing
120
+ pyzotero search -q "climate" --json
121
+
122
+ # List all collections
123
+ pyzotero listcollections
124
+
125
+ # List available item types
126
+ pyzotero itemtypes
127
+ ```
128
+
129
+ ## Output Format
130
+
131
+ By default, the CLI outputs human-readable text with a subset of metadata including:
132
+ - Title, authors, date, publication
133
+ - Volume, issue, DOI, URL
134
+ - PDF attachments (with local file paths)
135
+
136
+ Use the `--json` flag to output structured JSON.
137
+
96
138
  # Installation
97
139
 
98
140
  * Using [uv][11]: `uv add pyzotero`
99
141
  * Using [pip][10]: `pip install pyzotero`
100
142
  * Using Anaconda:`conda install conda-forge::pyzotero`
143
+
144
+ ## Optional: Command-Line Interface
145
+
146
+ Pyzotero includes an optional command-line interface for searching and querying your local Zotero library.
147
+
148
+ ### Installing the CLI
149
+
150
+ To install Pyzotero with the CLI:
151
+
152
+ * Using [uv][11]: `uv add "pyzotero[cli]"`
153
+ * Using [pip][10]: `pip install "pyzotero[cli]"`
154
+
155
+ ### Using the CLI without installing
156
+
157
+ If you just want to use the CLI without permanently installing Pyzotero, you can run it directly:
158
+
159
+ * Using [uvx][11]: `uvx --from "pyzotero[cli]" pyzotero search -q "your query"`
160
+ * Using [pipx][10]: `pipx run --spec "pyzotero[cli]" pyzotero search -q "your query"`
161
+
162
+ See the [Command-Line Interface](#command-line-interface) section below for usage details.
163
+
164
+ ## Installing from Source
165
+
101
166
  * From a local clone, if you wish to install Pyzotero from a specific branch:
102
167
 
103
168
  Example:
@@ -28,11 +28,74 @@ for item in items:
28
28
 
29
29
  Full documentation of available Pyzotero methods, code examples, and sample output is available on [Read The Docs][3].
30
30
 
31
+ # Command-Line Interface
32
+
33
+ Pyzotero includes an optional command-line interface for searching and querying your local Zotero library. The CLI must be installed separately (see [Installation](#optional-command-line-interface)).
34
+
35
+ ## Basic Usage
36
+
37
+ The CLI connects to your local Zotero installation and allows you to search your library, list collections, and view item types:
38
+
39
+ ```bash
40
+ # Search for top-level items
41
+ pyzotero search -q "machine learning"
42
+
43
+ # Search with full-text mode
44
+ pyzotero search -q "climate change" --fulltext
45
+
46
+ # Filter by item type
47
+ pyzotero search -q "methodology" --itemtype book --itemtype journalArticle
48
+
49
+ # Search for top-level items within a collection
50
+ pyzotero search --collection ABC123 -q "test"
51
+
52
+ # Output as JSON for machine processing
53
+ pyzotero search -q "climate" --json
54
+
55
+ # List all collections
56
+ pyzotero listcollections
57
+
58
+ # List available item types
59
+ pyzotero itemtypes
60
+ ```
61
+
62
+ ## Output Format
63
+
64
+ By default, the CLI outputs human-readable text with a subset of metadata including:
65
+ - Title, authors, date, publication
66
+ - Volume, issue, DOI, URL
67
+ - PDF attachments (with local file paths)
68
+
69
+ Use the `--json` flag to output structured JSON.
70
+
31
71
  # Installation
32
72
 
33
73
  * Using [uv][11]: `uv add pyzotero`
34
74
  * Using [pip][10]: `pip install pyzotero`
35
75
  * Using Anaconda:`conda install conda-forge::pyzotero`
76
+
77
+ ## Optional: Command-Line Interface
78
+
79
+ Pyzotero includes an optional command-line interface for searching and querying your local Zotero library.
80
+
81
+ ### Installing the CLI
82
+
83
+ To install Pyzotero with the CLI:
84
+
85
+ * Using [uv][11]: `uv add "pyzotero[cli]"`
86
+ * Using [pip][10]: `pip install "pyzotero[cli]"`
87
+
88
+ ### Using the CLI without installing
89
+
90
+ If you just want to use the CLI without permanently installing Pyzotero, you can run it directly:
91
+
92
+ * Using [uvx][11]: `uvx --from "pyzotero[cli]" pyzotero search -q "your query"`
93
+ * Using [pipx][10]: `pipx run --spec "pyzotero[cli]" pyzotero search -q "your query"`
94
+
95
+ See the [Command-Line Interface](#command-line-interface) section below for usage details.
96
+
97
+ ## Installing from Source
98
+
36
99
  * From a local clone, if you wish to install Pyzotero from a specific branch:
37
100
 
38
101
  Example:
@@ -54,6 +54,24 @@ Using `pip <http://www.pip-installer.org/en/latest/index.html>`_: ``pip install
54
54
 
55
55
  Using `Anaconda <https://www.anaconda.com/distribution/>`_: ``conda install conda-forge::pyzotero``
56
56
 
57
+ -------------------------------
58
+ Optional: Command-Line Interface
59
+ -------------------------------
60
+
61
+ Pyzotero includes an optional command-line interface for searching and querying your local Zotero library.
62
+
63
+ To install Pyzotero with the CLI:
64
+
65
+ * Using `uv <https://docs.astral.sh/uv/>`_: ``uv add "pyzotero[cli]"``
66
+ * Using `pip <http://www.pip-installer.org/en/latest/index.html>`_: ``pip install "pyzotero[cli]"``
67
+
68
+ If you just want to use the CLI without permanently installing Pyzotero:
69
+
70
+ * Using `uvx <https://docs.astral.sh/uv/>`_: ``uvx --from "pyzotero[cli]" pyzotero search -q "your query"``
71
+ * Using `pipx <https://pipx.pypa.io/>`_: ``pipx run --spec "pyzotero[cli]" pyzotero search -q "your query"``
72
+
73
+ See :ref:`cli-usage` for usage details.
74
+
57
75
  From a local clone, if you wish to install Pyzotero from a specific branch:
58
76
 
59
77
  .. code-block:: bash
@@ -81,6 +99,70 @@ Testing requires installation of the ``dev`` dependency group (see above).
81
99
 
82
100
  Run ``pytest .`` from the top-level directory.
83
101
 
102
+ .. _cli-usage:
103
+
104
+ ==========================
105
+ Command-Line Interface Usage
106
+ ==========================
107
+
108
+ The Pyzotero CLI connects to your local Zotero installation and allows you to search your library, list collections, and view item types.
109
+
110
+ Basic Commands
111
+ --------------
112
+
113
+ Search for top-level items:
114
+
115
+ .. code-block:: bash
116
+
117
+ pyzotero search -q "machine learning"
118
+
119
+ Search with full-text mode:
120
+
121
+ .. code-block:: bash
122
+
123
+ pyzotero search -q "climate change" --fulltext
124
+
125
+ Filter by item type:
126
+
127
+ .. code-block:: bash
128
+
129
+ pyzotero search -q "methodology" --itemtype book --itemtype journalArticle
130
+
131
+ Search for top-level items within a collection:
132
+
133
+ .. code-block:: bash
134
+
135
+ pyzotero search --collection ABC123 -q "test"
136
+
137
+ Output as JSON for machine processing:
138
+
139
+ .. code-block:: bash
140
+
141
+ pyzotero search -q "climate" --json
142
+
143
+ List all collections:
144
+
145
+ .. code-block:: bash
146
+
147
+ pyzotero listcollections
148
+
149
+ List available item types:
150
+
151
+ .. code-block:: bash
152
+
153
+ pyzotero itemtypes
154
+
155
+ Output Format
156
+ -------------
157
+
158
+ By default, the CLI outputs human-readable text with all relevant metadata including:
159
+
160
+ * Title, authors, date, publication
161
+ * Volume, issue, DOI, URL
162
+ * PDF attachments (with local file paths)
163
+
164
+ Use the ``--json`` flag to output structured JSON suitable for consumption by other tools and agents.
165
+
84
166
 
85
167
  ======================
86
168
  Building Documentation
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pyzotero"
3
- version = "1.6.16"
3
+ version = "1.7.0"
4
4
  description = "Python wrapper for the Zotero API"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.9"
@@ -33,6 +33,12 @@ Repository = "https://github.com/urschrei/pyzotero"
33
33
  Tracker = "https://github.com/urschrei/pyzotero/issues"
34
34
  documentation = "https://pyzotero.readthedocs.org"
35
35
 
36
+ [project.optional-dependencies]
37
+ cli = ["click>=8.0.0"]
38
+
39
+ [project.scripts]
40
+ pyzotero = "pyzotero.cli:main"
41
+
36
42
  [dependency-groups]
37
43
  dev = [
38
44
  "pytest >= 8.4.2",
@@ -0,0 +1,310 @@
1
+ """Command-line interface for pyzotero."""
2
+
3
+ import json
4
+ import sys
5
+
6
+ import click
7
+
8
+ from pyzotero import zotero
9
+
10
+
11
+ def _get_zotero_client(locale="en-US"):
12
+ """Get a Zotero client configured for local access."""
13
+ return zotero.Zotero(library_id="0", library_type="user", local=True, locale=locale)
14
+
15
+
16
+ @click.group()
17
+ @click.option(
18
+ "--locale",
19
+ default="en-US",
20
+ help="Locale for localized strings (default: en-US)",
21
+ )
22
+ @click.pass_context
23
+ def main(ctx, locale):
24
+ """Search local Zotero library."""
25
+ ctx.ensure_object(dict)
26
+ ctx.obj["locale"] = locale
27
+
28
+
29
+ @main.command()
30
+ @click.option(
31
+ "-q",
32
+ "--query",
33
+ help="Search query string",
34
+ default="",
35
+ )
36
+ @click.option(
37
+ "--fulltext",
38
+ is_flag=True,
39
+ help="Enable full-text search (qmode='everything')",
40
+ )
41
+ @click.option(
42
+ "--itemtype",
43
+ multiple=True,
44
+ help="Filter by item type (can be specified multiple times for OR search)",
45
+ )
46
+ @click.option(
47
+ "--collection",
48
+ help="Filter by collection key (returns only items in this collection)",
49
+ )
50
+ @click.option(
51
+ "--limit",
52
+ type=int,
53
+ default=1000000,
54
+ help="Maximum number of results to return (default: 1000000)",
55
+ )
56
+ @click.option(
57
+ "--json",
58
+ "output_json",
59
+ is_flag=True,
60
+ help="Output results as JSON",
61
+ )
62
+ @click.pass_context
63
+ def search(ctx, query, fulltext, itemtype, collection, limit, output_json): # noqa: PLR0912, PLR0915
64
+ """Search local Zotero library.
65
+
66
+ Examples:
67
+ pyzotero search -q "machine learning"
68
+
69
+ pyzotero search -q "climate change" --fulltext
70
+
71
+ pyzotero search -q "methodology" --itemtype book --itemtype journalArticle
72
+
73
+ pyzotero search --collection ABC123 -q "test"
74
+
75
+ pyzotero search -q "climate" --json
76
+
77
+ """
78
+ try:
79
+ locale = ctx.obj.get("locale", "en-US")
80
+ zot = _get_zotero_client(locale)
81
+
82
+ # Build query parameters
83
+ params = {"limit": limit}
84
+
85
+ if query:
86
+ params["q"] = query
87
+
88
+ if fulltext:
89
+ params["qmode"] = "everything"
90
+
91
+ if itemtype:
92
+ # Join multiple item types with || for OR search
93
+ params["itemType"] = " || ".join(itemtype)
94
+
95
+ # Execute search using collection_items_top() if collection specified, otherwise top()
96
+ if collection:
97
+ results = zot.collection_items_top(collection, **params)
98
+ else:
99
+ results = zot.top(**params)
100
+
101
+ # Handle empty results
102
+ if not results:
103
+ if output_json:
104
+ click.echo(json.dumps([]))
105
+ else:
106
+ click.echo("No results found.")
107
+ return
108
+
109
+ # Build output data structure
110
+ output_items = []
111
+ for item in results:
112
+ data = item.get("data", {})
113
+
114
+ title = data.get("title", "No title")
115
+ item_type = data.get("itemType", "Unknown")
116
+ date = data.get("date", "No date")
117
+ item_key = data.get("key", "")
118
+ publication = data.get("publicationTitle", "")
119
+ volume = data.get("volume", "")
120
+ issue = data.get("issue", "")
121
+ doi = data.get("DOI", "")
122
+ url = data.get("url", "")
123
+
124
+ # Format creators (authors, editors, etc.)
125
+ creators = data.get("creators", [])
126
+ creator_names = []
127
+ for creator in creators:
128
+ if "lastName" in creator:
129
+ if "firstName" in creator:
130
+ creator_names.append(
131
+ f"{creator['firstName']} {creator['lastName']}"
132
+ )
133
+ else:
134
+ creator_names.append(creator["lastName"])
135
+ elif "name" in creator:
136
+ creator_names.append(creator["name"])
137
+
138
+ # Check for PDF attachments
139
+ pdf_attachments = []
140
+ num_children = item.get("meta", {}).get("numChildren", 0)
141
+ if num_children > 0:
142
+ children = zot.children(item_key)
143
+ for child in children:
144
+ child_data = child.get("data", {})
145
+ if child_data.get("contentType") == "application/pdf":
146
+ # Extract file URL from links.enclosure.href
147
+ file_url = (
148
+ child.get("links", {}).get("enclosure", {}).get("href", "")
149
+ )
150
+ if file_url:
151
+ pdf_attachments.append(file_url)
152
+
153
+ # Build item object for JSON output
154
+ item_obj = {
155
+ "key": item_key,
156
+ "itemType": item_type,
157
+ "title": title,
158
+ "creators": creator_names,
159
+ "date": date,
160
+ "publication": publication,
161
+ "volume": volume,
162
+ "issue": issue,
163
+ "doi": doi,
164
+ "url": url,
165
+ "pdfAttachments": pdf_attachments,
166
+ }
167
+ output_items.append(item_obj)
168
+
169
+ # Output results
170
+ if output_json:
171
+ click.echo(json.dumps(output_items, indent=2))
172
+ else:
173
+ click.echo(f"\nFound {len(results)} items:\n")
174
+ for idx, item_obj in enumerate(output_items, 1):
175
+ authors_str = (
176
+ ", ".join(item_obj["creators"])
177
+ if item_obj["creators"]
178
+ else "No authors"
179
+ )
180
+
181
+ click.echo(f"{idx}. [{item_obj['itemType']}] {item_obj['title']}")
182
+ click.echo(f" Authors: {authors_str}")
183
+ click.echo(f" Date: {item_obj['date']}")
184
+ click.echo(f" Publication: {item_obj['publication']}")
185
+ click.echo(f" Volume: {item_obj['volume']}")
186
+ click.echo(f" Issue: {item_obj['issue']}")
187
+ click.echo(f" DOI: {item_obj['doi']}")
188
+ click.echo(f" URL: {item_obj['url']}")
189
+ click.echo(f" Key: {item_obj['key']}")
190
+
191
+ if item_obj["pdfAttachments"]:
192
+ click.echo(" PDF Attachments:")
193
+ for pdf_url in item_obj["pdfAttachments"]:
194
+ click.echo(f" {pdf_url}")
195
+
196
+ click.echo()
197
+
198
+ except Exception as e:
199
+ click.echo(f"Error: {e!s}", err=True)
200
+ sys.exit(1)
201
+
202
+
203
+ @main.command()
204
+ @click.option(
205
+ "--limit",
206
+ type=int,
207
+ help="Maximum number of collections to return (default: all)",
208
+ )
209
+ @click.pass_context
210
+ def listcollections(ctx, limit):
211
+ """List all collections in the local Zotero library.
212
+
213
+ Examples:
214
+ pyzotero listcollections
215
+
216
+ pyzotero listcollections --limit 10
217
+
218
+ """
219
+ try:
220
+ locale = ctx.obj.get("locale", "en-US")
221
+ zot = _get_zotero_client(locale)
222
+
223
+ # Build query parameters
224
+ params = {}
225
+ if limit:
226
+ params["limit"] = limit
227
+
228
+ # Get all collections
229
+ collections = zot.collections(**params)
230
+
231
+ if not collections:
232
+ click.echo(json.dumps([]))
233
+ return
234
+
235
+ # Build a mapping of collection keys to names for parent lookup
236
+ collection_map = {}
237
+ for collection in collections:
238
+ data = collection.get("data", {})
239
+ key = data.get("key", "")
240
+ name = data.get("name", "")
241
+ if key:
242
+ collection_map[key] = name if name else None
243
+
244
+ # Build JSON output
245
+ output = []
246
+ for collection in collections:
247
+ data = collection.get("data", {})
248
+ meta = collection.get("meta", {})
249
+
250
+ name = data.get("name", "")
251
+ key = data.get("key", "")
252
+ num_items = meta.get("numItems", 0)
253
+ parent_collection = data.get("parentCollection", "")
254
+
255
+ collection_obj = {
256
+ "id": key,
257
+ "name": name if name else None,
258
+ "items": num_items,
259
+ }
260
+
261
+ # Add parent information if it exists
262
+ if parent_collection:
263
+ parent_name = collection_map.get(parent_collection)
264
+ collection_obj["parent"] = {
265
+ "id": parent_collection,
266
+ "name": parent_name,
267
+ }
268
+ else:
269
+ collection_obj["parent"] = None
270
+
271
+ output.append(collection_obj)
272
+
273
+ # Output as JSON
274
+ click.echo(json.dumps(output, indent=2))
275
+
276
+ except Exception as e:
277
+ click.echo(f"Error: {e!s}", err=True)
278
+ sys.exit(1)
279
+
280
+
281
+ @main.command()
282
+ @click.pass_context
283
+ def itemtypes(ctx):
284
+ """List all valid item types.
285
+
286
+ Examples:
287
+ pyzotero itemtypes
288
+
289
+ """
290
+ try:
291
+ locale = ctx.obj.get("locale", "en-US")
292
+ zot = _get_zotero_client(locale)
293
+
294
+ # Get all item types
295
+ item_types = zot.item_types()
296
+
297
+ if not item_types:
298
+ click.echo(json.dumps([]))
299
+ return
300
+
301
+ # Output as JSON array
302
+ click.echo(json.dumps(item_types, indent=2))
303
+
304
+ except Exception as e:
305
+ click.echo(f"Error: {e!s}", err=True)
306
+ sys.exit(1)
307
+
308
+
309
+ if __name__ == "__main__":
310
+ main()
@@ -43,24 +43,21 @@ 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
48
  DEFAULT_ITEM_LIMIT = 100
50
- TOO_MANY_REQUESTS = 429
51
49
 
52
50
 
53
51
  def build_url(base_url, path, args_dict=None):
54
52
  """Build a valid URL so we don't have to worry about string concatenation errors and
55
53
  leading / trailing slashes etc.
56
- Returns a list in the structure of urlparse.ParseResult
57
54
  """
58
55
  base_url = base_url.removesuffix("/")
59
- url_parts = list(urlparse(base_url))
60
- url_parts[2] += path
56
+ parsed = urlparse(base_url)
57
+ new_path = str(PurePosixPath(parsed.path) / path.removeprefix("/"))
61
58
  if args_dict:
62
- url_parts[4] = urlencode(args_dict)
63
- return urlunparse(url_parts)
59
+ return urlunparse(parsed._replace(path=new_path, query=urlencode(args_dict)))
60
+ return urlunparse(parsed._replace(path=new_path))
64
61
 
65
62
 
66
63
  def merge_params(url, params):
@@ -451,8 +448,6 @@ class Zotero:
451
448
  Returns a JSON document
452
449
  """
453
450
  full_url = build_url(self.endpoint, request)
454
- # The API doesn't return this any more, so we have to cheat
455
- self.self_link = request
456
451
  # ensure that we wait if there's an active backoff
457
452
  self._check_backoff()
458
453
  # don't set locale if the url already contains it
@@ -485,6 +480,8 @@ class Zotero:
485
480
  timeout=timeout,
486
481
  )
487
482
  self.request.encoding = "utf-8"
483
+ # The API doesn't return this any more, so we have to cheat
484
+ self.self_link = self.request.url
488
485
  except httpx.UnsupportedProtocol:
489
486
  # File URI handler logic
490
487
  fc = File_Client()
@@ -515,18 +512,15 @@ class Zotero:
515
512
  try:
516
513
  for key, value in self.request.links.items():
517
514
  parsed = urlparse(value["url"])
518
- fragment = f"{parsed[2]}?{parsed[4]}"
515
+ fragment = urlunparse(("", "", parsed.path, "", parsed.query, ""))
519
516
  extracted[key] = fragment
520
517
  # add a 'self' link
521
- parsed = list(urlparse(self.self_link))
522
- # strip 'format' query parameter
523
- stripped = "&".join(
524
- ["=".join(p) for p in parse_qsl(parsed[4])[:2] if p[0] != "format"],
525
- )
526
- # rebuild url fragment
527
- # this is a death march
518
+ parsed = urlparse(str(self.self_link))
519
+ # strip 'format' query parameter and rebuild query string
520
+ query_params = [(k, v) for k, v in parse_qsl(parsed.query) if k != "format"]
521
+ # rebuild url fragment with just path and query (consistent with other links)
528
522
  extracted["self"] = urlunparse(
529
- [parsed[0], parsed[1], parsed[2], parsed[3], stripped, parsed[5]],
523
+ ("", "", parsed.path, "", urlencode(query_params), "")
530
524
  )
531
525
  except KeyError:
532
526
  # No links present, because it's a single item
@@ -572,7 +566,7 @@ class Zotero:
572
566
  )
573
567
  if backoff:
574
568
  self._set_backoff(backoff)
575
- return req.status_code == NOT_MODIFIED
569
+ return req.status_code == httpx.codes.NOT_MODIFIED
576
570
  # Still plenty of life left in't
577
571
  return False
578
572
 
@@ -1647,7 +1641,7 @@ def error_handler(zot, req, exc=None):
1647
1641
 
1648
1642
  if error_codes.get(req.status_code):
1649
1643
  # check to see whether its 429
1650
- if req.status_code == TOO_MANY_REQUESTS:
1644
+ if req.status_code == httpx.codes.TOO_MANY_REQUESTS:
1651
1645
  # try to get backoff or delay duration
1652
1646
  delay = req.headers.get("backoff") or req.headers.get("retry-after")
1653
1647
  if not delay:
@@ -413,7 +413,28 @@ class ZoteroTests(unittest.TestCase):
413
413
  zot.tags()
414
414
  self.assertEqual(zot.links["next"], "/users/436/items/top?limit=1&start=1")
415
415
  self.assertEqual(zot.links["last"], "/users/436/items/top?limit=1&start=2319")
416
- self.assertEqual(zot.links["alternate"], "/users/436/items/top?")
416
+ self.assertEqual(zot.links["alternate"], "/users/436/items/top")
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"])
417
438
 
418
439
  @httpretty.activate
419
440
  def testParseGroupsJSONDoc(self):
@@ -1614,6 +1635,27 @@ class ZoteroTests(unittest.TestCase):
1614
1635
  # Verify the mock was called
1615
1636
  mock_iterfollow.assert_called_once()
1616
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
+
1617
1659
  @httpretty.activate
1618
1660
  def test_publications_user(self):
1619
1661
  """Test the publications method for user libraries"""
File without changes
File without changes
File without changes
File without changes
File without changes