pyzotero 1.6.17__py3-none-any.whl → 1.7.1__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,352 @@
1
+ """Command-line interface for pyzotero."""
2
+
3
+ import json
4
+ import sys
5
+
6
+ import click
7
+
8
+ from pyzotero import zotero
9
+ from pyzotero.zotero import chunks
10
+
11
+
12
+ def _get_zotero_client(locale="en-US"):
13
+ """Get a Zotero client configured for local access."""
14
+ return zotero.Zotero(library_id="0", library_type="user", local=True, locale=locale)
15
+
16
+
17
+ @click.group()
18
+ @click.option(
19
+ "--locale",
20
+ default="en-US",
21
+ help="Locale for localized strings (default: en-US)",
22
+ )
23
+ @click.pass_context
24
+ def main(ctx, locale):
25
+ """Search local Zotero library."""
26
+ ctx.ensure_object(dict)
27
+ ctx.obj["locale"] = locale
28
+
29
+
30
+ @main.command()
31
+ @click.option(
32
+ "-q",
33
+ "--query",
34
+ help="Search query string",
35
+ default="",
36
+ )
37
+ @click.option(
38
+ "--fulltext",
39
+ is_flag=True,
40
+ help="Search full-text content including PDFs. Retrieves parent items when attachments match.",
41
+ )
42
+ @click.option(
43
+ "--itemtype",
44
+ multiple=True,
45
+ help="Filter by item type (can be specified multiple times for OR search)",
46
+ )
47
+ @click.option(
48
+ "--collection",
49
+ help="Filter by collection key (returns only items in this collection)",
50
+ )
51
+ @click.option(
52
+ "--limit",
53
+ type=int,
54
+ default=1000000,
55
+ help="Maximum number of results to return (default: 1000000)",
56
+ )
57
+ @click.option(
58
+ "--json",
59
+ "output_json",
60
+ is_flag=True,
61
+ help="Output results as JSON",
62
+ )
63
+ @click.pass_context
64
+ def search(ctx, query, fulltext, itemtype, collection, limit, output_json): # noqa: PLR0912, PLR0915
65
+ """Search local Zotero library.
66
+
67
+ By default, searches top-level items in titles and metadata.
68
+
69
+ When --fulltext is enabled, searches all items including attachment content
70
+ (PDFs, documents, etc.). If a match is found in an attachment, the parent
71
+ bibliographic item is retrieved and included in results.
72
+
73
+ Examples:
74
+ pyzotero search -q "machine learning"
75
+
76
+ pyzotero search -q "climate change" --fulltext
77
+
78
+ pyzotero search -q "methodology" --itemtype book --itemtype journalArticle
79
+
80
+ pyzotero search --collection ABC123 -q "test"
81
+
82
+ pyzotero search -q "climate" --json
83
+
84
+ """
85
+ try:
86
+ locale = ctx.obj.get("locale", "en-US")
87
+ zot = _get_zotero_client(locale)
88
+
89
+ # Build query parameters
90
+ params = {"limit": limit}
91
+
92
+ if query:
93
+ params["q"] = query
94
+
95
+ if fulltext:
96
+ params["qmode"] = "everything"
97
+
98
+ if itemtype:
99
+ # Join multiple item types with || for OR search
100
+ params["itemType"] = " || ".join(itemtype)
101
+
102
+ # Execute search
103
+ # When fulltext is enabled, use items() or collection_items() to get both
104
+ # top-level items and attachments. Otherwise use top() or collection_items_top()
105
+ # to only get top-level items.
106
+ if fulltext:
107
+ if collection:
108
+ results = zot.collection_items(collection, **params)
109
+ else:
110
+ results = zot.items(**params)
111
+
112
+ # When using fulltext, we need to retrieve parent items for any attachments
113
+ # that matched, since most full-text content comes from PDFs and other attachments
114
+ top_level_items = []
115
+ attachment_items = []
116
+
117
+ for item in results:
118
+ data = item.get("data", {})
119
+ if "parentItem" in data:
120
+ attachment_items.append(item)
121
+ else:
122
+ top_level_items.append(item)
123
+
124
+ # Retrieve parent items for attachments in batches of 50
125
+ parent_items = []
126
+ if attachment_items:
127
+ parent_ids = list(
128
+ {item["data"]["parentItem"] for item in attachment_items}
129
+ )
130
+ for chunk in chunks(parent_ids, 50):
131
+ parent_items.extend(zot.get_subset(chunk))
132
+
133
+ # Combine top-level items and parent items, removing duplicates by key
134
+ all_items = top_level_items + parent_items
135
+ items_dict = {item["data"]["key"]: item for item in all_items}
136
+ results = list(items_dict.values())
137
+ # Non-fulltext search: use top() or collection_items_top() as before
138
+ elif collection:
139
+ results = zot.collection_items_top(collection, **params)
140
+ else:
141
+ results = zot.top(**params)
142
+
143
+ # Handle empty results
144
+ if not results:
145
+ if output_json:
146
+ click.echo(json.dumps([]))
147
+ else:
148
+ click.echo("No results found.")
149
+ return
150
+
151
+ # Build output data structure
152
+ output_items = []
153
+ for item in results:
154
+ data = item.get("data", {})
155
+
156
+ title = data.get("title", "No title")
157
+ item_type = data.get("itemType", "Unknown")
158
+ date = data.get("date", "No date")
159
+ item_key = data.get("key", "")
160
+ publication = data.get("publicationTitle", "")
161
+ volume = data.get("volume", "")
162
+ issue = data.get("issue", "")
163
+ doi = data.get("DOI", "")
164
+ url = data.get("url", "")
165
+
166
+ # Format creators (authors, editors, etc.)
167
+ creators = data.get("creators", [])
168
+ creator_names = []
169
+ for creator in creators:
170
+ if "lastName" in creator:
171
+ if "firstName" in creator:
172
+ creator_names.append(
173
+ f"{creator['firstName']} {creator['lastName']}"
174
+ )
175
+ else:
176
+ creator_names.append(creator["lastName"])
177
+ elif "name" in creator:
178
+ creator_names.append(creator["name"])
179
+
180
+ # Check for PDF attachments
181
+ pdf_attachments = []
182
+ num_children = item.get("meta", {}).get("numChildren", 0)
183
+ if num_children > 0:
184
+ children = zot.children(item_key)
185
+ for child in children:
186
+ child_data = child.get("data", {})
187
+ if child_data.get("contentType") == "application/pdf":
188
+ # Extract file URL from links.enclosure.href
189
+ file_url = (
190
+ child.get("links", {}).get("enclosure", {}).get("href", "")
191
+ )
192
+ if file_url:
193
+ pdf_attachments.append(file_url)
194
+
195
+ # Build item object for JSON output
196
+ item_obj = {
197
+ "key": item_key,
198
+ "itemType": item_type,
199
+ "title": title,
200
+ "creators": creator_names,
201
+ "date": date,
202
+ "publication": publication,
203
+ "volume": volume,
204
+ "issue": issue,
205
+ "doi": doi,
206
+ "url": url,
207
+ "pdfAttachments": pdf_attachments,
208
+ }
209
+ output_items.append(item_obj)
210
+
211
+ # Output results
212
+ if output_json:
213
+ click.echo(json.dumps(output_items, indent=2))
214
+ else:
215
+ click.echo(f"\nFound {len(results)} items:\n")
216
+ for idx, item_obj in enumerate(output_items, 1):
217
+ authors_str = (
218
+ ", ".join(item_obj["creators"])
219
+ if item_obj["creators"]
220
+ else "No authors"
221
+ )
222
+
223
+ click.echo(f"{idx}. [{item_obj['itemType']}] {item_obj['title']}")
224
+ click.echo(f" Authors: {authors_str}")
225
+ click.echo(f" Date: {item_obj['date']}")
226
+ click.echo(f" Publication: {item_obj['publication']}")
227
+ click.echo(f" Volume: {item_obj['volume']}")
228
+ click.echo(f" Issue: {item_obj['issue']}")
229
+ click.echo(f" DOI: {item_obj['doi']}")
230
+ click.echo(f" URL: {item_obj['url']}")
231
+ click.echo(f" Key: {item_obj['key']}")
232
+
233
+ if item_obj["pdfAttachments"]:
234
+ click.echo(" PDF Attachments:")
235
+ for pdf_url in item_obj["pdfAttachments"]:
236
+ click.echo(f" {pdf_url}")
237
+
238
+ click.echo()
239
+
240
+ except Exception as e:
241
+ click.echo(f"Error: {e!s}", err=True)
242
+ sys.exit(1)
243
+
244
+
245
+ @main.command()
246
+ @click.option(
247
+ "--limit",
248
+ type=int,
249
+ help="Maximum number of collections to return (default: all)",
250
+ )
251
+ @click.pass_context
252
+ def listcollections(ctx, limit):
253
+ """List all collections in the local Zotero library.
254
+
255
+ Examples:
256
+ pyzotero listcollections
257
+
258
+ pyzotero listcollections --limit 10
259
+
260
+ """
261
+ try:
262
+ locale = ctx.obj.get("locale", "en-US")
263
+ zot = _get_zotero_client(locale)
264
+
265
+ # Build query parameters
266
+ params = {}
267
+ if limit:
268
+ params["limit"] = limit
269
+
270
+ # Get all collections
271
+ collections = zot.collections(**params)
272
+
273
+ if not collections:
274
+ click.echo(json.dumps([]))
275
+ return
276
+
277
+ # Build a mapping of collection keys to names for parent lookup
278
+ collection_map = {}
279
+ for collection in collections:
280
+ data = collection.get("data", {})
281
+ key = data.get("key", "")
282
+ name = data.get("name", "")
283
+ if key:
284
+ collection_map[key] = name if name else None
285
+
286
+ # Build JSON output
287
+ output = []
288
+ for collection in collections:
289
+ data = collection.get("data", {})
290
+ meta = collection.get("meta", {})
291
+
292
+ name = data.get("name", "")
293
+ key = data.get("key", "")
294
+ num_items = meta.get("numItems", 0)
295
+ parent_collection = data.get("parentCollection", "")
296
+
297
+ collection_obj = {
298
+ "id": key,
299
+ "name": name if name else None,
300
+ "items": num_items,
301
+ }
302
+
303
+ # Add parent information if it exists
304
+ if parent_collection:
305
+ parent_name = collection_map.get(parent_collection)
306
+ collection_obj["parent"] = {
307
+ "id": parent_collection,
308
+ "name": parent_name,
309
+ }
310
+ else:
311
+ collection_obj["parent"] = None
312
+
313
+ output.append(collection_obj)
314
+
315
+ # Output as JSON
316
+ click.echo(json.dumps(output, indent=2))
317
+
318
+ except Exception as e:
319
+ click.echo(f"Error: {e!s}", err=True)
320
+ sys.exit(1)
321
+
322
+
323
+ @main.command()
324
+ @click.pass_context
325
+ def itemtypes(ctx):
326
+ """List all valid item types.
327
+
328
+ Examples:
329
+ pyzotero itemtypes
330
+
331
+ """
332
+ try:
333
+ locale = ctx.obj.get("locale", "en-US")
334
+ zot = _get_zotero_client(locale)
335
+
336
+ # Get all item types
337
+ item_types = zot.item_types()
338
+
339
+ if not item_types:
340
+ click.echo(json.dumps([]))
341
+ return
342
+
343
+ # Output as JSON array
344
+ click.echo(json.dumps(item_types, indent=2))
345
+
346
+ except Exception as e:
347
+ click.echo(f"Error: {e!s}", err=True)
348
+ sys.exit(1)
349
+
350
+
351
+ if __name__ == "__main__":
352
+ main()
pyzotero/zotero.py CHANGED
@@ -51,14 +51,13 @@ DEFAULT_ITEM_LIMIT = 100
51
51
  def build_url(base_url, path, args_dict=None):
