utils/crdt-sync/crdt_sync/_config.py
Krzysztof kuhy Rudnicki 6154dc09bf Pin pre-commit's ruff to 0.16.7 and satisfy the CPY001 it brings
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
2026-09-12 18:11:24 +02:00

200 lines
7.5 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki
"""The one place every app learns how to reach Firebase.
Before this module each caller re-read ``~/.config/crdt-sync/firebase.json``
by hand, so a change to the schema meant editing every consumer, and each
consumer got to invent its own half of the credential-cache convention. The
apps share one Firebase project and one account, so they should share one
loader.
Two things live here:
* :class:`FirebaseConfig` -- the parsed, validated config file.
* :func:`firebase_client_for` -- the whole "config file to usable client"
path, including the per-app credential cache that keeps a fresh process
from re-authenticating. ``wake_alarm``'s PC side runs every minute and
``diet_guard``'s every 15, so caching the refresh token is what makes the
sign-in cost negligible rather than constant.
Deliberately *not* here: any choice about which backend an app uses. That
stays a constructor change at the call site, per :mod:`crdt_sync._remote`.
"""
from __future__ import annotations
from dataclasses import dataclass
import json
from pathlib import Path
from typing import TYPE_CHECKING
from crdt_sync._firebase import _DEFAULT_TIMEOUT_SECONDS, FirebaseSyncClient
from crdt_sync._firebase_auth import FileCredentialStore, FirebaseTokenProvider
from crdt_sync._mirror import MirrorSyncClient
if TYPE_CHECKING:
from crdt_sync._remote import RemoteStore
CONFIG_DIR = Path.home() / ".config" / "crdt-sync"
CONFIG_FILE = CONFIG_DIR / "firebase.json"
PASSWORD_FILE = CONFIG_DIR / "password"
# The scaffold ships "_comment_*" keys explaining where each value comes
# from. They are documentation for a human filling the file in, not config.
_COMMENT_PREFIX = "_comment"
class ConfigError(Exception):
"""The shared Firebase config is missing, malformed or unfilled."""
@dataclass(frozen=True)
class FirebaseConfig:
"""Everything needed to reach the shared Firebase project.
``api_key`` is not a secret -- it ships inside the Android APKs, and the
security rules are what actually protect the data. The password is.
"""
api_key: str
database_url: str
project_id: str
uid: str
email: str
@classmethod
def load(cls, path: Path | None = None) -> FirebaseConfig:
"""Return the config at ``path``, defaulting to :data:`CONFIG_FILE`.
Raises:
ConfigError: If the file is absent, unparsable, missing a key, or
still holding a scaffold placeholder -- each reported with the
specific field at fault, because the alternative is a
confusing auth failure much later.
"""
source = path if path is not None else CONFIG_FILE
try:
raw = json.loads(source.read_text(encoding="utf-8"))
except FileNotFoundError as exc:
msg = f"{source} does not exist; see utils/firebase/README.md"
raise ConfigError(msg) from exc
except OSError as exc:
msg = f"{source} could not be read: {exc}"
raise ConfigError(msg) from exc
except ValueError as exc:
msg = f"{source} is not valid JSON: {exc}"
raise ConfigError(msg) from exc
if not isinstance(raw, dict):
msg = f"{source} must contain a JSON object"
raise ConfigError(msg)
data = {k: v for k, v in raw.items() if not k.startswith(_COMMENT_PREFIX)}
fields = {
"api_key": "apiKey",
"database_url": "databaseUrl",
"project_id": "projectId",
"uid": "uid",
"email": "email",
}
missing = [key for key in fields.values() if not str(data.get(key, "")).strip()]
if missing:
msg = f"{source} is missing or has empty: {', '.join(missing)}"
raise ConfigError(msg)
unfilled = [key for key in fields.values() if "PASTE_" in str(data[key])]
if unfilled:
msg = f"{source} still holds the placeholder for: {', '.join(unfilled)}"
raise ConfigError(msg)
return cls(**{attr: str(data[key]) for attr, key in fields.items()})
def read_password(self, path: Path | None = None) -> str:
"""Return the sync account's password.
Stripped, because a text editor that appends a trailing newline would
otherwise turn into an authentication failure with no visible cause.
"""
source = path if path is not None else PASSWORD_FILE
try:
password = source.read_text(encoding="utf-8").strip()
except OSError as exc:
msg = f"{source} could not be read: {exc}"
raise ConfigError(msg) from exc
if not password or "PASTE_" in password:
msg = f"{source} is empty or still holds the scaffold placeholder"
raise ConfigError(msg)
return password
def credential_store_for(app_name: str) -> FileCredentialStore:
"""Return the credential cache for ``app_name``.
Per app rather than shared: two apps signing in as the same account still
refresh tokens on their own schedules, and a single file written by
several processes at once is a corruption waiting to happen.
"""
return FileCredentialStore(
Path.home() / ".config" / app_name / "firebase_auth.json"
)
def firebase_client_for(
app_name: str,
*,
config: FirebaseConfig | None = None,
timeout_seconds: float = _DEFAULT_TIMEOUT_SECONDS,
) -> FirebaseSyncClient:
"""Return a signed-in client for ``app_name``.
Reuses the cached refresh token when there is one and signs in with the
password only when there is not, so the common path costs no
authentication round trip at all.
Args:
app_name: Names the credential cache, e.g. ``"diet_guard"``.
config: Overrides the on-disk config; for tests.
timeout_seconds: Per-request network timeout. Without this the
client fell back to its own default and a caller's tighter
budget reached the GitHub backend only.
Raises:
ConfigError: If the shared config is unusable.
FirebaseAuthError: If the credentials are rejected.
"""
resolved = config if config is not None else FirebaseConfig.load()
auth = FirebaseTokenProvider(resolved.api_key, credential_store_for(app_name))
if not auth.has_session():
auth.sign_in(resolved.email, resolved.read_password())
return FirebaseSyncClient(
resolved.database_url, auth, timeout_seconds=timeout_seconds
)
def mirror_client_for(
app_name: str,
github_client: RemoteStore,
*,
config: FirebaseConfig | None = None,
) -> MirrorSyncClient:
"""Return a Firebase-primary client that still mirrors to ``github_client``.
This is what an app uses *during* the cutover. Firebase is authoritative,
so its failures fail the tick; GitHub is kept in step but never allowed
to. Reads union both, which is what lets devices cut over one at a time
without a migrated PC losing sight of an un-migrated phone.
Rolling back is deleting the call: pass the GitHub client straight to
:func:`crdt_sync.sync_log` again. Retiring the mirror is swapping this for
:func:`firebase_client_for`. Both are one-line changes at the call site,
which is the whole point of the :class:`~crdt_sync.RemoteStore` seam.
Args:
app_name: Names the credential cache, e.g. ``"diet_guard"``.
github_client: The existing GitHub client, kept as the fallback.
config: Overrides the on-disk config; for tests.
"""
return MirrorSyncClient(
primary=firebase_client_for(app_name, config=config),
mirror=github_client,
)