mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-10 19:27:14 +01:00
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.
91 lines
3.7 KiB
Python
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
|