From b77145010c5826313ee3d921efb3dd0b9ba7c9cc Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Mon, 7 Sep 2026 15:22:38 +0200 Subject: [PATCH] Stop asking for meals on a shared free day Third app onto freedays (~/utils/freedays), the shared pool of pre-declared days when every gate app stands down. due_slots() returns nothing on a free day, so no slot is ever due, the lock never opens and nothing is said about the missing meals. Read from one local file, never the network, so this cannot hang the systemd-fired gate -- and an unreadable pool reads as "not free", which means the normal rules apply rather than the gate quietly switching itself off. Tested explicitly: fail closed, not open. The conftest redirect is load-bearing. The pool lives under ~/.local/share, outside every diet_guard path the suite already isolates, so without it a real free day on this machine would fail every "the gate is due" test here for a reason that looks nothing like the cause. gatelock moves v0.7.2 -> v0.7.3, and the pylint hook's own pins (which had drifted further, to gatelock v0.7.1 and crdt-sync v0.6.0) come with it. Not cosmetic: pip refuses two different direct URLs for one distribution, so any stale pin alongside freedays -- which requires v0.7.3 -- is an unresolvable environment rather than a warning. requirements.txt also picks up pylint 4.0.8, pytest-randomly 5.0.0 and ruff 0.16.6, which the freshness gate flagged once this file was touched. 1043 tests pass at 100% coverage across the pytest-randomly major bump. Co-Authored-By: Claude Opus 5 --- .pre-commit-config.yaml | 9 ++++++-- app/pubspec.yaml | 2 +- diet_guard/_gate.py | 9 ++++++++ diet_guard/tests/conftest.py | 9 ++++++++ diet_guard/tests/test_gate.py | 41 ++++++++++++++++++++++++++++++++++- pyproject.toml | 3 ++- requirements.txt | 9 ++++---- 7 files changed, 73 insertions(+), 9 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6e8b768..c097a29 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -102,8 +102,13 @@ repos: additional_dependencies: - pytest - requests - - gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.7.1#subdirectory=gatelock - - crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.6.0#subdirectory=crdt-sync + # Kept in step with pyproject.toml on purpose: pip refuses two + # different direct URLs for one distribution, so a stale pin here + # becomes an unresolvable hook environment the moment freedays -- + # which requires gatelock v0.7.3 -- joins the list. + - gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.7.3#subdirectory=gatelock + - crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.1#subdirectory=crdt-sync + - freedays @ git+https://github.com/kuhyx/utils@freedays-v0.1.0#subdirectory=freedays - mcp>=2,<3 - pydantic # Test suites intentionally use patterns (protected-access, magic-value diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 40b41f1..7d599be 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -2,7 +2,7 @@ name: diet_guard_app description: "Companion phone app for diet_guard: log meals on the go." publish_to: 'none' -version: 1.0.76+1 +version: 1.0.77+1 environment: sdk: ^3.12.2 diff --git a/diet_guard/_gate.py b/diet_guard/_gate.py index c3d502e..e119ca6 100644 --- a/diet_guard/_gate.py +++ b/diet_guard/_gate.py @@ -17,6 +17,8 @@ from __future__ import annotations from typing import TYPE_CHECKING +import freedays + from diet_guard._meal_schedule_store import current_schedule from diet_guard._slots import missing_slots, slot_label from diet_guard._state import ( @@ -38,8 +40,15 @@ def due_slots(now: datetime | None = None) -> tuple[int, ...]: Returns: The slot hours that still need a meal logged (empty == nothing due). + + Empty on a shared free day, whatever the schedule says. The pool is read + from a single local file and never the network, so this cannot hang the + systemd-fired gate, and an unreadable pool reads as "not free" -- the + normal rules apply rather than the gate switching itself off. """ reference = now if now is not None else now_local() + if freedays.is_free_day(reference.date()): + return () return missing_slots(reference, logged_slots_today(), current_schedule()) diff --git a/diet_guard/tests/conftest.py b/diet_guard/tests/conftest.py index a5b06a6..9dd9a67 100644 --- a/diet_guard/tests/conftest.py +++ b/diet_guard/tests/conftest.py @@ -24,6 +24,7 @@ from contextlib import ExitStack from typing import TYPE_CHECKING from unittest.mock import patch +import freedays import pytest from diet_guard._estimator import Nutrition @@ -103,6 +104,14 @@ def _isolate_state(tmp_path: Path) -> Iterator[None]: ~20 redirects CPython refuses to compile it. """ redirects = [ + # The shared free-day pool lives under ~/.local/share, outside every + # diet_guard path below. Without this a real free day on the + # developer's machine would make every "the gate is due" test here + # fail, for a reason that looks nothing like the cause. + patch( + "freedays._api.resolve_paths", + lambda paths: paths or freedays.Paths.under(tmp_path / "freedays"), + ), patch( "diet_guard._budget.BUDGET_FILE", tmp_path / ".budget", diff --git a/diet_guard/tests/test_gate.py b/diet_guard/tests/test_gate.py index a4ba52e..74efc6b 100644 --- a/diet_guard/tests/test_gate.py +++ b/diet_guard/tests/test_gate.py @@ -6,11 +6,17 @@ here the logged set is mocked and ``now`` is injected to drive each decision. from __future__ import annotations -from datetime import UTC, datetime +from datetime import UTC, date, datetime +from typing import TYPE_CHECKING from unittest.mock import patch +import freedays + from diet_guard._gate import due_slots, gate_is_due, gate_message +if TYPE_CHECKING: + from pathlib import Path + def _at(hour: int) -> datetime: """Return a fixed local datetime at ``hour``.""" @@ -77,3 +83,36 @@ class TestGateMessage: with _logged(set()): message = gate_message(_at(17)) assert message == "Log your meals for 08:00, 12:00, 16:00 to unlock." + + +class TestFreeDays: + """The shared pool stands the gate down entirely.""" + + def test_a_free_day_leaves_nothing_due(self, tmp_path: Path) -> None: + with _logged(set()): + assert due_slots(_at(20)), "precondition: slots are due without a free day" + freedays.mark( + _at(20).date(), + paths=freedays.Paths.under(tmp_path / "freedays"), + now=_at(20).date(), + ) + assert due_slots(_at(20)) == () + assert not gate_is_due(_at(20)) + + def test_a_free_day_on_another_date_changes_nothing(self, tmp_path: Path) -> None: + with _logged(set()): + freedays.mark( + date(2026, 12, 24), + paths=freedays.Paths.under(tmp_path / "freedays"), + now=_at(20).date(), + ) + assert due_slots(_at(20)) + assert gate_is_due(_at(20)) + + def test_an_unreadable_pool_leaves_the_gate_armed(self, tmp_path: Path) -> None: + """Fail closed: a corrupt pool must not switch the gate off.""" + pool_dir = tmp_path / "freedays" + pool_dir.mkdir(exist_ok=True) + (pool_dir / "free_days.json").write_text("{ not json", encoding="utf-8") + with _logged(set()): + assert gate_is_due(_at(20)) diff --git a/pyproject.toml b/pyproject.toml index 88efcbb..5168027 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,8 +4,9 @@ version = "1.0.0" description = "Tkinter/systemd log-to-unlock meal gate with calorie/macro budget tracking" requires-python = ">=3.14" dependencies = [ - "gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.7.2#subdirectory=gatelock", + "gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.7.3#subdirectory=gatelock", "crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.1#subdirectory=crdt-sync", + "freedays @ git+https://github.com/kuhyx/utils@freedays-v0.1.0#subdirectory=freedays", "requests==2.34.2", ] diff --git a/requirements.txt b/requirements.txt index 89d8d08..d3f0eea 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,16 +4,17 @@ bandit==1.9.4 codespell==2.4.3 coverage==7.16.0 crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.1#subdirectory=crdt-sync -gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.7.2#subdirectory=gatelock +freedays @ git+https://github.com/kuhyx/utils@freedays-v0.1.0#subdirectory=freedays +gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.7.3#subdirectory=gatelock mcp==2.1.1 mypy==2.3.1 pre-commit==4.6.2 pydantic==2.13.5 -pylint==4.0.7 +pylint==4.0.8 pytest==9.1.1 pytest-cov==7.1.0 -pytest-randomly==4.1.0 +pytest-randomly==5.0.0 pytest-sugar==1.1.1 pytest-xdist==3.8.0 requests==2.34.2 -ruff==0.16.5 +ruff==0.16.6