diff --git a/freedays/README.md b/freedays/README.md index 4e38345..1f59e38 100644 --- a/freedays/README.md +++ b/freedays/README.md @@ -63,13 +63,19 @@ guarded by a drift test against this package. ## Free days are not sick days -screen-locker's sick days stay exactly where they are, with their rolling -windows, their 120-character justifications and their escalating countdown. -They are *designed to be hard to take*. +Sick days are a separate pool: `sickdays` (`../sickdays`), uncapped, picked +per date, with its own file (`~/.local/share/sickdays/`) and its own remote +node (`sickdays-sync/`). Nothing is shared with this pool — not the budget, +not the file, not the sync path. It replaced screen-locker's capped sick day. -These are the opposite, and merging the two would have to ruin one of them: -rolling windows forbid consecutive days, which is the entire point of a -holiday. So the policies stay separate. Only the storage is shared. +A date is never in both pools. Since 0.4.0 `mark` refuses a sick day +(`SickDayConflictError`), importing `sickdays` at call time, by name — +sickdays depends on freedays, never the reverse, so there is no import +cycle. Where sickdays is not installed (the gaming daemon's root env, CI) +the mark is **allowed** and a warning is logged: there is no local sick-day +pool there to conflict with, and refusing every free day would break a +load-bearing package for nothing. The reverse direction is sickdays' own +check, which fails closed. ## How it syncs diff --git a/freedays/freedays/__init__.py b/freedays/freedays/__init__.py index 8a94798..d803ec3 100644 --- a/freedays/freedays/__init__.py +++ b/freedays/freedays/__init__.py @@ -20,9 +20,9 @@ Two properties are deliberate and load-bearing: * **One global pool.** A free day is free for every app at once. There is no per-app dimension to mark, and none to forget to mark. -Free days are *not* sick days. Sick days stay where they are, with their -rolling windows and their justification friction, because they exist to be -hard to take. These exist to be easy: declared in advance, no reason +Free days are *not* sick days. Sick days are a separate, uncapped pool +(``sickdays``), and a date is never in both: :func:`mark` refuses a sick day +(:mod:`freedays._sick_guard`). Free days are declared in advance, no reason required, consecutive runs allowed, capped only by an annual budget. """ @@ -48,6 +48,7 @@ from freedays._errors import ( FreeDayError, NotFreeError, PastDateError, + SickDayConflictError, ) from freedays._model import FreeDay from freedays._paths import Paths @@ -65,6 +66,7 @@ __all__ = [ "NotFreeError", "PastDateError", "Paths", + "SickDayConflictError", "Status", "SyncUnavailableError", "actor_id", diff --git a/freedays/freedays/_api.py b/freedays/freedays/_api.py index f8d1994..21e0c2d 100644 --- a/freedays/freedays/_api.py +++ b/freedays/freedays/_api.py @@ -41,6 +41,7 @@ from freedays._model import ( to_free_day, ) from freedays._paths import Paths, resolve_paths +from freedays._sick_guard import refuse_if_sick if TYPE_CHECKING: from datetime import date @@ -140,6 +141,7 @@ def mark( PastDateError: If ``day`` has already passed. AlreadyFreeError: If ``day`` is already free. BudgetExhaustedError: If that year has no days left. + SickDayConflictError: If ``day`` is a sick day (``sickdays``). """ resolved = resolve_paths(paths) reference = today(now) @@ -153,6 +155,7 @@ def mark( if existing is not None and is_free_record(existing): msg = f"{to_iso(day)} is already a free day" raise AlreadyFreeError(msg) + refuse_if_sick(day) if is_exhausted(log, day.year, budget=budget): msg = f"no free days left in {day.year} -- all {budget} are spent" raise BudgetExhaustedError(msg) diff --git a/freedays/freedays/_errors.py b/freedays/freedays/_errors.py index 94d5445..283c15d 100644 --- a/freedays/freedays/_errors.py +++ b/freedays/freedays/_errors.py @@ -33,3 +33,7 @@ class AlreadyFreeError(FreeDayError): class NotFreeError(FreeDayError): """The day is not marked free, so there is nothing to release.""" + + +class SickDayConflictError(FreeDayError): + """The day is a sick day (``sickdays``); a date is never in both pools.""" diff --git a/freedays/freedays/_sick_guard.py b/freedays/freedays/_sick_guard.py new file mode 100644 index 0000000..c450a09 --- /dev/null +++ b/freedays/freedays/_sick_guard.py @@ -0,0 +1,57 @@ +# Copyright (c) 2026 Krzysztof Rudnicki +"""A date is never both a free day and a sick day (0.4.0). + +Sick days are a separate pool (``sickdays``, its own file and remote node). +sickdays depends on freedays, never the other way round, so this module +imports it *at call time, by name* -- no module-level import, no cycle. + +If sickdays is not installed the mark is **allowed** and a warning logged. +freedays is load-bearing on hosts that never get sickdays (the gaming +daemon's root env, CI), and on such a host there is no local sick-day pool +to conflict with; refusing every free day there would be a regression for +nothing. The other direction (a sick day on a free date) is refused by +sickdays itself, which fails closed. +""" + +from __future__ import annotations + +import importlib +import logging +from typing import TYPE_CHECKING, cast + +from freedays._day import to_iso +from freedays._errors import SickDayConflictError + +if TYPE_CHECKING: + from collections.abc import Callable + from datetime import date + +_logger = logging.getLogger(__name__) + + +def is_sick_predicate() -> Callable[[date], bool] | None: + """``sickdays.is_sick_day``, imported now; ``None`` (logged) if absent.""" + try: + module = importlib.import_module("sickdays") + except ImportError: + _logger.warning( + "sickdays is not installed: cannot check the sick-day pool, " + "so the free day is marked unchecked" + ) + return None + return cast("Callable[[date], bool]", module.is_sick_day) + + +def refuse_if_sick(day: date) -> None: + """Refuse to mark ``day`` free when it is a sick day. + + Raises: + SickDayConflictError: ``day`` is a sick day. + """ + is_sick = is_sick_predicate() + if is_sick is not None and is_sick(day): + msg = ( + f"{to_iso(day)} is a sick day -- remove it with " + "'sickdays remove' before marking it free" + ) + raise SickDayConflictError(msg) diff --git a/freedays/freedays/tests/conftest.py b/freedays/freedays/tests/conftest.py index 5c9e831..c80fa7e 100644 --- a/freedays/freedays/tests/conftest.py +++ b/freedays/freedays/tests/conftest.py @@ -37,6 +37,8 @@ def _redirect_state(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: for module in ("freedays._api", "freedays._audit", "freedays._sync"): monkeypatch.setattr(f"{module}.resolve_paths", _resolve) + # Never consult the real sick-day pool; test_sick_guard covers the check. + monkeypatch.setattr("freedays._sick_guard.is_sick_predicate", lambda: None) @pytest.fixture diff --git a/freedays/freedays/tests/test_hmac.py b/freedays/freedays/tests/test_hmac.py index 19e5918..93e146e 100644 --- a/freedays/freedays/tests/test_hmac.py +++ b/freedays/freedays/tests/test_hmac.py @@ -24,7 +24,10 @@ def test_a_generated_key_round_trips(tmp_path: Path) -> None: key_file = tmp_path / "hmac.key" created = generate_key(key_file) assert created is not None - assert load_key(key_file) == created + # load_key strips whitespace, and a random key can begin or end with a + # whitespace byte (~5% of keys): signing and verifying both strip, so + # the stripped key is the one that must round-trip. + assert load_key(key_file) == created.strip() def test_two_generated_keys_differ(tmp_path: Path) -> None: diff --git a/freedays/freedays/tests/test_sick_guard.py b/freedays/freedays/tests/test_sick_guard.py new file mode 100644 index 0000000..4948a5a --- /dev/null +++ b/freedays/freedays/tests/test_sick_guard.py @@ -0,0 +1,60 @@ +# Copyright (c) 2026 Krzysztof Rudnicki +"""A sick day cannot be marked free; no sickdays means the mark is allowed.""" + +from __future__ import annotations + +from datetime import date +import sys +import types + +import pytest + +from freedays._api import is_free_day, mark +from freedays._errors import SickDayConflictError +from freedays._sick_guard import is_sick_predicate + +TODAY = date(2026, 10, 16) +SICK = date(2026, 10, 20) + + +def _fake_sickdays(monkeypatch: pytest.MonkeyPatch) -> None: + fake = types.ModuleType("sickdays") + + def _is_sick_day(day: date) -> bool: + return day == SICK + + vars(fake)["is_sick_day"] = _is_sick_day + monkeypatch.setitem(sys.modules, "sickdays", fake) + + +def test_the_predicate_is_sickdays_own(monkeypatch: pytest.MonkeyPatch) -> None: + _fake_sickdays(monkeypatch) + predicate = is_sick_predicate() + assert predicate is not None + assert predicate(SICK) + assert not predicate(TODAY) + + +def test_no_sickdays_is_logged_and_allowed( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + monkeypatch.setitem(sys.modules, "sickdays", None) + with caplog.at_level("WARNING"): + assert is_sick_predicate() is None + assert "sickdays is not installed" in caplog.text + + +def test_a_sick_day_cannot_be_marked_free(monkeypatch: pytest.MonkeyPatch) -> None: + _fake_sickdays(monkeypatch) + predicate = is_sick_predicate() + monkeypatch.setattr("freedays._sick_guard.is_sick_predicate", lambda: predicate) + with pytest.raises(SickDayConflictError, match="sickdays remove"): + mark(SICK, now=TODAY) + assert not is_free_day(SICK, now=TODAY) + mark(TODAY, now=TODAY) + assert is_free_day(TODAY, now=TODAY) + + +def test_without_sickdays_the_mark_goes_through() -> None: + mark(SICK, now=TODAY) + assert is_free_day(SICK, now=TODAY) diff --git a/freedays/pyproject.toml b/freedays/pyproject.toml index 9ff8260..60b069d 100644 --- a/freedays/pyproject.toml +++ b/freedays/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "freedays" -version = "0.3.1" +version = "0.4.0" description = "Shared pre-declared free-day pool: one global list of days on which every gate app stands down" requires-python = ">=3.14" # Deliberately NOT gatelock. Its __init__ imports tkinter for the lock-window diff --git a/sickdays/sickdays/tests/test_hmac.py b/sickdays/sickdays/tests/test_hmac.py index 14f0543..ffd39a8 100644 --- a/sickdays/sickdays/tests/test_hmac.py +++ b/sickdays/sickdays/tests/test_hmac.py @@ -24,7 +24,10 @@ def test_a_generated_key_round_trips(tmp_path: Path) -> None: key_file = tmp_path / "hmac.key" created = generate_key(key_file) assert created is not None - assert load_key(key_file) == created + # load_key strips whitespace, and a random key can begin or end with a + # whitespace byte (~5% of keys): signing and verifying both strip, so + # the stripped key is the one that must round-trip. + assert load_key(key_file) == created.strip() def test_two_generated_keys_differ(tmp_path: Path) -> None: