mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-10 19:07:15 +01:00
Sync before locking the gate + manual "Fetch from sync" button
The PC gate forced a re-log when a meal was already logged on the phone but the 15-min sync had not yet propagated it. Two ways to close that gap: - The gate now pulls the shared log right before it would lock -- and only when a lock is otherwise due, so the network cost is paid on the rare tick that matters. A meal already pushed by the phone then unlocks without re-entry. Fails closed: any sync error (no token, network, disk write-back) falls through to the local lock decision, never skips it. - A "Fetch from sync" button on the lock screen runs the same pull on manual request: it auto-unlocks when the pull satisfies every pending slot, advances to the next meal on a partial pull, or reports no change. Disabled in --demo so the sandbox never triggers a real sync/push. - Both paths share one fail-closed wrapper, _sync.pull_shared_log(), which also keeps the single broad-except in one place. - _write_log is now atomic (temp file + Path.replace): the gate and the 15-min sync timer can now both write the log, so a torn read must be impossible. 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:
parent
e6812f7b4f
commit
be94b4e147
@ -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()
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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."""
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user