diet-guard/diet_guard/_sync.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

215 lines
8.3 KiB
Python

"""Cross-device log sync orchestration for diet_guard.
Pulls every other device's pushed log from GitHub-backed dumb storage
(``crdt_sync.GitHubSyncClient``), merges with the local log via
``crdt_sync``'s shared CRDT scheme (:mod:`diet_guard.sync_merge` adapts
diet_guard's entries to/from ``crdt_sync.Record``), re-signs every persisted
entry, rebuilds the food bank, and pushes this device's own merged log back
up in the new Record-based wire format.
The daily budget syncs alongside the food log in the same tick (see
:func:`_sync_budget`, called from :func:`run_sync`): a sibling
``budget.json`` per device, merged the same way but last-writer-wins per
edit rather than union-of-immutable-entries, since a budget (unlike a
food-log entry) can be edited repeatedly.
"""
from __future__ import annotations
import json
import logging
from crdt_sync import (
DeviceIdentity,
FileSyncStateStore,
GitHubSyncError,
Log,
RemoteStore,
RemoteSyncError,
SyncState,
merge_logs,
revision_of,
)
from diet_guard._constants import (
SYNC_STATE_FILE,
)
from diet_guard._device import device_identity
from diet_guard._foodbank import rebuild_food_bank
from diet_guard._state import DayLog, read_raw_log, resign_entry, write_raw_log
from diet_guard._sync_banks import _sync_budget, _sync_food_bank, _sync_manual_bank
from diet_guard._sync_client import _client_for_run
from diet_guard._sync_errors import SyncError
from diet_guard._sync_paths import _DEVICES_DIR, _REVS_DIR, _device_log_path
from diet_guard.sync_merge import (
daylog_to_log,
log_to_daylog,
parse_remote_log,
)
_logger = logging.getLogger(__name__)
# One small text node per device, each written only by its owner. Deliberately
# not one shared map: a whole-map write would erase every other device's entry,
# after which those peers would look permanently unchanged and never be
def _remote_revisions(client: RemoteStore) -> dict[str, str]:
"""Return every peer's published food-log revision, cheaply where possible.
Degrades to an empty map -- meaning "fetch everything", the old behaviour
-- on a backend without a bulk-map read, so correctness never depends on
the optimisation being available.
"""
get_string_map = getattr(client, "get_string_map", None)
if get_string_map is None:
return {}
try:
return get_string_map(_REVS_DIR)
except (GitHubSyncError, RemoteSyncError):
# A revision map that cannot be read is not worth failing a sync
# over; without it every peer is simply fetched, as before.
return {}
def _pull_remote_logs(
client: RemoteStore,
remote_revs: dict[str, str],
state: SyncState,
seen_revs: dict[str, str],
identity: DeviceIdentity,
) -> list[Log]:
"""Return every other device's last-pushed log, skipping this one.
A device whose pushed file is corrupt, truncated, or otherwise
unparsable (new or old wire format) is logged and skipped, same as one
that has never pushed at all -- GitHub is an external system boundary,
and one bad device's file must not stall merging in every other
device's.
A peer whose published revision matches the one already merged is skipped
without downloading it at all. This is the single largest traffic saving
in the fleet: these logs are hundreds of KB and this timer runs 96 times a
day, so re-reading an unchanged peer is pure waste.
"""
remote_logs: list[Log] = []
for device_id in client.list_directory(_DEVICES_DIR):
if identity.is_own(device_id):
continue
remote_rev = remote_revs.get(device_id)
if remote_rev is not None and remote_rev == state.peer_revs.get(device_id):
# Already merged, and that merge is in the local log. Carry the
# revision forward so it stays skipped next tick.
seen_revs[device_id] = remote_rev
continue
text = client.get_file_text(_device_log_path(device_id))
if text is None:
continue
try:
remote_logs.append(parse_remote_log(text))
except (TypeError, KeyError, ValueError, json.JSONDecodeError):
# Deliberately not recorded as seen: a corrupt push must be
# retried next tick, not remembered as merged.
_logger.warning("Unparsable log pushed by device %r, skipping", device_id)
continue
seen_revs[device_id] = remote_rev or revision_of(text)
return remote_logs
def run_sync() -> DayLog:
"""Run one full sync tick: pull, merge, re-sign, persist, push.
Every persisted entry is re-signed regardless of origin (not just
phone-origin ones): a signature computed on another device cannot be
trusted as this device's shared key sees it, and an inbound entry with no
signature at all would otherwise be silently dropped on the very next
read by :func:`diet_guard._state.load_log`. The daily budget syncs in
the same tick (see :func:`_sync_budget`), reusing this same client.
Returns:
The merged log as it now sits on disk locally, post re-sign.
Raises:
SyncError: If *neither* backend is configured -- no Firebase config and
no usable PAT.
crdt_sync.RemoteSyncError: Propagated from whichever backend failed for
any transport failure -- the caller (CLI/timer) decides how to
report it.
"""
client = _client_for_run()
identity = device_identity()
state_store = FileSyncStateStore(SYNC_STATE_FILE)
state = state_store.load()
remote_revs = _remote_revisions(client)
seen_revs: dict[str, str] = {}
merged = daylog_to_log(read_raw_log())
for remote_log in _pull_remote_logs(
client, remote_revs, state, seen_revs, identity
):
merged = merge_logs(merged, remote_log)
merged_daylog = log_to_daylog(merged)
resigned: DayLog = {
day: [resign_entry(entry) for entry in entries]
for day, entries in merged_daylog.items()
}
write_raw_log(resigned)
rebuild_food_bank(resigned)
_sync_budget(client)
_sync_food_bank(client)
_sync_manual_bank(client)
push_log = daylog_to_log(resigned)
push_json = json.dumps(
{record_id: record.to_dict() for record_id, record in push_log.items()},
indent=2,
)
revision = revision_of(push_json)
if revision != state.pushed_rev:
client.put_file_text(
_device_log_path(identity.device_id),
push_json,
message="diet_guard sync",
)
# Published after the log, never before: a peer that cached "seen rev
# X" against a log it never received would skip it forever.
client.put_file_text(
f"{_REVS_DIR}/{identity.device_id}",
revision,
message="diet_guard sync: revision",
)
state_store.save(SyncState(pushed_rev=revision, peer_revs=seen_revs))
return resigned
def pull_shared_log() -> str | None:
"""Run a sync tick, failing closed instead of raising.
A thin wrapper over :func:`run_sync` for callers that must never crash on a
sync error: the gate's automatic pre-lock refresh and the lock screen's
manual "Fetch from sync" button. Returns ``None`` on success, or a short
human-readable reason when the pull could not complete, so the caller keeps
its own lock decision rather than failing open.
The three caught types are the whole realistic failure surface of a run:
:class:`SyncError` (no/empty token), :class:`~crdt_sync.RemoteSyncError`
(the shared base of every backend's transport/auth failure), and
:class:`OSError` (reading the token or writing the merged log back). A bug
outside these is deliberately *not* swallowed -- it should surface, not be
silently reported as a sync outage.
``RemoteSyncError``, **not** ``GitHubSyncError``: since Firebase became the
primary backend, ``FirebaseSyncError``/``FirebaseAuthError`` are *siblings*
of ``GitHubSyncError`` under ``RemoteSyncError``, not subclasses. Catching
the GitHub type alone let every per-request Firebase failure escape, so
this fail-closed helper raised a traceback out of the gate's "Fetch from
sync" button instead of returning a reason and leaving the lock up.
"""
try:
run_sync()
except (SyncError, RemoteSyncError, OSError) as exc:
return f"sync unavailable ({exc})"
return None