Pyzotero: a Python client for the Zotero API pyzotero.readthedocs.io
zotero

Merge pull request #254 from urschrei/shugel/push-puvsruuyvovl

Add CLI

authored by urschrei.eurosky.social and committed by

GitHub 4776d1c7 4b57669a

+502 -2
+63
README.md
··· 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:
+82
doc/index.rst
··· 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 ··· 80 98 Testing requires installation of the ``dev`` dependency group (see above). 81 99 82 100 Run ``pytest .`` from the top-level directory. 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. 83 165 84 166 85 167 ======================
+7 -1
pyproject.toml
··· 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" ··· 32 32 Repository = "https://github.com/urschrei/pyzotero" 33 33 Tracker = "https://github.com/urschrei/pyzotero/issues" 34 34 documentation = "https://pyzotero.readthedocs.org" 35 + 36 + [project.optional-dependencies] 37 + cli = ["click>=8.0.0"] 38 + 39 + [project.scripts] 40 + pyzotero = "pyzotero.cli:main" 35 41 36 42 [dependency-groups] 37 43 dev = [
+310
src/pyzotero/cli.py
··· 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()
+40 -1
uv.lock
··· 168 168 ] 169 169 170 170 [[package]] 171 + name = "click" 172 + version = "8.1.8" 173 + source = { registry = "https://pypi.org/simple" } 174 + resolution-markers = [ 175 + "python_full_version < '3.10'", 176 + ] 177 + dependencies = [ 178 + { name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, 179 + ] 180 + sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593, upload-time = "2024-12-21T18:38:44.339Z" } 181 + wheels = [ 182 + { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188, upload-time = "2024-12-21T18:38:41.666Z" }, 183 + ] 184 + 185 + [[package]] 186 + name = "click" 187 + version = "8.3.0" 188 + source = { registry = "https://pypi.org/simple" } 189 + resolution-markers = [ 190 + "python_full_version >= '3.11'", 191 + "python_full_version == '3.10.*'", 192 + ] 193 + dependencies = [ 194 + { name = "colorama", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" }, 195 + ] 196 + sdist = { url = "https://files.pythonhosted.org/packages/46/61/de6cd827efad202d7057d93e0fed9294b96952e188f7384832791c7b2254/click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4", size = 276943, upload-time = "2025-09-18T17:32:23.696Z" } 197 + wheels = [ 198 + { url = "https://files.pythonhosted.org/packages/db/d3/9dcc0f5797f070ec8edf30fbadfb200e71d9db6b84d211e3b2085a7589a0/click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc", size = 107295, upload-time = "2025-09-18T17:32:22.42Z" }, 199 + ] 200 + 201 + [[package]] 171 202 name = "colorama" 172 203 version = "0.4.6" 173 204 source = { registry = "https://pypi.org/simple" } ··· 753 784 754 785 [[package]] 755 786 name = "pyzotero" 756 - version = "1.6.16" 787 + version = "1.7.0" 757 788 source = { editable = "." } 758 789 dependencies = [ 759 790 { name = "bibtexparser" }, 760 791 { name = "feedparser" }, 761 792 { name = "httpx" }, 762 793 { name = "whenever" }, 794 + ] 795 + 796 + [package.optional-dependencies] 797 + cli = [ 798 + { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, 799 + { name = "click", version = "8.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, 763 800 ] 764 801 765 802 [package.dev-dependencies] ··· 785 822 [package.metadata] 786 823 requires-dist = [ 787 824 { name = "bibtexparser", specifier = ">=1.4.3,<2.0.0" }, 825 + { name = "click", marker = "extra == 'cli'", specifier = ">=8.0.0" }, 788 826 { name = "feedparser", specifier = ">=6.0.12" }, 789 827 { name = "httpx", specifier = ">=0.28.1" }, 790 828 { name = "whenever", specifier = ">=0.8.8" }, 791 829 ] 830 + provides-extras = ["cli"] 792 831 793 832 [package.metadata.requires-dev] 794 833 dev = [