diet-guard/diet_guard/_cli.py
Krzysztof kuhy Rudnicki 38d66a8492 Make PC sync event-driven; retire the 15-min timer
The periodic diet-guard-sync.timer paid a network round-trip every ~15 min to
approximate the two moments the shared log actually changes on this device.
Sync now fires on those events instead:

* before a lock -- already present in _cli_gate._should_lock, which pulls only
  when a lock is otherwise due, so a phone-logged meal clears the lock and a
  not-due tick never touches the network;
* after a meal is logged here -- new _sync_events.publish_after_log, wired into
  all three write paths (_cli_log, _cli_gate, _mcp). This is what stops the
  PHONE nagging for a slot already logged on the PC, the mirror image of the
  rule that keeps the phone's own tick unconditional.

The gate's publish sits in cmd_gate AFTER MealGate.run() returns, never in the
unlock handler: that runs on the Tk thread with the window still up, so a
hanging network call would keep the user locked in behind a successful log. It
also covers the "Synced from another device" unlock, which hooking log_meal
would miss.

Accepted trade: a PC that is on but neither logging nor locking no longer
pulls, so an inbound phone edit lands at the next log or lock rather than
within 15 min. Bounded by slot spacing -- an unlogged day raises a lock.

install.sh now disables and removes the installed timer (guarded, since a fresh
machine has no such unit and `disable` would abort under `set -euo pipefail`);
the .service stays for manual one-off ticks.

Every test that logs a meal would otherwise reach the network through the new
call sites, so conftest patches publish_after_log at each of the three call
sites -- not on _sync_events, so test_sync_events still exercises the real
helper. Suite stays at ~8s, which is the actual leak detector.

Two files had to be split to commit at all, since file-length passes filenames
and both were already over the 250-line cap:
  _cli.py 466 -> 207, + _cli_args.py, _cli_init.py, _cli_log.py
  _mcp.py 370 -> 194, + _mcp_server.py (singleton + annotations), _mcp_read.py
Their conftest/test patch targets moved in the same commit; a stale target
redirects nothing and fails open.

Verified: 701 python tests at 100% branch coverage, 555 flutter tests,
`python -m diet_guard sync` -> 402 entries/exit 0, `gate --check` -> exit 0,
all five MCP tools still register, live ~/.local/share/diet_guard byte-identical.
Over-cap files: 44 -> 42.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RA9ynjqXPnexRQnR5oAuCC
2026-08-15 19:21:30 +02:00

206 lines
6.4 KiB
Python

