patristic-be / src /lib /storage /migrate.py
Mario33333's picture
deploy: reader-access security hardening (feature/audiobook@06a5ed8)
7c2d250 verified
Raw
History Blame Contribute Delete
5.79 kB
"""One-shot migration: copy PDFs from one storage backend to another.
Designed for the local -> r2 case but works in both directions. Reads
from the source backend, writes to the destination backend, and
optionally rewrites books.local_path so subsequent code paths know the
file is now hosted somewhere different.
Usage:
python -m src.lib.storage.migrate # local -> r2 (dry-run)
python -m src.lib.storage.migrate --apply # actually copy
python -m src.lib.storage.migrate --apply --update-paths
Idempotent: skips any book that already exists at the destination
(checked via Storage.exists). Re-run after a partial failure to finish.
"""
from __future__ import annotations
import argparse
import sys
from src.stage1_inventory.db import connect
from .factory import _resolve_backend_name # noqa: PLC2701 — internal but stable
from .local import LocalStorage
from .r2 import R2Storage
def _make(name: str):
if name == "local":
return LocalStorage()
if name == "r2":
return R2Storage()
raise ValueError(f"unknown backend {name!r}")
def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser(description="Copy PDFs between storage backends.")
p.add_argument("--from", dest="src", default="local", choices=("local", "r2"))
p.add_argument("--to", dest="dst", default="r2", choices=("local", "r2"))
p.add_argument("--apply", action="store_true",
help="Actually copy. Without it, prints the plan only.")
p.add_argument("--update-paths", action="store_true",
help="Rewrite books.local_path to the destination URI.")
p.add_argument("--book-id", default=None,
help="Limit to one book; otherwise iterates every row in books.")
args = p.parse_args(argv)
if args.src == args.dst:
print(f"src == dst ({args.src}); nothing to do.", file=sys.stderr)
return 1
src = _make(args.src)
# Defer destination construction — dry-run shouldn't need R2 creds just
# to print a plan. Built lazily on first use below.
dst = None
# Pull the inventory list (authoritative source of book_ids).
sql = "SELECT book_id, local_path FROM books"
sql_args: list = []
if args.book_id:
sql += " WHERE book_id = ?"
sql_args.append(args.book_id)
sql += " ORDER BY book_id"
with connect() as conn:
rows = conn.execute(sql, sql_args).fetchall()
if not rows:
print("No books in inventory.")
return 0
print(f"{args.src} -> {args.dst}: {len(rows)} book(s) in inventory")
print(f"backends.storage active = {_resolve_backend_name()!r}")
print(f"--apply = {args.apply}")
print(f"--update-paths = {args.update_paths}")
print()
copied = 0
skipped = 0
missing = 0
text_synced = 0
failed = 0
for r in rows:
bid = r["book_id"]
has_src_pdf = src.exists(bid)
# Dry run: never construct the destination, so no creds needed.
if not args.apply:
verb = "would copy PDF" if has_src_pdf else "no source PDF (text only)"
print(f" [plan] {bid}: {verb} {args.src} -> {args.dst}")
continue
if dst is None:
dst = _make(args.dst)
try:
# --- PDF copy (only if the source actually has it) ---
if not has_src_pdf:
missing += 1
print(f" [skip-pdf] {bid}: no PDF in {args.src}")
elif dst.exists(bid):
skipped += 1
print(f" [done] {bid}: PDF already in {args.dst}")
if args.update_paths:
_update_path_to(bid, dst, args.dst)
else:
local_path = src.ensure_local(bid)
uri = dst.put_pdf(bid, local_path)
copied += 1
print(f" [copy] {bid}: -> {uri}")
if args.update_paths:
_update_path_to(bid, dst, args.dst, uri=uri)
# --- Text bundle (independent of the PDF) ---
# Always (re)push the OCR/clean text from local disk if present.
# Idempotent, and decoupled from the PDF so a book whose source
# PDF was already pruned locally still gets its text layer to the
# cloud (the whole point of promoting a book is its text, too).
for tu in dst.put_text(bid):
text_synced += 1
print(f" [text] {bid}: -> {tu}")
except Exception as e: # noqa: BLE001
failed += 1
print(f" [FAIL] {bid}: {type(e).__name__}: {e}", file=sys.stderr)
print()
print(f"copied={copied} skipped(already)={skipped} "
f"missing(in src)={missing} text_synced={text_synced} failed={failed}")
return 0 if failed == 0 else 2
def _update_path_to(book_id: str, dst, dst_name: str, *, uri: str | None = None) -> None:
"""Rewrite books.local_path so the inventory reflects the new home."""
if uri is None:
# Recompute the URI without re-uploading.
if dst_name == "local":
from src.config import load_config
cfg = load_config()
uri = f"file://{(cfg.paths.raw_dir / f'{book_id}.pdf').resolve()}"
else: # r2
uri = f"r2://{dst._bucket}/{book_id}.pdf" # noqa: SLF001
with connect() as conn:
conn.execute(
"UPDATE books SET local_path = ?, updated_at = datetime('now') WHERE book_id = ?",
(uri, book_id),
)
conn.commit()
if __name__ == "__main__":
raise SystemExit(main())