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

234 lines
8.1 KiB
Python

"""Effective-from history of the daily kcal budget.
The budget is a single, freely-editable number (see :mod:`diet_guard._budget`),
but *classifying a past day* must use the budget that applied on that day --
otherwise lowering the budget silently reclassifies months of history, breaking
the adherence streak and the year-to-date tally for days that were in fact
adherent at the time.
The history is a forward-only list of ``(effective_from, kcal)`` entries. The
budget for a day is the newest entry whose ``effective_from`` is on or before
it. Editing the budget appends (or, for a same-day re-edit, replaces) the
entry for that day; every earlier day keeps whatever applied then.
Storage and sync are deliberately split:
* the entries live in their own file (``.budget_history``), leaving
:mod:`diet_guard._budget`'s ``.budget`` schema untouched at v2;
* they travel as extra ``hist:<YYYY-MM-DD>`` *fields* on the existing
``budget`` CRDT record (see :mod:`diet_guard.sync_merge`).
That second point is the load-bearing one. ``crdt_sync``'s ``merge_record``
does per-field last-writer-wins over the *union* of field names, and both
devices push the merged record rather than their own, so a device that knows
nothing about history can neither clobber those fields nor stop them
propagating -- it relays them untouched. No coordinated release is needed.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, datetime
import json
import logging
from diet_guard._constants import BUDGET_HISTORY_FILE
_logger = logging.getLogger(__name__)
_FILE_VERSION = 1
# The effective-from date the migration seeds, so the pre-history budget
# covers every day that was ever logged. Any real date is >= this.
EPOCH_DAY = "1970-01-01"
_EPOCH_ISO = "1970-01-01T00:00:00+00:00"
@dataclass(frozen=True)
class BudgetEntry:
"""One budget value and the date it started applying.
Attributes:
effective_from: ``YYYY-MM-DD``; the first day this value applies to.
kcal: The daily budget in kcal from that day onward.
edited_at: Local ISO-8601 timestamp of the edit that created it, used
to derive a deterministic Hlc for the sync merge.
"""
effective_from: str
kcal: int
edited_at: str
@dataclass(frozen=True)
class BudgetSchedule:
"""The budget as a function of the day, with a fallback for empty history.
Attributes:
entries: Ascending by ``effective_from``.
default: Used when no entry applies -- an unset device, or a day
before the earliest entry. In practice the second case never
arises, since the migration always seeds :data:`EPOCH_DAY`.
"""
entries: tuple[BudgetEntry, ...]
default: int
def for_day(self, day: str) -> int:
"""Return the budget that applied on ``day`` (a ``YYYY-MM-DD`` key)."""
applicable = [e for e in self.entries if e.effective_from <= day]
if not applicable:
return self.default
return applicable[-1].kcal
def history_from_json(raw: object) -> tuple[BudgetEntry, ...]:
"""Parse a stored history document, tolerating anything malformed.
Deliberately single-branched on bad input: a corrupt or unrecognised
history degrades to "no history", which callers already handle by falling
back to the current scalar budget. Every extra rejection branch would be
another branch the 100%-coverage gate has to reach for no behavioural gain.
Args:
raw: The parsed JSON document, of any shape.
Returns:
The entries, ascending by ``effective_from`` (empty if unusable).
"""
if not isinstance(raw, dict) or raw.get("v") != _FILE_VERSION:
return ()
stored = raw.get("e")
if not isinstance(stored, dict):
return ()
entries = []
for day, record in stored.items():
if not isinstance(record, dict):
continue
kcal = record.get("b")
if isinstance(kcal, bool) or not isinstance(kcal, int):
continue
edited_at = record.get("t")
entries.append(
BudgetEntry(
effective_from=str(day),
kcal=kcal,
edited_at=edited_at if isinstance(edited_at, str) else _EPOCH_ISO,
),
)
return tuple(sorted(entries, key=lambda e: e.effective_from))
def history_to_json(entries: tuple[BudgetEntry, ...]) -> dict[str, object]:
"""Serialize ``entries`` to the stored document shape."""
return {
"v": _FILE_VERSION,
"e": {e.effective_from: {"b": e.kcal, "t": e.edited_at} for e in entries},
}
def upsert(
entries: tuple[BudgetEntry, ...],
*,
kcal: int,
when: datetime,
) -> tuple[BudgetEntry, ...]:
"""Return ``entries`` with ``when``'s date set to ``kcal``.
Keyed on the date, so editing the budget twice in one day replaces that
day's entry instead of accumulating duplicates.
Args:
entries: The current history.
kcal: The new budget value.
when: The edit time; its local date becomes ``effective_from``.
Returns:
The updated history, ascending by ``effective_from``.
"""
day = when.date().isoformat()
kept = [e for e in entries if e.effective_from != day]
kept.append(
BudgetEntry(
effective_from=day,
kcal=int(kcal),
edited_at=when.isoformat(timespec="seconds"),
),
)
return tuple(sorted(kept, key=lambda e: e.effective_from))
def seed_from_budget(record: dict[str, object] | None) -> tuple[BudgetEntry, ...]:
"""Return the one-entry history that grandfathers an existing budget.
The pre-history budget is treated as having applied since
:data:`EPOCH_DAY`, so every already-logged day keeps the value it was
actually judged against. Reuses the record's own ``t`` rather than "now",
which is what lets two devices seed independently and still converge: both
derive the same value at the same Hlc wall time.
Args:
record: The raw ``.budget`` record, or None if never initialised.
Returns:
A single-entry history, or empty when there is no budget to grandfather.
"""
if record is None:
return ()
kcal = record.get("b")
if isinstance(kcal, bool) or not isinstance(kcal, int):
return ()
edited_at = record.get("t")
return (
BudgetEntry(
effective_from=EPOCH_DAY,
kcal=kcal,
edited_at=edited_at if isinstance(edited_at, str) else _EPOCH_ISO,
),
)
def read_raw_history() -> dict[str, object] | None:
"""Return the on-disk history document verbatim, or None if absent/corrupt.
Never raises: an unreadable history means "fall back to the scalar
budget", which is exactly the pre-history behaviour.
"""
if not BUDGET_HISTORY_FILE.exists():
return None
try:
with BUDGET_HISTORY_FILE.open() as handle:
document = json.load(handle)
except OSError, json.JSONDecodeError:
return None
if not isinstance(document, dict):
return None
return document
def write_raw_history(document: dict[str, object]) -> None:
"""Persist ``document`` verbatim, overwriting the history file."""
BUDGET_HISTORY_FILE.parent.mkdir(parents=True, exist_ok=True)
with BUDGET_HISTORY_FILE.open("w") as handle:
json.dump(document, handle)
def load_entries() -> tuple[BudgetEntry, ...]:
"""Return the stored history, or empty if none has been written yet.
Deliberately does not reach into :mod:`diet_guard._budget` to lazily seed:
that would be a circular import, and it would hide *where* seeding happens.
Seeding lives in :func:`diet_guard._budget.current_schedule` and
:func:`diet_guard._budget.write_budget`, which own the value being
grandfathered.
"""
raw = read_raw_history()
return () if raw is None else history_from_json(raw)
def record_budget_change(kcal: int, *, when: datetime | None = None) -> None:
"""Record a budget edit, effective from the day it was made."""
moment = when if when is not None else datetime.now(tz=UTC).astimezone()
write_raw_history(history_to_json(upsert(load_entries(), kcal=kcal, when=moment)))