From 981396442d9c05c9f6c66ea9c5f14ac915450e91 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Mon, 7 Sep 2026 15:28:54 +0200 Subject: [PATCH] Do not ring at all on a shared free day Fourth app onto freedays (~/utils/freedays), the shared pool of pre-declared days when every gate app stands down. _skip_reason is the right seam: it already exists to say why the alarm must not fire, and the free-day check joins the day gate and the once-per-day latch as another clock-independent reason. It goes first because it is the cheapest and the most absolute. "free day" means the alarm does not ring -- not that it rings later. The orchestrator is never started, so neither the alarm nor the workout lock behind it appears. That is the one app in this fleet where standing down is a physical event rather than an absent dialog, so it is worth being explicit: nothing happens that morning. Fail closed, as everywhere else: an unreadable pool reads as "not free" and the alarm rings normally. Silence is never the failure mode -- there is a test that a corrupt pool still wakes you up. The pool is one local file read, so the per-minute tick stays a millisecond-scale process with no network in it. The pylint hook's gatelock pin had drifted all the way back to v0.4.1; it moves to v0.7.3 alongside crdt-sync and freedays. pip refuses two different direct URLs for one distribution, so that stale pin would have made the hook environment unresolvable rather than merely old. requirements.txt also takes pytest-randomly 5.0.0 and ruff 0.16.6, which the freshness gate flagged once the file was touched. 171 tests, 100% coverage. Co-Authored-By: Claude Opus 5 --- .pre-commit-config.yaml | 8 ++++++- pyproject.toml | 1 + requirements.txt | 5 +++-- wake_alarm/_trigger.py | 13 +++++++++-- wake_alarm/tests/conftest.py | 8 +++++++ wake_alarm/tests/test_trigger.py | 37 +++++++++++++++++++++++++++++++- 6 files changed, 66 insertions(+), 6 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 472c207..f10641e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -95,7 +95,13 @@ repos: - --jobs=4 additional_dependencies: - pytest - - gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.4.1#subdirectory=gatelock + # Kept in step with pyproject.toml: pip refuses two different + # direct URLs for one distribution, so a stale pin here becomes + # an unresolvable hook environment once freedays -- which needs + # 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 # Test suites intentionally use patterns (protected-access, magic-value # comparisons) that don't belong in the source-code 10/10 gate. exclude: ^(\.venv/)|(^|/)(tests/|conftest\.py) diff --git a/pyproject.toml b/pyproject.toml index b020fc0..eecc030 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,7 @@ dependencies = [ # falls back to its stdlib GitHub path if the import fails, so a broken # install degrades the read instead of killing the per-minute trigger. "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", ] # Package discovery must be explicit: phone_app/ is a Flutter app, not a Python diff --git a/requirements.txt b/requirements.txt index 8723d0b..91d28fb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,13 +6,14 @@ 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 +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 mypy==2.3.1 pre-commit==4.6.2 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 -ruff==0.16.5 +ruff==0.16.6 diff --git a/wake_alarm/_trigger.py b/wake_alarm/_trigger.py index 1fa2266..c2df2bf 100644 --- a/wake_alarm/_trigger.py +++ b/wake_alarm/_trigger.py @@ -26,6 +26,8 @@ import logging import subprocess import sys +import freedays + from wake_alarm._constants import ( ALARM_DAYS, FALLBACK_ALARM_HOUR, @@ -81,8 +83,13 @@ def _fire_window(alarm: Alarm, now: datetime) -> tuple[datetime, datetime]: def _skip_reason(now: datetime) -> str | None: """Return why the alarm must not fire right now, or None if it may. - Checks only the gates that are independent of the clock: the day gate and - the once-per-day latch. + Checks only the gates that are independent of the clock: the shared + free-day pool, the day gate and the once-per-day latch. + + A free day means the alarm does not ring at all -- the orchestrator is + never started, so neither the alarm nor the workout lock behind it + appears. The pool is one local file read, never the network, so this + stays a millisecond-scale tick. Args: now: Current local time. @@ -90,6 +97,8 @@ def _skip_reason(now: datetime) -> str | None: Returns: A human-readable reason, or None when nothing blocks firing. """ + if freedays.is_free_day(now.date()): + return "free day" if now.weekday() not in ALARM_DAYS: return "not an alarm day" if was_trigger_fired_today(): diff --git a/wake_alarm/tests/conftest.py b/wake_alarm/tests/conftest.py index 592d07c..af0b8b7 100644 --- a/wake_alarm/tests/conftest.py +++ b/wake_alarm/tests/conftest.py @@ -21,6 +21,7 @@ from __future__ import annotations from typing import TYPE_CHECKING from unittest.mock import patch +import freedays import pytest if TYPE_CHECKING: @@ -36,5 +37,12 @@ def _hermetic(tmp_path: Path) -> Generator[None]: with ( patch("gatelock.log_integrity.DEFAULT_HMAC_KEY_FILE", key_file), patch("wake_alarm._state.WAKE_STATE_FILE", tmp_path / "wake_state.json"), + # The shared free-day pool lives under ~/.local/share, outside every + # path above. Without this, a real free day on the developer's + # machine would silence the alarm in every trigger test here. + patch( + "freedays._api.resolve_paths", + lambda paths: paths or freedays.Paths.under(tmp_path / "freedays"), + ), ): yield diff --git a/wake_alarm/tests/test_trigger.py b/wake_alarm/tests/test_trigger.py index a6b931d..2c2ae01 100644 --- a/wake_alarm/tests/test_trigger.py +++ b/wake_alarm/tests/test_trigger.py @@ -2,9 +2,11 @@ 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 import pytest from wake_alarm._sync import Alarm @@ -15,6 +17,9 @@ from wake_alarm._trigger import ( _skip_reason, ) +if TYPE_CHECKING: + from pathlib import Path + def _at(hour: int, minute: int) -> datetime: """Return a Sunday at the given local wall-clock time, timezone-aware. @@ -103,6 +108,36 @@ class TestSkipReason: ): assert _skip_reason(_at(7, 0)) == "already fired today" + def test_a_free_day_silences_the_alarm(self, tmp_path: Path) -> None: + """A free day means it does not ring at all, not that it rings later.""" + moment = _at(7, 0) + assert _skip_reason(moment) is None, "precondition: nothing else blocks" + + freedays.mark( + moment.date(), + paths=freedays.Paths.under(tmp_path / "freedays"), + now=moment.date(), + ) + assert _skip_reason(moment) == "free day" + + def test_a_free_day_on_another_date_does_not_silence_it( + self, tmp_path: Path + ) -> None: + moment = _at(7, 0) + freedays.mark( + date(2026, 12, 24), + paths=freedays.Paths.under(tmp_path / "freedays"), + now=moment.date(), + ) + assert _skip_reason(moment) is None + + def test_an_unreadable_pool_still_lets_the_alarm_ring(self, tmp_path: Path) -> None: + """Fail closed: a corrupt pool must not silence the morning routine.""" + pool_dir = tmp_path / "freedays" + pool_dir.mkdir(exist_ok=True) + (pool_dir / "free_days.json").write_text("{ not json", encoding="utf-8") + assert _skip_reason(_at(7, 0)) is None + class TestDecide: """Tests for the fire/don't-fire decision."""