diet-guard/diet_guard/_cli_log.py
Krzysztof kuhy Rudnicki 2bdb7925a8 Read the meal schedule from a forward-only history (Python)
Wires the Python side to the configurable schedule. No edit surface yet, so
nothing writes a schedule and current_schedule() returns the default -- the
derived hours are still 08/12/16/20 and the cutoff is still 22:00. Verified by
running it: before any edit the slots are unchanged, after recording 08-20 x5
they become 08:00, 11:00, 14:00, 17:00, 20:00, and a 2020 day still resolves
to the four-meal schedule.

_slots.py now takes the schedule as a *required* argument rather than reading
module constants. Required, not defaulted, on purpose: a default would let a
call site missed in this refactor keep deriving the old fixed hours on one
device, and a slot one device offers while the other does not is a checkpoint
that can never be satisfied. Making it required let mypy enumerate the nine
call sites instead of trusting a grep.

The history is forward-only and seeds the default at the epoch *before*
recording today's value, so switching to five meals leaves past days judged
against the four-meal schedule they actually ran under. Same shape and same
reasoning as _budget_history.py; it will ride the same CRDT record.

Two details worth naming:

- MEAL_SCHEDULE_FILE lands in DATA_DIR, so conftest's _isolate_state redirect
  is patched in this same commit rather than a later one. Confirmed with a
  canary that the redirect is live and that ~/.local/share/diet_guard/ has no
  .meal_schedule after a full run.
- _mcp_read imports _budget.current_schedule already, so the meal-schedule
  lookup is aliased rather than shadowing it.

_gatelock._pending_slots is spelled `is None` rather than `or` now. The two are
in fact equivalent -- if 0 is a slot it is necessarily the first slot, checked
exhaustively -- but the intent should not rest on that coincidence.

761 tests, 100% branch coverage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159b4E5hh512jYUeaJVNxfM
2026-08-16 14:28:22 +02:00

164 lines
5.7 KiB
Python

"""CLI handler for the ``ate`` subcommand.
Split out of :mod:`diet_guard._cli` to hold the repo's 250-line cap, following
the same thin-per-subcommand-handler shape as ``_cli_gate.py`` (``gate``) and
``_cli_sync.py`` (``sync``). The portion/macro value objects live here rather
than in ``_cli`` because ``_cmd_ate`` is their only consumer -- the argparse
layer builds them and hands them straight over.
This is also where a local write turns into a publish: see
:func:`diet_guard._sync_events.publish_after_log` for why that is event-driven
rather than a periodic timer.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING
from diet_guard._foodbank import remember_food
from diet_guard._meal_schedule_store import current_schedule
from diet_guard._portions import DEFAULT_ITEM_GRAMS, estimate_unit_grams
from diet_guard._resolve import ManualMacros, resolve_nutrition
from diet_guard._slots import slot_for_log
from diet_guard._state import log_meal, now_local
from diet_guard._sync_events import publish_after_log
if TYPE_CHECKING:
from collections.abc import Callable
@dataclass(frozen=True)
class ManualMacroArgs:
"""User-supplied calories/macros for ``ate``, all optional.
Grouping these keeps :func:`cmd_ate` within the argument-count limit and
makes "manual values were supplied" a single, testable value object.
Attributes:
kcal: Calories entered manually (None means look the food up instead).
protein: Protein grams, recorded alongside ``kcal``.
carbs: Carbohydrate grams, recorded alongside ``kcal``.
fat: Fat grams, recorded alongside ``kcal``.
"""
kcal: float | None
protein: float | None
carbs: float | None
fat: float | None
@dataclass(frozen=True)
class Portion:
"""How much was eaten and the basis for any typed macros.
Grouped so :func:`cmd_ate` stays within the argument-count limit.
Attributes:
grams: Explicit grams eaten, or None.
count: Number of items eaten (an alternative to ``grams``), or None.
per_grams: Reference weight the typed macros are stated for (e.g. 100
for a per-100 g label), or None to treat the macros as totals.
"""
grams: float | None
count: float | None
per_grams: float | None
def eaten_grams(
description: str,
portion: Portion,
) -> tuple[float | None, str | None]:
"""Resolve how many grams were eaten, plus a note if a weight was assumed.
A count of items is turned into grams via the staple's unit weight; an
unknown item falls back to a default weight, with a note so the estimate is
never silent.
Args:
description: The food name (used to look up a per-item weight).
portion: The user's portion inputs.
Returns:
``(grams, note)`` where ``grams`` may be None (no portion given) and
``note`` is a one-line caveat to print, or None.
"""
if portion.count is not None:
unit = estimate_unit_grams(description)
if unit is None:
return (
portion.count * DEFAULT_ITEM_GRAMS,
f"(assumed {DEFAULT_ITEM_GRAMS:g} g per item; "
"pass --grams to be exact)",
)
return portion.count * unit, None
return portion.grams, None
def cmd_ate(
emit: Callable[[str], None],
print_summary: Callable[[], None],
description: str,
portion: Portion,
macros: ManualMacroArgs,
) -> int:
"""Resolve and log a meal, tag its slot, bank it, publish, print the total.
Resolution order is manual, then food bank, then the staple table, then
Open Food Facts (see :func:`resolve_nutrition`). A per-item count or a
per-reference macro basis is converted to the amount actually eaten first,
and the food is remembered so next time it is served from local history.
Args:
emit: A one-line output sink (``_cli._emit``, passed in rather than
imported -- see ``_cli_sync.cmd_sync`` for why).
print_summary: Prints today's total and remaining budget.
description: The food as the user typed it.
portion: How much was eaten.
macros: Any manually supplied calories/macros.
Returns:
0 once logged, or 1 when the food could not be resolved at all.
"""
eaten, note = eaten_grams(description, portion)
if note is not None:
emit(note)
manual_macros = (
ManualMacros(
kcal=macros.kcal,
protein=macros.protein or 0.0,
carbs=macros.carbs or 0.0,
fat=macros.fat or 0.0,
per_grams=portion.per_grams,
)
if macros.kcal is not None
else None
)
nutrition = resolve_nutrition(
description,
grams=eaten,
manual_macros=manual_macros,
)
if nutrition is None:
emit(
f'no food bank, staple, or Open Food Facts match for "{description}". '
"re-run with --kcal <number> to log it manually.",
)
return 1
log_meal(description, nutrition, slot_for_log(now_local(), current_schedule()))
remember_food(description, nutrition)
macro_str = f"P{nutrition.protein_g:g} C{nutrition.carbs_g:g} F{nutrition.fat_g:g}"
portion_str = f"{nutrition.grams:g} g" if nutrition.grams else "portion n/a"
emit(
f"logged: {description} {nutrition.kcal:g} kcal "
f"({macro_str}) [{nutrition.source}, {portion_str}]",
)
# Publish straight away rather than waiting for a periodic tick: until this
# lands, the phone still believes this slot is unlogged and will nag for it.
reason = publish_after_log()
if reason is not None:
emit(f"logged locally, not yet published ({reason}).")
print_summary()
return 0