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."""