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 <noreply@anthropic.com>
This commit is contained in:
Krzysztof Rudnicki 2026-09-07 15:28:54 +02:00
parent 4e5908baa7
commit 981396442d
6 changed files with 66 additions and 6 deletions

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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():

View File

@ -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

View File

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