From 38d66a8492cd19495e71a5304b36f45393bfc698 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sat, 15 Aug 2026 19:21:30 +0200 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_01RA9ynjqXPnexRQnR5oAuCC --- CLAUDE.md | 34 ++- app/pubspec.yaml | 2 +- diet-guard-sync.timer | 14 -- diet_guard/_cli.py | 289 ++------------------------ diet_guard/_cli_args.py | 100 +++++++++ diet_guard/_cli_gate.py | 10 + diet_guard/_cli_init.py | 85 ++++++++ diet_guard/_cli_log.py | 162 +++++++++++++++ diet_guard/_mcp.py | 230 +++----------------- diet_guard/_mcp_read.py | 186 +++++++++++++++++ diet_guard/_mcp_server.py | 49 +++++ diet_guard/_sync_events.py | 50 +++++ diet_guard/tests/conftest.py | 8 + diet_guard/tests/test_cli.py | 45 +++- diet_guard/tests/test_cli_gate.py | 44 ++++ diet_guard/tests/test_mcp.py | 62 ++++-- diet_guard/tests/test_mcp_averages.py | 30 +-- diet_guard/tests/test_sync_events.py | 54 +++++ install.sh | 21 +- 19 files changed, 928 insertions(+), 547 deletions(-) delete mode 100644 diet-guard-sync.timer create mode 100644 diet_guard/_cli_args.py create mode 100644 diet_guard/_cli_init.py create mode 100644 diet_guard/_cli_log.py create mode 100644 diet_guard/_mcp_read.py create mode 100644 diet_guard/_mcp_server.py create mode 100644 diet_guard/_sync_events.py create mode 100644 diet_guard/tests/test_sync_events.py diff --git a/CLAUDE.md b/CLAUDE.md index 5c629a1..22acc8f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,13 +36,43 @@ see the unit file's comments and `wait_for_display()`. ## Cross-device sync -`diet-guard-sync.timer` runs `python -m diet_guard sync` every ~15 min, -headless. It pulls every other device's log from `kuhyx/syncs` +A sync tick pulls every other device's log from `kuhyx/syncs` (`diet-guard-sync/`, dumb file storage via the REST Contents API), merges (`_sync_merge.merge_logs`: union by `id`, tombstone wins, legacy `(time, desc)` dedup), **re-signs every persisted entry**, rebuilds the food bank, pushes back. +**On the PC, sync is event-driven — there is no periodic timer.** The old +`diet-guard-sync.timer` (~15 min) was deleted: it paid a network round-trip on +every idle tick to approximate the two moments state actually changes here. + +- **Before a lock** — `_cli_gate._should_lock` runs the cheap local + `gate_is_due()` first and pulls *only* when a lock is otherwise due, so a + meal just logged on the phone clears the lock and a not-due tick never + touches the network. +- **After a meal is logged** — `_sync_events.publish_after_log`, called from + all three write paths (`_cli`, `_cli_gate`, `_mcp`). This is what stops the + *phone* nagging for a slot already logged on the PC. +- The gate's publish lives 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 does not + pull, 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. +- **`gate --demo` publishes too.** The call sits after the `finally`, so it + runs in demo as well. "Safe, closeable" means it cannot trap you at the + keyboard — not that it is offline: demo already writes real entries through + `log_meal` (only the *slot* is synthetic), so publishing them is consistent + rather than a new leak. +- `pull_shared_log()` is misnamed: it runs a *full* tick (pull, merge, re-sign, + persist, **push**, plus budget and both food banks), not just a pull. +- Every test that logs a meal would otherwise hit the network through these new + call sites; `conftest._isolate_state` patches `publish_after_log` at each of + the three call sites (not on `_sync_events`, so a direct test still exercises + the real helper). + - **Re-sign on every merge, not just phone-origin entries.** `_entry_is_valid()` drops unsigned entries once a machine has the HMAC key, and the phone never holds that key — skipping the re-sign loses every phone-logged meal on the diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 559fbf6..ca28145 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -2,7 +2,7 @@ name: diet_guard_app description: "Companion phone app for diet_guard: log meals on the go." publish_to: 'none' -version: 1.0.15+1 +version: 1.0.16+1 environment: sdk: ^3.12.2 diff --git a/diet-guard-sync.timer b/diet-guard-sync.timer deleted file mode 100644 index 4c2fdd1..0000000 --- a/diet-guard-sync.timer +++ /dev/null @@ -1,14 +0,0 @@ -[Unit] -Description=Periodically sync the Diet Guard log with other devices - -[Timer] -# Every 15 minutes -- matches the phone app's WorkManager periodic sync (15 -# min is also Android's WorkManager periodic floor), so both sides converge -# on roughly the same cadence. Persistent catches a run missed while the -# machine was suspended; OnCalendar re-arms every period regardless (see -# diet-guard-gate.timer's comment for why OnCalendar over OnBootSec here too). -OnCalendar=*-*-* *:00/15:00 -Persistent=true - -[Install] -WantedBy=timers.target diff --git a/diet_guard/_cli.py b/diet_guard/_cli.py index bc48fb2..a326036 100644 --- a/diet_guard/_cli.py +++ b/diet_guard/_cli.py @@ -16,36 +16,26 @@ are left of the day's budget, plus which meal slots still need logging. from __future__ import annotations -import argparse -from dataclasses import dataclass import sys from typing import TYPE_CHECKING from diet_guard._budget import ( - Biometrics, BudgetFileCorruptError, BudgetNotInitializedError, - compute_target_budget, daily_budget, protein_target_g, - write_budget, ) -from diet_guard._cli_averages import cmd_averages, register_averages_subparser +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_sync import cmd_sync, register_sync_subparser -from diet_guard._foodbank import remember_food +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._portions import ( - DEFAULT_ITEM_GRAMS, - estimate_unit_grams, -) -from diet_guard._resolve import ManualMacros, resolve_nutrition -from diet_guard._slots import day_slots, slot_for_log, slot_label +from diet_guard._slots import day_slots, slot_label from diet_guard._state import ( entry_kcal, - log_meal, logged_slots_today, - now_local, today_entries, today_total_kcal, today_total_macros, @@ -53,53 +43,13 @@ from diet_guard._state import ( ) 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) -# Accepted answers for the sex prompt that map to the male BMR constant. -_MALE_ANSWERS = {"m", "male"} -_FEMALE_ANSWERS = {"f", "female"} - - -@dataclass(frozen=True) -class _ManualMacros: - """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 _emit(text: str = "") -> None: @@ -117,88 +67,6 @@ def _ask(label: str) -> str: return sys.stdin.readline().strip() -def _parse_args(argv: list[str]) -> argparse.Namespace: - """Parse diet_guard CLI arguments.""" - parser = argparse.ArgumentParser( - prog="diet_guard", - description="Log calories and check your daily budget.", - ) - sub = parser.add_subparsers(dest="command", required=True) - - sub.add_parser( - "init", - help="Compute your starting daily budget from biometrics.", - ) - - ate = sub.add_parser("ate", help="Log a meal you just ate.") - ate.add_argument("description", help='What you ate, e.g. "big mac".') - ate.add_argument( - "--grams", - type=float, - default=None, - help="Portion size in grams (default: OFF serving size, else 100 g).", - ) - ate.add_argument( - "--kcal", - type=float, - default=None, - help="Calories entered manually; skips the food bank and OFF lookup.", - ) - ate.add_argument( - "--protein", - type=float, - default=None, - help="Protein in grams (recorded with --kcal to seed the food bank).", - ) - ate.add_argument( - "--carbs", - type=float, - default=None, - help="Carbohydrate in grams (recorded with --kcal).", - ) - ate.add_argument( - "--fat", - type=float, - default=None, - help="Fat in grams (recorded with --kcal).", - ) - ate.add_argument( - "--per", - type=float, - default=None, - help="Grams the macros are stated for (e.g. 100 for a per-100 g label);" - " the typed macros are scaled from this to how much you ate.", - ) - ate.add_argument( - "--count", - type=float, - default=None, - help="Number of items eaten (e.g. 5 apples) instead of --grams;" - " multiplied by the staple's unit weight.", - ) - - sub.add_parser("status", help="Show today's calories and budget band.") - sub.add_parser("undo", help="Remove today's most recent entry.") - register_averages_subparser(sub) - register_sync_subparser(sub) - - gate = sub.add_parser( - "gate", - help="Log-to-unlock screen gate (intended to be run by a timer).", - ) - gate.add_argument( - "--check", - action="store_true", - help="Headless: exit 0 if NOT due, 1 if a lock is due. Prints, no window.", - ) - gate.add_argument( - "--demo", - action="store_true", - help="Show the lock in safe demo mode (local grab + close button).", - ) - return parser.parse_args(argv) - - def _print_summary() -> None: """Print today's total and how much of the daily budget is left. @@ -232,137 +100,6 @@ def _print_entry_line(entry: dict[str, object]) -> None: ) -def _read_init_inputs() -> tuple[Biometrics, float, float] | None: - """Prompt for biometrics on stdin; return (bio, activity, deficit) or None. - - Returns None (after printing why) on any unparsable or out-of-range input, - so a typo never sets a wrong budget. - """ - try: - weight = float(_ask("weight in kg:")) - height = float(_ask("height in cm:")) - age = float(_ask("age in years:")) - sex_raw = _ask("sex (m/f):").lower() - activity = float( - _ask( - "activity factor " - "(1.2 sedentary / 1.375 light / 1.55 moderate / 1.725 active):", - ), - ) - deficit = float(_ask("daily deficit in kcal (e.g. 200):")) - except ValueError: - _emit("that was not a number; nothing was set.") - return None - - if sex_raw in _MALE_ANSWERS: - is_male = True - elif sex_raw in _FEMALE_ANSWERS: - is_male = False - else: - _emit('sex must be "m" or "f"; nothing was set.') - return None - - bio = Biometrics( - weight_kg=weight, - height_cm=height, - age_years=age, - is_male=is_male, - ) - return bio, activity, deficit - - -def _cmd_init() -> int: - """Compute the starting budget from biometrics and write it.""" - inputs = _read_init_inputs() - if inputs is None: - return 2 - bio, activity, deficit = inputs - budget = compute_target_budget( - bio, - activity_factor=activity, - deficit_kcal=deficit, - ) - write_budget(budget, weight_kg=bio.weight_kg) - _emit(f"budget computed from your biometrics: {budget:g} kcal/day.") - _emit("edit it any time from the gate's calendar tab or the phone app.") - return 0 - - -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(description: str, portion: _Portion, macros: _ManualMacros) -> int: - """Resolve and log a meal, tag its slot, bank it, then 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. - """ - 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 to log it manually.", - ) - return 1 - log_meal(description, nutrition, slot_for_log(now_local())) - 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}]", - ) - _print_summary() - return 0 - - def _print_slot_status() -> None: """Print each meal slot as logged / DUE / upcoming for today.""" logged = logged_slots_today() @@ -422,10 +159,12 @@ def _cmd_undo() -> int: def _dispatch_ate(args: argparse.Namespace) -> int: """Marshal ``ate``'s flags into value objects, then log the meal.""" - return _cmd_ate( + return cmd_ate( + _emit, + _print_summary, args.description, - _Portion(grams=args.grams, count=args.count, per_grams=args.per), - _ManualMacros( + Portion(grams=args.grams, count=args.count, per_grams=args.per), + ManualMacroArgs( kcal=args.kcal, protein=args.protein, carbs=args.carbs, @@ -443,7 +182,7 @@ def _dispatch_gate(args: argparse.Namespace) -> int: # 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(), + "init": lambda _args: cmd_init(_emit, _ask), "ate": _dispatch_ate, "status": lambda _args: _cmd_status(), "averages": lambda _args: cmd_averages(_emit), @@ -462,5 +201,5 @@ def main(argv: list[str] | None = None) -> int: Returns: A process exit code (0 on success). """ - args = _parse_args(sys.argv[1:] if argv is None else argv) + args = parse_args(sys.argv[1:] if argv is None else argv) return _COMMANDS[args.command](args) diff --git a/diet_guard/_cli_args.py b/diet_guard/_cli_args.py new file mode 100644 index 0000000..1ba8a23 --- /dev/null +++ b/diet_guard/_cli_args.py @@ -0,0 +1,100 @@ +"""Argument parsing for the diet_guard CLI. + +Split out of :mod:`diet_guard._cli` to hold the repo's 250-line cap. This is +the declarative half -- every subcommand and flag the CLI accepts -- kept apart +from the handlers that act on them, so adding a flag never grows a file that +also contains logic. + +Subparsers owned by a sibling handler register themselves through their own +module (``register_averages_subparser``, ``register_sync_subparser``) rather +than being spelled out here, so a subcommand's flags live next to its code. +""" + +from __future__ import annotations + +import argparse + +from diet_guard._cli_averages import register_averages_subparser +from diet_guard._cli_sync import register_sync_subparser + + +def parse_args(argv: list[str]) -> argparse.Namespace: + """Parse diet_guard CLI arguments.""" + parser = argparse.ArgumentParser( + prog="diet_guard", + description="Log calories and check your daily budget.", + ) + sub = parser.add_subparsers(dest="command", required=True) + + sub.add_parser( + "init", + help="Compute your starting daily budget from biometrics.", + ) + + ate = sub.add_parser("ate", help="Log a meal you just ate.") + ate.add_argument("description", help='What you ate, e.g. "big mac".') + ate.add_argument( + "--grams", + type=float, + default=None, + help="Portion size in grams (default: OFF serving size, else 100 g).", + ) + ate.add_argument( + "--kcal", + type=float, + default=None, + help="Calories entered manually; skips the food bank and OFF lookup.", + ) + ate.add_argument( + "--protein", + type=float, + default=None, + help="Protein in grams (recorded with --kcal to seed the food bank).", + ) + ate.add_argument( + "--carbs", + type=float, + default=None, + help="Carbohydrate in grams (recorded with --kcal).", + ) + ate.add_argument( + "--fat", + type=float, + default=None, + help="Fat in grams (recorded with --kcal).", + ) + ate.add_argument( + "--per", + type=float, + default=None, + help="Grams the macros are stated for (e.g. 100 for a per-100 g label);" + " the typed macros are scaled from this to how much you ate.", + ) + ate.add_argument( + "--count", + type=float, + default=None, + help="Number of items eaten (e.g. 5 apples) instead of --grams;" + " multiplied by the staple's unit weight.", + ) + + sub.add_parser("status", help="Show today's calories and budget band.") + sub.add_parser("undo", help="Remove today's most recent entry.") + register_averages_subparser(sub) + register_sync_subparser(sub) + + gate = sub.add_parser( + "gate", + help="Log-to-unlock screen gate (intended to be run by a timer).", + ) + gate.add_argument( + "--check", + action="store_true", + help="Headless: exit 0 if NOT due, 1 if a lock is due. Prints, no window.", + ) + gate.add_argument( + "--demo", + action="store_true", + help="Show the lock in safe demo mode (local grab + close button).", + ) + return parser.parse_args(argv) diff --git a/diet_guard/_cli_gate.py b/diet_guard/_cli_gate.py index 563ec20..a58b80a 100644 --- a/diet_guard/_cli_gate.py +++ b/diet_guard/_cli_gate.py @@ -14,6 +14,7 @@ from diet_guard._gate import gate_is_due from diet_guard._gatelock import MealGate, acquire_gate_lock, release_gate_lock from diet_guard._gatelock_support import wait_for_display from diet_guard._sync import pull_shared_log +from diet_guard._sync_events import publish_after_log if TYPE_CHECKING: from collections.abc import Callable @@ -79,4 +80,13 @@ def cmd_gate(emit: Callable[[str], None], *, check: bool, demo: bool) -> int: MealGate(demo_mode=demo).run() finally: release_gate_lock(handle) + # Publish only once ``run()`` has returned -- i.e. the mainloop has ended and + # the window is gone. Deliberately NOT inside the unlock handler: that runs + # on the Tk thread with the lock still up, so a slow or hanging network call + # would keep the user locked in behind a successful log. Here the worst case + # is a late publish. One call site covers every unlock reason, including the + # "Synced from another device" path, which hooking the log itself would miss. + reason = publish_after_log() + if reason is not None: + emit(f"logged locally, not yet published ({reason}).") return 0 diff --git a/diet_guard/_cli_init.py b/diet_guard/_cli_init.py new file mode 100644 index 0000000..b81fcc3 --- /dev/null +++ b/diet_guard/_cli_init.py @@ -0,0 +1,85 @@ +"""CLI handler for the ``init`` 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``), +``_cli_log.py`` (``ate``) and ``_cli_sync.py`` (``sync``). + +The biometrics prompted for here are used **once** and discarded: only the +computed budget (and the body weight, which the protein target needs) is ever +persisted. See ``CLAUDE.md``'s "Biometrics are used once and discarded". +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from diet_guard._budget import Biometrics, compute_target_budget, write_budget + +if TYPE_CHECKING: + from collections.abc import Callable + +# Accepted answers for the sex prompt that map to the male BMR constant. +MALE_ANSWERS = {"m", "male"} +FEMALE_ANSWERS = {"f", "female"} + + +def read_init_inputs( + emit: Callable[[str], None], + ask: Callable[[str], str], +) -> tuple[Biometrics, float, float] | None: + """Prompt for biometrics on stdin; return (bio, activity, deficit) or None. + + Returns None (after printing why) on any unparsable or out-of-range input, + so a typo never sets a wrong budget. + """ + try: + weight = float(ask("weight in kg:")) + height = float(ask("height in cm:")) + age = float(ask("age in years:")) + sex_raw = ask("sex (m/f):").lower() + activity = float( + ask( + "activity factor " + "(1.2 sedentary / 1.375 light / 1.55 moderate / 1.725 active):", + ), + ) + deficit = float(ask("daily deficit in kcal (e.g. 200):")) + except ValueError: + emit("that was not a number; nothing was set.") + return None + + if sex_raw in MALE_ANSWERS: + is_male = True + elif sex_raw in FEMALE_ANSWERS: + is_male = False + else: + emit('sex must be "m" or "f"; nothing was set.') + return None + + bio = Biometrics( + weight_kg=weight, + height_cm=height, + age_years=age, + is_male=is_male, + ) + return bio, activity, deficit + + +def cmd_init( + emit: Callable[[str], None], + ask: Callable[[str], str], +) -> int: + """Compute the starting budget from biometrics and write it.""" + inputs = read_init_inputs(emit, ask) + if inputs is None: + return 2 + bio, activity, deficit = inputs + budget = compute_target_budget( + bio, + activity_factor=activity, + deficit_kcal=deficit, + ) + write_budget(budget, weight_kg=bio.weight_kg) + emit(f"budget computed from your biometrics: {budget:g} kcal/day.") + emit("edit it any time from the gate's calendar tab or the phone app.") + return 0 diff --git a/diet_guard/_cli_log.py b/diet_guard/_cli_log.py new file mode 100644 index 0000000..fd54eb2 --- /dev/null +++ b/diet_guard/_cli_log.py @@ -0,0 +1,162 @@ +"""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._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 to log it manually.", + ) + return 1 + log_meal(description, nutrition, slot_for_log(now_local())) + 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 diff --git a/diet_guard/_mcp.py b/diet_guard/_mcp.py index 16c1ec6..989e54f 100644 --- a/diet_guard/_mcp.py +++ b/diet_guard/_mcp.py @@ -33,211 +33,24 @@ Safety invariants (do not break when adding tools): from __future__ import annotations -import logging import sys from typing import Any -from mcp.server import MCPServer -from mcp.types import ToolAnnotations from pydantic import BaseModel -from diet_guard._averages import ( - PeriodAverage, - band_label, - monthly_average, - weekly_average, -) -from diet_guard._budget import BudgetError, current_schedule, daily_budget -from diet_guard._gate import due_slots +# Importing these IS what registers the read tools: the module-level +# ``@mcp.tool`` decorators in ``_mcp_read`` run against the shared singleton at +# import time. They are re-exported via ``__all__`` rather than imported bare so +# the names are genuinely used -- drop this and the server starts write-only. +from diet_guard._mcp_read import get_averages, get_slots, get_status, list_today +from diet_guard._mcp_server import APPENDS, logger, mcp from diet_guard._resolve import ManualMacros, resolve_nutrition -from diet_guard._slots import current_slot, day_slots, slot_for_log, slot_label -from diet_guard._state import ( - consumption_band, - load_log, - logged_slots_today, - now_local, - today_entries, - today_total_kcal, - today_total_macros, -) +from diet_guard._slots import slot_for_log from diet_guard._state import ( log_meal as record_meal, ) - -# Log to STDERR only -- STDOUT carries the MCP JSON-RPC protocol frames, so a -# single stray stdout write would corrupt the stream and kill the session. -logging.basicConfig( - level=logging.INFO, - stream=sys.stderr, - format="%(asctime)s [%(levelname)s] diet-guard-mcp: %(message)s", -) -logger = logging.getLogger(__name__) - -mcp = MCPServer("diet-guard") - -# Machine-readable versions of what the docstrings already say, so a client -# can decide what to run unattended. The day a "read" tool starts writing, -# these annotations are what has to change with it. -_READS_ONLY = ToolAnnotations( - read_only_hint=True, - idempotent_hint=True, - open_world_hint=False, -) -# log_meal is the one tool that writes. It appends and never rewrites or -# deletes, so it is not destructive; it is not idempotent either, because a -# second confirmed call logs a second meal. It reaches Open Food Facts when -# macros are not supplied, which is exactly what open_world means. -_APPENDS = ToolAnnotations( - read_only_hint=False, - destructive_hint=False, - idempotent_hint=False, - open_world_hint=True, -) - - -def _entry_view(entry: dict[str, object]) -> dict[str, Any]: - """Project a stored log entry to a safe, JSON-friendly summary. - - Deliberately drops the ``hmac`` field and any unknown keys: the signature - is internal integrity metadata, not something a client needs. - - Args: - entry: A stored food-log entry. - - Returns: - A flat dict of the display-relevant fields. - """ - return { - "time": entry.get("time"), - "desc": entry.get("desc"), - "kcal": entry.get("kcal"), - "protein_g": entry.get("protein_g"), - "carbs_g": entry.get("carbs_g"), - "fat_g": entry.get("fat_g"), - "grams": entry.get("grams"), - "source": entry.get("source"), - "slot": entry.get("slot"), - } - - -# ────────────────────────────────────────────────────────────── -# Read tools (consumed intake + qualitative band only; NEVER the budget number) -# ────────────────────────────────────────────────────────────── - - -@mcp.tool(title="Today's intake status", annotations=_READS_ONLY) -def get_status() -> dict[str, Any]: - """Return today's intake status without ever revealing the daily budget. - - Reports the calories and macros *consumed* so far, the qualitative - :func:`consumption_band` (``"on track"`` / ``"approaching limit"`` / - ``"OVER BUDGET"``, or ``None`` when no budget has been set yet), and the - meal-slot picture (which slots are due, which are already logged, and the - current slot). The raw budget number is intentionally withheld -- only the - band is exposed, mirroring how the CLI status shows a label to an automated - caller rather than the anchor number. - """ - try: - band: str | None = consumption_band() - except BudgetError: - # No budget set (or a corrupt file): surface the absence, not a number. - band = None - return { - "consumed_kcal": today_total_kcal(), - "consumed_macros_g": _macros_dict(today_total_macros()), - "consumption_band": band, - "budget_initialized": band is not None, - "due_slots": [slot_label(slot) for slot in due_slots()], - "logged_slots": sorted(logged_slots_today()), - "current_slot": current_slot(now_local()), - } - - -@mcp.tool(title="Meals logged today", annotations=_READS_ONLY) -def list_today() -> dict[str, Any]: - """List today's logged meals (valid entries only), newest last. - - Returns the per-entry description, calories, macros, portion, source, and - slot -- the same data the CLI ``status`` listing renders, minus the internal - HMAC signature. - """ - entries = today_entries() - return { - "count": len(entries), - "entries": [_entry_view(entry) for entry in entries], - } - - -def _period_view(period: PeriodAverage) -> dict[str, Any]: - """Render one period for the wire, WITHOUT its average budget. - - ``avg_budget`` is dropped on purpose: it is the daily budget by another - name, and the module invariant is that the number never leaves via this - interface. What remains -- the mean intake plus the qualitative band -- is - the same trade ``get_status`` already makes (a band plus a consumed number - bounds the budget loosely; the exact figure stays on the machine). - """ - return { - "start": period.start, - "end": period.end, - "logged_days": period.logged_days, - "elapsed_days": period.elapsed_days, - "avg_kcal": period.avg_kcal, - "band": None if period.band is None else period.band.value, - "band_label": band_label(period.band), - } - - -@mcp.tool(title="Weekly and monthly average intake", annotations=_READS_ONLY) -def get_averages() -> dict[str, Any]: - """Return average kcal/day for this and last week, and this and last month. - - Each period's average is over its *logged* days only and is classified - ``"under"`` / ``"slightly_over"`` / ``"very_over"`` against the mean of the - budgets that applied on those same days -- never against today's budget, so - a budget edit does not retroactively reclassify past weeks. - - Every period ends at **yesterday**: today is still being logged, and a - half-logged day would drag the mean far enough to flip the band. A period - with no finished logged day yet reports ``avg_kcal: null`` rather than a - flattering zero. - - The per-period average budget is deliberately not returned (see the module - docstring's budget invariant); only the intake and the band are. - """ - try: - schedule = current_schedule(default=daily_budget()) - except BudgetError: - # No budget set, or a corrupt file: no yardstick, so no bands. - return {"budget_initialized": False, "periods": {}} - log = load_log() - periods = { - "this_week": weekly_average(log, schedule=schedule, weeks_ago=0), - "last_week": weekly_average(log, schedule=schedule, weeks_ago=1), - "this_month": monthly_average(log, schedule=schedule, months_ago=0), - "last_month": monthly_average(log, schedule=schedule, months_ago=1), - } - return { - "budget_initialized": True, - "excludes_today": True, - "periods": {name: _period_view(period) for name, period in periods.items()}, - } - - -@mcp.tool(title="Meal slots for the day", annotations=_READS_ONLY) -def get_slots() -> dict[str, Any]: - """Return the day's fixed meal slots and which one is current. - - Pure schedule information (08:00 / 12:00 / 16:00 / 20:00 by default) with no - budget or intake data attached. - """ - return { - "day_slots": [ - {"hour": slot, "label": slot_label(slot)} for slot in day_slots() - ], - "current_slot": current_slot(now_local()), - } - +from diet_guard._state import now_local +from diet_guard._sync_events import publish_after_log # ────────────────────────────────────────────────────────────── # Gated write tool (preview unless confirm=True; NEVER allowlist this) @@ -260,7 +73,7 @@ class Macros(BaseModel): fat: float = 0.0 -@mcp.tool(title="Log a meal (gated write)", annotations=_APPENDS) +@mcp.tool(title="Log a meal (gated write)", annotations=APPENDS) def log_meal( description: str, grams: float | None = None, @@ -343,6 +156,9 @@ def log_meal( logger.warning("log_meal write failed: %s", exc) return {"ok": False, "reason": "could not write the food log."} logger.info("log_meal applied: %s (%g kcal)", description, nutrition.kcal) + # Same rule as the CLI: publish on the event, not on a clock. Fail-closed, + # so a sync outage still reports the local write as applied. + published = publish_after_log() is None return { "ok": True, "applied": True, @@ -351,15 +167,10 @@ def log_meal( "logged": resolved, "target_slot": target_slot, "signed": "hmac" in entry, + "published": published, } -def _macros_dict(macros: tuple[float, float, float]) -> dict[str, float]: - """Return a ``(protein, carbs, fat)`` triple as a labelled dict.""" - protein, carbs, fat = macros - return {"protein": protein, "carbs": carbs, "fat": fat} - - def main() -> None: """Run the MCP server over stdio (STDOUT = JSON-RPC, STDERR = logs).""" logger.info("Starting diet-guard MCP server (python=%s)", sys.executable) @@ -368,3 +179,16 @@ def main() -> None: if __name__ == "__main__": main() + + +#: Re-exported so ``from diet_guard._mcp import get_status`` keeps working for +#: the tests and any client that imported them from here before the split. +__all__ = [ + "Macros", + "get_averages", + "get_slots", + "get_status", + "list_today", + "log_meal", + "main", +] diff --git a/diet_guard/_mcp_read.py b/diet_guard/_mcp_read.py new file mode 100644 index 0000000..27d6f13 --- /dev/null +++ b/diet_guard/_mcp_read.py @@ -0,0 +1,186 @@ +"""The MCP server's read-only tools. + +Split from :mod:`diet_guard._mcp` (which now holds the gated write tool) to +hold the repo's 250-line cap. Importing this module is what *registers* these +tools -- the ``@mcp.tool`` decorators run at import time against the singleton +in :mod:`diet_guard._mcp_server` -- so ``_mcp`` imports it for its side effect +and must keep doing so, however unused the name looks. + +The budget invariant lives here as much as in ``_mcp``: no tool in this file +returns the raw budget, the remaining-budget number, the stored body weight or +the protein target. Only consumed intake and the qualitative band go out. +""" + +from __future__ import annotations + +from typing import Any + +from diet_guard._averages import ( + PeriodAverage, + band_label, + monthly_average, + weekly_average, +) +from diet_guard._budget import BudgetError, current_schedule, daily_budget +from diet_guard._gate import due_slots +from diet_guard._mcp_server import READS_ONLY, mcp +from diet_guard._slots import current_slot, day_slots, slot_label +from diet_guard._state import ( + consumption_band, + load_log, + logged_slots_today, + now_local, + today_entries, + today_total_kcal, + today_total_macros, +) + + +def _macros_dict(macros: tuple[float, float, float]) -> dict[str, float]: + """Return a ``(protein, carbs, fat)`` triple as a labelled dict.""" + protein, carbs, fat = macros + return {"protein": protein, "carbs": carbs, "fat": fat} + + +def _entry_view(entry: dict[str, object]) -> dict[str, Any]: + """Project a stored log entry to a safe, JSON-friendly summary. + + Deliberately drops the ``hmac`` field and any unknown keys: the signature + is internal integrity metadata, not something a client needs. + + Args: + entry: A stored food-log entry. + + Returns: + A flat dict of the display-relevant fields. + """ + return { + "time": entry.get("time"), + "desc": entry.get("desc"), + "kcal": entry.get("kcal"), + "protein_g": entry.get("protein_g"), + "carbs_g": entry.get("carbs_g"), + "fat_g": entry.get("fat_g"), + "grams": entry.get("grams"), + "source": entry.get("source"), + "slot": entry.get("slot"), + } + + +# ────────────────────────────────────────────────────────────── +# Read tools (consumed intake + qualitative band only; NEVER the budget number) +# ────────────────────────────────────────────────────────────── + + +@mcp.tool(title="Today's intake status", annotations=READS_ONLY) +def get_status() -> dict[str, Any]: + """Return today's intake status without ever revealing the daily budget. + + Reports the calories and macros *consumed* so far, the qualitative + :func:`consumption_band` (``"on track"`` / ``"approaching limit"`` / + ``"OVER BUDGET"``, or ``None`` when no budget has been set yet), and the + meal-slot picture (which slots are due, which are already logged, and the + current slot). The raw budget number is intentionally withheld -- only the + band is exposed, mirroring how the CLI status shows a label to an automated + caller rather than the anchor number. + """ + try: + band: str | None = consumption_band() + except BudgetError: + # No budget set (or a corrupt file): surface the absence, not a number. + band = None + return { + "consumed_kcal": today_total_kcal(), + "consumed_macros_g": _macros_dict(today_total_macros()), + "consumption_band": band, + "budget_initialized": band is not None, + "due_slots": [slot_label(slot) for slot in due_slots()], + "logged_slots": sorted(logged_slots_today()), + "current_slot": current_slot(now_local()), + } + + +@mcp.tool(title="Meals logged today", annotations=READS_ONLY) +def list_today() -> dict[str, Any]: + """List today's logged meals (valid entries only), newest last. + + Returns the per-entry description, calories, macros, portion, source, and + slot -- the same data the CLI ``status`` listing renders, minus the internal + HMAC signature. + """ + entries = today_entries() + return { + "count": len(entries), + "entries": [_entry_view(entry) for entry in entries], + } + + +def _period_view(period: PeriodAverage) -> dict[str, Any]: + """Render one period for the wire, WITHOUT its average budget. + + ``avg_budget`` is dropped on purpose: it is the daily budget by another + name, and the module invariant is that the number never leaves via this + interface. What remains -- the mean intake plus the qualitative band -- is + the same trade ``get_status`` already makes (a band plus a consumed number + bounds the budget loosely; the exact figure stays on the machine). + """ + return { + "start": period.start, + "end": period.end, + "logged_days": period.logged_days, + "elapsed_days": period.elapsed_days, + "avg_kcal": period.avg_kcal, + "band": None if period.band is None else period.band.value, + "band_label": band_label(period.band), + } + + +@mcp.tool(title="Weekly and monthly average intake", annotations=READS_ONLY) +def get_averages() -> dict[str, Any]: + """Return average kcal/day for this and last week, and this and last month. + + Each period's average is over its *logged* days only and is classified + ``"under"`` / ``"slightly_over"`` / ``"very_over"`` against the mean of the + budgets that applied on those same days -- never against today's budget, so + a budget edit does not retroactively reclassify past weeks. + + Every period ends at **yesterday**: today is still being logged, and a + half-logged day would drag the mean far enough to flip the band. A period + with no finished logged day yet reports ``avg_kcal: null`` rather than a + flattering zero. + + The per-period average budget is deliberately not returned (see the module + docstring's budget invariant); only the intake and the band are. + """ + try: + schedule = current_schedule(default=daily_budget()) + except BudgetError: + # No budget set, or a corrupt file: no yardstick, so no bands. + return {"budget_initialized": False, "periods": {}} + log = load_log() + periods = { + "this_week": weekly_average(log, schedule=schedule, weeks_ago=0), + "last_week": weekly_average(log, schedule=schedule, weeks_ago=1), + "this_month": monthly_average(log, schedule=schedule, months_ago=0), + "last_month": monthly_average(log, schedule=schedule, months_ago=1), + } + return { + "budget_initialized": True, + "excludes_today": True, + "periods": {name: _period_view(period) for name, period in periods.items()}, + } + + +@mcp.tool(title="Meal slots for the day", annotations=READS_ONLY) +def get_slots() -> dict[str, Any]: + """Return the day's fixed meal slots and which one is current. + + Pure schedule information (08:00 / 12:00 / 16:00 / 20:00 by default) with no + budget or intake data attached. + """ + return { + "day_slots": [ + {"hour": slot, "label": slot_label(slot)} for slot in day_slots() + ], + "current_slot": current_slot(now_local()), + } diff --git a/diet_guard/_mcp_server.py b/diet_guard/_mcp_server.py new file mode 100644 index 0000000..64f2f74 --- /dev/null +++ b/diet_guard/_mcp_server.py @@ -0,0 +1,49 @@ +"""The MCP server singleton and its shared tool annotations. + +Extracted from :mod:`diet_guard._mcp` to hold the repo's 250-line cap. It has +to be a *separate* module rather than a section of ``_mcp``: the tool modules +need ``mcp`` at import time to register themselves with ``@mcp.tool``, so +keeping the singleton alongside them would make every split a circular import. + +Also the single place stdio logging is configured, which is load-bearing: +STDOUT carries the MCP JSON-RPC protocol frames, so a stray write there +corrupts the stream and kills the session. Everything logs to STDERR. +""" + +from __future__ import annotations + +import logging +import sys + +from mcp.server import MCPServer +from mcp.types import ToolAnnotations + +# Log to STDERR only -- STDOUT carries the MCP JSON-RPC protocol frames, so a +# single stray stdout write would corrupt the stream and kill the session. +logging.basicConfig( + level=logging.INFO, + stream=sys.stderr, + format="%(asctime)s [%(levelname)s] diet-guard-mcp: %(message)s", +) +logger = logging.getLogger("diet_guard._mcp") + +mcp = MCPServer("diet-guard") + +# Machine-readable versions of what the docstrings already say, so a client +# can decide what to run unattended. The day a "read" tool starts writing, +# these annotations are what has to change with it. +READS_ONLY = ToolAnnotations( + read_only_hint=True, + idempotent_hint=True, + open_world_hint=False, +) +# log_meal is the one tool that writes. It appends and never rewrites or +# deletes, so it is not destructive; it is not idempotent either, because a +# second confirmed call logs a second meal. It reaches Open Food Facts when +# macros are not supplied, which is exactly what open_world means. +APPENDS = ToolAnnotations( + read_only_hint=False, + destructive_hint=False, + idempotent_hint=False, + open_world_hint=True, +) diff --git a/diet_guard/_sync_events.py b/diet_guard/_sync_events.py new file mode 100644 index 0000000..a353752 --- /dev/null +++ b/diet_guard/_sync_events.py @@ -0,0 +1,50 @@ +"""Event-driven sync triggers: publish when state changes, not on a clock. + +diet_guard used to run a ``diet-guard-sync.timer`` every ~15 min. That cadence +was a poor proxy for the two moments the shared log actually changes on this +device, and it paid a network round-trip on every idle tick in between: + +* **before a lock** -- already handled in :func:`diet_guard._cli_gate._should_lock`, + which pulls only when a lock is otherwise due, so a meal just logged on the + phone clears the lock without a manual re-entry. +* **after a meal is logged here** -- this module. Publishing immediately is + what stops the *phone* nagging for a slot already logged on the PC; the + mirror image of the rule in ``CLAUDE.md`` that keeps the phone's tick + unconditional. + +Both go through :func:`~diet_guard._sync.pull_shared_log`, which despite its +name runs a *full* tick (pull, merge, re-sign, persist, push, plus budget and +both food banks) and fails closed -- a sync outage returns a reason string +rather than raising, so it can never turn a successful local log into an error. + +The trade this makes explicit: a PC that is on but neither logging nor locking +no longer pulls at all, so an inbound phone edit lands at the next log or lock +rather than within 15 min. That is bounded by the meal-slot spacing, since a +day that goes unlogged raises a lock by construction. +""" + +from __future__ import annotations + +import logging + +from diet_guard._sync import pull_shared_log + +_logger = logging.getLogger(__name__) + + +def publish_after_log() -> str | None: + """Run a full sync tick after a local write, swallowing any outage. + + Call this *after* the local log write is durable, never before: the local + entry is the source of truth for this device, and a network failure must + leave it intact and the caller's own success path untouched. + + Returns: + ``None`` when the tick completed, or a short human-readable reason when + it could not -- callers that have somewhere to show it (the CLI) print + it; callers that do not (the gate, the MCP tool) let it fall to the log. + """ + reason = pull_shared_log() + if reason is not None: + _logger.info("post-log sync skipped: %s", reason) + return reason diff --git a/diet_guard/tests/conftest.py b/diet_guard/tests/conftest.py index bb01fcd..606502b 100644 --- a/diet_guard/tests/conftest.py +++ b/diet_guard/tests/conftest.py @@ -144,6 +144,14 @@ def _isolate_state(tmp_path: Path) -> Iterator[None]: "diet_guard._sync_client.CONFIG_FILE", tmp_path / "nonexistent-firebase.json", ), + # Logging a meal now publishes immediately (`_sync_events`), so every + # test that logs one would otherwise open a real connection through a + # path that has nothing to do with what it is asserting. Patched at the + # *call sites* rather than on `_sync_events`, so a test that imports + # `publish_after_log` directly still exercises the real helper. + patch("diet_guard._cli_log.publish_after_log", return_value=None), + patch("diet_guard._cli_gate.publish_after_log", return_value=None), + patch("diet_guard._mcp.publish_after_log", return_value=None), ): yield diff --git a/diet_guard/tests/test_cli.py b/diet_guard/tests/test_cli.py index 29aa72b..43b7a00 100644 --- a/diet_guard/tests/test_cli.py +++ b/diet_guard/tests/test_cli.py @@ -11,13 +11,14 @@ import io from typing import TYPE_CHECKING from unittest.mock import patch -from diet_guard import _cli +from diet_guard import _cli, _cli_log from diet_guard._budget import ( BudgetFileCorruptError, BudgetNotInitializedError, write_budget, ) -from diet_guard._cli import _eaten_grams, _Portion, main +from diet_guard._cli import main +from diet_guard._cli_log import Portion, eaten_grams from diet_guard._estimator import Nutrition if TYPE_CHECKING: @@ -37,23 +38,21 @@ class TestEatenGrams: def test_count_of_known_staple(self) -> None: """A count of a known staple multiplies by its unit weight, no note.""" - grams, note = _eaten_grams( - "apple", _Portion(grams=None, count=5, per_grams=None) - ) + grams, note = eaten_grams("apple", Portion(grams=None, count=5, per_grams=None)) assert grams == 5 * 182 assert note is None def test_count_of_unknown_item_warns(self) -> None: """A count of an unknown item uses the default and flags the assumption.""" - grams, note = _eaten_grams( - "mystery", _Portion(grams=None, count=3, per_grams=None) + grams, note = eaten_grams( + "mystery", Portion(grams=None, count=3, per_grams=None) ) assert grams is not None assert note is not None def test_explicit_grams(self) -> None: """An explicit gram portion passes straight through.""" - grams, note = _eaten_grams("x", _Portion(grams=300, count=None, per_grams=None)) + grams, note = eaten_grams("x", Portion(grams=300, count=None, per_grams=None)) assert grams == 300 assert note is None @@ -113,22 +112,46 @@ class TestAte: def test_logs_and_summarizes(self, capsys: pytest.CaptureFixture[str]) -> None: """A resolved meal is logged, banked, and summarized.""" write_budget(2000) - with patch.object(_cli, "resolve_nutrition", return_value=_NUT): + with patch.object(_cli_log, "resolve_nutrition", return_value=_NUT): assert main(["ate", "big mac"]) == 0 assert "logged:" in capsys.readouterr().out + def test_publishes_after_logging(self) -> None: + """The meal is published immediately, not left for a periodic tick.""" + write_budget(2000) + with ( + patch.object(_cli_log, "resolve_nutrition", return_value=_NUT), + patch.object(_cli_log, "publish_after_log", return_value=None) as publish, + ): + assert main(["ate", "big mac"]) == 0 + publish.assert_called_once_with() + + def test_sync_outage_still_logs_locally( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + """A failed publish reports itself without failing the local log.""" + write_budget(2000) + with ( + patch.object(_cli_log, "resolve_nutrition", return_value=_NUT), + patch.object(_cli_log, "publish_after_log", return_value="no token"), + ): + assert main(["ate", "big mac"]) == 0 + out = capsys.readouterr().out + assert "logged:" in out + assert "not yet published (no token)" in out + def test_note_printed_for_assumed_weight( self, capsys: pytest.CaptureFixture[str] ) -> None: """An assumed per-item weight prints its caveat.""" write_budget(2000) - with patch.object(_cli, "resolve_nutrition", return_value=_NUT): + with patch.object(_cli_log, "resolve_nutrition", return_value=_NUT): main(["ate", "mystery", "--count", "3"]) assert "assumed" in capsys.readouterr().out def test_unresolved_food(self, capsys: pytest.CaptureFixture[str]) -> None: """An unresolvable food returns a failure and a manual-entry hint.""" - with patch.object(_cli, "resolve_nutrition", return_value=None): + with patch.object(_cli_log, "resolve_nutrition", return_value=None): assert main(["ate", "nonsense"]) == 1 assert "--kcal" in capsys.readouterr().out diff --git a/diet_guard/tests/test_cli_gate.py b/diet_guard/tests/test_cli_gate.py index ef088ba..4b1ae16 100644 --- a/diet_guard/tests/test_cli_gate.py +++ b/diet_guard/tests/test_cli_gate.py @@ -80,6 +80,50 @@ class TestCmdGate: factory.assert_not_called() assert "no lock needed" in lines[0] + def test_publishes_only_after_the_window_closes(self) -> None: + """The publish must not run while the lock is still up. + + A network call on the Tk thread would keep the user locked in behind a + successful log, so ordering here is the actual guarantee, not the call. + """ + calls: list[str] = [] + gate = MagicMock() + gate.run.side_effect = lambda: calls.append("run") + with ( + patch.object(_cli_gate, "gate_is_due", return_value=True), + _no_sync(), + patch.object(_cli_gate, "MealGate", return_value=gate), + patch.object(_cli_gate, "acquire_gate_lock", return_value=MagicMock()), + patch.object( + _cli_gate, + "release_gate_lock", + side_effect=lambda _h: calls.append("release"), + ), + patch.object(_cli_gate, "wait_for_display", return_value=True), + patch.object( + _cli_gate, + "publish_after_log", + side_effect=lambda: calls.append("publish"), + ), + ): + assert cmd_gate([].append, check=False, demo=False) == 0 + assert calls == ["run", "release", "publish"] + + def test_publish_outage_reported_after_unlock(self) -> None: + """A failed post-unlock publish reports without re-trapping the user.""" + lines: list[str] = [] + with ( + patch.object(_cli_gate, "gate_is_due", return_value=True), + _no_sync(), + patch.object(_cli_gate, "MealGate", return_value=MagicMock()), + patch.object(_cli_gate, "acquire_gate_lock", return_value=MagicMock()), + patch.object(_cli_gate, "release_gate_lock"), + patch.object(_cli_gate, "wait_for_display", return_value=True), + patch.object(_cli_gate, "publish_after_log", return_value="offline"), + ): + assert cmd_gate(lines.append, check=False, demo=False) == 0 + assert "not yet published (offline)" in lines[-1] + def test_gate_already_running(self) -> None: """A held single-instance lock means a second window is not opened.""" lines: list[str] = [] diff --git a/diet_guard/tests/test_mcp.py b/diet_guard/tests/test_mcp.py index 8b1343d..a0eed12 100644 --- a/diet_guard/tests/test_mcp.py +++ b/diet_guard/tests/test_mcp.py @@ -10,7 +10,7 @@ from __future__ import annotations from unittest.mock import patch -from diet_guard import _mcp +from diet_guard import _mcp, _mcp_read from diet_guard._budget import BudgetNotInitializedError from diet_guard._estimator import Nutrition @@ -29,14 +29,16 @@ def _nutrition(kcal: float = 250.0) -> Nutrition: class TestGetStatus: def test_band_and_intake(self) -> None: with ( - patch.object(_mcp, "today_total_kcal", return_value=640.0), - patch.object(_mcp, "today_total_macros", return_value=(30.0, 50.0, 12.0)), - patch.object(_mcp, "consumption_band", return_value="on track"), - patch.object(_mcp, "due_slots", return_value=(12,)), - patch.object(_mcp, "logged_slots_today", return_value={8}), - patch.object(_mcp, "current_slot", return_value=12), + patch.object(_mcp_read, "today_total_kcal", return_value=640.0), + patch.object( + _mcp_read, "today_total_macros", return_value=(30.0, 50.0, 12.0) + ), + patch.object(_mcp_read, "consumption_band", return_value="on track"), + patch.object(_mcp_read, "due_slots", return_value=(12,)), + patch.object(_mcp_read, "logged_slots_today", return_value={8}), + patch.object(_mcp_read, "current_slot", return_value=12), patch.object(_mcp, "now_local"), - patch.object(_mcp, "slot_label", return_value="12:00"), + patch.object(_mcp_read, "slot_label", return_value="12:00"), ): out = _mcp.get_status() assert out["consumed_kcal"] == 640.0 @@ -49,16 +51,16 @@ class TestGetStatus: def test_no_budget_hides_band(self) -> None: with ( - patch.object(_mcp, "today_total_kcal", return_value=0.0), - patch.object(_mcp, "today_total_macros", return_value=(0.0, 0.0, 0.0)), + patch.object(_mcp_read, "today_total_kcal", return_value=0.0), + patch.object(_mcp_read, "today_total_macros", return_value=(0.0, 0.0, 0.0)), patch.object( - _mcp, + _mcp_read, "consumption_band", side_effect=BudgetNotInitializedError(), ), - patch.object(_mcp, "due_slots", return_value=()), - patch.object(_mcp, "logged_slots_today", return_value=set()), - patch.object(_mcp, "current_slot", return_value=None), + patch.object(_mcp_read, "due_slots", return_value=()), + patch.object(_mcp_read, "logged_slots_today", return_value=set()), + patch.object(_mcp_read, "current_slot", return_value=None), patch.object(_mcp, "now_local"), ): out = _mcp.get_status() @@ -84,7 +86,7 @@ class TestListToday: "hmac": "deadbeef", }, ] - with patch.object(_mcp, "today_entries", return_value=entries): + with patch.object(_mcp_read, "today_entries", return_value=entries): out = _mcp.list_today() assert out["count"] == 1 view = out["entries"][0] @@ -94,7 +96,7 @@ class TestListToday: assert "hmac" not in view def test_empty(self) -> None: - with patch.object(_mcp, "today_entries", return_value=[]): + with patch.object(_mcp_read, "today_entries", return_value=[]): out = _mcp.list_today() assert out == {"count": 0, "entries": []} @@ -102,10 +104,10 @@ class TestListToday: class TestGetSlots: def test_lists_slots_and_current(self) -> None: with ( - patch.object(_mcp, "day_slots", return_value=(8, 12, 16, 20)), - patch.object(_mcp, "current_slot", return_value=16), + patch.object(_mcp_read, "day_slots", return_value=(8, 12, 16, 20)), + patch.object(_mcp_read, "current_slot", return_value=16), patch.object(_mcp, "now_local"), - patch.object(_mcp, "slot_label", side_effect=lambda s: f"{s:02d}:00"), + patch.object(_mcp_read, "slot_label", side_effect=lambda s: f"{s:02d}:00"), ): out = _mcp.get_slots() assert out["current_slot"] == 16 @@ -179,6 +181,28 @@ class TestLogMealGate: assert out["applied"] is True assert out["signed"] is False + def test_confirm_publishes_and_reports_it(self) -> None: + """A successful write publishes immediately and says so.""" + with ( + patch.object(_mcp, "resolve_nutrition", return_value=_nutrition()), + patch.object(_mcp, "record_meal", return_value={"desc": "apple"}), + patch.object(_mcp, "publish_after_log", return_value=None) as publish, + ): + out = _mcp.log_meal("apple", slot=8, confirm=True) + publish.assert_called_once_with() + assert out["published"] is True + + def test_confirm_applies_even_when_publish_fails(self) -> None: + """A sync outage leaves the local write applied, flagged unpublished.""" + with ( + patch.object(_mcp, "resolve_nutrition", return_value=_nutrition()), + patch.object(_mcp, "record_meal", return_value={"desc": "apple"}), + patch.object(_mcp, "publish_after_log", return_value="offline"), + ): + out = _mcp.log_meal("apple", slot=8, confirm=True) + assert out["applied"] is True + assert out["published"] is False + def test_confirm_write_failure_returns_gracefully(self) -> None: with ( patch.object(_mcp, "resolve_nutrition", return_value=_nutrition()), diff --git a/diet_guard/tests/test_mcp_averages.py b/diet_guard/tests/test_mcp_averages.py index 69c1b8e..d462355 100644 --- a/diet_guard/tests/test_mcp_averages.py +++ b/diet_guard/tests/test_mcp_averages.py @@ -4,7 +4,7 @@ from __future__ import annotations from unittest.mock import patch -from diet_guard import _mcp +from diet_guard import _mcp, _mcp_read from diet_guard._averages import AverageBand, PeriodAverage from diet_guard._budget import BudgetNotInitializedError @@ -28,12 +28,12 @@ def _period( class TestPeriodView: def test_never_carries_the_average_budget(self) -> None: # The module invariant: no tool hands an automated caller the budget. - view = _mcp._period_view(_period()) + view = _mcp_read._period_view(_period()) assert "avg_budget" not in view assert 2000.0 not in view.values() def test_carries_intake_band_and_coverage(self) -> None: - view = _mcp._period_view(_period()) + view = _mcp_read._period_view(_period()) assert view["avg_kcal"] == 2100.0 assert view["band"] == "slightly_over" assert view["band_label"] == "slightly over" @@ -43,7 +43,7 @@ class TestPeriodView: assert view["end"] == "2026-01-11" def test_empty_period_serializes_as_null_not_zero(self) -> None: - view = _mcp._period_view(_period(avg_kcal=None, band=None)) + view = _mcp_read._period_view(_period(avg_kcal=None, band=None)) assert view["avg_kcal"] is None assert view["band"] is None assert view["band_label"] == "no data" @@ -52,11 +52,11 @@ class TestPeriodView: class TestGetAverages: def test_returns_all_four_periods(self) -> None: with ( - patch.object(_mcp, "daily_budget", return_value=2000), - patch.object(_mcp, "current_schedule"), - patch.object(_mcp, "load_log", return_value={}), - patch.object(_mcp, "weekly_average", return_value=_period()), - patch.object(_mcp, "monthly_average", return_value=_period()), + patch.object(_mcp_read, "daily_budget", return_value=2000), + patch.object(_mcp_read, "current_schedule"), + patch.object(_mcp_read, "load_log", return_value={}), + patch.object(_mcp_read, "weekly_average", return_value=_period()), + patch.object(_mcp_read, "monthly_average", return_value=_period()), ): out = _mcp.get_averages() assert out["budget_initialized"] is True @@ -70,7 +70,7 @@ class TestGetAverages: def test_no_budget_degrades_to_empty_periods(self) -> None: with patch.object( - _mcp, + _mcp_read, "daily_budget", side_effect=BudgetNotInitializedError, ): @@ -79,11 +79,11 @@ class TestGetAverages: def test_no_response_field_leaks_the_budget_number(self) -> None: with ( - patch.object(_mcp, "daily_budget", return_value=2000), - patch.object(_mcp, "current_schedule"), - patch.object(_mcp, "load_log", return_value={}), - patch.object(_mcp, "weekly_average", return_value=_period()), - patch.object(_mcp, "monthly_average", return_value=_period()), + patch.object(_mcp_read, "daily_budget", return_value=2000), + patch.object(_mcp_read, "current_schedule"), + patch.object(_mcp_read, "load_log", return_value={}), + patch.object(_mcp_read, "weekly_average", return_value=_period()), + patch.object(_mcp_read, "monthly_average", return_value=_period()), ): out = _mcp.get_averages() flat = repr(out) diff --git a/diet_guard/tests/test_sync_events.py b/diet_guard/tests/test_sync_events.py new file mode 100644 index 0000000..ce889b4 --- /dev/null +++ b/diet_guard/tests/test_sync_events.py @@ -0,0 +1,54 @@ +"""Tests for the event-driven post-log sync trigger. + +These exercise the real :func:`publish_after_log`, not the conftest stub: the +autouse fixture patches it at each *call site*, so importing it here still +reaches the genuine helper (see ``conftest._isolate_state``). +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING +from unittest.mock import patch + +from diet_guard import _sync_events +from diet_guard._sync_events import publish_after_log + +if TYPE_CHECKING: + import pytest + + +class TestPublishAfterLog: + """A full tick fired by a local write, which must never raise.""" + + def test_success_returns_none(self) -> None: + """A completed tick reports no reason.""" + with patch.object( + _sync_events, + "pull_shared_log", + return_value=None, + ) as pull: + assert publish_after_log() is None + pull.assert_called_once_with() + + def test_outage_returns_reason(self) -> None: + """A failed tick surfaces the reason instead of raising.""" + with patch.object( + _sync_events, + "pull_shared_log", + return_value="sync unavailable (no token)", + ): + assert publish_after_log() == "sync unavailable (no token)" + + def test_outage_is_logged(self, caplog: pytest.LogCaptureFixture) -> None: + """The reason reaches the log for callers with nowhere to show it.""" + with ( + patch.object( + _sync_events, + "pull_shared_log", + return_value="sync unavailable (boom)", + ), + caplog.at_level(logging.INFO, logger="diet_guard._sync_events"), + ): + publish_after_log() + assert "boom" in caplog.text diff --git a/install.sh b/install.sh index 80f6c9f..219ff4f 100755 --- a/install.sh +++ b/install.sh @@ -27,7 +27,6 @@ readonly REPO_DIR="$SCRIPT_DIR" readonly SERVICE_SRC="$SCRIPT_DIR/diet-guard-gate.service" readonly TIMER_SRC="$SCRIPT_DIR/diet-guard-gate.timer" readonly SYNC_SERVICE_SRC="$SCRIPT_DIR/diet-guard-sync.service" -readonly SYNC_TIMER_SRC="$SCRIPT_DIR/diet-guard-sync.timer" readonly SYSTEMD_USER_DIR="$HOME/.config/systemd/user" readonly DATA_DIR="$HOME/.local/share/diet_guard" readonly BUDGET_FILE="$DATA_DIR/.budget" @@ -60,18 +59,26 @@ systemctl --user daemon-reload systemctl --user enable --now diet-guard-gate.timer echo " Timer enabled and started (fires the gate every ~30 min)." -# 4. systemd user timer + service (sync) ------------------------------------- -echo "[4/5] Installing the sync's systemd user timer + service..." +# 4. Retire the old periodic sync timer --------------------------------------- +# Sync is event-driven now (before a lock, after a meal is logged -- see +# diet_guard/_sync_events.py), so the ~15 min timer is gone. Removing the unit +# from the repo does NOT stop an already-installed copy: without this block it +# would keep firing forever from $SYSTEMD_USER_DIR with no unit file in git to +# explain it. `|| true` because a fresh machine has no such unit and `disable` +# exits non-zero there, which would abort the install under `set -euo pipefail`. +echo "[4/5] Removing the retired periodic sync timer (sync is event-driven)..." +systemctl --user disable --now diet-guard-sync.timer 2>/dev/null || true +rm -f "$SYSTEMD_USER_DIR/diet-guard-sync.timer" +# The *service* stays: it is how a manual one-off tick is run +# (`systemctl --user start diet-guard-sync`). Only the timer is retired. cp "$SYNC_SERVICE_SRC" "$SYSTEMD_USER_DIR/diet-guard-sync.service" -cp "$SYNC_TIMER_SRC" "$SYSTEMD_USER_DIR/diet-guard-sync.timer" systemctl --user daemon-reload -systemctl --user enable --now diet-guard-sync.timer -echo " Timer enabled and started (syncs the log every ~15 min)." +echo " Periodic sync timer removed; 'python -m diet_guard sync' still works." if [[ -e "$SYNC_TOKEN_FILE" ]]; then echo " Sync token already present at $SYNC_TOKEN_FILE." else echo " No sync token yet at $SYNC_TOKEN_FILE -- sync will no-op (and log a" - echo " failure) on every tick until you create a fine-grained GitHub PAT" + echo " failure) after every meal you log, until you create a GitHub PAT" echo " scoped to the diet-guard-sync repo's contents and save it there," echo " mode 600: chmod 600 \"$SYNC_TOKEN_FILE\"" fi