mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 21:27: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
200 lines
7.5 KiB
Python
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,
|
|
)
|