diet-guard/diet_guard/_sync.py
Krzysztof kuhy Rudnicki 8a9c7713b9
Some checks are pending
pre-commit / pre-commit (push) Waiting to run
Tests / test (3.10) (push) Waiting to run
Tests / test (3.11) (push) Waiting to run
Tests / test (3.12) (push) Waiting to run
Review fixes: specific sync exceptions, clearer names
Address code-review feedback on the sync-before-lock change:

- pull_shared_log now catches (SyncError, GitHubSyncError, OSError) instead
  of a blanket `except Exception`. crdt_sync wraps every requests transport
  error in GitHubSyncError, so those three are the whole realistic failure
  surface; the gate still fails closed on all of them, and a genuine bug now
  surfaces instead of being hidden as "sync unavailable". Drops the
  broad-exception pylint disable and the per-file BLE001 ignore in pyproject.
- Rename _cli_gate._is_due -> _should_lock: too easily confused with the pure
  local predicate gate_is_due it wraps. The new name says what it decides.
- Rename the list `satisfied` -> `satisfied_slots` in _reconcile_after_fetch;
  it's a list of slots, not a boolean.

No behaviour change; 100% coverage retained.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FtSmzACzJSGyEatgK9exkd
2026-07-11 18:33:43 +02:00

156 lines
5.5 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.
"""
from __future__ import annotations
import json
import logging
from crdt_sync import GitHubSyncClient, GitHubSyncError, Log, merge_logs
from diet_guard._constants import (
SYNC_DEVICE_ID,
SYNC_REPO_NAME,
SYNC_REPO_OWNER,
SYNC_TIMEOUT_SECONDS,
SYNC_TOKEN_FILE,
)
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_merge import daylog_to_log, log_to_daylog, parse_remote_log
_logger = logging.getLogger(__name__)
_DEVICES_DIR = "diet-guard-sync/devices"
class SyncError(Exception):
"""Raised when a sync run cannot even start (no usable PAT)."""
def _device_log_path(device_id: str) -> str:
"""Return the repo-relative path a device's full log is pushed to."""
return f"{_DEVICES_DIR}/{device_id}/food_log.json"
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
def _pull_remote_logs(client: GitHubSyncClient) -> 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.
"""
remote_logs: list[Log] = []
for device_id in client.list_directory(_DEVICES_DIR):
if device_id == SYNC_DEVICE_ID:
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):
_logger.warning("Unparsable log pushed by device %r, skipping", device_id)
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`.
Returns:
The merged log as it now sits on disk locally, post re-sign.
Raises:
SyncError: If the local PAT is missing or empty.
crdt_sync.GitHubSyncError: Propagated from the GitHub client for any
transport failure -- the caller (CLI/timer) decides how to
report it.
"""
token = _read_token()
client = GitHubSyncClient(
SYNC_REPO_OWNER, SYNC_REPO_NAME, token, timeout_seconds=SYNC_TIMEOUT_SECONDS
)
merged = daylog_to_log(read_raw_log())
for remote_log in _pull_remote_logs(client):
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)
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,
)
client.put_file_text(
_device_log_path(SYNC_DEVICE_ID),
push_json,
message="diet_guard sync",
)
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.GitHubSyncError`
(the client wraps every ``requests`` transport error in this), 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.
"""
try:
run_sync()
except (SyncError, GitHubSyncError, OSError) as exc:
return f"sync unavailable ({exc})"
return None