diet-guard/diet_guard/_cli_log.py
Krzysztof kuhy Rudnicki 8871408b5b perf(cli): publish off-thread so logging a meal returns immediately
`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
2026-08-21 13:22:26 +02:00

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