"""Command-line interface for diet_guard.
Examples:
python -m diet_guard init
python -m diet_guard ate "big mac"
python -m diet_guard ate "two slices of pizza" --grams 240
python -m diet_guard ate "protein shake" --kcal 180
python -m diet_guard status
python -m diet_guard averages
python -m diet_guard undo
The daily budget lives outside the repo (so it is never exposed online) but is
shown freely on this machine: ``status`` and each log print how many calories
are left of the day's budget, plus which meal slots still need logging.
"""
from __future__ import annotations
import sys
from typing import TYPE_CHECKING
from diet_guard._budget import (
BudgetFileCorruptError,
BudgetNotInitializedError,
daily_budget,
protein_target_g,
)
from diet_guard._cli_args import parse_args
from diet_guard._cli_averages import cmd_averages
from diet_guard._cli_gate import cmd_gate
from diet_guard._cli_init import cmd_init
from diet_guard._cli_log import ManualMacroArgs, Portion, cmd_ate
from diet_guard._cli_sync import cmd_sync
from diet_guard._gate import due_slots
from diet_guard._slots import day_slots, slot_label
from diet_guard._state import (
entry_kcal,
logged_slots_today,
today_entries,
today_total_kcal,
today_total_macros,
undo_last_today,
)
if TYPE_CHECKING:
import argparse
from collections.abc import Callable
# Column width for a meal description in the status listing.
_DESC_WIDTH = 24
# An ISO timestamp formats as "YYYY-MM-DDTHH:MM:SS"; HH:MM is chars 11..16.
_TIME_SLICE = slice(11, 16)
def _emit(text: str = "") -> None:
"""Write one line to stdout.
A thin wrapper over ``sys.stdout.write`` so genuine CLI output does not
trip ruff's ``T201`` (no ``print``) without resorting to a suppression.
"""
sys.stdout.write(f"{text}\n")
def _ask(label: str) -> str:
"""Print a prompt label and return one trimmed line from stdin."""
_emit(label)
return sys.stdin.readline().strip()
def _print_summary() -> None:
"""Print today's total and how much of the daily budget is left.
The budget number is shown here on purpose: it is "hidden" only in the
sense of never leaving this machine (it lives outside the repo), not hidden
from the user, who needs it to make portion decisions.
"""
total = today_total_kcal()
try:
budget = daily_budget()
except BudgetNotInitializedError:
_emit(
f"today: {total:g} kcal (budget not set - run: python -m diet_guard init)",
)
return
except BudgetFileCorruptError:
_emit(f"today: {total:g} kcal (budget file corrupt - re-run init)")
return
remaining = round(budget - total, 1)
_emit(f"today: {total:g} kcal - {remaining:g} kcal left of {budget:g}")
def _print_entry_line(entry: dict[str, object]) -> None:
"""Print a single log entry as 'HH:MM desc kcal (source)'."""
time_str = str(entry.get("time", ""))[_TIME_SLICE]
desc = str(entry.get("desc", "?"))
source = str(entry.get("source", ""))
_emit(
f" {time_str:>5} {desc:<{_DESC_WIDTH}.{_DESC_WIDTH}} "
f"{entry_kcal(entry):>6.0f} kcal ({source})",
)
def _print_slot_status() -> None:
"""Print each meal slot as logged / DUE / upcoming for today."""
logged = logged_slots_today()
due = set(due_slots())
parts: list[str] = []
for slot in day_slots():
if slot in logged:
mark = "logged"
elif slot in due:
mark = "DUE"
else:
mark = "upcoming"
parts.append(f"{slot_label(slot)} {mark}")
_emit("slots: " + " ".join(parts))
def _print_macro_status() -> None:
"""Print today's macros so far, with the protein target when it is known.
Mirrors the gate's dashboard on the command line so "how am I doing" is
answerable without opening the window. The protein target only appears once
the budget has been initialized with a body weight (see ``init``).
"""
protein, carbs, fat = today_total_macros()
line = f"macros: P{protein:g} C{carbs:g} F{fat:g} g"
target = protein_target_g()
if target is not None:
remaining = round(target - protein, 1)
line += f" - protein {protein:g}/{target:g} g ({remaining:g} left)"
_emit(line)
def _cmd_status() -> int:
"""Print today's entries, per-slot status, macros, and the budget remaining."""
entries = today_entries()
for entry in entries:
_print_entry_line(entry)
if entries:
_emit("-" * 48)
_print_slot_status()
_print_summary()
_print_macro_status()
return 0
def _cmd_undo() -> int:
"""Remove today's most recent entry and report what was removed."""
removed = undo_last_today()
if removed is None:
_emit("nothing to undo today.")
return 0
desc = str(removed.get("desc", "?"))
_emit(f"removed: {desc} ({entry_kcal(removed):g} kcal)")
_print_summary()
return 0
def _dispatch_ate(args: argparse.Namespace) -> int:
"""Marshal ``ate``'s flags into value objects, then log the meal."""
return cmd_ate(
_emit,
_print_summary,
args.description,
Portion(grams=args.grams, count=args.count, per_grams=args.per),
ManualMacroArgs(
kcal=args.kcal,
protein=args.protein,
carbs=args.carbs,
fat=args.fat,
),
)
def _dispatch_gate(args: argparse.Namespace) -> int:
"""Run the gate subcommand with its two flags."""
return cmd_gate(_emit, check=args.check, demo=args.demo)
# A table rather than an if-chain: the chain grew a return per subcommand and
# tripped ruff's return-count limit at the seventh, and a table also has no
# unreachable trailing branch for the 100%-coverage gate to chase.
_COMMANDS: dict[str, Callable[[argparse.Namespace], int]] = {
"init": lambda _args: cmd_init(_emit, _ask),
"ate": _dispatch_ate,
"status": lambda _args: _cmd_status(),
"averages": lambda _args: cmd_averages(_emit),
"sync": lambda _args: cmd_sync(_emit),
"gate": _dispatch_gate,
"undo": lambda _args: _cmd_undo(),
}
def main(argv: list[str] | None = None) -> int:
"""Dispatch a diet_guard subcommand.
Args:
argv: Argument list (defaults to ``sys.argv[1:]``).
Returns:
A process exit code (0 on success).
"""
args = parse_args(sys.argv[1:] if argv is None else argv)
return _COMMANDS[args.command](args)