diet-guard/diet_guard/_foodbank_rebuild.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

86 lines
3.2 KiB
Python

"""Rebuilding the derived food bank from the meal log.
Split out of :mod:`._foodbank` to keep both files under the repo's 250-line
limit. The sync tick calls :func:`rebuild_food_bank` after a merge. It *does*
persist, but through :mod:`._foodbank`'s private ``_write_bank`` rather than
opening the file here, so ``FOOD_BANK_FILE`` stays named by :mod:`._foodbank`
alone -- which is what ``tests/conftest.py`` redirects, and what
``test_state_redirect.py`` enforces.
"""
from __future__ import annotations
from diet_guard._coerce import as_float
from diet_guard._estimator import Nutrition
from diet_guard._foodbank import BankRecord, _apply_upsert, _write_bank
__all__ = ["rebuild_food_bank"]
def _entry_nutrition(entry: dict[str, object], *, source: str) -> Nutrition:
"""Build a :class:`Nutrition` from a raw log entry's macro fields."""
return Nutrition(
kcal=as_float(entry.get("kcal")),
protein_g=as_float(entry.get("protein_g")),
carbs_g=as_float(entry.get("carbs_g")),
fat_g=as_float(entry.get("fat_g")),
grams=as_float(entry.get("grams")),
source=source,
)
def rebuild_food_bank(log: dict[str, list[dict[str, object]]]) -> dict[str, BankRecord]:
"""Rebuild the bank from scratch by replaying ``log``'s entries, then persist it.
Replays in a fixed, device-independent order (by ``time`` then ``id``),
so two devices that converge on the same merged log also converge on the
same bank -- this is what lets the food bank stay *derived*, never
synced, with no counter-merge (CRDT) logic needed for ``count``. Mirrors
the Dart port's ``FoodBankService.rebuild`` exactly, including the
composite-meal branch (banks each component, then the composite itself).
Deleted (tombstoned) entries are skipped entirely, same as
:func:`diet_guard._state.load_log`.
Args:
log: A full log keyed by date, e.g. from
:func:`diet_guard._state.read_raw_log` after a sync merge.
Returns:
The freshly rebuilt bank (also written to disk).
"""
entries = sorted(
(
entry
for day_entries in log.values()
for entry in day_entries
if not entry.get("deleted")
),
key=lambda entry: (str(entry.get("time", "")), str(entry.get("id", ""))),
)
bank: dict[str, BankRecord] = {}
for entry in entries:
components = entry.get("components")
component_names: list[str] | None = None
if isinstance(components, list):
component_names = []
for component in components:
if not isinstance(component, dict):
continue
name = str(component.get("name", ""))
component_names.append(name)
_apply_upsert(
bank,
name,
_entry_nutrition(component, source="food bank"),
components=None,
)
_apply_upsert(
bank,
str(entry.get("desc", "")),
_entry_nutrition(entry, source=str(entry.get("source", "manual"))),
components=component_names,
)
_write_bank(bank)
return bank