#!/usr/bin/env python3 """Backup the SQLite database used by TakeYourMeds. Usage: python tools/backup_db.py [destination_dir] Defaults to ./data/backups/ and keeps the last 14 backups. """ from __future__ import annotations import argparse import gzip import logging import pathlib import shutil import sys from datetime import UTC, datetime from server.settings import settings logger = logging.getLogger(__name__) logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") DEFAULT_KEEP = 14 def backup_db(destination: pathlib.Path | None = None, keep: int = DEFAULT_KEEP) -> pathlib.Path: src = pathlib.Path(settings.db_path) if not src.exists(): raise FileNotFoundError(f"Database not found: {src}") dest_dir = destination or pathlib.Path(src.parent) / "backups" dest_dir.mkdir(parents=True, exist_ok=True) timestamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ") dest_file = dest_dir / f"medis-{timestamp}.sqlite.gz" # SQLite backup via online copy is safe because WAL is not enabled by default. # For guaranteed consistency under writes, use the backup API. import aiosqlite async def _copy() -> None: async with ( aiosqlite.connect(str(src)) as src_db, aiosqlite.connect(str(dest_file).replace(".gz", "")) as dst_db, ): await src_db.backup(dst_db) import asyncio plain = dest_file.with_suffix("") asyncio.run(_copy()) with open(plain, "rb") as f_in, gzip.open(dest_file, "wb") as f_out: shutil.copyfileobj(f_in, f_out) plain.unlink() logger.info("Backup created: %s", dest_file) _rotate(dest_dir, keep) return dest_file def _rotate(dest_dir: pathlib.Path, keep: int) -> None: backups = sorted(dest_dir.glob("medis-*.sqlite.gz")) if len(backups) > keep: for old in backups[:-keep]: old.unlink() logger.info("Removed old backup: %s", old.name) def main() -> int: parser = argparse.ArgumentParser(description="Backup TakeYourMeds SQLite database") parser.add_argument("destination", nargs="?", help="Backup directory (default: ./data/backups)") parser.add_argument("--keep", type=int, default=DEFAULT_KEEP, help=f"Number of backups to keep (default: {DEFAULT_KEEP})") args = parser.parse_args() dest = pathlib.Path(args.destination) if args.destination else None try: backup_db(dest, keep=args.keep) return 0 except Exception as exc: logger.error("Backup failed: %s", exc) return 1 if __name__ == "__main__": sys.exit(main())