mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 21:27:14 +01:00
Every request went through the module-level requests.get/put/post/delete helpers, so each one paid a fresh DNS+TCP+TLS handshake. Measured today against the real backends that is 95ms/req to api.github.com and 52ms to Realtime Database -- on a 27-request diet-guard tick, most of the wall clock. Each transport module now binds a pooling requests.Session to the name `requests`. A Session is a drop-in for those helpers, so all 9 call sites and all 16 tests that patch `<module>.requests.<verb>` are untouched; the real module stays available as `_requests` for the RequestException/Response references. Sessions are per-module rather than shared because the tests use sequence-consuming side_effect lists, where one module could otherwise eat another's queued response. Transient failures now retry, restricted to GET: a PUT here carries a Contents-API sha, and replaying one whose response was merely lost would write against a stale precondition on a remote the phone also reads. Also thread timeout_seconds through firebase_client_for, which accepted none, so a caller's tighter budget silently reached the GitHub backend only. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VCucBhUkHBBHHbsY7bSdGV
199 lines
7.5 KiB
Python
199 lines
7.5 KiB
Python
"""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,
|
|
)
|