"""Storage protocol — what every backend must implement.""" from __future__ import annotations from pathlib import Path from typing import Protocol class Storage(Protocol): """Backend-agnostic PDF store keyed by book_id. All methods raise on hard failures (missing creds, network, etc.) — the caller decides whether to retry. Callers should never construct paths themselves; route everything through this interface. """ name: str def ensure_local(self, book_id: str) -> Path: """Return a local Path the caller can hand to PyMuPDF / open(). Local backend: returns the canonical raw_dir path; raises if the file is missing. R2 backend: checks the local cache; if missing, downloads from R2 first, then returns the cached path. Subsequent calls are free. """ ... def put_pdf(self, book_id: str, src: bytes | Path) -> str: """Store a PDF. `src` may be raw bytes (from a file_uploader) or a local Path. Returns a URI describing where it landed (`file://...` or `r2://bucket/key`).""" ... def exists(self, book_id: str) -> bool: """Cheap existence check at the backend (no download).""" ... def delete(self, book_id: str) -> None: """Remove the PDF from storage. No-op if absent.""" ... def list_book_ids(self) -> list[str]: """Every book_id that has a stored PDF — used by the migration tool to verify the source/destination inventories agree.""" ... def ensure_text_local(self, book_id: str) -> None: """Materialise the per-page OCR/clean JSON for ``book_id`` onto the local filesystem (``cfg.paths.ocr_dir`` / ``clean_dir``). Local backend: no-op — the JSON is already canonical on disk. R2 backend: downloads the per-book text bundle(s) and extracts the ``page_NNNN.json`` files so the disk-reading code (page endpoints, PDF export builder) works unchanged on an ephemeral cloud disk. Idempotent and cheap on repeat calls. A book with no text bundle (extraction never run, or no clean stage) is left untouched. """ ... def put_text(self, book_id: str) -> list[str]: """Push the local OCR/clean per-page JSON for ``book_id`` to storage. Local backend: no-op (disk is canonical) — returns ``[]``. R2 backend: zips ``ocr_dir/{book_id}`` and ``clean_dir/{book_id}`` into one object each and uploads them, returning the storage URIs. Mirrors :meth:`put_pdf` so promoting a book to the cloud carries its text layer, not just the PDF. """ ...