diet-guard/diet_guard/_gatelock_kuchnia.py
Krzysztof kuhy Rudnicki 26f1770090 feat: import Kuchnia Wikinga catering deliveries into the food bank
Pulls the day's delivered dishes and their real per-portion macros out of
panel.kuchniavikinga.pl, so a catering meal is one keypress instead of five
retyped numbers.

A delivered meal is not an eaten meal, so nothing is ever logged unattended:
dishes land in the *curated* bank, and entries are written only after an
explicit confirmation. Auto-logging would let the gate satisfy its own
checkpoint from a delivery note, and would record what the courier dropped off
rather than what was eaten.

Three surfaces:
  - `diet-guard kuchnia [--log] [--yes]`
  - the lock screen's "Today's delivery" button, which prefills the meal form
    dish by dish (the user still clicks Log & Continue)
  - a warm-up after each logged meal, on the existing background thread

All event-driven; no new systemd timer. The two automatic paths go through
`refresh_delivery_once`, guarded to one fetch per day.

The panel is an undocumented white-labelled Dietly API; the endpoint map was
recovered from its JS bundles and confirmed against live responses. Notes,
including the traps, are in docs/kuchnia-wikinga.md.

Load-bearing details:

* Macros are per portion and `weight` is that portion in grams -- proven, not
  assumed: 4P+4C+9F reproduces the stated calories within ~1% on every meal of
  the captured day. `_energy_is_consistent` re-runs that check per dish and
  drops any that fails; a per-100g mix-up is invisible in a payload and would
  silently skew every logged meal by a factor of several.
* `bank_dishes` compares nutritional fields before calling `add_manual_entry`,
  which restamps `t` unconditionally. Without the compare, every refresh
  republishes the whole curated bank to every peer. Tests assert on call count.
* Duplicate log suppression reads today's synced log, not a local marker:
  `log_meal` mints a fresh uuid, so a re-import after marker loss would merge
  as duplicates on every peer.
* The gate hook is in `on_focus_ready`, not `_cli_gate._should_lock`:
  `gate_is_due()` never reads the food bank, so a refresh there could not
  change the lock decision while adding a third-party round trip to the ~105ms
  `gate --check` fast path.
* `requests` is lazy-imported so the gate's not-due tick does not pay for it.
* Credentials and the cached session cookie live in ~/.config/diet_guard at
  mode 600, outside the synced tree. The credentials file is written by hand,
  never by the package -- the same contract as sync_token.

`_GateDashboard` is merged into `_GateMealFlow`, and both "pull it in from
elsewhere" flows share one `_PullFlows` mixin: pylint's max-parents counts
every MRO entry, so the gate's controller chain had no room for a new class.

Also adds a side-by-side row for the two pull buttons -- stacking them cost
42px and pushed the tab past the viewport at 1366x768.

989 tests, 100% branch coverage, pre-commit clean.
2026-08-22 23:04:22 +02:00

91 lines
3.7 KiB
Python

"""Off-thread catering fetch for the lock screen's "Load delivery" button.
Its own module because :mod:`diet_guard._gatelock_mealflow` is already at the
repo's 250-line cap, and because the worker contract here is worth stating
once, plainly.
Modelled on :mod:`diet_guard._gatelock_fetch`, but it cannot reuse
:func:`~diet_guard._gatelock_fetch.start_fetch`: that is typed
``Callable[[], str | None] -> Queue[str | None]`` and this fetch returns
*dishes*. Squeezing them through by stashing results on the instance from the
worker would reintroduce exactly the cross-thread state that module's docstring
exists to forbid, so the pattern is copied rather than bent:
* **The worker touches no widget.** Tcl is not thread-safe, and that includes
``after`` -- the worker only ``put``s on a queue, and a poll scheduled on the
Tk thread picks it up. That also makes the completion path testable with no
thread at all: put a result, call the poll, assert.
* **The worker always feeds the queue**, even if the call raises. It has
nowhere to propagate to, so an escaping exception would leave the poll
waiting forever with the button disabled and the user stuck behind the lock.
* **Daemon thread**, so a hung request can never keep the process alive after
the window is gone -- the gate is a ``Type=oneshot`` unit and must exit.
Nothing here logs anything. The fetch only *offers* dishes; writing entries is
a separate, explicitly confirmed step (:mod:`diet_guard._kuchnia_log`).
"""
from __future__ import annotations
from collections.abc import Callable
from contextlib import suppress
import queue
import threading
from typing import TYPE_CHECKING, NamedTuple
if TYPE_CHECKING: # pragma: no cover - typing only
from collections.abc import Sequence
from datetime import date
from diet_guard._kuchnia_parse import Dish
#: What ``_kuchnia_import.refresh_delivery`` looks like to this module.
RefreshFn = Callable[["date"], tuple["Sequence[Dish]", "str | None"]]
class DeliveryResult(NamedTuple):
"""What the worker hands back: the dishes, or why there are none."""
dishes: tuple[Dish, ...]
reason: str | None
#: Reported when the worker itself fails unexpectedly. ``refresh_delivery`` is
#: already fail-closed, so reaching this means a bug rather than an outage.
UNEXPECTED = "catering fetch failed (unexpected error)"
def start_delivery_fetch(
refresh: RefreshFn,
day: date,
) -> queue.Queue[DeliveryResult]:
"""Run ``refresh(day)`` on a daemon thread; return its result queue.
Args:
refresh: A ``refresh_delivery``-shaped callable returning
``(dishes, reason)``. Passed in rather than imported so the gate
can be tested without an HTTP stack.
day: The delivery date to fetch.
Returns:
A queue that will receive exactly one :class:`DeliveryResult`.
"""
result: queue.Queue[DeliveryResult] = queue.Queue(maxsize=1)
def _worker() -> None:
"""Runs OFF the Tk thread. Touches no widget, by construction."""
outcome = DeliveryResult(dishes=(), reason=UNEXPECTED)
# ``refresh`` is already fail-closed, so an exception here is a bug --
# but it must not escape this thread. There is nowhere to propagate to,
# so it would surface only as an unraisable-thread warning while the
# poll waits forever and the user stays stuck behind the lock.
# ``suppress`` rather than a broad ``except``; the pre-seeded UNEXPECTED
# outcome is what reaches the window.
with suppress(Exception):
dishes, reason = refresh(day)
outcome = DeliveryResult(dishes=tuple(dishes), reason=reason)
result.put(outcome)
threading.Thread(target=_worker, daemon=True).start()
return result