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.
96 lines
2.6 KiB
Python
96 lines
2.6 KiB
Python
"""stack zot fetch — the open-access CLI (no network, no docker)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sqlite3
|
|
|
|
import pytest
|
|
from typer.testing import CliRunner
|
|
|
|
import cli.zot as zot_cli
|
|
import zot.fetch as fetch_pkg
|
|
from cli import app
|
|
from tests.zot.fetch.conftest import mk_item
|
|
|
|
runner = CliRunner()
|
|
|
|
|
|
@pytest.fixture
|
|
def held(monkeypatch):
|
|
calls = []
|
|
|
|
def fake_hold(fn, hold):
|
|
calls.append(hold)
|
|
return fn()
|
|
|
|
monkeypatch.setattr(zot_cli, "_hold_zotero", fake_hold)
|
|
return calls
|
|
|
|
|
|
def _db_path(zdb) -> str:
|
|
return zdb.con.execute("PRAGMA database_list").fetchone()[2]
|
|
|
|
|
|
def test_dry_run_reads_only_and_never_stops_zotero(zdb, held, monkeypatch, tmp_path):
|
|
mk_item(zdb, title="Open", doi="10.1/o")
|
|
zdb.commit()
|
|
seen = {}
|
|
|
|
def fake_run(db, refs, **kw):
|
|
seen.update(refs=list(refs), **kw)
|
|
return {"seen": 1}
|
|
|
|
monkeypatch.setattr(fetch_pkg, "run", fake_run)
|
|
r = runner.invoke(
|
|
app,
|
|
[
|
|
"zot",
|
|
"fetch",
|
|
"--all",
|
|
"--dry-run",
|
|
"--db",
|
|
_db_path(zdb),
|
|
"--ledger",
|
|
str(tmp_path / "l.jsonl"),
|
|
],
|
|
)
|
|
assert r.exit_code == 0, r.output
|
|
assert held == [False]
|
|
assert "1 candidate item(s)" in r.output and "fetch: seen=1" in r.output
|
|
assert seen["dry_run"] is True and len(seen["refs"]) == 1
|
|
|
|
|
|
def test_real_run_holds_zotero(zdb, held, monkeypatch, tmp_path):
|
|
monkeypatch.setattr(fetch_pkg, "run", lambda db, refs, **kw: {"attempted": 0})
|
|
r = runner.invoke(
|
|
app, ["zot", "fetch", "--type", "journalArticle", "--db", _db_path(zdb)]
|
|
)
|
|
assert r.exit_code == 0, r.output
|
|
assert held == [True]
|
|
|
|
|
|
def test_bad_population_is_a_usage_error(zdb, held):
|
|
r = runner.invoke(app, ["zot", "fetch", "--db", _db_path(zdb)])
|
|
assert r.exit_code == 2 and "exactly one of" in r.output
|
|
|
|
|
|
def test_locked_database_names_the_desktop(zdb, held, monkeypatch):
|
|
def locked(db, refs, **kw):
|
|
raise sqlite3.OperationalError("database is locked")
|
|
|
|
monkeypatch.setattr(fetch_pkg, "run", locked)
|
|
r = runner.invoke(
|
|
app, ["zot", "fetch", "--all", "--no-hold", "--db", _db_path(zdb)]
|
|
)
|
|
assert r.exit_code == 2
|
|
assert "Zotero desktop" in r.output and held == [False]
|
|
|
|
|
|
def test_other_sqlite_errors_are_not_disguised(zdb, held, monkeypatch):
|
|
def broken(db, refs, **kw):
|
|
raise sqlite3.OperationalError("no such table: items")
|
|
|
|
monkeypatch.setattr(fetch_pkg, "run", broken)
|
|
r = runner.invoke(app, ["zot", "fetch", "--all", "--db", _db_path(zdb)])
|
|
assert r.exit_code == 1 and isinstance(r.exception, sqlite3.OperationalError)
|