mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-10 20:27:11 +01:00
`diet-guard log ...` blocked for ~15.5s after every meal. The publish itself has to happen -- until it lands the phone still believes the slot is unlogged and nags for it -- but only ~2.5s of that is the 293KB push the phone needs; the rest is pulling peers' banks, which publishing does not require. The local write is already durable before the publish starts, so the worst case is a late publish, which is the trade `_sync_events` already documents for the event-driven model. Moving it to a worker thread lets the caller print and return at once. Measured: 15.5s -> 2ms before the prompt comes back. The thread is deliberately non-daemon: the CLI's output is already flushed, so the process lingers only as long as the push, and the publish is never killed halfway. A failure still surfaces, via a callback the caller supplies, just after its own output rather than in the middle of it. Deliberately NOT applied to the gate. `diet-guard-gate.service` is `Type=oneshot`, so systemd reaps the unit the moment the main thread exits and a background publish would be killed mid-push. The gate keeps calling `publish_after_log` and waiting, which is correct there -- its publish already runs after the window is gone, where nobody is waiting on it. `conftest._isolate_state` follows the rename to `publish_after_log_detached`; without that every meal-logging test would reach the network through a newly unpatched seam. Verified with `scripts/check_patch_targets.py` and by timing the real command against a temporary data dir, never the live log. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LJ5YnA3CERitQ57HjMu2Qz
171 lines
6.1 KiB
Python
171 lines
6.1 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_detached
|
|
|
|
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.
|
|
#
|
|
# On a background thread, though: the full tick measures ~15.5s and this is
|
|
# an interactive command, so blocking the terminal on it after every meal
|
|
# is the whole complaint. The local write above is already durable, so the
|
|
# worst case is a late publish -- the trade `_sync_events` documents. The
|
|
# thread is non-daemon, so the process waits for the push at exit even
|
|
# though the user already has their output.
|
|
publish_after_log_detached(
|
|
lambda reason: emit(f"logged locally, not yet published ({reason})."),
|
|
)
|
|
print_summary()
|
|
return 0
|