Attaches an open-access PDF to Zotero items that lack a full text. Built on the selection, ledger and attach machinery from PR #826 (feat/zot-fetch-z1, left untouched on its branch); delivery is the stack's legal open-access cascade only — every Unpaywall location → Semantic Scholar openAccessPdf → PMC S3 → PMC OA service → Europe PMC (prisma.fetch.fetch_one with no proxied client, so its mirror tier never runs). No DOI? a Crossref title match first. Downloads must be real PDFs before they are attached; the run ledgers every attempt (attached / no_candidate / error; misses wait 30 days unless --retry). From the #828 review list: - Lazy selection: select_ids() is one cheap query (deleted and full-text items excluded in SQL for --all) and iter_refs() reads each item only when the run reaches it, so --limit bites before ~167k file-less items are materialised while the desktop is held down. - A locked database (--no-hold with the desktop writing) aborts the run with a usage error that names the Zotero desktop, instead of a raw sqlite3.OperationalError — or one error per item. - --dry-run reads only (checked against the running desktop), so it no longer stops/starts zotero; UNPAYWALL_EMAIL is documented in --help. Tests: 66 (scratch Zotero DB, no network), 100% of src/zot/fetch.
74 lines
2.6 KiB
Python
74 lines
2.6 KiB
Python
"""zot.fetch.oa — the open-access cascade wrapper (no network)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
import zot.fetch.oa as oa
|
|
from zot.fetch.select import ItemRef
|
|
|
|
|
|
def _ref(doi: str = "10.1/x", title: str = "A paper") -> ItemRef:
|
|
return ItemRef(
|
|
item_id=7,
|
|
key="ABCD1234",
|
|
item_type="journalArticle",
|
|
title=title,
|
|
doi=doi,
|
|
isbn="",
|
|
year="2024",
|
|
)
|
|
|
|
|
|
def test_make_client_identifies_itself_and_follows_redirects():
|
|
with oa.make_client("me@example.org") as c:
|
|
assert c.headers["User-Agent"] == "stack-zot-fetch/1 (mailto:me@example.org)"
|
|
assert c.follow_redirects is True
|
|
|
|
|
|
def test_find_runs_the_cascade_without_a_proxied_client(monkeypatch, tmp_path):
|
|
seen = {}
|
|
|
|
def fake_fetch_one(item, **kw):
|
|
seen.update(item=item, kw=kw)
|
|
return "pmc", tmp_path / "x.pdf"
|
|
|
|
monkeypatch.setattr(oa, "fetch_one", fake_fetch_one)
|
|
out = oa.find("CLIENT", _ref(), email="e@x", scratch=tmp_path)
|
|
assert out == ("pmc", tmp_path / "x.pdf")
|
|
assert seen["item"].doi == "10.1/x" and seen["item"].zot_id == 7
|
|
assert "client_proxied" not in seen["kw"] # the mirror tier never runs
|
|
assert seen["kw"]["client"] == "CLIENT" and seen["kw"]["scratch"] == tmp_path
|
|
|
|
|
|
def test_preview_uses_the_items_doi(monkeypatch):
|
|
monkeypatch.setattr(
|
|
oa, "unpaywall_urls", lambda c, doi, email: [f"https://oa/{doi}"]
|
|
)
|
|
monkeypatch.setattr(oa, "resolve_doi_from_title", lambda c, t: pytest_fail())
|
|
assert oa.preview(None, _ref(), email="e") == ("10.1/x", ["https://oa/10.1/x"])
|
|
|
|
|
|
def test_preview_resolves_a_missing_doi_from_the_title(monkeypatch):
|
|
monkeypatch.setattr(oa, "resolve_doi_from_title", lambda c, t: "10.9/found")
|
|
monkeypatch.setattr(oa, "unpaywall_urls", lambda c, doi, email: [])
|
|
assert oa.preview(None, _ref(doi=""), email="e") == ("10.9/found", [])
|
|
|
|
|
|
def test_preview_without_doi_or_match_tries_nothing(monkeypatch):
|
|
monkeypatch.setattr(oa, "resolve_doi_from_title", lambda c, t: None)
|
|
called = []
|
|
monkeypatch.setattr(oa, "unpaywall_urls", lambda *a: called.append(a) or [])
|
|
assert oa.preview(None, _ref(doi=""), email="e") == ("", [])
|
|
assert oa.preview(None, _ref(doi="", title=""), email="e") == ("", [])
|
|
assert called == []
|
|
|
|
|
|
def pytest_fail(): # pragma: no cover — only runs if preview misbehaves
|
|
raise AssertionError("title lookup must not run when the item has a DOI")
|
|
|
|
|
|
def test_find_returns_none_when_nothing_is_open(monkeypatch, tmp_path: Path):
|
|
monkeypatch.setattr(oa, "fetch_one", lambda item, **kw: None)
|
|
assert oa.find(None, _ref(), email="e", scratch=tmp_path) is None
|