Developer guide¶
For people changing Paperful. Setup, tests, and pull requests are in CONTRIBUTING. The design reference is architecture. This page is the rules a change has to keep, and where things go.
The rule: mirror first¶
Reference managers are flaky. Paperful copies the user’s files and metadata
into its own mirror (out/, state/) and works from that copy. The manager
API is a narrow door with three uses:
Refresh the mirror. One listing of what changed.
Write back when asked. Attach,
--apply, a note. Always explicit.Check the door.
ping, write support, authorize.
Anything else is a call Paperful should not make. Before adding a manager call, ask which of the three it is. If it is none, read the mirror.
What a command gets¶
cli._connect(cfg) is how a command opens the library. It returns a
MirrorFirstBackend (catalogue.py):
_connect
├─ manager answers → run_sync (what changed) → MirrorFirstBackend(catalogue, live)
├─ manager down, mirror on disk → MirrorFirstBackend(catalogue, None)
└─ manager down, no mirror → exit 2 with next steps
Call on the backend |
Served by |
|---|---|
|
|
|
|
|
|
|
|
|
the item folder; the manager only when the folder has no PDF |
|
the manager, then that item is re-read into its folder. |
With the manager down, the write calls raise LibraryError and
supports_write() is false. Check it before a batch and exit 2 with
_no_write(backend).
Use cli._live_backend(cfg) only when comparing the manager with the mirror
is the command’s job. Today that is sync, snapshot, restore, and
attachments.
Rules for new code¶
A verb that only reads never needs the manager running. Add it to
READ_VERBSintests/test_mirror_first.py; the test runs it with the manager closed.A verb that writes goes through the backend it was given. The write-through is in
MirroredBackend(library.py). A new write method on an adapter needs a matching method there, or the mirror goes stale until the next refresh.Could not read is never “nothing there”. Adapter reads raise
LibraryReadError. A missing key isNone. Do not catch a read error and carry on with an empty list: skip the item, count it, and say so.pyzotero and the raw client stay in
zot.py,library.py, andattach.py. A test enforces it. Nobackend.zlin command code.Whole-library reads happen in
sync.pyand nowhere else. If a feature seems to need a library listing, it needsbackend.items_in_scope(None), which is the mirror.Nothing under
out/is deleted. An item that left is marked, or moved toout/_trash/. A folder the item no longer belongs in is folded into one it does (mirror.fold_folder).Bytes from the manager go into the item folder, not a side cache (
state/pdf-cache/is only for[mirror].pdfs = "none").Sources never write
manifestorout/. The pipeline does.--applyis explicit and dry-run is the default on every verb that writes to the manager.
Module map¶
Module |
Job |
May import |
|---|---|---|
|
Zotero client, |
|
|
Authorize and upload |
|
|
|
|
|
Other adapters (seeking testers) |
|
|
Manifest, filenames, record IO |
|
|
Folders by key, records → |
|
|
Write one item’s folders from payloads ( |
|
|
The refresh: delta or full, version last |
|
|
|
|
|
Collection, year, type selection |
|
|
Identifier, hygiene, and corpus-frequency logic. Take a backend; never name a manager |
the protocol |
|
Nested TOML sections applied onto |
|
|
One fetch lane each. Return bytes or a miss |
|
|
Order the lanes, save, attach, write the manifest |
most things |
|
Save a fetched PDF, write the manifest, attach when allowed |
|
|
OA parallel and serial fetch phases |
|
|
EZProxy recovery, handoff, dry-run rows for |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Typer wrappers via |
|
Flags, progress, exits. No logic of its own |
everything |
|
|
none |
|
Shared builders for CLI JSON and MCP ( |
|
|
Mirror-first interchange records for a scoped library export. Agent/MCP |
|
|
BibTeX/RIS from snowball / authorwatch proposal JSONL on disk |
|
|
First-line prefixes + |
none |
|
Classify and trash Paperful-owned notes |
|
|
Cited-in-PDF works missing from the library fingerprint |
|
|
Briefing/note/file DOI list vs |
|
|
Optional |
|
|
Missing-PDF sort: refs-gap cites × miss severity |
|
|
Contact-only missing-PDF rows (metadata / Twenty emails, CSV). No fetch |
|
|
Twenty People lookup (local cache) and |
httpx |
|
Optional stdio MCP: read-only gaps, snowball preview / trends, export, proposal_export, refs_gap, ask |
|
|
Localhost FastAPI: JSON capability API + mounts |
|
|
Server-rendered workbench (Jinja). |
CLI / MCP builders |
|
People lists → OpenAlex new works; |
OpenAlex client, |
|
Corpus / cited / coauthor / mix suggestions → |
|
|
Parse operator-saved RG / LinkedIn / Academia HTML or CSV (no network) |
stdlib HTML/CSV |
|
Crawl, hops, watch, thin briefing, frontier digest, |
OpenAlex; library protocol only on apply |
The refresh¶
run_sync (sync.py):
backend.changes(since)— rows changed after the stored library version (all rows on a first or--fullrefresh), the keys now in the library, the trash, the collections. Zotero’s local API leaves trashed items out of listings and has no/deleted; a removal is a key that stopped being listed.Work out which parents are affected: changed themselves, a child changed, or a child key the index knew is gone.
Rewrite each one with
snapshot.write_item(exact=True: the item’s whole collection membership is known, so folders move and fold).Mark or move parents that left (
mirror.retire). Refused when more than half the mirror would go: that is a different library.Write
out/_sync.json. Last. If any item could not be read, the version does not move and the next refresh covers the same ground.With
[mirror].pdfs = "all", copy PDFs (copy_pdfs). Whole library until it has completed once, then changed items only.
Every step can be repeated. A crash leaves valid records and an old version.
Disk schemas¶
Tier policy (1.0-ready contract; package tag may still wait on workbench polish):
Tier |
Policy |
|---|---|
T0 Trust |
Required keys frozen in code ( |
T1 Agent packs |
Same for top-level keys used by scripts |
T2 Additive |
Schema string stable; keys may grow; no frozenset required |
T3 Cache |
Layout may change any release; rebuild with |
Schema |
Location |
Writer |
Tier |
Frozenset / golden |
|---|---|---|---|---|
|
|
|
T0 |
|
|
|
|
T0 |
|
|
stdout |
|
T0 |
|
|
HTML comment in child notes |
|
T0 |
|
|
|
|
T1 |
|
|
|
|
T1 |
|
|
optional |
|
T2 |
additive; not in |
|
|
|
T1 |
|
|
|
|
T2 |
— |
|
|
|
T2 |
— |
|
|
|
T2 |
— |
|
item history sidecar |
|
T2 |
— |
|
standalone catalogue |
|
T2 |
— |
|
|
|
T2 |
— |
|
|
|
T2 |
— |
|
|
|
T2 |
— |
|
|
|
T2 |
— |
|
|
|
T2 |
— |
|
|
|
T2 |
— |
|
|
|
T2 |
— |
|
|
|
T2 |
— |
|
|
|
T2 |
— |
|
reachout / request ledger |
|
T2 |
— |
|
|
|
T2 |
— |
|
|
|
T2 |
— |
|
|
|
T2 |
— |
|
|
|
T2 |
Additive through 1.x |
|
snowball watch cursor |
|
T2 |
— |
|
co-author graph |
|
T2 |
— |
|
|
|
T2 |
— |
|
synthesize sidecar |
|
T2 |
— |
|
|
|
T2 |
— |
|
|
|
T2 |
— |
|
|
|
T2 |
— |
|
|
|
T2 |
— |
|
extracted text cache |
|
T3 |
Rebuildable |
|
LanceDB meta |
|
T3 |
Not a promise |
|
e2e stack reports |
|
T2 |
Internal / CI |
Consumers: recover --from-last-run, GUI Activity, and agent_ops read paperful.run_report.v1 (state/last-run.json). restore requires paperful.item.v1. Batch verbs and optional paperful mcp emit paperful.agent.json.v1.
The catalogue rebuilds an Item from a record. The test
test_item_from_record_matches_the_live_listing holds the two equal field
for field. A new Item field needs a home in the record and a case there.
Contract tests: tests/test_schema_freeze.py.
Adding things¶
A read verb. backend = _connect(cfg), _load_scope(backend, …), do
the work, write a run report. Add it to READ_VERBS. Disk-only verbs such as
authorwatch save still belong there so a closed manager is not an accident.
A write verb. Same, dry-run by default. Before applying:
if not backend.supports_write(): _exit_env(_no_write(backend), cfg).
A source. Subclass in paperful/sources/, register it, return a
PageResult. It gets a Context; it does not get the manifest or out/.
An adapter method. Ask first whether the mirror can serve it. If it is a
read, add it to MirrorCatalogue. If it is a write, add it to the adapter,
to MirroredBackend (refresh the item after), and to MirrorFirstBackend
(pass through, refuse offline).
A manager. Implement LibraryBackend. With a changes(since) method it
gets the mirror-first path for free. Zotero, Mendeley, and EndNote all
expose one. Without one, reads stay live.
Tests¶
uv run pytest runs offline. tests/conftest.py refuses every request a
real Zotero client makes, so a Zotero running on your machine is never read
by a test.
Fake |
Use |
|---|---|
|
A library with versions, a trash, and call counts. For the refresh and the catalogue |
|
Mixin for a stub client that lists |
|
A manager with write calls. For |
Count requests when the point of a change is fewer of them: FakeZotero.calls.
CI and local pitfalls¶
GitHub Actions (.github/workflows/ci.yml) runs uv sync --group dev --extra serve and uv run pytest on Ubuntu. A second job builds the Compose image and
smokes paperful doctor; it can pass while pytest fails, so check the test
job when CI is red.
CLI help assertions. On CI, CI / GITHUB_ACTIONS is set and Typer/Rich
style option names with ANSI codes. A flag like --apply is often split across
escape sequences, so assert "--apply" in result.stdout fails even though help
is correct. Strip SGR codes with tests.textutil.plain_text before matching
tokens (see the comment in that module). For table layout, several CLI test
modules widen the shared cli.console (width=250) so Rich does not ellipsize
cells — follow tests/test_cli.py when adding help or table assertions.
Dev sync and LanceDB. uv sync --group dev pulls lancedb for index/RAG
tests. PyPI wheels cover Linux x86_64/arm64, Windows, and macOS arm64; there
is no wheel for every macOS x86_64 / OS combo. If sync fails with “no wheel for
the current platform”, use native arm64 Python on Apple Silicon, run pytest
inside the Linux dev container / CI image, or temporarily sync without the dev
group only when you are not touching RAG tests.
Pytest inside Compose. The runtime image is for operators (paperful …), not
the full dev test suite. Reproduce CI with host uv run pytest or a
python:3.12-slim container plus uv sync --group dev --extra serve. Do not
expect host-only tests (for example default Zotero endpoint URLs) to pass when
PAPERFUL_ZOTERO_HOST is set for container → host networking.
Docs CI. Pushes that change docs/ or website/ also run strict Sphinx
(DOCS_STRICT=1) and may deploy Pages; new guide pages must be linked from
docs/index.md (tests/test_sphinx_docs.py).