Files
stack/tests/zot/fetch/test_run.py
kert bee5f57893 feat(zot): stack zot fetch — open-access full-text connector
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.
2026-10-27 14:20:05 -04:00

145 lines
4.9 KiB
Python

"""zot.fetch.run — the per-item loop (scratch Zotero DB, no network)."""
from __future__ import annotations
import shutil
import sqlite3
from pathlib import Path
import pytest
from tests.zot.fetch.conftest import mk_item
from zot.db import Db
from zot.fetch.ledger import Ledger
from zot.fetch.run import run, safe_name
from zot.fetch.select import item_ref
PDF = b"%PDF-1.7\n" + b"x" * 2048
def _copier(src: Path, dest_dir: Path) -> None:
dest_dir.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dest_dir / src.name)
def _finder_writing(tmp_path: Path, source: str = "unpaywall"):
def finder(client, ref, *, email, scratch):
p = scratch / f"{ref.key}.pdf"
p.write_bytes(PDF)
return source, p
return finder
def _go(zdb: Db, tmp_path: Path, refs, **kw):
lines: list[str] = []
kw.setdefault("copier", _copier)
stats = run(
zdb,
refs,
ledger=kw.pop("ledger", Ledger(tmp_path / "ledger.jsonl")),
client=None,
email="e@x",
storage_dir=tmp_path / "storage",
scratch_dir=tmp_path / "scratch",
out=lines.append,
**kw,
)
return stats, lines
def test_attaches_an_open_access_pdf_and_ledgers_it(zdb: Db, tmp_path: Path):
item_id = mk_item(zdb, title="Risk adjustment: a review", doi="10.1/a")
ref = item_ref(zdb, item_id)
stats, lines = _go(zdb, tmp_path, [ref], finder=_finder_writing(tmp_path, "pmc"))
assert stats["attached"] == 1 and stats["attempted"] == 1
row = zdb.con.execute(
"SELECT contentType, path FROM itemAttachments WHERE parentItemID = ?",
(item_id,),
).fetchone()
assert tuple(row) == ("application/pdf", "storage:Risk adjustment a review.pdf")
assert "source:pmc" in zdb.get_tags(item_id)
assert list((tmp_path / "storage").rglob("*.pdf")) # copied into storage
assert not list((tmp_path / "scratch").iterdir()) # scratch cleaned up
assert "attached pmc" in lines[-1]
led = Ledger(tmp_path / "ledger.jsonl")
assert not led.should_try(ref.key) # attached items are never retried
def test_no_open_access_copy_is_a_terminal_miss(zdb: Db, tmp_path: Path):
ref = item_ref(zdb, mk_item(zdb, title="Paywalled", doi="10.1/p"))
stats, lines = _go(zdb, tmp_path, [ref], finder=lambda c, r, **k: None)
assert stats["no_candidate"] == 1
assert "no open-access copy" in lines[-1]
assert not Ledger(tmp_path / "ledger.jsonl").should_try(ref.key)
def test_a_failing_item_is_rolled_back_and_the_run_goes_on(zdb: Db, tmp_path: Path):
bad = item_ref(zdb, mk_item(zdb, title="Bad", doi="10.1/bad"))
good = item_ref(zdb, mk_item(zdb, title="Good", doi="10.1/good"))
finder_ok = _finder_writing(tmp_path)
def finder(client, ref, **kw):
if ref.key == bad.key:
raise RuntimeError("cdn exploded")
return finder_ok(client, ref, **kw)
stats, lines = _go(zdb, tmp_path, [bad, good], finder=finder)
assert stats["error"] == 1 and stats["attached"] == 1
assert any("error RuntimeError: cdn exploded" in ln for ln in lines)
def test_a_locked_database_aborts_the_run(zdb: Db, tmp_path: Path):
ref = item_ref(zdb, mk_item(zdb, title="X", doi="10.1/x"))
def finder(client, ref, **kw):
raise sqlite3.OperationalError("database is locked")
with pytest.raises(sqlite3.OperationalError):
_go(zdb, tmp_path, [ref], finder=finder)
def test_ledger_skips_and_limit_stops_a_lazy_population(zdb: Db, tmp_path: Path):
ids = [mk_item(zdb, title=f"p{n}", doi=f"10.1/{n}") for n in range(5)]
led = Ledger(tmp_path / "ledger.jsonl")
led.record(item_ref(zdb, ids[0]).key, outcome="attached")
pulled = []
def lazy():
for i in ids:
pulled.append(i)
yield item_ref(zdb, i)
stats, _ = _go(
zdb, tmp_path, lazy(), ledger=led, limit=2, finder=lambda c, r, **k: None
)
assert stats["skipped"] == 1 and stats["attempted"] == 2
assert len(pulled) == 4 # one skipped, two tried, one pulled to see the limit
def test_dry_run_previews_and_writes_nothing(zdb: Db, tmp_path: Path):
a = item_ref(zdb, mk_item(zdb, title="Open", doi="10.1/o"))
b = item_ref(zdb, mk_item(zdb, title="Closed"))
def previewer(client, ref, *, email):
return (ref.doi, ["https://oa.example/o.pdf"]) if ref.doi else ("", [])
stats, lines = _go(
zdb,
tmp_path,
[a, b],
dry_run=True,
previewer=previewer,
finder=lambda *a, **k: pytest.fail("dry run must not download"),
)
assert stats["attempted"] == 2 and stats["attached"] == 0
assert " unpaywall https://oa.example/o.pdf" in lines
assert any("doi=-" in ln for ln in lines)
assert any("a real run also tries S2 and PMC" in ln for ln in lines)
assert not (tmp_path / "ledger.jsonl").exists()
def test_safe_name_falls_back_to_the_key():
assert safe_name("A/B: c?", "KEY") == "A B c"
assert safe_name("///", "KEY") == "KEY"