"""Tiny forward/!down migration runner for SQLite. Each file in /migrations has a ``-- up`` section and a ``-- down`` section. Applied migrations are tracked in ``schema_migrations``. Usage: python -m scripts.migrate up # apply all pending python -m scripts.migrate down # revert the most recent python -m scripts.migrate down 0 # revert everything (to a clean DB) python -m scripts.migrate status """ import os import sys from app import db MIGRATIONS_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "migrations") def _parse(path: str) -> tuple[str, str]: with open(path, encoding="utf-8") as f: text = f.read() lower = text.lower() up_idx = lower.find("-- up") down_idx = lower.find("-- down") if up_idx == -1 or down_idx == -1: raise ValueError(f"{path} must contain '-- up' and '-- down' markers") up_sql = text[up_idx + len("-- up"):down_idx].strip() down_sql = text[down_idx + len("-- down"):].strip() return up_sql, down_sql def _all_migrations() -> list[str]: return sorted(f for f in os.listdir(MIGRATIONS_DIR) if f.endswith(".sql")) def _ensure_table(conn) -> None: conn.executescript( "CREATE TABLE IF NOT EXISTS schema_migrations (" " name TEXT PRIMARY KEY," " applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')));" ) def _applied(conn) -> set[str]: _ensure_table(conn) return {r[0] for r in conn.execute("SELECT name FROM schema_migrations").fetchall()} def up() -> list[str]: done = [] conn = db.connect() try: applied = _applied(conn) for name in _all_migrations(): if name in applied: continue up_sql, _ = _parse(os.path.join(MIGRATIONS_DIR, name)) conn.executescript(up_sql) conn.execute("INSERT INTO schema_migrations (name) VALUES (?)", (name,)) conn.commit() done.append(name) return done finally: conn.close() def down(target: int | None = None) -> list[str]: """Revert migrations. ``target`` is how many to keep applied (default: keep all-but-last).""" reverted = [] conn = db.connect() try: applied = sorted(_applied(conn)) if target is None: to_revert = applied[-1:] if applied else [] else: to_revert = applied[target:] for name in reversed(to_revert): _, down_sql = _parse(os.path.join(MIGRATIONS_DIR, name)) conn.executescript(down_sql) conn.execute("DELETE FROM schema_migrations WHERE name = ?", (name,)) conn.commit() reverted.append(name) return reverted finally: conn.close() def status() -> None: conn = db.connect() try: applied = _applied(conn) for name in _all_migrations(): print(f"[{'x' if name in applied else ' '}] {name}") finally: conn.close() def main(argv: list[str]) -> int: cmd = argv[1] if len(argv) > 1 else "up" if cmd == "up": print("applied:", up() or "(nothing pending)") elif cmd == "down": tgt = int(argv[2]) if len(argv) > 2 else None print("reverted:", down(tgt) or "(nothing to revert)") elif cmd == "status": status() else: print(__doc__) return 2 return 0 if __name__ == "__main__": raise SystemExit(main(sys.argv))