mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 21:47:14 +01:00
The three Python packages pinned ruff twice at different versions: requirements.txt at 0.16.7, .pre-commit-config.yaml at v0.15.2. Only the older one ever ran, so `select = ["ALL"]` was enforced two minor versions behind. Measured before aligning: v0.15.2 reports 0 findings, every 0.16.x reports CPY001 on each file -- 70 crdt-sync, 23 freedays, 71 gatelock, and no other rule. Resolved by adding the notice rather than ignoring the rule, so the gate keeps its teeth. Two gatelock modules sat at exactly the 250-line cap, so a one-line header tipped them to 251. Both are split along a real seam rather than shaved: _widget_fills.py colour arithmetic over LockConfig, no tkinter import _surfaces_focus.py selects among existing surfaces, never mutates them The focus split keeps _surfaces.py's documented containment (it stays the only module that creates, moves or destroys windows), and the static guard in test_recovery_invariants.py globs _surfaces*.py, so the new sibling is covered with no list to extend. widgets.py re-exports the fills helpers, which keeps gatelock.widgets the single public surface its tests import. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019kMvSMtsVMB3d2uoZ56pbp
218 lines
7.3 KiB
Python
218 lines
7.3 KiB
Python
# Copyright (c) 2026 Krzysztof Rudnicki
|
|
"""One-shot migration of the GitHub-backed sync repo into Firebase RTDB.
|
|
|
|
Reads through the GitHub API rather than a local clone: a checkout goes stale
|
|
the moment any device syncs, and seeding Firebase from stale data would
|
|
silently roll back whatever changed in between.
|
|
|
|
Writes through the real :class:`crdt_sync.FirebaseSyncClient`, so the
|
|
migration exercises the same key escaping and error handling the apps will,
|
|
then reads every blob back and compares bytes before reporting success.
|
|
|
|
Usage::
|
|
|
|
python3 tool/migrate_github_to_firebase.py --dry-run
|
|
python3 tool/migrate_github_to_firebase.py
|
|
|
|
Configuration comes from ``~/.config/crdt-sync/firebase.json`` and the GitHub
|
|
token from ``~/.config/diet_guard/sync_token``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import base64
|
|
from dataclasses import dataclass
|
|
import json
|
|
import logging
|
|
from pathlib import Path
|
|
import sys
|
|
|
|
import requests
|
|
from requests.adapters import HTTPAdapter
|
|
from urllib3.util.retry import Retry
|
|
|
|
from crdt_sync import (
|
|
FirebaseSyncClient,
|
|
FirebaseTokenProvider,
|
|
MemoryCredentialStore,
|
|
)
|
|
|
|
_CONFIG_DIR = Path.home() / ".config" / "crdt-sync"
|
|
_GITHUB_TOKEN_FILE = Path.home() / ".config" / "diet_guard" / "sync_token"
|
|
_REPO = "kuhyx/syncs"
|
|
_API = "https://api.github.com"
|
|
_TIMEOUT_SECONDS = 30
|
|
|
|
# A migration that reports nothing is indistinguishable from one that did
|
|
# nothing, so progress goes to stdout -- via logging rather than print, which
|
|
# this project's lint config reserves for accidental debug output.
|
|
_logger = logging.getLogger("migrate")
|
|
|
|
# Paths deliberately left behind, each for a reason that would otherwise cost
|
|
# egress or storage forever. They stay in the GitHub repo, which is archived
|
|
# rather than deleted, so nothing is destroyed by skipping them.
|
|
_SKIP_PREFIXES = (
|
|
# Pre-migration sqlite_crdt format, last written 2026-07-17. Nothing
|
|
# reads it; `sync_service.dart` only touches `notes/`.
|
|
"todo-sync/changesets/",
|
|
# A replica that stopped syncing 2026-07-22: verified to hold 0 records,
|
|
# 0 tombstones and 0 newer fields that `pc` and `phone` lack. Carrying it
|
|
# over would cost a full re-download every tick forever, because a device
|
|
# that publishes no revision can never be skipped.
|
|
"diet-guard-sync/devices/desktop/",
|
|
)
|
|
|
|
# Not sync data -- documentation and directory placeholders.
|
|
_SKIP_NAMES = ("README.md", ".gitkeep")
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Blob:
|
|
"""One file in the sync repo, with its exact bytes."""
|
|
|
|
path: str
|
|
text: str
|
|
|
|
@property
|
|
def size(self) -> int:
|
|
"""Return the payload size in bytes."""
|
|
return len(self.text.encode("utf-8"))
|
|
|
|
|
|
def _github_session(token: str) -> requests.Session:
|
|
"""Return a session that retries transient failures.
|
|
|
|
GitHub drops keep-alive connections routinely, and a migration that gave
|
|
up half-way would leave Firebase holding a partial copy -- the one
|
|
outcome worse than not starting. Retries cover connection resets and 5xx,
|
|
plus 403/429 for secondary rate limiting.
|
|
"""
|
|
session = requests.Session()
|
|
session.headers.update(
|
|
{
|
|
"Authorization": f"Bearer {token}",
|
|
"Accept": "application/vnd.github+json",
|
|
}
|
|
)
|
|
retry = Retry(
|
|
total=5,
|
|
backoff_factor=1.0,
|
|
status_forcelist=(403, 429, 500, 502, 503, 504),
|
|
allowed_methods=frozenset({"GET"}),
|
|
raise_on_status=False,
|
|
)
|
|
session.mount("https://", HTTPAdapter(max_retries=retry))
|
|
return session
|
|
|
|
|
|
def _list_blobs(session: requests.Session) -> list[tuple[str, str]]:
|
|
"""Return ``(path, sha)`` for every file, via one recursive tree call."""
|
|
response = session.get(
|
|
f"{_API}/repos/{_REPO}/git/trees/HEAD",
|
|
params={"recursive": "1"},
|
|
timeout=_TIMEOUT_SECONDS,
|
|
)
|
|
response.raise_for_status()
|
|
tree = response.json()
|
|
if tree.get("truncated"):
|
|
# Would silently migrate only part of the repo.
|
|
msg = "GitHub truncated the tree listing; the repo is too large"
|
|
raise RuntimeError(msg)
|
|
return [
|
|
(item["path"], item["sha"]) for item in tree["tree"] if item["type"] == "blob"
|
|
]
|
|
|
|
|
|
def _fetch_blob(session: requests.Session, path: str, sha: str) -> Blob:
|
|
"""Return the exact bytes of one blob.
|
|
|
|
Uses the blobs API rather than the contents API: it is addressed by sha,
|
|
so it cannot race a concurrent push into returning a different file than
|
|
the tree listing described.
|
|
"""
|
|
response = session.get(
|
|
f"{_API}/repos/{_REPO}/git/blobs/{sha}", timeout=_TIMEOUT_SECONDS
|
|
)
|
|
response.raise_for_status()
|
|
payload = response.json()
|
|
raw = base64.b64decode(payload["content"])
|
|
return Blob(path=path, text=raw.decode("utf-8"))
|
|
|
|
|
|
def _should_migrate(path: str) -> bool:
|
|
if any(path.startswith(prefix) for prefix in _SKIP_PREFIXES):
|
|
return False
|
|
return Path(path).name not in _SKIP_NAMES
|
|
|
|
|
|
def _firebase_client() -> FirebaseSyncClient:
|
|
config = json.loads((_CONFIG_DIR / "firebase.json").read_text())
|
|
password = (_CONFIG_DIR / "password").read_text()
|
|
auth = FirebaseTokenProvider(config["apiKey"], MemoryCredentialStore())
|
|
auth.sign_in(config["email"], password)
|
|
return FirebaseSyncClient(config["databaseUrl"], auth)
|
|
|
|
|
|
def _report(blobs: list[Blob], skipped: list[str]) -> None:
|
|
"""Log exactly what will move and what will not, before anything moves."""
|
|
total = sum(blob.size for blob in blobs)
|
|
_logger.info("%d files to migrate, %d bytes total", len(blobs), total)
|
|
for blob in sorted(blobs, key=lambda b: -b.size):
|
|
_logger.info(" %8d %s", blob.size, blob.path)
|
|
if skipped:
|
|
_logger.info("%d skipped (see _SKIP_PREFIXES for why):", len(skipped))
|
|
for path in skipped:
|
|
_logger.info(" %s", path)
|
|
|
|
|
|
def main() -> int:
|
|
"""Run the migration, returning a process exit code."""
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument(
|
|
"--dry-run",
|
|
action="store_true",
|
|
help="list what would be migrated, write nothing",
|
|
)
|
|
args = parser.parse_args()
|
|
logging.basicConfig(stream=sys.stdout, level=logging.INFO, format="%(message)s")
|
|
|
|
session = _github_session(_GITHUB_TOKEN_FILE.read_text().strip())
|
|
entries = _list_blobs(session)
|
|
blobs = [
|
|
_fetch_blob(session, path, sha)
|
|
for path, sha in entries
|
|
if _should_migrate(path)
|
|
]
|
|
skipped = [path for path, _ in entries if not _should_migrate(path)]
|
|
_report(blobs, skipped)
|
|
|
|
if args.dry_run:
|
|
_logger.info("DRY RUN -- nothing written")
|
|
return 0
|
|
|
|
client = _firebase_client()
|
|
_logger.info("writing...")
|
|
for blob in blobs:
|
|
client.put_file_text(blob.path, blob.text, message="migration")
|
|
|
|
# Verify by reading back through the same client: a migration that
|
|
# reported success without checking would be indistinguishable from one
|
|
# that silently dropped a file.
|
|
_logger.info("verifying byte-for-byte...")
|
|
mismatches = [
|
|
blob.path for blob in blobs if client.get_file_text(blob.path) != blob.text
|
|
]
|
|
if mismatches:
|
|
_logger.error("FAILED -- %d file(s) did not round-trip:", len(mismatches))
|
|
for path in mismatches:
|
|
_logger.error(" %s", path)
|
|
return 1
|
|
|
|
_logger.info("OK -- %d files migrated and verified byte-for-byte", len(blobs))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|