Files
FabledGrimoire/src/scribe/restore.py
T
bvandeusenandClaude Opus 5.5 3008d29f89
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / TypeScript typecheck (push) Successful in 52s
CI & Build / integration (push) Successful in 1m2s
CI & Build / Python tests (push) Successful in 1m54s
CI & Build / Build & push image (push) Successful in 32s
fix(523): restore CLI imports the engine from scribe.models, where it lives
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 17:42:54 -04:00

96 lines
3.3 KiB
Python

"""Restore a Scribe backup into an empty instance, from the command line.
python -m scribe.restore /backups/scribe-20261015T020000Z.tar
This is how a fresh instance is brought back after losing the old one. Run it
inside the Scribe container, which already has the database settings and has
already migrated the schema on start. It reads either the scheduled backup's
tar (services/backup_file.py) or the JSON the admin download produces.
It refuses a database that already holds an account. A restore re-creates
every user, project and record from the file with new ids; into a live
instance it would add a second copy of everything rather than replace it.
(Settings → Backup & Restore still offers the upload for that case, on
purpose and with its own warning.)
Unlike the upload, nothing limits the file's size here, so this is also the
way in for a backup too big for the web upload.
Afterwards, restart Scribe: search indexes are not part of a backup and are
rebuilt from the restored records at startup.
"""
from __future__ import annotations
import argparse
import asyncio
import json
import sys
from pathlib import Path
from sqlalchemy import func, select
def load_backup(path: Path) -> dict:
"""The payload in `path`, whichever of the two backup formats it is."""
from scribe.services.backup_file import is_archive, read_archive
with open(path, "rb") as f:
if is_archive(f):
return read_archive(f)
return json.load(f)
async def _account_count() -> int:
from scribe.models import async_session
from scribe.models.user import User
async with async_session() as session:
return (await session.execute(select(func.count()).select_from(User))).scalar_one()
async def run(path: Path) -> int:
from scribe.models import engine
from scribe.services.backup import restore_full_backup
from scribe.services.backup_file import ArchiveError
try:
try:
payload = load_backup(path)
except FileNotFoundError:
print(f"There is no file at {path}.", file=sys.stderr)
return 2
except (ArchiveError, ValueError) as exc:
print(f"{path} isn't a backup this Scribe can read: {exc}", file=sys.stderr)
return 2
accounts = await _account_count()
if accounts:
print(
f"This database already holds {accounts} account(s). A restore adds a second "
"copy of everything rather than replacing it, so it only runs into an empty "
"instance. Start Scribe on an empty database and run this again.",
file=sys.stderr,
)
return 3
stats = await restore_full_backup(payload)
print(json.dumps({"restored": stats}, indent=2, default=str))
print("Restored. Restart Scribe so the search indexes are rebuilt.", file=sys.stderr)
return 0
finally:
await engine.dispose()
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
prog="python -m scribe.restore",
description="Restore a Scribe backup (.tar or .json) into an empty instance.",
)
parser.add_argument("file", type=Path, help="the backup file")
args = parser.parse_args(argv)
return asyncio.run(run(args.file))
if __name__ == "__main__":
sys.exit(main())