52
52
  """Build a valid URL so we don't have to worry about string concatenation errors and
53
53
  leading / trailing slashes etc.
54
- Returns a list in the structure of urlparse.ParseResult
55
54
  """
56
55
  base_url = base_url.removesuffix("/")
57
- url_parts = list(urlparse(base_url))
58
- url_parts[2] += path
56
+ parsed = urlparse(base_url)
57
+ new_path = str(PurePosixPath(parsed.path) / path.removeprefix("/"))
59
58
  if args_dict:
60
- url_parts[4] = urlencode(args_dict)
61
- return urlunparse(url_parts)
59
+ return urlunparse(parsed._replace(path=new_path, query=urlencode(args_dict)))
60
+ return urlunparse(parsed._replace(path=new_path))
62
61
 
63
62
 
64
63
  def merge_params(url, params):
@@ -513,7 +512,7 @@ class Zotero:
513
512
  try:
514
513
  for key, value in self.request.links.items():
515
514
  parsed = urlparse(value["url"])
516
- fragment = f"{parsed[2]}?{parsed[4]}"
515
+ fragment = urlunparse(("", "", parsed.path, "", parsed.query, ""))
517
516
  extracted[key] = fragment
518
517
  # add a 'self' link
519
518
  parsed = urlparse(str(self.self_link))
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: pyzotero
3
- Version: 1.6.17
3
+ Version: 1.7.1
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,80 @@ 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
+ ## Search Behaviour
130
+
131
+ By default, `pyzotero search` searches only top-level item titles and metadata fields.
132
+
133
+ When the `--fulltext` flag is used, the search expands to include all full-text indexed content, including PDFs and other attachments. Since most full-text content comes from PDF attachments rather than top-level items, the CLI automatically retrieves the parent bibliographic items for any matching attachments. This ensures you receive useful bibliographic records (journal articles, books, etc.) rather than raw attachment items.
134
+
135
+ ## Output Format
136
+
137
+ By default, the CLI outputs human-readable text with a subset of metadata including:
138
+ - Title, authors, date, publication
139
+ - Volume, issue, DOI, URL
140
+ - PDF attachments (with local file paths)
141
+
142
+ Use the `--json` flag to output structured JSON.
143
+
96
144
  # Installation
