Repository navigation
Add local Zotero as literature query and paper retrieval database #156
Description
Using the pyzotero python packgae, one could plugin their own zotero library to be able to search for literature by implementing the getid interface. In the python lůibrary, documentation on how to perform searches is available here: https://pyzotero.readthedocs.io/en/latest/#general-usage and https://www.zotero.org/support/dev/web_api/v3/basics#searching.
after search, the IDs (e.g. DOI) of the publications can be returned. Then the items as well the full text can be queried from Zenodo: https://www.zotero.org/support/dev/web_api/v3/fulltext_content
This should make it possible to implement Zotero as a fully supported database to query the papers stored by an individual. The API keys and collection related details can be provided as parameter to the database.
AI generated report on this issue:
Plan: Zotero as a literature query and retrieval database (#156)
Phase: Implementation planning (Research → Plan → Implement → Verify).
Branch: hechth/issue156 (already checked out).
Scope: new read-only Zotero database backend in src/aoptk/literature/databases/, built on
pyzotero, implementing the same interfaces as EuropePMC.
1. What the issue asks for
- Use
pyzoteroso a user can point aoptk at their own Zotero library (local desktop app and/or
api.zotero.orgaccount) and search it for literature by implementing theGetIDinterface. - After search, return the publication IDs (e.g. DOI).
- Then retrieve the items and their full text from Zotero
(full-text content endpoint). - API keys and collection details are supplied as parameters to the database object.
2. Decisions
Confirmed with the user:
| # | Decision | Choice |
|---|---|---|
| D1 | Backend | Both remote (api.zotero.org) and local desktop API (127.0.0.1:23119), selected by a local: bool flag |
| D2 | Interfaces | All five, like EuropePMC: GetID, GetMetadata, GetAbstract, GetPDF, GetPublication |
| D3 | Dependency | Optional extra aoptk[zotero] + lazy import inside the module |
Derived design decisions (reversible, but stated so the implementation is unambiguous):
| # | Question | Decision | Rationale |
|---|---|---|---|
| D4 | Where does the search term come from? | Constructor query: Query | None, exactly like EuropePMC/PMC |
GetID.get_ids(self) takes no arguments (src/aoptk/literature/get_id.py:14); the search term must be state |
| D5 | Which items are in scope? | If collection given → that collection's top-level items, else the whole library's top-level items (zot.top()); attachments/notes are never returned as publications |
Issue asks for "collection related details as parameter"; child items are attachments, not publications |
| D6 | ID preference order | DOI → PMID → PMCID → Zotero item key (as ID) |
Issue says "IDs (e.g. DOI)"; DOI/PMID/PMCID keep results interoperable with PMC/EuropePMC; the Zotero key is the always-available fallback |
| D7 | PMID/PMCID source in Zotero | Parsed from the free-text extra field (PMID: 123…, PMCID: PMC…), DOI from data["DOI"] |
Zotero has no dedicated PMID field; this is the convention Zotero's own importers write |
| D8 | Unsupported Query filters |
full_text_subset, only_preprint, exclude_preprint, licensing → NotImplementedError; date → client-side filter on the parsed publication date |
Mirrors PubMed.build_search_term raising for licensing (src/aoptk/literature/databases/pubmed.py:46). Zotero's Read API has no date/preprint/licence concepts. Alternative considered: raise for date too — rejected because we parse the date anyway for Metadata.year. |
| D9 | GetPublication full text |
Attachment full-text index via zot.fulltext_item(key); on local API (no documented per-item GET …/fulltext) or on ResourceNotFoundError, fall back to the attachment file + PyMuPDF text extraction |
Verified: pyzotero exposes fulltext_item; the local-API page documents only versioned bulk full-text reads, so a fallback is required. pymupdf is already a hard dependency. |
| D10 | Figures / tables in GetPublication |
Images attached to the item → list[Path] normalised to PNG via the existing convert_image_format; figure_descriptions=[], tables=[] |
Zotero has no JATS XML, so EuropePMC's XML parsing has no analogue; images cost nothing and feed the existing image pipeline |
| D11 | Search mode | Constructor flag full_text_search: bool = False → qmode="everything" instead of the default titleCreatorYear |
Only way to search inside the library's full text; kept explicit so default behaviour is the cheap one |
| D12 | Writes to the library | None. Read-only methods only | aoptk consumes literature; mutating a user's library is out of scope and dangerous |
3. Verified background (do not re-derive)
pyzoterolatest: 1.15.2 (uploaded 2026-09-18),requires-python >= 3.10(classifiers cover 3.11–3.14),
so it fits the project'srequires-python = ">=3.11". Transitive deps:feedparser,bibtexparser<2,
whenever,httpx2. Available on conda-forge (1.15.2); not on bioconda — another reason for D3.- Constructor (read from the 1.15.2 wheel):
Zotero(library_id=None, library_type=None, api_key=None, preserve_json_order=False, locale="en-US", local=False, client=None, upload_timeout=120, server_id=None, local_api_key=None).
local=Trueswitches the endpoint tohttp://127.0.0.1:23119/api; missing id/type raises
MissingCredentialsError;api_keyis optional for public libraries. - Methods used:
top(**kwargs),items(**kwargs),collection_items_top(collection, **kwargs),
children(itemkey),item(itemkey),fulltext_item(itemkey)(→{"content": …, "indexedPages": …}),
file(itemkey)(→ rawbytes),everything(query)for pagination.
dump()returnsNonein 1.15.2 and names the file after the attachment's own filename → we write
bytes ourselves so files land atstorage/{id}.pdf(same convention asEuropePMC._write_pdf). - Search kwargs:
qplusqmode∈ {titleCreatorYear(default),everything}. The@retrieve
decorator resetsurl_paramsafter every call, so search kwargs must be passed on every call. - Rate limiting is handled inside pyzotero: HTTP 429 records a server-supplied backoff and retries up to
MAX_RETRY_ATTEMPTS, then raisespyzotero.errors.TooManyRetriesError. We add no extra retry layer
(unlike EuropePMC'srequestsadapter), and no artificialsleep. - All pyzotero errors subclass
pyzotero.errors.PyZoteroError(includesResourceNotFoundError,
UserNotAuthorisedError,MissingCredentialsError,TooManyRetriesError). - Local API constraints (docs): Zotero must
be running with the local API enabled; reads are unauthenticated; only the locally logged-in user is
reachable (library_id→0), group ids return 400; no Atom; results are not paginated by default
(everything()still works — it stops when there is nonextlink);…/fileanswers with a 302 to a
file://URL, which pyzotero follows with its bundledfile://transport.
4. Design
4.1 Public surface (src/aoptk/literature/databases/zotero.py, new)
class Zotero(GetID, GetMetadata, GetAbstract, GetPDF, GetPublication):
"""Class to get data from a Zotero library (local desktop app or zotero.org) based on a query."""
def __init__( # noqa: PLR0913
self,
storage: Path,
figure_storage: Path,
query: Query | None = None,
*,
library_id: str | int | None = None,
library_type: Literal["user", "group"] = "user",
api_key: str | None = None,
collection: str | None = None,
local: bool = False,
full_text_search: bool = False,
) -> None: ...def __init__( # noqa: PLR0913— verified against the repo's ruff config that 8 arguments are flagged
even when keyword-only. Keeping the parameters flat is deliberate: the issue asks for the keys and
collection details "as parameter to the database". (Alternative the maintainer may prefer:
[tool.ruff.lint.per-file-ignores] "src/aoptk/literature/databases/zotero.py" = ["PLR0913"].)- Validation in
__init__:local=Falseandlibrary_id is None→ValueErrorwith an actionable message;
local=Trueandlibrary_type == "group"→ValueError(local API serves only the local user).
Withlocal=Trueandlibrary_id is None, default to0.api_keymay stayNone(public libraries). storage/figure_storagearemkdir(parents=True, exist_ok=True)d, and retrieved texts are written to
storage/{id}.txt, matchingEuropePMC/PMCso downstream examples work unchanged.build_search_term(query) -> tuple[str, dict](or a private_search_params): returns theqstring plus
the kwargs dict (qmode) and raisesNotImplementedErrorfor the four unsupported flags (D8). Named
build_search_termfor symmetry with the other databases;self.search_termholds theqstring so
tests/debug output look familiar.
4.2 Lazy dependency loading
def _build_client(library_id, library_type, api_key, local) -> "Zotero":
try:
from pyzotero import Zotero # noqa: PLC0415
except ImportError as error:
msg = "Zotero support requires the optional dependency pyzotero - install it with `pip install aoptk[zotero]`."
raise ImportError(msg) from error
return Zotero(library_id=library_id, library_type=library_type, api_key=api_key, local=local)- Import happens inside the function (verified: ruff accepts this with
# noqa: PLC0415); the module itself
must stay importable without pyzotero installed, otherwiseimport aoptkbreaks for everyone. - Exception types caught by retrieval loops: module-level
try: from pyzotero.errors import PyZoteroError; _ZOTERO_ERRORS = (PyZoteroError, OSError) / except ImportError: _ZOTERO_ERRORS = (OSError,).
Mirrors theexcept (HTTPError, MaxRetryError): continuecontract ofEuropePMC, and stays testable without
pyzotero installed (mocks raiseOSErrorsubclasses).
4.3 Interface → Zotero mapping
| Method | Zotero calls | Output |
|---|---|---|
get_ids() |
scoped items (top/collection_items_top with q, qmode) |
list[ID] per D6 |
get_publications_metadata(ids) |
same scoped items (cached) | Metadata(id, doi, pmid, pmcid, year, title, authors); authors = "Family I.I."/name strings from data["creators"], year = first 4 digits of data["date"] |
get_abstracts(ids) |
data["abstractNote"] |
Abstract(id, text), skip when empty (as EuropePMC skips missing abstracts); written to storage/{id}.txt |
get_pdfs(ids) |
children(key) → attachment with contentType == "application/pdf" (or .pdf filename) → file(key) bytes, or copy for linkMode == "linked_file" |
[PDF(storage / f"{id}.pdf")] |
get_publications(ids) |
metadata + abstractNote + full text (D9) + image attachments (D10) |
Publication(id, Abstract, full_text, tables=[], figures, figure_descriptions=[]); written to storage/{id}.txt |
Shared internals:
_get_scoped_items()— one fetch per instance, cached inself._items, filtered to top-level
(not data.get("parentItem"),itemTypenot in{"attachment", "note"}), then theQuery.date
client-side filter (D8).get_ids()and the lookups below all go through it, so a search-then-download run
makes exactly one item-list request._resolve(ids)— map requestedIDs to cached items; on miss, retry viazot.item(key)only if the ID
matches Zotero's 8-character key pattern; unresolvable IDs are skipped (same tolerance as the other databases)._get_attachment(item, predicate)— locate a child attachment by content type / filename.- Module-level helpers
_get_publication_id(item),_extract_identifier(extra, label),_get_year(date_str),
_get_authors(creators)— small pure functions, the primary unit-test targets (pattern:_get_publication_id
atsrc/aoptk/literature/databases/europepmc.py:450).
5. Implementation steps
Each step ends green on its own acceptance check.
- Dependency wiring —
pyproject.toml: addzotero = ["pyzotero>=1.15.2"]under
[project.optional-dependencies]; register the new pytest markerzotero_localin
markers. Runuv lock(CI runsuv lockthenuv sync --frozen, so a staleuv.lockfails the build).
Check:uv sync --frozen --extra dev --extra zotero && uv run python -c "import pyzotero". - Module skeleton + ID/metadata helpers — new
src/aoptk/literature/databases/zotero.pywith the class,
__init__validation,_build_client, and the pure helpers (ID preference order,extraparsing,
year, authors,build_search_term/NotImplementedErrorrules). No network code yet.
Check:uv run ruff check src/aoptk && uv run mypy src/aoptk --show-error-codes. GetID+GetMetadata— scoping, pagination viaeverything(), caching, date filter.
Check: mocked tests of step 7 pass.GetAbstract+GetPDF— abstracts fromabstractNote; PDF attachment download (stored + linked-file
copy) written tostorage/{id}.pdf, returningPDF.
Check: mocked tests pass;PymupdfParser(pdfs=…).get_publications()accepts the producedPDFobjects
(proves interchangeability with the existing pipeline).GetPublication— full text viafulltext_itemwith the PyMuPDF fallback (D9), image attachments
normalised viaconvert_image_format(..., "png"),storage/{id}.txtwritten.
Check: mocked tests pass;docsdoctest build unaffected.- Test fixtures + unit tests (see §6).
Check:uv run pytest tests/test_zotero_mocked.py -vpasses with pyzotero uninstalled as well as installed. - Integration tests + service gating (see §6).
Check:uv run pytest tests/test_zotero.py -v→ passes or reportsxfail/skipped, nevererror, in CI. - Examples —
examples/download_from_zotero.pyin the style of the existing scripts (env-driven:
ZOTERO_LIBRARY_ID,ZOTERO_LIBRARY_TYPE,ZOTERO_API_KEY,ZOTERO_COLLECTION), showing
get_ids()→get_publications(); a local-mode snippet in the module docstring.
Check:uv run python examples/download_from_zotero.py --help-style smoke (guard the network call behind
the same env-var checks the other examples rely on). - Documentation —
README.md: add Zotero to the databases sentence and an "Use your own Zotero library"
subsection withpip install aoptk[zotero], remote and local usage;.env.example: the four Zotero
variables;README.dev.md: how to run the gated tests (--zotero-local/ env vars, Zotero must be running).
Google-style docstrings on every public method sosphinx-autoapipicks them up.
Check:uv run make -C docs coverage doctest htmlanduv run cffconvert --validate(unchanged → still valid). - CI —
.github/workflows/build.yml: install with--extra zoteroin the test job so the real import
path is exercised; keep lint/typecheck jobs as they are (mypy already setsignore_missing_imports).
Check:uv run python -m pytest -m "not openai and not zotero_local" -qlocally reproduces the CI set. - Final verification (§7) and commit split:
feat: read literature from a Zotero library,test: …,
docs: …— logically separated per the project's principles.
6. Testing plan
pyzotero will not be installed on most developer machines and never on a CI runner that only installs
--extra dev, so the load-bearing tests must be mocked and pyzotero-free.
tests/test_zotero_mocked.py (new, always runs). A FakeZoteroClient (plain Python object returning the
dicts from the fixture) injected by monkeypatching aoptk.literature.databases.zotero._build_client. Cases:
test_can_create,test_implements_interface(all five ABCs — the convention in everytests/test_*.py).- Constructor validation: remote without
library_id→ValueError;local=True+library_type="group"→
ValueError;local=Truedefaultslibrary_idto0. - Missing dependency path: monkeypatch
builtins.__import__sofrom pyzotero import Zoteroraises
ImportError, assert our actionable message. build_search_term: plain term;full_text_search=True→qmode="everything"; each unsupported flag raises
NotImplementedError.get_idsID preference: DOI only / DOI+PMID / PMCID only / none → Zotero key; children filtered out.get_publications_metadata: DOI/PMID/PMCID parsed fromextra,yearfrom"2025-03-14","March 2025",
""; authors fromlastName+firstName, and single-fieldnamecreators.get_abstracts: abstract written tostorage/{id}.txt; item withoutabstractNoteskipped.get_pdfs: stored-file attachment written tostorage/{id}.pdf;linked_filecopied; item without a PDF
attachment skipped.get_publications:fulltext_itemcontent used; onOSError-based failure the PyMuPDF fallback is used;
image attachments converted to PNG.- Pagination: fake client with
links["next"]→ all pages collected. - Tolerance: a client raising
OSError(and, when importable,pyzotero.errors.PyZoteroErrorguarded by
pytest.importorskip("pyzotero")) → that ID skipped, the rest returned; andget_ids()re-raising a hard
auth error rather than silently returning[].
tests/test_zotero.py (new, gated integration). Real library, following
tests/service_availability.py's xfail-on-outage convention:
require_zotero_service()— probehttps://api.zotero.org; module-level autouse fixture like
test_aop_wiki.py's; additionallypytest.skipunlessZOTERO_LIBRARY_ID+ZOTERO_API_KEYare set
(secrets are not available in forks' CI, and an unauthenticated probe of a private library proves nothing).pytest.mark.zotero_local+require_zotero_local_service()probinghttp://127.0.0.1:23119/api/users/0/items,
enabled only whenAOPKT_TEST_ZOTERO_LOCAL=1; assertsget_ids()→get_publications()on the user's own library.- Add the probes/helpers to
tests/service_availability.pynext to the existing*_endpointstuples, and
registerzotero_localin[tool.pytest.ini_options].markers.
Fixture data. tests/test_data/zotero_items.json — a hand-written item list modelled on
tests/test_data/europepmc_metadata.json, carrying the real identifiers of the articles already used in the
suite (DOI 10.1002/anie.202513902, PMID 40785269, PMCID PMC12416454, …) so expectations stay honest and
cross-checkable against the live Europe PMC/PMC tests. Never invent identifiers.
7. Verification checklist
uv lock && uv sync --frozen --extra dev --extra zotero
uv run python -m pytest -m "not openai" -v # full suite, Zotero integration xfail/skip
uv run python -m pytest tests/test_zotero_mocked.py -v
uv run ruff check && uv run ruff format --check
uv run mypy src/aoptk tests --show-error-codes --pretty
uv run make -C docs coverage doctest html
uv run python -m buildPlus, with pyzotero not installed (uv sync --frozen --extra dev): uv run python -c "import aoptk.literature.databases.zotero"
must succeed and instantiating Zotero(...) must raise the actionable ImportError.
Issue completion criteria: requirements satisfied (search → IDs → items → full text), tests pass,
documentation updated, remaining limitations documented (see §9).
8. Risks & mitigations
| Risk | Mitigation |
|---|---|
Local API has no documented per-item GET …/fulltext |
Implement fulltext_item first, fall back to attachment bytes + PyMuPDF (D9); both branches tested; state which branch ran in the method docstring |
pyzotero 1.x churned in 2025–2026 (module layout moved to src/pyzotero, dump() return type changed, exceptions renamed) |
Pin pyzotero>=1.15.2; touch only the stable read API (top, items, collection_items_top, children, item, file, fulltext_item, everything); catch the PyZoteroError base class, not specific subclasses |
| Local API cannot be tested in CI | Gated zotero_local marker + probe → xfail, mirroring the existing service-availability design; mocked tests carry the coverage |
Optional dep invisible to bioconda users (pyzotero is conda-forge only) |
Documented in README: conda users add -c conda-forge pyzotero; base install unaffected |
Large libraries make everything() slow |
Single cached item fetch per instance (D5/_get_scoped_items); collection parameter narrows scope; qmode defaults to the cheap title/creator search |
Query filters silently ignored |
Every unsupported filter raises NotImplementedError (D8); date filtering documented in the docstring |
| Writing into the user's library by accident | Only Read-API calls are used; no write/upload/delete calls in the module |
9. Known limitations to document (not to fix here)
- Zotero is a bibliography database: there is no full-text XML, so
Publication.tablesand
figure_descriptionsstay empty; figure text is only available via the PDF path (PymupdfParser). - No licence / preprint / open-access filters (issue's
Queryfields are silently meaningless for Zotero). - Local API: Zotero must be running with the local API enabled; only the logged-in user's library (no groups).
api_keyneeds "library access" permission; read-only keys are recommended in the docs.- Group libraries work only against the remote API.
10. Explicitly out of scope (candidate follow-up issues)
- Writing/importing into Zotero (
create_items,attachment_both,authorize_local) — needs local write
consent flows. - Syncing Zotero attachments into
figure_storagerecursively, and sub-collection traversal
(all_collections) when a user wants a collection tree. - Mapping Zotero saved searches to
Query(both APIs expose them). - Regenerating the untracked
aoptk_literature.pumlarchitecture diagrams (not in git today — mention in the
PR whether the maintainer wants them refreshed).
11. Open questions for the user
- Naming:
Zotero(matches thePMC/PubMed/EuropePMC/AOPWikistyle) shadowspyzotero.Zoteroin
from pyzotero import Zotero— acceptable since our import is function-local? (Plan assumes yes.) - Should
full_text_searchdefault toTrue?everythingis a heavier server-side query; the plan defaults
it toFalse(title/creator/year), mirroring the conservative defaults elsewhere. - Is the client-side
Query.datefilter (D8) acceptable, or should it raiseNotImplementedErrorlike the
other four flags?
Activity