diet-guard/diet_guard/_cli_init.py
Krzysztof kuhy Rudnicki 3e51e614b1 Split the last seven Python files; extend the redirect guard to all five paths
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
2026-08-15 20:37:05 +02:00

87 lines
2.7 KiB
Python

"""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 write_budget
from diet_guard._budget_biometrics import Biometrics, compute_target_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