mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 19:27:16 +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
|
## 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
|
||||||
|
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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."""
|
||||||
|
|||||||
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"):
|
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
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
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]
|
[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
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user