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

138 lines
5.2 KiB
Python

"""Food-bank <-> ``crdt_sync.Record`` adapters, for both halves of the bank.
The derived bank's merge clock is the record's own ``count``, not a wall time:
last-writer-wins therefore means *max-count-wins*, which is the correct merge
for a derived counter (the device that has seen more of the log has the higher
count) and is idempotent, since the count only moves when the log does.
The curated bank is one record per normalized name, LWW by an ``editedAt``
stamp -- those foods were never eaten, so they are not derivable from any log.
"""
from __future__ import annotations
from datetime import datetime, timezone
import json
from typing import TYPE_CHECKING
from crdt_sync import Hlc, Record
from diet_guard._device import device_id
from diet_guard._foodbank_manual import record_edit_time
from diet_guard.sync_merge._clock import _wall_time_ms
if TYPE_CHECKING:
from collections.abc import Mapping
from crdt_sync import Log
def food_bank_to_log(bank: Mapping[str, object]) -> Log:
"""Convert the log-derived food bank into a ``crdt_sync.Log``.
The bank is *derived* -- both devices replay the same synced log and
compute the same records -- so this exists to make them agree
**immediately** rather than only after each has replayed, and to let a
device that is missing part of the log still autocomplete the other's
foods.
The clock is the record's own ``count``, not a wall time. That makes
last-writer-wins mean *max-count-wins*, which is the right merge for a
derived counter: a device that has seen more of the log has the higher
count, and re-merging is idempotent because the count does not move
unless the log did. It also avoids inventing an edit timestamp for a
record nobody edits.
"""
log: Log = {}
for name, record in bank.items():
if not isinstance(record, dict):
continue
count = record.get("count")
ticks = int(count) if isinstance(count, (int, float)) else 0
hlc = Hlc.new_tick(device_id(), wall_time_ms=ticks)
log[name] = Record(id=name, fields={"body": (dict(record), hlc)})
return log
def log_to_food_bank(log: Log) -> dict[str, dict[str, object]]:
"""Convert a merged food-bank ``Log`` back into on-disk bank shape."""
bank: dict[str, dict[str, object]] = {}
for name, record in log.items():
if record.deleted:
continue
body, _hlc = record.fields.get("body", ({}, None))
if isinstance(body, dict):
bank[name] = dict(body)
return bank
def parse_remote_food_bank(text: str) -> Log:
"""Parse one device's pushed ``food_bank.json`` into a ``crdt_sync.Log``.
Raises:
TypeError: If the top-level JSON isn't an object.
KeyError: Via ``Record.from_dict``, on a record missing a key.
ValueError: Via ``json.loads``/``Hlc.from_str`` on malformed input.
"""
raw = json.loads(text)
if not isinstance(raw, dict):
msg = f"top-level food-bank payload is not a JSON object: {raw!r}"
raise TypeError(msg)
return {record_id: Record.from_dict(data) for record_id, data in raw.items()}
def manual_bank_to_log(bank: Mapping[str, object]) -> Log:
"""Convert the hand-curated food bank into a ``crdt_sync.Log``.
One record per normalized food name, carrying the whole bank record as an
opaque ``body`` -- the same shape food-log entries use. Unlike an entry
a curated food is editable, so its Hlc comes from the record's own ``t``
edit stamp (like the budget's) rather than a fixed birth time.
"""
log: Log = {}
for name, record in bank.items():
if not isinstance(record, dict):
continue
hlc = Hlc.new_tick(
device_id(),
wall_time_ms=_wall_time_ms(record_edit_time(record)),
)
body = {k: v for k, v in record.items() if k != "t"}
log[name] = Record(id=name, fields={"body": (body, hlc)})
return log
def log_to_manual_bank(log: Log) -> dict[str, dict[str, object]]:
"""Convert a merged curated-bank ``Log`` back into on-disk bank shape."""
bank: dict[str, dict[str, object]] = {}
for name, record in log.items():
if record.deleted:
continue
body, hlc = record.fields.get("body", ({}, None))
if not isinstance(body, dict):
continue
stored = dict(body)
if hlc is not None:
winning = datetime.fromtimestamp(hlc.wall_time_ms / 1000, tz=timezone.utc)
stored["t"] = winning.astimezone().isoformat(timespec="seconds")
bank[name] = stored
return bank
def parse_remote_manual_bank(text: str) -> Log:
"""Parse one device's pushed curated-bank text into a ``crdt_sync.Log``.
Raises on malformed data; the caller logs-and-skips, matching every other
remote parser here.
Raises:
TypeError: If the top-level JSON isn't an object.
KeyError: Via ``Record.from_dict``, on a record missing a key.
ValueError: Via ``json.loads``/``Hlc.from_str`` on malformed input.
"""
raw = json.loads(text)
if not isinstance(raw, dict):
msg = f"top-level curated-bank payload is not a JSON object: {raw!r}"
raise TypeError(msg)
return {record_id: Record.from_dict(data) for record_id, data in raw.items()}