diet-guard/diet_guard/_sync_client.py
Krzysztof kuhy Rudnicki 1e201acbdc Split _sync.py and its tests; extract the gate fixtures from conftest
_sync.py 498 -> 165, into siblings that each own one concern:

  _sync_client.py  picking a backend and building the remote store
  _sync_banks.py   pull/merge/push for the food bank and the budget
  _sync_paths.py   the kuhyx/syncs wire layout, shared by both
  _sync_errors.py  SyncError, so _sync and _sync_client need not import
                   each other

The patch targets moved with the code they guard, which is the whole risk of
this commit. unittest.mock.patch and monkeypatch.setattr both bind to a
*module object*, so a patch left pointing at _sync after CONFIG_FILE and
SYNC_TOKEN_FILE moved to _sync_client redirects nothing. That is not
hypothetical here: the first run of the suite took 9m18s instead of 7s and
raised 401s, because tests were reaching the real GitHub API with a fake
token. Repointed:

  conftest       SYNC_TOKEN_FILE, CONFIG_FILE      -> _sync_client
  test_sync*     GitHubSyncClient (30 patch sites) -> _sync_client
  test_backend   mirror/firebase_client_for        -> _sync_client

The repo's own _test_guard caught the same leak independently, which is the
behaviour worth keeping: two guards, both fail-closed.

test_sync.py 482 -> 205, splitting the budget and pull/revision classes into
siblings and the shared fakes into _sync_fakes.py.

conftest.py 284 -> 155, moving the Tk/gatelock fixtures to _gate_fixtures.py.
Importing them back is what registers them (pytest_plugins is an error in a
non-root conftest), and they are named in __all__ because ruff's autofix
otherwise deletes the "unused" import and silently disables Tk blocking --
it did exactly that once here. Verified afterwards with a canary asserting
_gatelock.tk is still a MagicMock, not merely that the suite passes.

Verified: 692 tests / 100% coverage unchanged; ~/.local/share/diet_guard
byte-identical before and after the run; cd /tmp && python3 -m diet_guard sync
-> 402 entries, exit 0. Gate 47 -> 44.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MK3HCZrM75sRHL1gL8usve
2026-08-15 18:51:18 +02:00

132 lines
4.7 KiB
Python

"""Choosing and building the remote store one sync tick will use.
Split out of ``_sync.py`` for file size.
Two backends, and the tick needs exactly one: Firebase (primary, configured
at ``~/.config/crdt-sync/``) or a GitHub PAT mirror at
``~/.config/diet_guard/sync_token``. Neither configured is not an error --
:func:`_client_for_run` raises :class:`~diet_guard._sync.SyncError` and the
caller logs ``sync not configured`` and no-ops, which is what a machine that
has never been set up should do on a 15-minute timer.
``ConfigError`` subclasses ``Exception`` directly rather than
``RemoteSyncError``, so it is caught explicitly here and translated -- a
sibling-not-subclass relationship that has bitten this file before.
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
from crdt_sync import (
CONFIG_FILE,
ConfigError,
FirebaseAuthError,
GitHubSyncClient,
RemoteSyncError,
firebase_client_for,
mirror_client_for,
)
from diet_guard._constants import (
SYNC_REPO_NAME,
SYNC_REPO_OWNER,
SYNC_TIMEOUT_SECONDS,
SYNC_TOKEN_FILE,
)
from diet_guard._sync_errors import SyncError
if TYPE_CHECKING:
from crdt_sync import RemoteStore
_logger = logging.getLogger(__name__)
def _remote_client(github: GitHubSyncClient) -> RemoteStore:
"""Return the backend to sync against.
Firebase when ``~/.config/crdt-sync/`` is set up, with GitHub kept as a
mirror so a device that has not moved yet still converges; GitHub alone
otherwise. An unconfigured machine keeps syncing exactly as before.
Every sub-sync here (log, budget, food bank, manual bank) takes the client
as a parameter, so this one swap covers all of them.
The config file is checked before constructing anything, so an
unconfigured machine never reaches the network -- otherwise a suite that
blocks real sockets fails here rather than in the code under test.
Rolling back is deleting this function and passing ``github`` straight
through: no data moves either way.
"""
if not CONFIG_FILE.is_file():
return github
try:
return mirror_client_for("diet_guard", github)
except (ConfigError, FirebaseAuthError, RemoteSyncError) as exc:
_logger.warning("Firebase unavailable, syncing via GitHub only: %s", exc)
return github
def _client_for_run() -> RemoteStore:
"""Return the backend for one sync run, requiring only *one* to be set up.
The PAT is no longer mandatory. Firebase is the primary backend now, and
requiring a GitHub token before ever constructing it meant a
Firebase-configured machine with no PAT could not sync at all -- the
commits that fixed exactly this ("Auto-sync on a Firebase-only device")
changed only the Dart side, leaving the Python half behind.
A missing PAT is therefore fatal only when Firebase is *also* unconfigured,
which is the genuine "nothing is set up" case the caller must report.
"""
try:
token = _read_token()
except SyncError:
if not CONFIG_FILE.is_file():
raise
# Firebase-only: there is no GitHub client to mirror to, so use the
# primary directly rather than failing the whole run.
#
# ConfigError is translated rather than propagated: it is *not* a
# RemoteSyncError (it subclasses Exception directly), so letting it out
# would escape every caller's catch tuple and raise a traceback out of
# the gate's fail-closed "Fetch from sync" button -- the exact failure
# the RemoteSyncError swap was made to stop. A config file that exists
# but is unusable is reached by precisely this branch.
try:
return firebase_client_for("diet_guard")
except ConfigError as exc:
message = f"Firebase config at {CONFIG_FILE} is unusable: {exc}"
raise SyncError(message) from exc
return _remote_client(
GitHubSyncClient(
SYNC_REPO_OWNER,
SYNC_REPO_NAME,
token,
timeout_seconds=SYNC_TIMEOUT_SECONDS,
)
)
def _read_token() -> str:
"""Return the saved sync PAT, stripped of trailing whitespace.
Raises:
SyncError: If the token file is missing or empty -- the user has not
completed the one-time github.com setup step yet.
"""
if not SYNC_TOKEN_FILE.exists():
message = (
f"no sync token at {SYNC_TOKEN_FILE} -- create a fine-grained "
"GitHub PAT scoped to the syncs repo's contents and "
f"save it there (mode 600), then re-run sync"
)
raise SyncError(message)
token = SYNC_TOKEN_FILE.read_text().strip()
if not token:
msg = f"{SYNC_TOKEN_FILE} is empty"
raise SyncError(msg)
return token