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

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

View File

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

View File

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

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"):
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

View File

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

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

View File

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