pyzotero 1.6.16__py3-none-any.whl → 1.7.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pyzotero/cli.py ADDED
@@ -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()
pyzotero/zotero.py CHANGED
@@ -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:
@@ -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:
@@ -0,0 +1,9 @@
1
+ pyzotero/__init__.py,sha256=5QI4Jou9L-YJAf_oN9TgRXVKgt_Unc39oADo2Ch8bLI,243
2
+ pyzotero/cli.py,sha256=H7R9Nx5OEyrET1S19kQrC2g_rDq_7_RE9iCe_dKBEwY,9126
3
+ pyzotero/filetransport.py,sha256=umLik1LLmrpgaNmyjvtBoqqcaMgIq79PYsTvN5vG-gY,5530
4
+ pyzotero/zotero.py,sha256=4qb7jLl1lNkDv3WpEPLW2L0SbleTtGYlQ6Rloz-hmN0,76497
5
+ pyzotero/zotero_errors.py,sha256=6obx9-pBO0z1bxt33vuzDluELvA5kSLCsfc-uGc3KNw,2660
6
+ pyzotero-1.7.0.dist-info/WHEEL,sha256=eh7sammvW2TypMMMGKgsM83HyA_3qQ5Lgg3ynoecH3M,79
7
+ pyzotero-1.7.0.dist-info/entry_points.txt,sha256=MzN7IMRj_oPNmDCsseYFPum3bHWE1gFxywhlbFbcn2k,48
8
+ pyzotero-1.7.0.dist-info/METADATA,sha256=P26XM2Jb11lOpAw4McrEZFwNpwvr9jyMfvC_2v6MVr8,9233
9
+ pyzotero-1.7.0.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: uv 0.8.19
2
+ Generator: uv 0.8.24
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ pyzotero = pyzotero.cli:main
3
+
@@ -1,7 +0,0 @@
1
- pyzotero/__init__.py,sha256=5QI4Jou9L-YJAf_oN9TgRXVKgt_Unc39oADo2Ch8bLI,243
2
- pyzotero/filetransport.py,sha256=umLik1LLmrpgaNmyjvtBoqqcaMgIq79PYsTvN5vG-gY,5530
3
- pyzotero/zotero.py,sha256=wDKLPjNt-Eo1XUnLlgAscNKZkDI6J60_nVbplXsGk8A,76441
4
- pyzotero/zotero_errors.py,sha256=6obx9-pBO0z1bxt33vuzDluELvA5kSLCsfc-uGc3KNw,2660
5
- pyzotero-1.6.16.dist-info/WHEEL,sha256=pFCy50wRV2h7SjJ35YOsQUupaV45rMdgpNIvnXbG5bE,79
6
- pyzotero-1.6.16.dist-info/METADATA,sha256=oFB4Mcb6MtVf9eyd2cOz0LCwpzrT-CU6uOIzwDvh3jU,7292
7
- pyzotero-1.6.16.dist-info/RECORD,,