freedays 0.4.0: mark refuses a sick day (call-time sickdays import, allow+warn if absent)

Also fixes a ~5% flake in the hmac round-trip test (freedays and sickdays):
load_key strips whitespace, so a random key edged with a whitespace byte
never compared equal to the raw bytes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xfj1sxKH9VBPqpcAni8Jry
This commit is contained in:
Krzysztof Rudnicki 2026-10-16 08:28:44 +02:00
parent af8c6c78bf
commit c490ba5992
10 changed files with 152 additions and 12 deletions

View File

@ -63,13 +63,19 @@ guarded by a drift test against this package.
## Free days are not sick days ## Free days are not sick days
screen-locker's sick days stay exactly where they are, with their rolling Sick days are a separate pool: `sickdays` (`../sickdays`), uncapped, picked
windows, their 120-character justifications and their escalating countdown. per date, with its own file (`~/.local/share/sickdays/`) and its own remote
They are *designed to be hard to take*. 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: A date is never in both pools. Since 0.4.0 `mark` refuses a sick day
rolling windows forbid consecutive days, which is the entire point of a (`SickDayConflictError`), importing `sickdays` at call time, by name —
holiday. So the policies stay separate. Only the storage is shared. 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 ## How it syncs

View File

@ -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 * **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. 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 Free days are *not* sick days. Sick days are a separate, uncapped pool
rolling windows and their justification friction, because they exist to be (``sickdays``), and a date is never in both: :func:`mark` refuses a sick day
hard to take. These exist to be easy: declared in advance, no reason (:mod:`freedays._sick_guard`). Free days are declared in advance, no reason
required, consecutive runs allowed, capped only by an annual budget. required, consecutive runs allowed, capped only by an annual budget.
""" """
@ -48,6 +48,7 @@ from freedays._errors import (
FreeDayError, FreeDayError,
NotFreeError, NotFreeError,
PastDateError, PastDateError,
SickDayConflictError,
) )
from freedays._model import FreeDay from freedays._model import FreeDay
from freedays._paths import Paths from freedays._paths import Paths
@ -65,6 +66,7 @@ __all__ = [
"NotFreeError", "NotFreeError",
"PastDateError", "PastDateError",
"Paths", "Paths",
"SickDayConflictError",
"Status", "Status",
"SyncUnavailableError", "SyncUnavailableError",
"actor_id", "actor_id",

View File

@ -41,6 +41,7 @@ from freedays._model import (
to_free_day, to_free_day,
) )
from freedays._paths import Paths, resolve_paths from freedays._paths import Paths, resolve_paths
from freedays._sick_guard import refuse_if_sick
if TYPE_CHECKING: if TYPE_CHECKING:
from datetime import date from datetime import date
@ -140,6 +141,7 @@ def mark(
PastDateError: If ``day`` has already passed. PastDateError: If ``day`` has already passed.
AlreadyFreeError: If ``day`` is already free. AlreadyFreeError: If ``day`` is already free.
BudgetExhaustedError: If that year has no days left. BudgetExhaustedError: If that year has no days left.
SickDayConflictError: If ``day`` is a sick day (``sickdays``).
""" """
resolved = resolve_paths(paths) resolved = resolve_paths(paths)
reference = today(now) reference = today(now)
@ -153,6 +155,7 @@ def mark(
if existing is not None and is_free_record(existing): if existing is not None and is_free_record(existing):
msg = f"{to_iso(day)} is already a free day" msg = f"{to_iso(day)} is already a free day"
raise AlreadyFreeError(msg) raise AlreadyFreeError(msg)
refuse_if_sick(day)
if is_exhausted(log, day.year, budget=budget): if is_exhausted(log, day.year, budget=budget):
msg = f"no free days left in {day.year} -- all {budget} are spent" msg = f"no free days left in {day.year} -- all {budget} are spent"
raise BudgetExhaustedError(msg) raise BudgetExhaustedError(msg)

View File

@ -33,3 +33,7 @@ class AlreadyFreeError(FreeDayError):
class NotFreeError(FreeDayError): class NotFreeError(FreeDayError):
"""The day is not marked free, so there is nothing to release.""" """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."""

View File

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

View File

@ -37,6 +37,8 @@ def _redirect_state(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
for module in ("freedays._api", "freedays._audit", "freedays._sync"): for module in ("freedays._api", "freedays._audit", "freedays._sync"):
monkeypatch.setattr(f"{module}.resolve_paths", _resolve) 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 @pytest.fixture

View File

@ -24,7 +24,10 @@ def test_a_generated_key_round_trips(tmp_path: Path) -> None:
key_file = tmp_path / "hmac.key" key_file = tmp_path / "hmac.key"
created = generate_key(key_file) created = generate_key(key_file)
assert created is not None 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: def test_two_generated_keys_differ(tmp_path: Path) -> None:

View File

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

View File

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "freedays" 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" description = "Shared pre-declared free-day pool: one global list of days on which every gate app stands down"
requires-python = ">=3.14" requires-python = ">=3.14"
# Deliberately NOT gatelock. Its __init__ imports tkinter for the lock-window # Deliberately NOT gatelock. Its __init__ imports tkinter for the lock-window

View File

@ -24,7 +24,10 @@ def test_a_generated_key_round_trips(tmp_path: Path) -> None:
key_file = tmp_path / "hmac.key" key_file = tmp_path / "hmac.key"
created = generate_key(key_file) created = generate_key(key_file)
assert created is not None 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: def test_two_generated_keys_differ(tmp_path: Path) -> None: