Review fixes: specific sync exceptions, clearer names
Some checks are pending
pre-commit / pre-commit (push) Waiting to run
Tests / test (3.10) (push) Waiting to run
Tests / test (3.11) (push) Waiting to run
Tests / test (3.12) (push) Waiting to run

Address code-review feedback on the sync-before-lock change:

- pull_shared_log now catches (SyncError, GitHubSyncError, OSError) instead
  of a blanket `except Exception`. crdt_sync wraps every requests transport
  error in GitHubSyncError, so those three are the whole realistic failure
  surface; the gate still fails closed on all of them, and a genuine bug now
  surfaces instead of being hidden as "sync unavailable". Drops the
  broad-exception pylint disable and the per-file BLE001 ignore in pyproject.
- Rename _cli_gate._is_due -> _should_lock: too easily confused with the pure
  local predicate gate_is_due it wraps. The new name says what it decides.
- Rename the list `satisfied` -> `satisfied_slots` in _reconcile_after_fetch;
  it's a list of slots, not a boolean.

No behaviour change; 100% coverage retained.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FtSmzACzJSGyEatgK9exkd
This commit is contained in:
Krzysztof Rudnicki 2026-07-11 18:33:43 +02:00
parent 0976c84254
commit 8a9c7713b9
6 changed files with 43 additions and 30 deletions

View File

@ -19,12 +19,15 @@ 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.
def _should_lock(emit: Callable[[str], None]) -> bool:
"""Decide whether to lock, pulling from sync first if a lock looks 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.
This wraps the pure, local :func:`~diet_guard._gate.gate_is_due` predicate
with one network step: the cheap local check runs first, and only when it
says a lock is due do we pull the shared log (via :func:`pull_shared_log`,
which fails closed) and re-read the freshly written log. So a meal just
logged on the phone can clear the lock, while a not-due tick never touches
the network at all.
"""
if not gate_is_due():
return False
@ -40,8 +43,8 @@ def cmd_gate(emit: Callable[[str], None], *, check: bool, demo: bool) -> int:
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. 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
:func:`_should_lock`, which -- only when a lock is otherwise due -- pulls
the shared log first (via :func:`pull_shared_log`) 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.
@ -56,10 +59,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 = _is_due(emit)
due = _should_lock(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 _is_due(emit):
if not demo and not _should_lock(emit):
emit("ok - no lock needed right now.")
return 0
handle = acquire_gate_lock()

View File

@ -283,9 +283,9 @@ class _GateMealFlow(_GateNutrition):
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]
satisfied_slots = [slot for slot in self._pending if slot not in still_due]
self._refresh_dashboard()
if not satisfied:
if not satisfied_slots:
self._set_status("No new meals found in sync.")
return
self._pending = [slot for slot in self._pending if slot in still_due]
@ -294,7 +294,7 @@ class _GateMealFlow(_GateNutrition):
return
self._clear_inputs()
self._refresh_slot_header()
count = len(satisfied)
count = len(satisfied_slots)
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()

View File

@ -13,7 +13,7 @@ from __future__ import annotations
import json
import logging
from crdt_sync import GitHubSyncClient, Log, merge_logs
from crdt_sync import GitHubSyncClient, GitHubSyncError, Log, merge_logs
from diet_guard._constants import (
SYNC_DEVICE_ID,
@ -138,12 +138,18 @@ def pull_shared_log() -> str | None:
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.
human-readable reason when the pull could not complete, so the caller keeps
its own lock decision rather than failing open.
The three caught types are the whole realistic failure surface of a run:
:class:`SyncError` (no/empty token), :class:`~crdt_sync.GitHubSyncError`
(the client wraps every ``requests`` transport error in this), and
:class:`OSError` (reading the token or writing the merged log back). A bug
outside these is deliberately *not* swallowed -- it should surface, not be
silently reported as a sync outage.
"""
try:
run_sync()
except Exception as exc: # pylint: disable=broad-exception-caught
except (SyncError, GitHubSyncError, OSError) as exc:
return f"sync unavailable ({exc})"
return None

View File

@ -8,7 +8,7 @@ from datetime import datetime, timezone
from unittest.mock import MagicMock, patch
from diet_guard import _cli_gate
from diet_guard._cli_gate import _is_due, cmd_gate
from diet_guard._cli_gate import _should_lock, cmd_gate
from diet_guard._estimator import Nutrition
from diet_guard._gate import gate_is_due
from diet_guard._state import log_meal
@ -109,7 +109,7 @@ class TestCmdGate:
assert "display not ready" in lines[0]
class TestIsDue:
class TestShouldLock:
"""The decision helper only pays the network cost when a lock is due."""
def test_not_due_skips_sync(self) -> None:
@ -119,7 +119,7 @@ class TestIsDue:
patch.object(_cli_gate, "gate_is_due", return_value=False),
patch.object(_cli_gate, "pull_shared_log", pull),
):
assert _is_due([].append) is False
assert _should_lock([].append) is False
pull.assert_not_called()
def test_due_then_cleared_by_sync(self) -> None:
@ -129,7 +129,7 @@ class TestIsDue:
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
assert _should_lock([].append) is False
pull.assert_called_once()
def test_due_and_still_due_after_sync(self) -> None:
@ -139,7 +139,7 @@ class TestIsDue:
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
assert _should_lock([].append) is True
pull.assert_called_once()
def test_pull_failure_is_reported(self) -> None:
@ -151,7 +151,7 @@ class TestIsDue:
_cli_gate, "pull_shared_log", return_value="sync unavailable (x)"
),
):
assert _is_due(lines.append) is True
assert _should_lock(lines.append) is True
assert "sync unavailable (x)" in lines[0]

View File

@ -213,9 +213,17 @@ class TestPullSharedLog:
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")):
def test_returns_reason_on_expected_failure(self) -> None:
"""A real sync failure (here a network error) becomes a reason string."""
with patch.object(_sync, "run_sync", side_effect=_sync.GitHubSyncError("boom")):
reason = _sync.pull_shared_log()
assert reason is not None
assert "boom" in reason
def test_unexpected_error_is_not_swallowed(self) -> None:
"""A bug outside the known failure surface surfaces, not hidden."""
with (
patch.object(_sync, "run_sync", side_effect=KeyError("bug")),
pytest.raises(KeyError),
):
_sync.pull_shared_log()

View File

@ -42,10 +42,6 @@ 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"