diet-guard/diet_guard/_foodbank_manual.py
Krzysztof kuhy Rudnicki 42d15b6a91 Grade against 3.14 too, not just run on it
`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
2026-08-29 11:35:06 +02:00

88 lines
3.1 KiB
Python

"""Hand-curated food bank entries: foods added without ever eating them.
The main food bank (:mod:`diet_guard._foodbank`) is *derived* -- every record
is replayed from the synced food log, so both devices compute the same bank
from the same input and it needs no sync of its own. This module holds the
part that is **not** derivable: entries the user added by hand in the phone
app's food-bank screen, which never appear in any log.
Those were previously phone-only, which made them the last piece of
device-local state in the app. They now sync like everything else -- one
``crdt_sync`` record per normalized name, last-writer-wins by edit time -- so
a food added on the phone autocompletes in the PC gate and vice versa.
Kept in a separate file from the derived bank on purpose: the derived bank is
rewritten wholesale on every log write (:func:`diet_guard._foodbank.
rebuild_food_bank`), so storing curated entries there would lose them on the
next meal.
"""
from __future__ import annotations
from datetime import UTC, datetime
import json
import logging
from diet_guard._constants import MANUAL_BANK_FILE
_logger = logging.getLogger(__name__)
# On-disk shape: {normalized_name: {..bank record fields.., "t": <local ISO>}}.
# ``t`` is the edit timestamp the sync merge compares; it is not part of the
# record's nutritional meaning.
ManualRecord = dict[str, object]
_EPOCH_ISO = "1970-01-01T00:00:00+00:00"
def read_manual_bank() -> dict[str, ManualRecord]:
"""Return the hand-curated bank, or an empty dict on any error.
Never raises: a missing or unreadable file simply means "no curated
entries", which every caller already handles.
"""
if not MANUAL_BANK_FILE.exists():
return {}
try:
with MANUAL_BANK_FILE.open() as handle:
bank = json.load(handle)
except OSError, json.JSONDecodeError:
_logger.warning("Manual food bank %s is unreadable", MANUAL_BANK_FILE)
return {}
if not isinstance(bank, dict):
return {}
return {str(key): value for key, value in bank.items() if isinstance(value, dict)}
def write_manual_bank(bank: dict[str, ManualRecord]) -> None:
"""Persist ``bank`` verbatim, overwriting the file."""
MANUAL_BANK_FILE.parent.mkdir(parents=True, exist_ok=True)
with MANUAL_BANK_FILE.open("w") as handle:
json.dump(bank, handle)
def record_edit_time(record: ManualRecord) -> str:
"""Return ``record``'s edit timestamp, or the epoch when it has none."""
stamp = record.get("t")
return stamp if isinstance(stamp, str) else _EPOCH_ISO
def add_manual_entry(name: str, record: ManualRecord) -> None:
"""Add or replace the curated entry for ``name``, stamping the edit time.
Args:
name: The food's display name; its normalized form is the key.
record: The bank fields to store (``t`` is set here, not by callers).
"""
bank = read_manual_bank()
stamped = dict(record)
stamped["t"] = (
datetime.now(tz=UTC)
.astimezone()
.isoformat(
timespec="seconds",
)
)
bank[name.strip().casefold()] = stamped
write_manual_bank(bank)