mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-10 19:07:15 +01:00
`requires-python` and the CI pin already said 3.14, but ruff, mypy and pylint were still grading against 3.10 -- three tools judging an interpreter nothing runs any more, and ruff withholding the pyupgrade fixes that only become safe at the newer target. `pin_ci_python.py` rewrites exactly these three knobs. The fixes ruff then had to make are what the stale target was hiding: 18 files on `datetime.timezone.utc` instead of `datetime.UTC` (UP017), plus ruff's stable formatter applying PEP 758 -- `except (A, B):` becomes `except A, B:`. That second one makes the source 3.14-only at the syntax level, which is exactly what `requires-python = ">=3.14"` already promised; kuhy chose it deliberately over holding ruff a release back (2026-08-29). ruff, ruff-format, mypy --strict and pylint all clean at the new target. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SfnJmxxsZ534AKk16rC2Gr
138 lines
5.2 KiB
Python
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 UTC, datetime
|
|
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=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()}
|