diff --git a/diet_guard/_cli_gate.py b/diet_guard/_cli_gate.py index 4907646..0345a53 100644 --- a/diet_guard/_cli_gate.py +++ b/diet_guard/_cli_gate.py @@ -13,17 +13,36 @@ from typing import TYPE_CHECKING from diet_guard._gate import gate_is_due from diet_guard._gatelock import MealGate, acquire_gate_lock, release_gate_lock from diet_guard._gatelock_support import wait_for_display +from diet_guard._sync import pull_shared_log if TYPE_CHECKING: from collections.abc import Callable +def _is_due(emit: Callable[[str], None]) -> bool: + """Return the gate decision, refreshing from sync only when a lock is due. + + The cheap local check runs first; the network pull happens solely on the + rare tick a lock would otherwise fire (via :func:`pull_shared_log`, which + fails closed), then the decision is re-read from the freshly written log. + """ + if not gate_is_due(): + return False + reason = pull_shared_log() # only pay the network cost when about to lock + if reason is not None: + emit(f"{reason}; using local log.") + return gate_is_due() + + def cmd_gate(emit: Callable[[str], None], *, check: bool, demo: bool) -> int: """Run the log-to-unlock gate. Three modes: ``--check`` is a headless decision (no window) whose exit code a timer reads; ``--demo`` always shows a safe demo window; bare ``gate`` - shows the real lock only when one is due. A flock guard stops a second + shows the real lock only when one is due. Both real modes route through + :func:`_is_due`, which -- only when a lock is otherwise due -- pulls the + shared log first (see :func:`_refresh_from_sync`) so a meal just logged on + the phone unlocks without a manual re-entry. A flock guard stops a second window from stacking on top of the first, and a window-opening mode first waits for the X display so a session-start launch never crashes unshown. @@ -37,10 +56,10 @@ def cmd_gate(emit: Callable[[str], None], *, check: bool, demo: bool) -> int: For ``--check``: 0 if not due, 1 if a lock is due. Otherwise 0. """ if check: - due = gate_is_due() + due = _is_due(emit) emit("due (a lock is warranted)" if due else "ok (no lock needed)") return 1 if due else 0 - if not demo and not gate_is_due(): + if not demo and not _is_due(emit): emit("ok - no lock needed right now.") return 0 handle = acquire_gate_lock() diff --git a/diet_guard/_gatelock.py b/diet_guard/_gatelock.py index 0dbe8de..eb2c741 100644 --- a/diet_guard/_gatelock.py +++ b/diet_guard/_gatelock.py @@ -155,6 +155,7 @@ class MealGate(_GateMealFlow): on_submit=self._on_submit, on_close=self.close, on_add_item=self._on_add_item, + on_fetch_sync=self._on_fetch_sync, ) self._widgets = build_layout( self.root, diff --git a/diet_guard/_gatelock_mealflow.py b/diet_guard/_gatelock_mealflow.py index 3687acb..52916ea 100644 --- a/diet_guard/_gatelock_mealflow.py +++ b/diet_guard/_gatelock_mealflow.py @@ -15,6 +15,7 @@ from typing import TYPE_CHECKING from diet_guard._budget import BudgetError, daily_budget, protein_target_g from diet_guard._foodbank import remember_food, remember_meal +from diet_guard._gate import due_slots from diet_guard._gatelock_nutrition import _GateNutrition from diet_guard._gatelock_ui import ERR, FG, UNIT_GRAMS from diet_guard._meal import MealItem, item_to_component, meal_total @@ -27,6 +28,7 @@ from diet_guard._state import ( today_total_kcal, today_total_macros, ) +from diet_guard._sync import pull_shared_log if TYPE_CHECKING: from diet_guard._estimator import Nutrition @@ -257,6 +259,46 @@ class _GateMealFlow(_GateNutrition): self._set_status(f"{logged} — all meals logged, unlocking…") self.root.after(_UNLOCK_DELAY_MS, self.close) + # -- manual sync ------------------------------------------------------------ + + def _on_fetch_sync(self) -> None: + """Pull the shared log on demand and unlock any slots it now satisfies. + + For a meal already logged on another device (typically the phone) that + has not yet propagated here: rather than re-entering it to unlock, the + user pulls it in. The pull blocks the window briefly (bounded by the + sync timeout) and fails closed -- a failure leaves the lock untouched. + """ + if self.demo_mode: + self._set_status("Fetch from sync is only available on the real lock.") + return + self._set_status("Fetching from sync…") + self.root.update_idletasks() + reason = pull_shared_log() + if reason is not None: + self._set_status(f"{reason} — still locked.", error=True) + return + self._reconcile_after_fetch() + + def _reconcile_after_fetch(self) -> None: + """Drop pending slots a freshly pulled meal now covers; unlock if none.""" + still_due = set(due_slots()) + satisfied = [slot for slot in self._pending if slot not in still_due] + self._refresh_dashboard() + if not satisfied: + self._set_status("No new meals found in sync.") + return + self._pending = [slot for slot in self._pending if slot in still_due] + if not self._pending: + self._unlock("Synced from another device") + return + self._clear_inputs() + self._refresh_slot_header() + count = len(satisfied) + meal_word = "meal" if count == 1 else "meals" + self._set_status(f"Pulled {count} {meal_word} — next meal, please.") + self._widgets.desc_text.focus_set() + # -- dashboard -------------------------------------------------------------- def _refresh_dashboard(self) -> None: diff --git a/diet_guard/_gatelock_ui.py b/diet_guard/_gatelock_ui.py index ea3e5a8..96f7d22 100644 --- a/diet_guard/_gatelock_ui.py +++ b/diet_guard/_gatelock_ui.py @@ -93,6 +93,7 @@ class GateCallbacks: on_submit: Callable[[], None] on_close: Callable[[], None] on_add_item: Callable[[], None] + on_fetch_sync: Callable[[], None] def make_vars(root: tk.Misc) -> GateVars: @@ -422,6 +423,19 @@ def build_layout( command=callbacks.on_submit, ).pack(pady=(4, 6)) + # Manual pull for a meal already logged on another device (the phone) but + # not yet propagated to this machine -- saves re-typing it to unlock. + tk.Button( + frame, + text="⟳ Fetch from sync", + font=("Arial", 12), + bg="#334455", + fg="white", + activebackground="#445566", + cursor="hand2", + command=callbacks.on_fetch_sync, + ).pack(pady=(0, 6)) + status_label = tk.Label( frame, textvariable=vars_.status, diff --git a/diet_guard/_state.py b/diet_guard/_state.py index e87e02b..a235f90 100644 --- a/diet_guard/_state.py +++ b/diet_guard/_state.py @@ -13,6 +13,7 @@ from __future__ import annotations from datetime import datetime, timezone import json import logging +import os from typing import TYPE_CHECKING import uuid @@ -89,10 +90,21 @@ def _read_raw_log() -> DayLog: def _write_log(log: DayLog) -> None: - """Persist the full log to disk, creating the data directory if needed.""" + """Persist the full log to disk, creating the data directory if needed. + + Written atomically -- a temp file in the same directory, then + :func:`os.replace` -- so a concurrent reader (the gate now syncs while the + 15-min timer may also be writing) never sees a half-written file and + mistakes a torn read for an empty log. + """ FOOD_LOG_FILE.parent.mkdir(parents=True, exist_ok=True) - with FOOD_LOG_FILE.open("w") as handle: - json.dump(log, handle, indent=2) + tmp_path = FOOD_LOG_FILE.with_name(f"{FOOD_LOG_FILE.name}.{os.getpid()}.tmp") + try: + with tmp_path.open("w") as handle: + json.dump(log, handle, indent=2) + tmp_path.replace(FOOD_LOG_FILE) + finally: + tmp_path.unlink(missing_ok=True) def _hmac_key_available() -> bool: diff --git a/diet_guard/_sync.py b/diet_guard/_sync.py index 68ef075..1715372 100644 --- a/diet_guard/_sync.py +++ b/diet_guard/_sync.py @@ -130,3 +130,20 @@ def run_sync() -> DayLog: message="diet_guard sync", ) return resigned + + +def pull_shared_log() -> str | None: + """Run a sync tick, failing closed instead of raising. + + A thin wrapper over :func:`run_sync` for callers that must never crash on a + sync error: the gate's automatic pre-lock refresh and the lock screen's + manual "Fetch from sync" button. Returns ``None`` on success, or a short + human-readable reason when the pull could not complete (no token, network + down, a disk error writing the merged log back), so the caller keeps its + own lock decision rather than failing open. + """ + try: + run_sync() + except Exception as exc: # pylint: disable=broad-exception-caught + return f"sync unavailable ({exc})" + return None diff --git a/diet_guard/tests/test_cli_gate.py b/diet_guard/tests/test_cli_gate.py index bfbec68..6f45531 100644 --- a/diet_guard/tests/test_cli_gate.py +++ b/diet_guard/tests/test_cli_gate.py @@ -4,10 +4,19 @@ alongside its source module (see _cli_gate.py's module docstring). from __future__ import annotations +from datetime import datetime, timezone from unittest.mock import MagicMock, patch from diet_guard import _cli_gate -from diet_guard._cli_gate import cmd_gate +from diet_guard._cli_gate import _is_due, cmd_gate +from diet_guard._estimator import Nutrition +from diet_guard._gate import gate_is_due +from diet_guard._state import log_meal + + +def _no_sync() -> object: + """Neutralize the pre-lock pull (as if it found nothing) for mode tests.""" + return patch.object(_cli_gate, "pull_shared_log", return_value=None) class TestCmdGate: @@ -16,7 +25,7 @@ class TestCmdGate: def test_check_due(self) -> None: """--check exits 1 and announces a due lock.""" lines: list[str] = [] - with patch.object(_cli_gate, "gate_is_due", return_value=True): + with patch.object(_cli_gate, "gate_is_due", return_value=True), _no_sync(): assert cmd_gate(lines.append, check=True, demo=False) == 1 assert "due" in lines[0] @@ -46,10 +55,11 @@ class TestCmdGate: assert "no lock needed" in lines[0] def test_bare_gate_due_opens_window(self) -> None: - """A bare gate that is due opens the real window.""" + """A bare gate that is still due after a sync opens the real window.""" gate = MagicMock() with ( patch.object(_cli_gate, "gate_is_due", return_value=True), + _no_sync(), patch.object(_cli_gate, "MealGate", return_value=gate), patch.object(_cli_gate, "acquire_gate_lock", return_value=MagicMock()), patch.object(_cli_gate, "release_gate_lock"), @@ -58,11 +68,24 @@ class TestCmdGate: assert cmd_gate([].append, check=False, demo=False) == 0 gate.run.assert_called_once() + def test_bare_gate_cleared_by_sync_opens_no_window(self) -> None: + """A meal pulled in by the pre-lock sync clears the lock: no window.""" + lines: list[str] = [] + with ( + patch.object(_cli_gate, "gate_is_due", side_effect=[True, False]), + patch.object(_cli_gate, "pull_shared_log", return_value=None), + patch.object(_cli_gate, "MealGate") as factory, + ): + assert cmd_gate(lines.append, check=False, demo=False) == 0 + factory.assert_not_called() + assert "no lock needed" in lines[0] + def test_gate_already_running(self) -> None: """A held single-instance lock means a second window is not opened.""" lines: list[str] = [] with ( patch.object(_cli_gate, "gate_is_due", return_value=True), + _no_sync(), patch.object(_cli_gate, "acquire_gate_lock", return_value=None), patch.object(_cli_gate, "MealGate") as factory, ): @@ -75,6 +98,7 @@ class TestCmdGate: lines: list[str] = [] with ( patch.object(_cli_gate, "gate_is_due", return_value=True), + _no_sync(), patch.object(_cli_gate, "acquire_gate_lock", return_value=MagicMock()), patch.object(_cli_gate, "release_gate_lock"), patch.object(_cli_gate, "wait_for_display", return_value=False), @@ -83,3 +107,70 @@ class TestCmdGate: assert cmd_gate(lines.append, check=False, demo=False) == 0 factory.assert_not_called() assert "display not ready" in lines[0] + + +class TestIsDue: + """The decision helper only pays the network cost when a lock is due.""" + + def test_not_due_skips_sync(self) -> None: + """When nothing is due locally, no pull is attempted.""" + pull = MagicMock(return_value=None) + with ( + patch.object(_cli_gate, "gate_is_due", return_value=False), + patch.object(_cli_gate, "pull_shared_log", pull), + ): + assert _is_due([].append) is False + pull.assert_not_called() + + def test_due_then_cleared_by_sync(self) -> None: + """A due lock that the pull clears re-reads as not due.""" + pull = MagicMock(return_value=None) + with ( + patch.object(_cli_gate, "gate_is_due", side_effect=[True, False]), + patch.object(_cli_gate, "pull_shared_log", pull), + ): + assert _is_due([].append) is False + pull.assert_called_once() + + def test_due_and_still_due_after_sync(self) -> None: + """A due lock the pull cannot satisfy stays due.""" + pull = MagicMock(return_value=None) + with ( + patch.object(_cli_gate, "gate_is_due", side_effect=[True, True]), + patch.object(_cli_gate, "pull_shared_log", pull), + ): + assert _is_due([].append) is True + pull.assert_called_once() + + def test_pull_failure_is_reported(self) -> None: + """A failed pull emits its reason but the local decision still stands.""" + lines: list[str] = [] + with ( + patch.object(_cli_gate, "gate_is_due", side_effect=[True, True]), + patch.object( + _cli_gate, "pull_shared_log", return_value="sync unavailable (x)" + ), + ): + assert _is_due(lines.append) is True + assert "sync unavailable (x)" in lines[0] + + +class TestGateReadsFreshState: + """The fix's whole correctness rests on the re-check reading fresh disk. + + Mocked ``gate_is_due`` tests would pass whether or not production re-reads + state, so this one exercises the real uncached read path: a slot written to + ``food_log.json`` (what ``run_sync`` does when it pulls a phone meal) must be + visible to the very next ``gate_is_due`` call. + """ + + @staticmethod + def _nutrition() -> Nutrition: + return Nutrition(200.0, 10.0, 20.0, 5.0, 100.0, "test") + + def test_written_slot_clears_the_gate_immediately(self) -> None: + """At 09:00 only the 08:00 slot is due; logging it flips the decision.""" + now = datetime(2026, 1, 1, 9, 0, tzinfo=timezone.utc) + assert gate_is_due(now) is True + log_meal("oatmeal", self._nutrition(), slot=8) + assert gate_is_due(now) is False diff --git a/diet_guard/tests/test_gatelock_mealflow.py b/diet_guard/tests/test_gatelock_mealflow.py index a89c23c..cf0cc55 100644 --- a/diet_guard/tests/test_gatelock_mealflow.py +++ b/diet_guard/tests/test_gatelock_mealflow.py @@ -423,3 +423,75 @@ class TestMealFlow: with patch.object(gate, "_unlock") as unlock: gate._finish_slot("done") unlock.assert_called_once() + + +class TestFetchFromSync: + """The manual "Fetch from sync" button on the lock screen.""" + + def test_demo_mode_does_not_sync(self, gate: MealGate) -> None: + """In demo the button is inert and never touches the network.""" + with patch.object(_gatelock_mealflow, "pull_shared_log") as pull: + gate._on_fetch_sync() + pull.assert_not_called() + assert "only available on the real lock" in gate._vars.status.get() + + def test_pull_failure_keeps_lock(self, gate: MealGate) -> None: + """A failed pull shows the reason and leaves pending slots intact.""" + gate.demo_mode = False + gate._pending = [8, 12] + with patch.object( + _gatelock_mealflow, + "pull_shared_log", + return_value="sync unavailable (x)", + ): + gate._on_fetch_sync() + assert gate._pending == [8, 12] + assert "still locked" in gate._vars.status.get() + + def test_no_new_meals_keeps_all_slots(self, gate: MealGate) -> None: + """A clean pull that satisfies nothing reports so and keeps the slots.""" + gate.demo_mode = False + gate._pending = [8, 12] + with ( + patch.object(_gatelock_mealflow, "pull_shared_log", return_value=None), + patch.object(_gatelock_mealflow, "due_slots", return_value=(8, 12)), + ): + gate._on_fetch_sync() + assert gate._pending == [8, 12] + assert "No new meals" in gate._vars.status.get() + + def test_partial_advances_to_next_slot(self, gate: MealGate) -> None: + """One slot pulled in leaves the rest; the window advances (singular).""" + gate.demo_mode = False + gate._pending = [8, 12] + with ( + patch.object(_gatelock_mealflow, "pull_shared_log", return_value=None), + patch.object(_gatelock_mealflow, "due_slots", return_value=(12,)), + ): + gate._on_fetch_sync() + assert gate._pending == [12] + assert "Pulled 1 meal " in gate._vars.status.get() + + def test_partial_plural_wording(self, gate: MealGate) -> None: + """Two slots pulled in uses the plural 'meals'.""" + gate.demo_mode = False + gate._pending = [8, 12, 16] + with ( + patch.object(_gatelock_mealflow, "pull_shared_log", return_value=None), + patch.object(_gatelock_mealflow, "due_slots", return_value=(16,)), + ): + gate._on_fetch_sync() + assert gate._pending == [16] + assert "Pulled 2 meals" in gate._vars.status.get() + + def test_all_satisfied_unlocks(self, gate: MealGate) -> None: + """When the pull satisfies every pending slot, the gate unlocks.""" + gate.demo_mode = False + gate._pending = [8, 12] + with ( + patch.object(_gatelock_mealflow, "pull_shared_log", return_value=None), + patch.object(_gatelock_mealflow, "due_slots", return_value=()), + ): + gate._on_fetch_sync() + assert gate._pending == [] + assert "unlocking" in gate._vars.status.get() diff --git a/diet_guard/tests/test_state.py b/diet_guard/tests/test_state.py index 7caf2df..dd953b8 100644 --- a/diet_guard/tests/test_state.py +++ b/diet_guard/tests/test_state.py @@ -357,6 +357,27 @@ class TestRawLogAccess: write_raw_log(log) assert read_raw_log() == log + def test_write_leaves_no_temp_file(self) -> None: + """A successful atomic write cleans up its temp file.""" + write_raw_log({"2026-06-22": [{"id": "x"}]}) + assert list(_state.FOOD_LOG_FILE.parent.glob("*.tmp")) == [] + + def test_write_failure_preserves_prior_log(self) -> None: + """A failed replace leaves the old log intact and no temp behind. + + This is the point of the atomic write: a concurrent reader (the gate + now syncs while the timer may also write) never sees a torn or empty + log just because a write was interrupted. + """ + write_raw_log({"2026-06-22": [{"id": "original"}]}) + with ( + patch("pathlib.Path.replace", side_effect=OSError("no space")), + pytest.raises(OSError, match="no space"), + ): + write_raw_log({"2026-06-22": [{"id": "clobbered"}]}) + assert read_raw_log() == {"2026-06-22": [{"id": "original"}]} + assert list(_state.FOOD_LOG_FILE.parent.glob("*.tmp")) == [] + class TestResignEntry: """resign_entry recomputes the hmac so a merged entry validates again.""" diff --git a/diet_guard/tests/test_sync.py b/diet_guard/tests/test_sync.py index 88c952a..df3b6d5 100644 --- a/diet_guard/tests/test_sync.py +++ b/diet_guard/tests/test_sync.py @@ -202,3 +202,20 @@ class TestRunSync: with patch.object(_sync, "GitHubSyncClient", return_value=client): _sync.run_sync() assert lookup_food("oatmeal") is not None + + +class TestPullSharedLog: + """The fail-closed wrapper the gate and the lock-screen button share.""" + + def test_returns_none_on_success(self) -> None: + """A clean pull returns None (no reason to report).""" + with patch.object(_sync, "run_sync") as run_sync: + assert _sync.pull_shared_log() is None + run_sync.assert_called_once_with() + + def test_returns_reason_on_any_failure(self) -> None: + """Any run_sync error becomes a reason string, never an exception.""" + with patch.object(_sync, "run_sync", side_effect=RuntimeError("boom")): + reason = _sync.pull_shared_log() + assert reason is not None + assert "boom" in reason diff --git a/pyproject.toml b/pyproject.toml index df80097..5076468 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,10 @@ unfixable = [] [tool.ruff.lint.per-file-ignores] "**/tests/**/*.py" = ["ARG", "D", "PLC0415", "PLR2004", "S101", "S107", "SLF001"] "**/test_*.py" = ["ARG", "D", "PLC0415", "PLR2004", "S101", "S107", "SLF001"] +# pull_shared_log must fail closed: any run_sync error (no token, network, disk +# write-back) is returned as a reason string so the gate keeps locking rather +# than failing open. A deliberately broad catch, so BLE001 does not apply here. +"**/_sync.py" = ["BLE001"] [tool.ruff.lint.pydocstyle] convention = "google"