mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-11 22:33:53 +01:00
Ninth batch of the 250-line sweep. Every Python file in the repo is now under cap (over-cap count 25 -> 18; the 18 remaining are all Dart). _foodbank.py 365 -> 210 (+ _foodbank_search, _foodbank_rebuild) test_averages.py 351 -> 222 (+ test_averages_report.py) _estimator.py 325 -> 137 (+ _estimator_off.py) _budget.py 323 -> 227 (+ _budget_biometrics, _budget_derived) test_foodbank.py 311 -> 117 (+ test_foodbank_rebuild.py) _averages.py 298 -> 174 (+ _averages_periods, _averages_report) test_budget_history.py 288 -> 162 (+ test_budget_history_io.py) The split boundaries are constrained by which module owns each conftest-patched state path, not by line count. BUDGET_FILE stays in _budget, FOOD_BANK_FILE in _foodbank: the derived/search/rebuild halves reach the file only through their parent's private helpers. Two of the new modules had copied FOOD_BANK_FILE into their headers as an incidental import, which is exactly the leak the guard is for -- both cleaned. test_state_redirect.py now covers all five redirected paths (FOOD_LOG_FILE, BUDGET_FILE, BUDGET_HISTORY_FILE, FOOD_BANK_FILE, MANUAL_BANK_FILE) rather than just the food log, in both directions: only the owning module may name each constant, and each redirect demonstrably moves a real write off the live path. Two rewires fail-closed rather than silently: `_budget.budget_weight` and `_estimator.requests` were module-attribute references no import rewrite touches, and both raised AttributeError immediately. The latter appeared only after ruff removed the now-unused `requests` import from _estimator -- the import was load-bearing solely as a patch target. Verified: 707 Python tests / 100% branch coverage, 5 layout-fits tests under xvfb, check_patch_targets 0, pre-commit clean, ~/.local/share/diet_guard byte-identical. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RA9ynjqXPnexRQnR5oAuCC
210 lines
6.5 KiB
Python
210 lines
6.5 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,
|
|
)
|
|
from diet_guard._budget_derived import 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,
|
|
)
|
|
from diet_guard._state_sync import (
|
|
undo_last_today,
|
|
)
|
|
from diet_guard._state_today import (
|
|
logged_slots_today,
|
|
today_entries,
|
|
today_total_kcal,
|
|
today_total_macros,
|
|
)
|
|
|
|
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)
|