mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 18:07:24 +01:00
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:
parent
af8c6c78bf
commit
c490ba5992
@ -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
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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."""
|
||||
|
||||
57
freedays/freedays/_sick_guard.py
Normal file
57
freedays/freedays/_sick_guard.py
Normal 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)
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
60
freedays/freedays/tests/test_sick_guard.py
Normal file
60
freedays/freedays/tests/test_sick_guard.py
Normal 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)
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user