diet-guard/diet_guard/_gatelock.py
Krzysztof kuhy Rudnicki 84898c0272 feat: split oversized modules for 500-line limit, fix kasa coverage gap
Split diet_guard/_gatelock.py, wake_alarm/_alarm.py, and the
usage_report.py/_usage_report_parsing.py pair into focused
sub-modules so every Python file is <= 500 lines, satisfying
test_file_length.py. Install python-kasa into .venv (declared in
requirements but missing after the 3.13->3.14 venv upgrade),
fixing 8 failing smart_plug tests and restoring 100% coverage.

Also includes prior in-progress work from the working tree: the
wake_alarm Progress/View/Hardware field-grouping refactor,
brother_printer query module + tests, diet_guard foodbank/state/cli
updates, new shared coerce/logging_setup helpers, morning_routine
orchestrator tweaks, dwm window-manager config, gaming scripts, and
misc maintenance/digital-wellbeing script updates.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 07:19:37 +02:00

196 lines
8.2 KiB
Python

"""Fullscreen "log your meals to unlock" gate window for diet_guard.
This reuses the proven screen-locker *mechanism* -- an ``overrideredirect``
fullscreen window with a global input grab and disabled VT switching -- but
hardens two latent gaps in that original so a grabbed window can never become a
trap:
* **VT switching is restored on every exit path**, not just the clean one:
``atexit`` covers a crash/uncaught exception, signal handlers cover
SIGTERM/SIGINT, and a ``try/finally`` covers normal return.
* **Every callback error is swallowed and surfaced**, via a
``report_callback_exception`` override on the Tk root, so no exception can
propagate out of the grabbed event loop and leave a dead window.
The window walks the user through each *missing* meal slot in turn (coming home
at 17:00 backfills 08:00, then 12:00, then 16:00) and dismisses only once every
elapsed slot carries a logged meal.
Resolution is built around one idea: the macro fields plus the "per" field hold
the food's nutrition *as a reference for some amount*, and how much you ate
scales that reference into the total that is logged. Measure by **grams** and
the reference is "per 100 g" off a label; measure by **items** and it is "per 1
item" (with the piece's approximate weight, which you can correct). Either way
the total shown in the preview is exactly what gets recorded, and changing how
much you ate never rewrites the reference fields, so the two cannot desync. As
you type, the picker offers your banked foods and built-in staples, so a common
food fills in one click. Leaving the calorie field blank looks the food up
(food bank, then staples, then Open Food Facts), fills the fields, names the
source, and offers alternatives. A running dashboard makes the day's calories
prominent, with macros and the protein target beneath. The unlock condition is
*logging*, never *estimating correctly*: a manual calorie value always works
offline, so a dead OFF endpoint can never trap you behind the lock.
Building ``MealGate`` spans several sibling modules to keep each under the
repo's 500-line limit: :mod:`._gatelock_core` provides the shared leaf
widget/field helpers, root window, and state (``_GateCore``, ``_GateRoot``,
``_GateState``); :mod:`._gatelock_window` provides the fullscreen window setup,
input grab, and exit-path lifecycle (``_GateWindow``);
:mod:`._gatelock_nutrition` provides the reference->total nutrition maths and
food lookup (``_GateNutrition``); and :mod:`._gatelock_mealflow` provides the
submit/log flow and dashboard (``_GateMealFlow``). ``MealGate`` wires these
mixins together and owns construction, layout, and event binding.
"""
from __future__ import annotations
import contextlib
import fcntl
import sys
import tkinter as tk
from typing import TYPE_CHECKING
from python_pkg.diet_guard._constants import GATE_LOCK_FILE
from python_pkg.diet_guard._gate import due_slots
from python_pkg.diet_guard._gatelock_core import _GateRoot, _GateState
from python_pkg.diet_guard._gatelock_mealflow import _GateMealFlow
from python_pkg.diet_guard._gatelock_ui import GateCallbacks, build_layout, make_vars
from python_pkg.diet_guard._gatelock_window import _GateWindow
from python_pkg.diet_guard._slots import current_slot, day_slots
from python_pkg.diet_guard._state import now_local
if TYPE_CHECKING:
from typing import TextIO
def _assert_not_under_pytest() -> None:
"""Raise if a real Tk gate is being built inside a pytest run.
Defence-in-depth: prevents a real fullscreen window from locking the screen
when a test forgets to mock ``tk.Tk``. When ``tk`` is mocked the module
name is no longer ``tkinter``, so genuine mocked tests pass straight through.
"""
if "pytest" in sys.modules and getattr(tk, "__name__", "") == "tkinter":
msg = "SAFETY: MealGate built under pytest with real tkinter (tk.Tk unmocked)"
raise RuntimeError(msg)
def acquire_gate_lock() -> TextIO | None:
"""Acquire the gate's single-instance ``flock``.
Returns:
An open file handle that must be kept alive for the gate's lifetime
(closing it releases the lock), or None if another gate already holds
it -- in which case the caller must not open a second window.
"""
GATE_LOCK_FILE.parent.mkdir(parents=True, exist_ok=True)
handle = GATE_LOCK_FILE.open("w", encoding="utf-8")
try:
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError:
handle.close()
return None
return handle
def release_gate_lock(handle: TextIO) -> None:
"""Release the single-instance lock and close its handle."""
with contextlib.suppress(OSError):
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
handle.close()
def _pending_slots(*, demo_mode: bool) -> list[int]:
"""Return the slots the window must collect before it can unlock.
In production this is exactly the elapsed-but-unlogged slots. In demo mode
-- where there may be nothing genuinely due -- fall back to a representative
slot so the UI is always demonstrable.
Args:
demo_mode: Whether the window is a safe sandbox.
Returns:
The slot hours to collect, ascending.
"""
pending = list(due_slots())
if pending:
return pending
if demo_mode:
return [current_slot(now_local()) or day_slots()[0]]
return []
class MealGate(_GateWindow, _GateMealFlow):
"""A fullscreen lock that dismisses only once every missing slot is logged."""
def __init__(self, *, demo_mode: bool = True) -> None:
"""Build the lock window.
Args:
demo_mode: When True, use a local (not global) input grab and add a
close button, so the gate can be exercised without locking the
real session. Production passes False.
"""
_assert_not_under_pytest()
self.demo_mode = demo_mode
self._vt_disabled = False
self._pending = _pending_slots(demo_mode=demo_mode)
# All mutable logical state (provenance, suggestions, meal-in-progress)
# lives in one bundle; see _GateState for the per-field rationale.
self._state = _GateState()
self.root = _GateRoot()
self.root.on_callback_error = self._handle_callback_error
self.root.title("Diet Gate" + (" [DEMO]" if demo_mode else ""))
self._vars = make_vars(self.root)
self._build()
def _build(self) -> None:
"""Lay out the UI, wire events, seed the first prompt, and grab input."""
self._setup_window()
callbacks = GateCallbacks(
on_unit_change=self._on_unit_change,
on_submit=self._on_submit,
on_close=self.close,
on_add_item=self._on_add_item,
)
self._widgets = build_layout(
self.root,
self._vars,
callbacks,
demo_mode=self.demo_mode,
)
self._wire_events()
self._relabel_basis()
self._refresh_slot_header()
self._refresh_dashboard()
self._refresh_projection()
self._grab_input()
self._widgets.desc_text.focus_set()
def _wire_events(self) -> None:
"""Bind the live per-keystroke events to the freshly built widgets.
Construction-time commands (button and option-menu) are wired inside
``build_layout``; the key bindings that drive lookup, scaling, and
submission are connected here, where the controller methods are in scope.
"""
widgets = self._widgets
widgets.desc_text.bind("<KeyRelease>", self._on_desc_keyrelease)
widgets.desc_text.bind("<Return>", self._on_desc_return)
widgets.suggestion_box.bind(
"<<ListboxSelect>>",
self._on_suggestion_select,
)
for entry in (widgets.amount_entry, widgets.per_entry):
entry.bind("<KeyRelease>", self._on_amount_change)
entry.bind("<Return>", self._on_return)
for entry in self._macro_entries():
entry.bind("<Return>", self._on_return)
entry.bind("<KeyRelease>", self._on_macro_edit)
def _on_desc_return(self, _event: tk.Event[tk.Misc]) -> str:
"""Submit on Enter in the description box, suppressing the newline."""
self._on_submit()
return "break"