97
145
 
98
146
  * Using [uv][11]: `uv add pyzotero`
99
147
  * Using [pip][10]: `pip install pyzotero`
100
148
  * Using Anaconda:`conda install conda-forge::pyzotero`
149
+
150
+ ## Optional: Command-Line Interface
151
+
152
+ Pyzotero includes an optional command-line interface for searching and querying your local Zotero library.
153
+
154
+ ### Installing the CLI
155
+
156
+ To install Pyzotero with the CLI:
157
+
158
+ * Using [uv][11]: `uv add "pyzotero[cli]"`
159
+ * Using [pip][10]: `pip install "pyzotero[cli]"`
160
+
161
+ ### Using the CLI without installing
162
+
163
+ If you just want to use the CLI without permanently installing Pyzotero, you can run it directly:
164
+
165
+ * Using [uvx][11]: `uvx --from "pyzotero[cli]" pyzotero search -q "your query"`
166
+ * Using [pipx][10]: `pipx run --spec "pyzotero[cli]" pyzotero search -q "your query"`
167
+
168
+ See the [Command-Line Interface](#command-line-interface) section below for usage details.
169
+
170
+ ## Installing from Source
171
+
101
172
  * From a local clone, if you wish to install Pyzotero from a specific branch:
102
173
 
103
174
  Example:
@@ -0,0 +1,9 @@
1
+ pyzotero/__init__.py,sha256=5QI4Jou9L-YJAf_oN9TgRXVKgt_Unc39oADo2Ch8bLI,243
2
+ pyzotero/cli.py,sha256=4vWX8SobVoHouJ1k7cumqtuWKqeFPPp5QyZzfdzBDOc,11023
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.1.dist-info/WHEEL,sha256=eh7sammvW2TypMMMGKgsM83HyA_3qQ5Lgg3ynoecH3M,79
7
+ pyzotero-1.7.1.dist-info/entry_points.txt,sha256=MzN7IMRj_oPNmDCsseYFPum3bHWE1gFxywhlbFbcn2k,48
8
+ pyzotero-1.7.1.dist-info/METADATA,sha256=-fxVGQetAnFkecnXs67FhGSJfsbL9i_a7VOMg7_Rz2c,9776
9
+ pyzotero-1.7.1.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: uv 0.8.23
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=2Mjvr15sHRHR1MCeifniIXVtqqC4n-P_kDr8Z5Vdkhw,76427
4
- pyzotero/zotero_errors.py,sha256=6obx9-pBO0z1bxt33vuzDluELvA5kSLCsfc-uGc3KNw,2660
5
- pyzotero-1.6.17.dist-info/WHEEL,sha256=n2u5OFBbdZvCiUKAmfnY1Po2j3FB_NWfuUlt5WiAjrk,79
6
- pyzotero-1.6.17.dist-info/METADATA,sha256=3h20funfN9wdaduCVZ8IBFFUB1K4IlH_Tlrj-OD7BmA,7292
7
- pyzotero-1.6.17.dist-info/RECORD,,