mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-11 15:08:19 +01:00
sickdays 0.1.0 + earned_time 0.11.0: uncapped sick-day pool, 20:00 sick shutdown floor
sickdays: per-date pool, no cap or reason, add/remove today or later only, import_legacy for the migration. LWW state flip per date (no tombstones), Firebase RTDB sickdays-sync/devices/<uuid>/sick_days.json, HMAC audit trail. Readers fail closed (unreadable pool = not sick, logged); writers refuse a corrupt pool. Free-day cross-check via a lazily imported freedays predicate. earned_time: resolve(..., sick=True) floors the final shutdown at 20:00 (capped at the ceiling); gaming, terms and base unchanged. Result dataclasses moved to _resolution.py for the line cap. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xfj1sxKH9VBPqpcAni8Jry
This commit is contained in:
parent
ba34b603a1
commit
894d76f811
73
.github/workflows/sickdays-precommit.yml
vendored
Normal file
73
.github/workflows/sickdays-precommit.yml
vendored
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
name: sickdays pre-commit
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
# The workflow's own file counts as sickdays: editing only this file would
|
||||||
|
# otherwise trigger nothing, so a fix to the job itself could never prove
|
||||||
|
# itself.
|
||||||
|
paths: ['sickdays/**', '.github/workflows/sickdays-precommit.yml']
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
paths: ['sickdays/**', '.github/workflows/sickdays-precommit.yml']
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
pre-commit:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.x"
|
||||||
|
# Only pre-commit itself. Every linting hook runs in its own isolated
|
||||||
|
# env -- ruff, mypy, pylint, bandit and codespell each come from their
|
||||||
|
# own repo+rev, and the local hooks need nothing beyond bash and
|
||||||
|
# python3 -- so installing the full requirements.txt spent 9s a run on
|
||||||
|
# packages no pre-commit-stage hook imports. Verified by running all
|
||||||
|
# three configs green in a venv holding pre-commit and nothing else.
|
||||||
|
# The pin is read out of requirements.txt rather than repeated here, so
|
||||||
|
# it cannot drift from what the dependency-freshness gate governs.
|
||||||
|
- name: Install pre-commit
|
||||||
|
run: |
|
||||||
|
pip install "$(grep '^pre-commit==' sickdays/requirements.txt)"
|
||||||
|
|
||||||
|
# Keyed on THIS package's config. pre-commit/action@v3.0.1 caches on
|
||||||
|
# hashFiles('.pre-commit-config.yaml') -- the repo ROOT's -- so all three
|
||||||
|
# package jobs shared one key, raced to overwrite it with hook sets built
|
||||||
|
# from four different configs, and editing a per-package config did not
|
||||||
|
# invalidate it. The cache was therefore silently wrong rather than
|
||||||
|
# missing, and the hooks reinstalled from scratch on every run (38-49s).
|
||||||
|
- uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ~/.cache/pre-commit
|
||||||
|
key: >-
|
||||||
|
pre-commit-sickdays-${{ env.pythonLocation }}-${{
|
||||||
|
hashFiles('sickdays/.pre-commit-config.yaml') }}
|
||||||
|
# Prefix fallback, or this fix would evaporate on the very
|
||||||
|
# event that motivated it: bumping a hook rev changes the
|
||||||
|
# exact key, and a total miss means reinstalling every hook
|
||||||
|
# again. pre-commit's store is keyed internally by hook
|
||||||
|
# repo+rev, so a superseded entry is ignored disk rather
|
||||||
|
# than a wrong hook, and cache still re-saves under the new
|
||||||
|
# exact key after a prefix-only hit.
|
||||||
|
restore-keys: pre-commit-sickdays-${{ env.pythonLocation }}-
|
||||||
|
# mypy re-analyses the stdlib and every third-party stub from scratch
|
||||||
|
# without this, which measured 10s of the hook step in CI against 0.25s
|
||||||
|
# warm locally -- a 19MB cache dir does the whole difference. Keyed on
|
||||||
|
# the config, not the sources: mypy's own cache metadata records the
|
||||||
|
# interpreter, the flag set and a hash per source file, so it re-checks
|
||||||
|
# what changed and cannot pass stale code. Deliberately NOT keyed on
|
||||||
|
# github.sha -- that re-saves 19MB x3 every commit and would evict the
|
||||||
|
# 68MB hook caches under the repo's 10GB LRU budget. A fixed key keeps
|
||||||
|
# the expensive part, the dependency analysis, which is what costs 10s.
|
||||||
|
- uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: .mypy_cache
|
||||||
|
key: >-
|
||||||
|
mypy-sickdays-${{ env.pythonLocation }}-${{
|
||||||
|
hashFiles('sickdays/.pre-commit-config.yaml') }}
|
||||||
|
restore-keys: mypy-sickdays-${{ env.pythonLocation }}-
|
||||||
|
- name: Run the hooks
|
||||||
|
run: >-
|
||||||
|
pre-commit run --config sickdays/.pre-commit-config.yaml
|
||||||
|
--all-files --show-diff-on-failure
|
||||||
44
.github/workflows/sickdays-tests.yml
vendored
Normal file
44
.github/workflows/sickdays-tests.yml
vendored
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
name: sickdays tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
# The workflow's own file counts as sickdays: editing only this file would
|
||||||
|
# otherwise trigger nothing, so a fix to the job itself could never prove
|
||||||
|
# itself.
|
||||||
|
paths: ['sickdays/**', '.github/workflows/sickdays-tests.yml']
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
paths: ['sickdays/**', '.github/workflows/sickdays-tests.yml']
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: sickdays
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.x"
|
||||||
|
cache: pip
|
||||||
|
cache-dependency-path: sickdays/requirements.txt
|
||||||
|
|
||||||
|
# crdt-sync and freedays are siblings in this repo, so they install from
|
||||||
|
# the checkout rather than from a tag -- CI must test what is on this
|
||||||
|
# branch, not what was last released. Deliberately NOT gatelock:
|
||||||
|
# importing it pulls in tkinter, and this job has no display precisely
|
||||||
|
# because sickdays must work without one.
|
||||||
|
- name: Install the sibling packages
|
||||||
|
run: pip install ../crdt-sync && pip install --no-deps ../freedays
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pip install -r requirements.txt && pip install --no-deps -e .
|
||||||
|
|
||||||
|
# No display needed: sickdays draws nothing. It is policy, a JSON file
|
||||||
|
# and a CLI.
|
||||||
|
- name: Run tests with coverage
|
||||||
|
run: python -m pytest -v
|
||||||
@ -1,7 +1,7 @@
|
|||||||
# utils
|
# utils
|
||||||
|
|
||||||
Monorepo of shared gates and libraries (dep_freshness, file_length, md_naming,
|
Monorepo of shared gates and libraries (dep_freshness, file_length, md_naming,
|
||||||
repo_contract, coverage_gaps, crdt-sync, gatelock, freedays, earned_time, music_theory, ...).
|
repo_contract, coverage_gaps, crdt-sync, gatelock, freedays, sickdays, earned_time, music_theory, ...).
|
||||||
Python packages with a root-level `<pkg>/tests` run with `PYTHONPATH=.`;
|
Python packages with a root-level `<pkg>/tests` run with `PYTHONPATH=.`;
|
||||||
subprojects with their own `pyproject.toml` run via `scripts/run_subproject_tests.sh <dir>`.
|
subprojects with their own `pyproject.toml` run via `scripts/run_subproject_tests.sh <dir>`.
|
||||||
`scripts/check_repo_contract.sh` is the REAL shared gate (not a shim): never
|
`scripts/check_repo_contract.sh` is the REAL shared gate (not a shim): never
|
||||||
|
|||||||
@ -48,6 +48,13 @@ never a same-day cut):
|
|||||||
The two floors are derived separately (`_resolve._shutdown_floor`), so the
|
The two floors are derived separately (`_resolve._shutdown_floor`), so the
|
||||||
ladder cannot move a gaming minute.
|
ladder cannot move a gaming minute.
|
||||||
|
|
||||||
|
**Sick days (0.11.0, `_sick.py`).** `resolve(..., sick=True)` floors the
|
||||||
|
*final* shutdown at 20:00 (`SICK_SHUTDOWN_FLOOR_MINUTES`), capped at the
|
||||||
|
ceiling: 18:50 becomes 20:00, 21:30 stays 21:30 (nothing stacks on the floor).
|
||||||
|
Base, terms, gaming, waivers and penalties are unchanged; earners still earn.
|
||||||
|
`sick` is the consumer's answer (`sickdays.is_sick_day`, never imported here).
|
||||||
|
Live credits are paid out of `Resolution.sick_lift_minutes` first (a grace lift).
|
||||||
|
|
||||||
**The tutor cutover (`TUTOR_FROM`, one constant in `_ladder.py`).** The
|
**The tutor cutover (`TUTOR_FROM`, one constant in `_ladder.py`).** The
|
||||||
registry is per day: `earners_for(day)` is `EARNERS` before it and
|
registry is per day: `earners_for(day)` is `EARNERS` before it and
|
||||||
`TUTOR_EARNERS` from it. From `TUTOR_FROM` Anki is retired (neither penalised
|
`TUTOR_EARNERS` from it. From `TUTOR_FROM` Anki is retired (neither penalised
|
||||||
@ -100,10 +107,9 @@ per-matcher reader `_match.ROW_UNITS`):
|
|||||||
- Rollout order: no minute row may be written until every consumer process
|
- Rollout order: no minute row may be written until every consumer process
|
||||||
runs 0.8.0. A 0.7.0 reader pays each row as a 15-minute block.
|
runs 0.8.0. A 0.7.0 reader pays each row as a 15-minute block.
|
||||||
|
|
||||||
Days before `TUTOR_FROM` resolve on
|
Days before `TUTOR_FROM` resolve on `EARNERS` exactly as before. Consumers must
|
||||||
`EARNERS` exactly as before. Consumers must iterate `earners_for(day)`, never
|
iterate `earners_for(day)`, never `EARNERS`; `resolve`/`base_for` default to
|
||||||
`EARNERS`; `resolve`/`base_for` default to it, and ignore answers for an
|
it, and ignore answers for an earner of another day's registry.
|
||||||
earner of another day's registry.
|
|
||||||
|
|
||||||
**The gap waiver (`ANKI_WAIVED_FROM`, 2026-10-09).** anki-guard never wrote a
|
**The gap waiver (`ANKI_WAIVED_FROM`, 2026-10-09).** anki-guard never wrote a
|
||||||
credit, and the tutor is not confirmed yet, so from `ANKI_WAIVED_FROM` until
|
credit, and the tutor is not confirmed yet, so from `ANKI_WAIVED_FROM` until
|
||||||
@ -152,7 +158,8 @@ How far to trust a gate is computed from its ledger, never listed by name:
|
|||||||
|
|
||||||
## API
|
## API
|
||||||
|
|
||||||
- `resolve(answers, day=None, earners=EARNERS) -> Resolution` — the one sum.
|
- `resolve(answers, day=None, earners=None, *, first_credits=None, sick=False)`
|
||||||
|
`-> Resolution` — the one sum. `sick_shutdown_minutes(minutes, day) -> int`.
|
||||||
- `base_for(day=None, earners=EARNERS) -> Base` — the day's floors.
|
- `base_for(day=None, earners=EARNERS) -> Base` — the day's floors.
|
||||||
- `done_today(earner, ledger, key_file, *, now=None) -> bool | None`
|
- `done_today(earner, ledger, key_file, *, now=None) -> bool | None`
|
||||||
- `first_credit_at(earner, ledger, key_file, day) -> float | None` — unix time
|
- `first_credit_at(earner, ledger, key_file, day) -> float | None` — unix time
|
||||||
@ -168,9 +175,8 @@ How far to trust a gate is computed from its ledger, never listed by name:
|
|||||||
on a day, and every earner of every registry (what a ledger watcher must
|
on a day, and every earner of every registry (what a ledger watcher must
|
||||||
watch). They read `earned_time.EARNERS` through the package, so a consumer
|
watch). They read `earned_time.EARNERS` through the package, so a consumer
|
||||||
test that patches it still registers its stand-in.
|
test that patches it still registers its stand-in.
|
||||||
- `shutdown_minutes_for(earner, day) -> int`,
|
- `shutdown_minutes_for(earner, day) -> int`, `extra_shutdown_minutes_for(earner,
|
||||||
`extra_shutdown_minutes_for(earner, day) -> int`,
|
day) -> int`, `shutdown_ceiling_for(day) -> int`, `on_ladder(day) -> bool` — what a status
|
||||||
`shutdown_ceiling_for(day) -> int`, `on_ladder(day) -> bool` — what a status
|
|
||||||
view needs to list what is still left to earn. `Earner.shutdown_for(units,
|
view needs to list what is still left to earn. `Earner.shutdown_for(units,
|
||||||
day=None)` is the day-aware sum (default today). Never read
|
day=None)` is the day-aware sum (default today). Never read
|
||||||
`earner.shutdown_minutes` directly: it is the pre-ladder value.
|
`earner.shutdown_minutes` directly: it is the pre-ladder value.
|
||||||
@ -237,14 +243,8 @@ on the day itself is logged and does not count.
|
|||||||
## Install
|
## Install
|
||||||
|
|
||||||
```console
|
```console
|
||||||
pip install --user --break-system-packages --no-deps "earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.10.0#subdirectory=earned_time"
|
pip install --user --break-system-packages --no-deps "earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.11.0#subdirectory=earned_time"
|
||||||
```
|
```
|
||||||
|
|
||||||
The root `steam-backlog-enforcer.service` sets `HOME=/home/kuhy`, so it imports
|
The root `steam-backlog-enforcer.service` sets `HOME=/home/kuhy`, so it imports
|
||||||
from the same user site-packages.
|
from the same user site-packages. Test: `scripts/run_subproject_tests.sh earned_time`.
|
||||||
|
|
||||||
## Test
|
|
||||||
|
|
||||||
```console
|
|
||||||
scripts/run_subproject_tests.sh earned_time
|
|
||||||
```
|
|
||||||
|
|||||||
@ -19,9 +19,11 @@ A consumer's whole integration::
|
|||||||
if e.ledger is not None
|
if e.ledger is not None
|
||||||
}
|
}
|
||||||
answers["workout"] = my_workout_count()
|
answers["workout"] = my_workout_count()
|
||||||
day = earned_time.resolve(answers)
|
day = earned_time.resolve(answers, sick=sickdays.is_sick_day())
|
||||||
apply(day.gaming_minutes)
|
apply(day.gaming_minutes)
|
||||||
|
|
||||||
|
``sick`` (0.11.0) only raises shutdown to at least 20:00; gaming is unchanged.
|
||||||
|
|
||||||
How far to trust a gate is computed, not listed: :func:`maturity` (and
|
How far to trust a gate is computed, not listed: :func:`maturity` (and
|
||||||
``python -m earned_time maturity``).
|
``python -m earned_time maturity``).
|
||||||
|
|
||||||
@ -80,6 +82,7 @@ from earned_time._policy import (
|
|||||||
from earned_time._quota import TUTOR_QUOTA_FROM, TUTOR_QUOTA_MINUTES, tutor_quota_for
|
from earned_time._quota import TUTOR_QUOTA_FROM, TUTOR_QUOTA_MINUTES, tutor_quota_for
|
||||||
from earned_time._registry import all_earners, earners_for, registries
|
from earned_time._registry import all_earners, earners_for, registries
|
||||||
from earned_time._resolve import Base, Resolution, Term, base_for, resolve
|
from earned_time._resolve import Base, Resolution, Term, base_for, resolve
|
||||||
|
from earned_time._sick import SICK_SHUTDOWN_FLOOR_MINUTES, sick_shutdown_minutes
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"ANKI",
|
"ANKI",
|
||||||
@ -96,6 +99,7 @@ __all__ = [
|
|||||||
"READING",
|
"READING",
|
||||||
"SHUTDOWN_BASE_MINUTES",
|
"SHUTDOWN_BASE_MINUTES",
|
||||||
"SHUTDOWN_CEILING_MINUTES",
|
"SHUTDOWN_CEILING_MINUTES",
|
||||||
|
"SICK_SHUTDOWN_FLOOR_MINUTES",
|
||||||
"TUTOR",
|
"TUTOR",
|
||||||
"TUTOR_EARNERS",
|
"TUTOR_EARNERS",
|
||||||
"TUTOR_FROM",
|
"TUTOR_FROM",
|
||||||
@ -131,6 +135,7 @@ __all__ = [
|
|||||||
"resolve",
|
"resolve",
|
||||||
"shutdown_ceiling_for",
|
"shutdown_ceiling_for",
|
||||||
"shutdown_minutes_for",
|
"shutdown_minutes_for",
|
||||||
|
"sick_shutdown_minutes",
|
||||||
"today_window",
|
"today_window",
|
||||||
"tutor_quota_for",
|
"tutor_quota_for",
|
||||||
"verified",
|
"verified",
|
||||||
|
|||||||
84
earned_time/earned_time/_resolution.py
Normal file
84
earned_time/earned_time/_resolution.py
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
|
"""What :func:`earned_time.resolve` returns: the base, each term, the sum.
|
||||||
|
|
||||||
|
Split from :mod:`earned_time._resolve` (0.11.0) to keep both under the line
|
||||||
|
cap; ``earned_time._resolve`` still re-exports all three.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from earned_time._policy import Earner
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Base:
|
||||||
|
"""The floor for one day, after every penalty in force on it.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
gaming_minutes: Gaming time a day that earned nothing gets.
|
||||||
|
shutdown_minutes: Shutdown time of such a day, minutes after midnight.
|
||||||
|
"""
|
||||||
|
|
||||||
|
gaming_minutes: int
|
||||||
|
shutdown_minutes: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Term:
|
||||||
|
"""What one earner contributed.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
earner: The earner.
|
||||||
|
answer: The consumer's answer: a unit count, or ``None`` for
|
||||||
|
"could not check" (and for no answer at all).
|
||||||
|
gaming_minutes: Gaming minutes earned.
|
||||||
|
shutdown_minutes: Shutdown minutes earned.
|
||||||
|
"""
|
||||||
|
|
||||||
|
earner: Earner
|
||||||
|
answer: int | None
|
||||||
|
gaming_minutes: int
|
||||||
|
shutdown_minutes: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Resolution:
|
||||||
|
"""One day's budget and shutdown, and what earned them.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
day: The day resolved.
|
||||||
|
base: Its floor.
|
||||||
|
terms: One entry per registered earner, in registry order.
|
||||||
|
gaming_minutes: Base plus terms, capped at the gaming ceiling.
|
||||||
|
shutdown_minutes: Base plus terms, capped at the shutdown ceiling;
|
||||||
|
on a sick day raised to the sick floor (:mod:`earned_time._sick`).
|
||||||
|
sick: Whether the day was resolved as a sick day (0.11.0).
|
||||||
|
sick_lift_minutes: How far the sick floor raised ``shutdown_minutes``
|
||||||
|
above base plus terms (``0`` unless sick): a consumer adding live
|
||||||
|
credits on top pays them out of this first, as with a grace lift.
|
||||||
|
"""
|
||||||
|
|
||||||
|
day: date
|
||||||
|
base: Base
|
||||||
|
terms: tuple[Term, ...]
|
||||||
|
gaming_minutes: int
|
||||||
|
shutdown_minutes: int
|
||||||
|
sick: bool = False
|
||||||
|
sick_lift_minutes: int = 0
|
||||||
|
|
||||||
|
def term(self, name: str) -> Term:
|
||||||
|
"""The term of the earner called ``name``.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
KeyError: No such earner was resolved.
|
||||||
|
"""
|
||||||
|
for candidate in self.terms:
|
||||||
|
if candidate.earner.name == name:
|
||||||
|
return candidate
|
||||||
|
raise KeyError(name)
|
||||||
@ -21,7 +21,6 @@ whose penalty has not started does not lower it (0.6.1).
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from datetime import UTC, date, datetime
|
from datetime import UTC, date, datetime
|
||||||
import logging
|
import logging
|
||||||
from typing import TYPE_CHECKING, Final
|
from typing import TYPE_CHECKING, Final
|
||||||
@ -35,6 +34,8 @@ from earned_time._policy import (
|
|||||||
Earner,
|
Earner,
|
||||||
)
|
)
|
||||||
from earned_time._registry import all_earners, earners_for
|
from earned_time._registry import all_earners, earners_for
|
||||||
|
from earned_time._resolution import Base, Resolution, Term
|
||||||
|
from earned_time._sick import sick_shutdown_minutes
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
@ -44,67 +45,6 @@ _logger: Final = logging.getLogger(__name__)
|
|||||||
Answer = int | bool | None
|
Answer = int | bool | None
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class Base:
|
|
||||||
"""The floor for one day, after every penalty in force on it.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
gaming_minutes: Gaming time a day that earned nothing gets.
|
|
||||||
shutdown_minutes: Shutdown time of such a day, minutes after midnight.
|
|
||||||
"""
|
|
||||||
|
|
||||||
gaming_minutes: int
|
|
||||||
shutdown_minutes: int
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class Term:
|
|
||||||
"""What one earner contributed.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
earner: The earner.
|
|
||||||
answer: The consumer's answer: a unit count, or ``None`` for
|
|
||||||
"could not check" (and for no answer at all).
|
|
||||||
gaming_minutes: Gaming minutes earned.
|
|
||||||
shutdown_minutes: Shutdown minutes earned.
|
|
||||||
"""
|
|
||||||
|
|
||||||
earner: Earner
|
|
||||||
answer: int | None
|
|
||||||
gaming_minutes: int
|
|
||||||
shutdown_minutes: int
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class Resolution:
|
|
||||||
"""One day's budget and shutdown, and what earned them.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
day: The day resolved.
|
|
||||||
base: Its floor.
|
|
||||||
terms: One entry per registered earner, in registry order.
|
|
||||||
gaming_minutes: Base plus terms, capped at the gaming ceiling.
|
|
||||||
shutdown_minutes: Base plus terms, capped at the shutdown ceiling.
|
|
||||||
"""
|
|
||||||
|
|
||||||
day: date
|
|
||||||
base: Base
|
|
||||||
terms: tuple[Term, ...]
|
|
||||||
gaming_minutes: int
|
|
||||||
shutdown_minutes: int
|
|
||||||
|
|
||||||
def term(self, name: str) -> Term:
|
|
||||||
"""The term of the earner called ``name``.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
KeyError: No such earner was resolved.
|
|
||||||
"""
|
|
||||||
for candidate in self.terms:
|
|
||||||
if candidate.earner.name == name:
|
|
||||||
return candidate
|
|
||||||
raise KeyError(name)
|
|
||||||
|
|
||||||
|
|
||||||
def _today() -> date:
|
def _today() -> date:
|
||||||
return datetime.now(tz=UTC).astimezone().date()
|
return datetime.now(tz=UTC).astimezone().date()
|
||||||
|
|
||||||
@ -194,6 +134,7 @@ def resolve(
|
|||||||
earners: tuple[Earner, ...] | None = None,
|
earners: tuple[Earner, ...] | None = None,
|
||||||
*,
|
*,
|
||||||
first_credits: Mapping[str, date | None] | None = None,
|
first_credits: Mapping[str, date | None] | None = None,
|
||||||
|
sick: bool = False,
|
||||||
) -> Resolution:
|
) -> Resolution:
|
||||||
"""Sum the base and every earned term, each capped at its ceiling.
|
"""Sum the base and every earned term, each capped at its ceiling.
|
||||||
|
|
||||||
@ -209,6 +150,9 @@ def resolve(
|
|||||||
first_credits: Per earner name, its first real credit day (``None``
|
first_credits: Per earner name, its first real credit day (``None``
|
||||||
for never, or could not check), e.g. ``Maturity.first_credit``.
|
for never, or could not check), e.g. ``Maturity.first_credit``.
|
||||||
Only ever delays a penalty; omitted, 0.5.0's ``penalty_from``.
|
Only ever delays a penalty; omitted, 0.5.0's ``penalty_from``.
|
||||||
|
sick: The day is a sick day (the consumer asks ``sickdays``): shutdown
|
||||||
|
is at least 20:00 (:func:`~earned_time._sick.sick_shutdown_minutes`).
|
||||||
|
Gaming and every term are unchanged.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The resolution, with a term for every registered earner.
|
The resolution, with a term for every registered earner.
|
||||||
@ -238,11 +182,17 @@ def resolve(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
gaming = base.gaming_minutes + sum(t.gaming_minutes for t in terms)
|
gaming = base.gaming_minutes + sum(t.gaming_minutes for t in terms)
|
||||||
shutdown = base.shutdown_minutes + sum(t.shutdown_minutes for t in terms)
|
earned = min(
|
||||||
|
shutdown_ceiling_for(target),
|
||||||
|
base.shutdown_minutes + sum(t.shutdown_minutes for t in terms),
|
||||||
|
)
|
||||||
|
shutdown = sick_shutdown_minutes(earned, target) if sick else earned
|
||||||
return Resolution(
|
return Resolution(
|
||||||
day=target,
|
day=target,
|
||||||
base=base,
|
base=base,
|
||||||
terms=tuple(terms),
|
terms=tuple(terms),
|
||||||
gaming_minutes=min(GAMING_CEILING_MINUTES, gaming),
|
gaming_minutes=min(GAMING_CEILING_MINUTES, gaming),
|
||||||
shutdown_minutes=min(shutdown_ceiling_for(target), shutdown),
|
shutdown_minutes=shutdown,
|
||||||
|
sick=sick,
|
||||||
|
sick_lift_minutes=shutdown - earned,
|
||||||
)
|
)
|
||||||
|
|||||||
39
earned_time/earned_time/_sick.py
Normal file
39
earned_time/earned_time/_sick.py
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
|
"""The sick-day shutdown floor (0.11.0).
|
||||||
|
|
||||||
|
On a sick day shutdown is never earlier than 20:00: the resolved shutdown is
|
||||||
|
``max(SICK_SHUTDOWN_FLOOR_MINUTES, the normal ladder result)``, still capped
|
||||||
|
at the day's ceiling. It is applied to the *final* sum, never to the base --
|
||||||
|
raising the base would stack every earner's minutes on top of 20:00.
|
||||||
|
|
||||||
|
Only shutdown moves. Gaming time, the registry, waivers and penalties are
|
||||||
|
untouched, and every earner still earns on a sick day.
|
||||||
|
|
||||||
|
Whether a day is sick is the consumer's answer (the ``sickdays`` pool):
|
||||||
|
this package is stdlib-only and never imports it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING, Final
|
||||||
|
|
||||||
|
from earned_time._ladder import shutdown_ceiling_for
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
# 20:00, minutes after local midnight.
|
||||||
|
SICK_SHUTDOWN_FLOOR_MINUTES: Final = 20 * 60
|
||||||
|
|
||||||
|
|
||||||
|
def sick_shutdown_minutes(minutes: int, day: date) -> int:
|
||||||
|
"""``minutes`` (a resolved shutdown) with the sick-day floor applied.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
minutes: The normal resolved shutdown, minutes after midnight.
|
||||||
|
day: The day, which picks the ceiling.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
``min(ceiling, max(SICK_SHUTDOWN_FLOOR_MINUTES, minutes))``.
|
||||||
|
"""
|
||||||
|
return min(shutdown_ceiling_for(day), max(SICK_SHUTDOWN_FLOOR_MINUTES, minutes))
|
||||||
89
earned_time/earned_time/tests/test_sick.py
Normal file
89
earned_time/earned_time/tests/test_sick.py
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
|
"""The sick-day shutdown floor (0.11.0): 20:00 at least, gaming untouched."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from earned_time import (
|
||||||
|
SICK_SHUTDOWN_FLOOR_MINUTES,
|
||||||
|
resolve,
|
||||||
|
shutdown_ceiling_for,
|
||||||
|
sick_shutdown_minutes,
|
||||||
|
)
|
||||||
|
|
||||||
|
LADDER_DAY = date(2026, 10, 16)
|
||||||
|
PRE_LADDER_DAY = date(2026, 10, 4)
|
||||||
|
|
||||||
|
NOTHING: dict[str, int | bool | None] = {
|
||||||
|
"workout": 0,
|
||||||
|
"leetcode": False,
|
||||||
|
"reading": False,
|
||||||
|
"tutor": 0,
|
||||||
|
}
|
||||||
|
SOME: dict[str, int | bool | None] = {**NOTHING, "workout": 1, "leetcode": True}
|
||||||
|
EVERYTHING: dict[str, int | bool | None] = {
|
||||||
|
"workout": 1,
|
||||||
|
"leetcode": True,
|
||||||
|
"reading": True,
|
||||||
|
"tutor": 60,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_floor_is_eight_pm() -> None:
|
||||||
|
assert SICK_SHUTDOWN_FLOOR_MINUTES == 20 * 60
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_sick_day_that_earned_nothing_shuts_down_at_eight() -> None:
|
||||||
|
normal = resolve(NOTHING, LADDER_DAY)
|
||||||
|
sick = resolve(NOTHING, LADDER_DAY, sick=True)
|
||||||
|
assert normal.shutdown_minutes == 18 * 60 + 50
|
||||||
|
assert sick.shutdown_minutes == 20 * 60
|
||||||
|
assert sick.sick
|
||||||
|
assert sick.sick_lift_minutes == 70
|
||||||
|
assert not normal.sick
|
||||||
|
assert normal.sick_lift_minutes == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_earning_past_the_floor_is_not_stacked_on_it() -> None:
|
||||||
|
"""max(20:00, earned) -- never 20:00 plus what was earned."""
|
||||||
|
normal = resolve(SOME, LADDER_DAY)
|
||||||
|
sick = resolve(SOME, LADDER_DAY, sick=True)
|
||||||
|
assert normal.shutdown_minutes > 20 * 60
|
||||||
|
assert sick.shutdown_minutes == normal.shutdown_minutes
|
||||||
|
assert sick.sick_lift_minutes == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_fully_done_sick_day_still_stops_at_the_ceiling() -> None:
|
||||||
|
sick = resolve(EVERYTHING, LADDER_DAY, sick=True)
|
||||||
|
assert sick.shutdown_minutes == shutdown_ceiling_for(LADDER_DAY) == 23 * 60
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_floor_applies_before_the_ladder_too() -> None:
|
||||||
|
answers = {"workout": 0, "leetcode": False, "reading": False}
|
||||||
|
normal = resolve(answers, PRE_LADDER_DAY)
|
||||||
|
sick = resolve(answers, PRE_LADDER_DAY, sick=True)
|
||||||
|
assert normal.shutdown_minutes == 19 * 60
|
||||||
|
assert sick.shutdown_minutes == 20 * 60
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("answers", [NOTHING, SOME, EVERYTHING])
|
||||||
|
def test_gaming_and_every_term_are_unchanged(
|
||||||
|
answers: dict[str, int | bool | None],
|
||||||
|
) -> None:
|
||||||
|
normal = resolve(answers, LADDER_DAY)
|
||||||
|
sick = resolve(answers, LADDER_DAY, sick=True)
|
||||||
|
assert sick.gaming_minutes == normal.gaming_minutes
|
||||||
|
assert sick.terms == normal.terms
|
||||||
|
assert sick.base == normal.base
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_helper_is_capped_at_the_ceiling(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
assert sick_shutdown_minutes(18 * 60, LADDER_DAY) == 20 * 60
|
||||||
|
assert sick_shutdown_minutes(21 * 60, LADDER_DAY) == 21 * 60
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"earned_time._sick.shutdown_ceiling_for", lambda _day: 19 * 60 + 30
|
||||||
|
)
|
||||||
|
assert sick_shutdown_minutes(18 * 60, LADDER_DAY) == 19 * 60 + 30
|
||||||
@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "earned-time"
|
name = "earned-time"
|
||||||
version = "0.10.0"
|
version = "0.11.0"
|
||||||
description = "Penalty-then-reward registry: what every gate app earns in gaming time and shutdown time"
|
description = "Penalty-then-reward registry: what every gate app earns in gaming time and shutdown time"
|
||||||
requires-python = ">=3.14"
|
requires-python = ">=3.14"
|
||||||
dependencies = []
|
dependencies = []
|
||||||
|
|||||||
146
sickdays/.pre-commit-config.yaml
Normal file
146
sickdays/.pre-commit-config.yaml
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
# Pre-commit Configuration for sickdays
|
||||||
|
# Install: pre-commit install && pre-commit install --hook-type pre-push
|
||||||
|
# Run: pre-commit run --all-files
|
||||||
|
# Update: pre-commit autoupdate
|
||||||
|
|
||||||
|
# NOTE: this subproject lives inside the ~/src/utils monorepo, whose git root is
|
||||||
|
# one level up. pre-commit always runs hooks from the git root, so every path
|
||||||
|
# below is written relative to THAT, not to this directory, and the top-level
|
||||||
|
# `files` key keeps these hooks off the sibling subprojects. CI invokes this
|
||||||
|
# config exactly the same way:
|
||||||
|
# pre-commit run --config sickdays/.pre-commit-config.yaml --all-files
|
||||||
|
files: ^sickdays/
|
||||||
|
|
||||||
|
default_language_version:
|
||||||
|
python: python3
|
||||||
|
default_stages: [pre-commit]
|
||||||
|
fail_fast: false
|
||||||
|
|
||||||
|
repos:
|
||||||
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
|
rev: v4.6.0
|
||||||
|
hooks:
|
||||||
|
- id: trailing-whitespace
|
||||||
|
args: [--markdown-linebreak-ext=md]
|
||||||
|
- id: end-of-file-fixer
|
||||||
|
- id: check-yaml
|
||||||
|
- id: check-json
|
||||||
|
- id: check-toml
|
||||||
|
- id: check-added-large-files
|
||||||
|
args: [--maxkb=2000]
|
||||||
|
- id: check-merge-conflict
|
||||||
|
- id: detect-private-key
|
||||||
|
- id: debug-statements
|
||||||
|
- id: name-tests-test
|
||||||
|
args: [--pytest-test-first]
|
||||||
|
- id: check-ast
|
||||||
|
- id: mixed-line-ending
|
||||||
|
args: [--fix=lf]
|
||||||
|
- id: requirements-txt-fixer
|
||||||
|
|
||||||
|
- repo: local
|
||||||
|
hooks:
|
||||||
|
- id: file-length-cap
|
||||||
|
name: file length <= 250 lines
|
||||||
|
entry: bash scripts/check_file_length.sh
|
||||||
|
language: system
|
||||||
|
pass_filenames: true
|
||||||
|
- id: subproject-scope
|
||||||
|
name: subproject scope regex still matches
|
||||||
|
entry: scripts/check_subproject_scope.sh sickdays
|
||||||
|
language: script
|
||||||
|
# always_run so the top-level `files` filter cannot skip the one hook
|
||||||
|
# whose job is to notice that filter has stopped matching anything.
|
||||||
|
always_run: true
|
||||||
|
pass_filenames: false
|
||||||
|
- id: no-noqa
|
||||||
|
name: Block noqa comments
|
||||||
|
entry: '(?i)#\s*(noqa|type:\s*ignore)'
|
||||||
|
language: pygrep
|
||||||
|
types: [python]
|
||||||
|
- id: no-ruff-noqa
|
||||||
|
name: Block ruff noqa file-level comments
|
||||||
|
entry: '(?i)#\s*ruff:\s*noqa'
|
||||||
|
language: pygrep
|
||||||
|
types: [python]
|
||||||
|
|
||||||
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
|
rev: v0.17.0
|
||||||
|
hooks:
|
||||||
|
- id: ruff
|
||||||
|
args: [--fix, --unsafe-fixes, --exit-non-zero-on-fix, --show-fixes]
|
||||||
|
types_or: [python, pyi]
|
||||||
|
- id: ruff-format
|
||||||
|
types_or: [python, pyi]
|
||||||
|
|
||||||
|
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||||
|
rev: v1.13.0
|
||||||
|
hooks:
|
||||||
|
- id: mypy
|
||||||
|
args:
|
||||||
|
- --ignore-missing-imports
|
||||||
|
- --no-error-summary
|
||||||
|
- --disable-error-code=no-untyped-def
|
||||||
|
- --disable-error-code=no-untyped-call
|
||||||
|
- --disable-error-code=var-annotated
|
||||||
|
- --disable-error-code=no-any-unimported
|
||||||
|
- --disable-error-code=type-arg
|
||||||
|
- --disable-error-code=no-any-return
|
||||||
|
- --disable-error-code=misc
|
||||||
|
- --disable-error-code=unused-ignore
|
||||||
|
- --disable-error-code=unreachable
|
||||||
|
- --disable-error-code=assignment
|
||||||
|
- --disable-error-code=no-redef
|
||||||
|
- --disable-error-code=attr-defined
|
||||||
|
- --disable-error-code=arg-type
|
||||||
|
- --disable-error-code=union-attr
|
||||||
|
- --disable-error-code=call-overload
|
||||||
|
- --disable-error-code=return-value
|
||||||
|
- --disable-error-code=redundant-cast
|
||||||
|
- --disable-error-code=empty-body
|
||||||
|
- --disable-error-code=list-item
|
||||||
|
|
||||||
|
- repo: https://github.com/pylint-dev/pylint
|
||||||
|
rev: v4.0.8
|
||||||
|
hooks:
|
||||||
|
- id: pylint
|
||||||
|
args:
|
||||||
|
- --rcfile=sickdays/pyproject.toml
|
||||||
|
- --fail-under=10
|
||||||
|
- --jobs=4
|
||||||
|
additional_dependencies:
|
||||||
|
- pytest
|
||||||
|
# Kept in step with pyproject.toml, so pylint resolves the crdt_sync
|
||||||
|
# imports instead of scoring every one of them as import-error.
|
||||||
|
- crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.2#subdirectory=crdt-sync
|
||||||
|
|
||||||
|
- repo: https://github.com/PyCQA/bandit
|
||||||
|
rev: 1.9.4
|
||||||
|
hooks:
|
||||||
|
- id: bandit
|
||||||
|
args:
|
||||||
|
- -c
|
||||||
|
- sickdays/pyproject.toml
|
||||||
|
- --severity-level=high
|
||||||
|
- --confidence-level=medium
|
||||||
|
- --skip=B113
|
||||||
|
additional_dependencies: ["bandit[toml]"]
|
||||||
|
exclude: ^(sickdays/sickdays/tests/|.*test.*\.py$)
|
||||||
|
|
||||||
|
- repo: local
|
||||||
|
hooks:
|
||||||
|
- id: pytest-coverage
|
||||||
|
name: pytest with coverage enforcement
|
||||||
|
entry: scripts/run_subproject_tests.sh sickdays
|
||||||
|
language: script
|
||||||
|
types: [python]
|
||||||
|
pass_filenames: false
|
||||||
|
require_serial: true
|
||||||
|
stages: [pre-push]
|
||||||
|
|
||||||
|
- repo: https://github.com/codespell-project/codespell
|
||||||
|
rev: v2.3.0
|
||||||
|
hooks:
|
||||||
|
- id: codespell
|
||||||
|
args:
|
||||||
|
- --skip=*.json,*.lock,.git,__pycache__,.venv
|
||||||
157
sickdays/README.md
Normal file
157
sickdays/README.md
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
# sickdays
|
||||||
|
|
||||||
|
One shared pool of **sick days** that every gate respects.
|
||||||
|
|
||||||
|
Every day is a normal day until it is marked sick. There is no cap, no
|
||||||
|
budget, no reason to give and no countdown. The one rule is the calendar:
|
||||||
|
only today or a later day can be marked or un-marked — never a past one.
|
||||||
|
|
||||||
|
This replaces screen-locker's capped sick day (rolling windows, 120-character
|
||||||
|
justifications). It is **not** the free-day pool: `freedays` is a separate
|
||||||
|
pool with its own file and remote node, and a date is never in both.
|
||||||
|
|
||||||
|
## For a gate app
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sickdays
|
||||||
|
|
||||||
|
if sickdays.is_sick_day(today): # default: today
|
||||||
|
... # apply this gate's sick-day rules
|
||||||
|
```
|
||||||
|
|
||||||
|
`is_sick_day()` reads one local JSON file, never the network, and **never
|
||||||
|
raises**. A missing file means "no sick days". An unreadable or corrupt file
|
||||||
|
is logged as a warning and also reads as *not sick*: it fails closed, so the
|
||||||
|
alarm rings and the locks apply.
|
||||||
|
|
||||||
|
## For a person
|
||||||
|
|
||||||
|
```console
|
||||||
|
$ sickdays add # today
|
||||||
|
$ sickdays add 2026-10-20 # or 'tomorrow'
|
||||||
|
$ sickdays remove 2026-10-20 # today or later only
|
||||||
|
$ sickdays list # every sick day, oldest first
|
||||||
|
$ sickdays check tomorrow # exit 0 if sick, 1 if not -- for scripts
|
||||||
|
$ sickdays sync # pull and push now
|
||||||
|
```
|
||||||
|
|
||||||
|
`python -m sickdays ...` is the same command. Exit codes: `0` done, `1`
|
||||||
|
refused (past day, already sick, not sick, a free day, sync unavailable),
|
||||||
|
`2` input not understood.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
| call | does |
|
||||||
|
|---|---|
|
||||||
|
| `is_sick_day(day=None, *, log_path=None, now=None) -> bool` | the gate question; never raises |
|
||||||
|
| `sick_days(*, log_path=None) -> frozenset[date]` | every date currently sick |
|
||||||
|
| `add_sick_day(day, *, is_free=None, paths=None, now=None) -> SickDay` | today or later; refuses a free day |
|
||||||
|
| `remove_sick_day(day, *, paths=None, now=None) -> SickDay` | today or later |
|
||||||
|
| `import_legacy(days, *, paths=None) -> list[date]` | migration only: may write past dates; idempotent |
|
||||||
|
| `ensure_not_sick(day, *, paths=None)` | for freedays' callers, before `freedays.mark` |
|
||||||
|
| `sync()`, `sync_quietly()` | one pull/merge/push tick |
|
||||||
|
|
||||||
|
Refusals subclass `SickDayError`: `PastDateError`, `AlreadySickError`,
|
||||||
|
`NotSickError`, `FreeDayConflictError`, `SickDayConflictError`,
|
||||||
|
`ConflictCheckUnavailableError`, `UnreadablePoolError`. Writers refuse an
|
||||||
|
existing-but-corrupt pool (`UnreadablePoolError`) instead of overwriting it
|
||||||
|
with an empty one; `sync` reads it as empty, so a peer's copy restores it.
|
||||||
|
|
||||||
|
`import_legacy` is for screen-locker's `sick_history.json` (2026-06-21 and
|
||||||
|
2026-07-24, both before every gate start). It skips the free-day check.
|
||||||
|
|
||||||
|
## A date is never in both pools
|
||||||
|
|
||||||
|
Neither package imports the other at module level, so they can never form an
|
||||||
|
import cycle:
|
||||||
|
|
||||||
|
- **Adding a sick day** checks a predicate `is_free(day) -> bool`. By default
|
||||||
|
`add_sick_day` imports `freedays` *lazily*, by name, at call time
|
||||||
|
(`_conflict.free_day_predicate`). If freedays is not importable the add is
|
||||||
|
**refused** (`ConflictCheckUnavailableError`). The invariant is hard, so
|
||||||
|
"could not check" never counts as "not free". A phone port or a test passes
|
||||||
|
its own `is_free`.
|
||||||
|
- **Marking a free day** is freedays' write. Its callers (CLIs, phone, lock
|
||||||
|
screen) call `sickdays.ensure_not_sick(day)` first. That needs no freedays
|
||||||
|
import at all. freedays itself does not check yet.
|
||||||
|
|
||||||
|
## Storage
|
||||||
|
|
||||||
|
| what | where |
|
||||||
|
|---|---|
|
||||||
|
| the pool | `~/.local/share/sickdays/sick_days.json` |
|
||||||
|
| device uuid (per install) | `~/.local/share/sickdays/device_id` |
|
||||||
|
| audit trail (HMAC-signed, append-only) | `~/.local/share/sickdays/audit.jsonl` + `hmac.key` |
|
||||||
|
| sync bookkeeping (local only) | `~/.local/share/sickdays/sync_state.json` |
|
||||||
|
|
||||||
|
Days are local calendar dates (`_day.today()`), the same as freedays.
|
||||||
|
|
||||||
|
## Sync and the wire format (for the phone)
|
||||||
|
|
||||||
|
Firebase RTDB only, shared credential `~/.config/crdt-sync/` (crdt-sync's
|
||||||
|
`firebase_client_for("sickdays")`). One directory per device; each device
|
||||||
|
writes only its own node:
|
||||||
|
|
||||||
|
| RTDB node | value |
|
||||||
|
|---|---|
|
||||||
|
| `sickdays-sync/devices/<uuid>/sick_days~2Ejson` | a JSON **string** holding the log (below) |
|
||||||
|
| `sickdays-sync/revs/<uuid>` | hex SHA-256 of that exact string |
|
||||||
|
|
||||||
|
The logical path is `sickdays-sync/devices/<uuid>/sick_days.json`. crdt-sync
|
||||||
|
escapes the `.` in keys as `~2E`. Write the log first, then the rev; a peer
|
||||||
|
skips any device whose rev it has already merged. The `<uuid>` is a
|
||||||
|
persisted per-install uuid4, never `pc`/`phone`.
|
||||||
|
|
||||||
|
The log is a JSON object keyed by ISO date. Each value is a crdt-sync record:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"2026-10-20": {
|
||||||
|
"id": "2026-10-20",
|
||||||
|
"fields": {
|
||||||
|
"state": ["sick", "2026-10-16T08:12:03.517Z-0000-<uuid>"],
|
||||||
|
"actor": ["<uuid>", "2026-10-16T08:12:03.517Z-0000-<uuid>"]
|
||||||
|
},
|
||||||
|
"deleted": false,
|
||||||
|
"deleted_hlc": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `id` equals the key: `YYYY-MM-DD`, local date.
|
||||||
|
- `state` is `"sick"` or `"cleared"`. Anything else, or a missing record,
|
||||||
|
means a normal day. `actor` is the uuid of the device that last wrote it.
|
||||||
|
- Each field is `[value, hlc]`, and every field of one write carries the
|
||||||
|
**same** HLC. The HLC string is
|
||||||
|
`YYYY-MM-DDTHH:MM:SS.mmmZ-<counter, 4 hex>-<node uuid>` (UTC wall time). It
|
||||||
|
orders as the tuple `(wall_ms, counter, node_id)`. A new tick is
|
||||||
|
`max(now_ms, last_own.wall_ms)`, with the counter bumped when the wall time
|
||||||
|
did not move.
|
||||||
|
- **Merge = per-field last-writer-wins**: for each date, keep the field with
|
||||||
|
the greater HLC. So the latest add *or remove* of a date wins on every
|
||||||
|
device, and a date can be added and removed any number of times.
|
||||||
|
- **Remove = write `state: "cleared"`. Never write `deleted: true`.**
|
||||||
|
crdt-sync OR-merges tombstones, so a deleted date could never be marked
|
||||||
|
sick again. A tombstoned record reads as "not sick".
|
||||||
|
|
||||||
|
Don't hand-roll this on Dart: use `crdt_sync_dart` (`syncLog`, `logToJson`
|
||||||
|
/ `logFromJson`), exactly as wake-alarm's `free_day_sync.dart` does for
|
||||||
|
`freedays-sync`. Only the prefix (`sickdays-sync/devices`), the filename
|
||||||
|
(`sick_days.json`) and the `"sick"` state value differ. There is no
|
||||||
|
`consumed` or `reason` field.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```console
|
||||||
|
$ ./install.sh # own venv (crdt-sync + freedays + sickdays), CLI on PATH, 15-min sync timer
|
||||||
|
$ ./install.sh --no-timer
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```console
|
||||||
|
$ pip install ../crdt-sync && pip install --no-deps ../freedays
|
||||||
|
$ pip install -r requirements.txt && pip install --no-deps -e .
|
||||||
|
$ python -m pytest # 100% branch coverage is enforced
|
||||||
|
$ pre-commit run --config sickdays/.pre-commit-config.yaml --all-files # from the repo root
|
||||||
|
```
|
||||||
119
sickdays/install.sh
Executable file
119
sickdays/install.sh
Executable file
@ -0,0 +1,119 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Install the sickdays CLI and its sync timer for the current user.
|
||||||
|
#
|
||||||
|
# sickdays gets its OWN venv, like freedays: the pool is shared by every gate,
|
||||||
|
# so the command that manages it must not depend on whichever app venv
|
||||||
|
# happened to be active. freedays goes into the same venv because adding a
|
||||||
|
# sick day checks the free-day pool first (a date is never in both).
|
||||||
|
# Idempotent: rerun it after any change.
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_NAME="$(basename "$0")"
|
||||||
|
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
UTILS_DIR="$(dirname "$REPO_DIR")"
|
||||||
|
readonly SCRIPT_NAME REPO_DIR UTILS_DIR
|
||||||
|
readonly STATE_DIR="${HOME}/.local/share/sickdays"
|
||||||
|
readonly VENV_DIR="${STATE_DIR}/venv"
|
||||||
|
readonly BIN_DIR="${HOME}/.local/bin"
|
||||||
|
readonly UNIT_DIR="${HOME}/.config/systemd/user"
|
||||||
|
readonly SYSTEM_PYTHON="/usr/bin/python3"
|
||||||
|
|
||||||
|
log() { printf 'install: %s\n' "$1" >&2; }
|
||||||
|
fail() { printf 'install: FAILED -- %s\n' "$1" >&2; exit 1; }
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
echo "Usage: $SCRIPT_NAME [--no-timer]"
|
||||||
|
echo " --no-timer install the CLI only; do not enable the sync timer"
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
INSTALL_TIMER=1
|
||||||
|
|
||||||
|
build_venv() {
|
||||||
|
log "building the sickdays venv at $VENV_DIR"
|
||||||
|
mkdir -p "$STATE_DIR"
|
||||||
|
if [[ ! -x "$VENV_DIR/bin/python" ]]; then
|
||||||
|
"$SYSTEM_PYTHON" -m venv "$VENV_DIR" || fail "could not create the venv"
|
||||||
|
fi
|
||||||
|
# Siblings install from the working tree, not from a tag: this script runs
|
||||||
|
# from a checkout, and installing the released tag here would silently
|
||||||
|
# test something other than what is on disk.
|
||||||
|
"$VENV_DIR/bin/pip" install --quiet --upgrade pip || fail "pip self-upgrade"
|
||||||
|
"$VENV_DIR/bin/pip" install --quiet "$UTILS_DIR/crdt-sync" \
|
||||||
|
|| fail "the crdt-sync sibling package"
|
||||||
|
"$VENV_DIR/bin/pip" install --quiet --no-deps "$UTILS_DIR/freedays" \
|
||||||
|
|| fail "the freedays sibling package"
|
||||||
|
"$VENV_DIR/bin/pip" install --quiet --no-deps -e "$REPO_DIR" \
|
||||||
|
|| fail "sickdays itself"
|
||||||
|
}
|
||||||
|
|
||||||
|
link_cli() {
|
||||||
|
log "putting the sickdays command on PATH at $BIN_DIR/sickdays"
|
||||||
|
mkdir -p "$BIN_DIR"
|
||||||
|
ln -sf "$VENV_DIR/bin/sickdays" "$BIN_DIR/sickdays"
|
||||||
|
}
|
||||||
|
|
||||||
|
install_timer() {
|
||||||
|
log "installing the sync timer"
|
||||||
|
mkdir -p "$UNIT_DIR"
|
||||||
|
install -m 644 "$REPO_DIR/systemd/sickdays-sync.service" "$UNIT_DIR/"
|
||||||
|
install -m 644 "$REPO_DIR/systemd/sickdays-sync.timer" "$UNIT_DIR/"
|
||||||
|
systemctl --user daemon-reload || fail "systemctl daemon-reload"
|
||||||
|
systemctl --user enable --now sickdays-sync.timer || fail "enabling the timer"
|
||||||
|
}
|
||||||
|
|
||||||
|
verify() {
|
||||||
|
# Two environments, either of which can be broken on its own: the CLI's
|
||||||
|
# venv, and the system python the gate apps import the library from.
|
||||||
|
log "verifying"
|
||||||
|
"$BIN_DIR/sickdays" list >/dev/null || fail "the sickdays CLI does not run"
|
||||||
|
"$VENV_DIR/bin/python" -c "import freedays" \
|
||||||
|
|| fail "freedays is missing from the venv; adding a sick day would refuse"
|
||||||
|
"$SYSTEM_PYTHON" -c "import sickdays" 2>/dev/null || {
|
||||||
|
log "NOTE: the system python cannot import sickdays yet."
|
||||||
|
log " The gate apps need it there; their own install.sh does that."
|
||||||
|
}
|
||||||
|
if [[ "$INSTALL_TIMER" -eq 1 ]]; then
|
||||||
|
systemctl --user is-enabled sickdays-sync.timer >/dev/null \
|
||||||
|
|| fail "the sync timer is not enabled"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
main() {
|
||||||
|
build_venv
|
||||||
|
link_cli
|
||||||
|
if [[ "$INSTALL_TIMER" -eq 1 ]]; then
|
||||||
|
install_timer
|
||||||
|
fi
|
||||||
|
verify
|
||||||
|
|
||||||
|
echo "============================================================"
|
||||||
|
"$BIN_DIR/sickdays" list
|
||||||
|
echo "============================================================"
|
||||||
|
log "done. 'sickdays add' marks today sick, everywhere."
|
||||||
|
if [[ ":$PATH:" != *":$BIN_DIR:"* ]]; then
|
||||||
|
log "NOTE: $BIN_DIR is not on your PATH; add it to use 'sickdays' bare."
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case $1 in
|
||||||
|
--no-timer)
|
||||||
|
INSTALL_TIMER=0
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
-h|--help)
|
||||||
|
usage
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unknown option: $1" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
main "$@"
|
||||||
177
sickdays/pyproject.toml
Normal file
177
sickdays/pyproject.toml
Normal file
@ -0,0 +1,177 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=61"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "sickdays"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Shared sick-day pool: per-date, uncapped, synced; every gate respects it"
|
||||||
|
requires-python = ">=3.14"
|
||||||
|
# Deliberately NOT gatelock (its __init__ imports tkinter); the HMAC lives in
|
||||||
|
# sickdays/_hmac.py, as in freedays. freedays is a runtime dependency of the
|
||||||
|
# cross-pool check only, and is imported lazily by name (sickdays/_conflict.py)
|
||||||
|
# so the two packages can never form an import cycle.
|
||||||
|
dependencies = [
|
||||||
|
"crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.2#subdirectory=crdt-sync",
|
||||||
|
"freedays @ git+https://github.com/kuhyx/utils@freedays-v0.3.1#subdirectory=freedays",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
sickdays = "sickdays._cli:main"
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
include = ["sickdays*"]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
target-version = "py314"
|
||||||
|
include = ["*.py", "**/*.py"]
|
||||||
|
exclude = [".git", ".venv", "__pycache__", "build", "dist", ".eggs"]
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = ["ALL"]
|
||||||
|
ignore = [
|
||||||
|
"D203",
|
||||||
|
"D213",
|
||||||
|
"COM812",
|
||||||
|
"ISC001",
|
||||||
|
"S603",
|
||||||
|
]
|
||||||
|
fixable = ["ALL"]
|
||||||
|
unfixable = []
|
||||||
|
|
||||||
|
[tool.ruff.lint.per-file-ignores]
|
||||||
|
"**/tests/**/*.py" = ["ARG", "D", "PLC0415", "PLR2004", "S101", "SLF001"]
|
||||||
|
"**/test_*.py" = ["ARG", "D", "PLC0415", "PLR2004", "S101", "SLF001"]
|
||||||
|
|
||||||
|
[tool.ruff.lint.pydocstyle]
|
||||||
|
convention = "google"
|
||||||
|
|
||||||
|
[tool.ruff.lint.isort]
|
||||||
|
force-single-line = false
|
||||||
|
force-sort-within-sections = true
|
||||||
|
known-first-party = ["sickdays"]
|
||||||
|
|
||||||
|
[tool.ruff.lint.flake8-quotes]
|
||||||
|
docstring-quotes = "double"
|
||||||
|
inline-quotes = "double"
|
||||||
|
|
||||||
|
[tool.ruff.lint.flake8-tidy-imports]
|
||||||
|
ban-relative-imports = "all"
|
||||||
|
|
||||||
|
[tool.ruff.format]
|
||||||
|
quote-style = "double"
|
||||||
|
indent-style = "space"
|
||||||
|
skip-magic-trailing-comma = false
|
||||||
|
line-ending = "auto"
|
||||||
|
docstring-code-format = true
|
||||||
|
|
||||||
|
[tool.mypy]
|
||||||
|
python_version = "3.14"
|
||||||
|
strict = true
|
||||||
|
warn_return_any = true
|
||||||
|
warn_unused_configs = true
|
||||||
|
disallow_untyped_defs = true
|
||||||
|
disallow_incomplete_defs = true
|
||||||
|
check_untyped_defs = true
|
||||||
|
disallow_untyped_decorators = true
|
||||||
|
no_implicit_optional = true
|
||||||
|
warn_redundant_casts = true
|
||||||
|
warn_unused_ignores = true
|
||||||
|
warn_no_return = true
|
||||||
|
warn_unreachable = true
|
||||||
|
disallow_any_unimported = true
|
||||||
|
disallow_any_explicit = false
|
||||||
|
disallow_any_generics = true
|
||||||
|
disallow_subclassing_any = true
|
||||||
|
strict_equality = true
|
||||||
|
extra_checks = true
|
||||||
|
ignore_missing_imports = true
|
||||||
|
show_error_codes = true
|
||||||
|
color_output = true
|
||||||
|
exclude = [".venv/"]
|
||||||
|
|
||||||
|
[tool.pylint.main]
|
||||||
|
# The repo root on the path, so pylint resolves the package's own imports the
|
||||||
|
# same way pytest does instead of reporting import-error for them.
|
||||||
|
init-hook = "import sys; sys.path.insert(0, 'sickdays')"
|
||||||
|
analyse-fallback-blocks = true
|
||||||
|
persistent = true
|
||||||
|
jobs = 0
|
||||||
|
py-version = "3.14"
|
||||||
|
ignore = [".venv", "__pycache__"]
|
||||||
|
# Test suites intentionally use patterns (protected-access, fixture-arg
|
||||||
|
# shadowing) that don't apply to source code; ruff's per-file-ignores carry
|
||||||
|
# the equivalent exemptions, so tests stay linted -- by ruff. This is the same
|
||||||
|
# source-only pylint scope wake-alarm / diet-guard / testsAndMisc use. It has
|
||||||
|
# to be `ignore-paths`, not `ignore = ["tests"]`: `ignore` only matches the
|
||||||
|
# basename of what pylint is *given*, and pre-commit hands it explicit file
|
||||||
|
# paths, so a directory name in `ignore` never fires under the hook.
|
||||||
|
ignore-paths = ["^(.*/)?tests/.*$"]
|
||||||
|
ignore-patterns = [".*\\.pyi$"]
|
||||||
|
|
||||||
|
[tool.pylint.messages_control]
|
||||||
|
enable = "all"
|
||||||
|
disable = []
|
||||||
|
|
||||||
|
[tool.pylint.design]
|
||||||
|
min-public-methods = 0
|
||||||
|
max-module-lines = 1000
|
||||||
|
max-attributes = 10
|
||||||
|
|
||||||
|
[tool.pylint.typecheck]
|
||||||
|
generated-members = [
|
||||||
|
".*\\.assert_called_once_with",
|
||||||
|
".*\\.assert_called_once",
|
||||||
|
".*\\.assert_called",
|
||||||
|
".*\\.assert_not_called",
|
||||||
|
".*\\.assert_any_call",
|
||||||
|
".*\\.call_args",
|
||||||
|
".*\\.call_args_list",
|
||||||
|
".*\\.call_count",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.bandit]
|
||||||
|
exclude_dirs = ["tests", ".venv"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["sickdays/tests"]
|
||||||
|
python_files = ["test_*.py", "*_test.py"]
|
||||||
|
python_classes = ["Test*"]
|
||||||
|
python_functions = ["test_*"]
|
||||||
|
addopts = [
|
||||||
|
"-v",
|
||||||
|
"--strict-markers",
|
||||||
|
"--strict-config",
|
||||||
|
"-ra",
|
||||||
|
"--cov=sickdays",
|
||||||
|
"--cov-branch",
|
||||||
|
"--cov-report=term-missing",
|
||||||
|
"--cov-report=lcov",
|
||||||
|
]
|
||||||
|
filterwarnings = [
|
||||||
|
"error",
|
||||||
|
"ignore::DeprecationWarning",
|
||||||
|
"default::pytest.PytestUnraisableExceptionWarning",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.coverage.run]
|
||||||
|
source = ["sickdays"]
|
||||||
|
branch = true
|
||||||
|
omit = [
|
||||||
|
"*/__pycache__/*",
|
||||||
|
"*/tests/*",
|
||||||
|
"*/.venv/*",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.coverage.report]
|
||||||
|
fail_under = 100
|
||||||
|
show_missing = true
|
||||||
|
skip_covered = false
|
||||||
|
exclude_lines = [
|
||||||
|
"pragma: no cover",
|
||||||
|
"raise NotImplementedError",
|
||||||
|
"raise AssertionError",
|
||||||
|
"if TYPE_CHECKING:",
|
||||||
|
'if __name__ == "__main__":',
|
||||||
|
]
|
||||||
|
partial_branches = ["pragma: no branch"]
|
||||||
10
sickdays/requirements.txt
Normal file
10
sickdays/requirements.txt
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
# Exact pins, enforced by the shared dependency-freshness gate: a `>=`
|
||||||
|
# floor is satisfied by anything newer, so it tells a checker nothing
|
||||||
|
# about whether this repo is actually current.
|
||||||
|
bandit[toml]==1.9.4
|
||||||
|
mypy==2.4.0
|
||||||
|
pre-commit==4.7.0
|
||||||
|
pylint==4.1.3
|
||||||
|
pytest==9.1.1
|
||||||
|
pytest-cov==7.1.0
|
||||||
|
ruff==0.17.1
|
||||||
82
sickdays/sickdays/__init__.py
Normal file
82
sickdays/sickdays/__init__.py
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
|
"""One shared pool of sick days that every gate respects.
|
||||||
|
|
||||||
|
A gate's whole integration::
|
||||||
|
|
||||||
|
import sickdays
|
||||||
|
|
||||||
|
if sickdays.is_sick_day(today):
|
||||||
|
... # the sick-day rules for this gate apply
|
||||||
|
|
||||||
|
It reads one local file, never the network, and never raises: an unreadable
|
||||||
|
pool is logged and reads as "not sick", so the failure mode is "the normal
|
||||||
|
rules apply", never "everything is switched off".
|
||||||
|
|
||||||
|
Sick days are not free days. They are a separate pool, with a separate file
|
||||||
|
and a separate remote node, and a date is never in both
|
||||||
|
(:mod:`sickdays._conflict`). There is no cap and no justification: every day
|
||||||
|
is normal by default, and any day from today on can be marked or un-marked.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sickdays._api import (
|
||||||
|
actor_id,
|
||||||
|
add_sick_day,
|
||||||
|
import_legacy,
|
||||||
|
is_sick_day,
|
||||||
|
load,
|
||||||
|
remove_sick_day,
|
||||||
|
sick_days,
|
||||||
|
)
|
||||||
|
from sickdays._conflict import (
|
||||||
|
FreePredicate,
|
||||||
|
ensure_not_free,
|
||||||
|
ensure_not_sick,
|
||||||
|
free_day_predicate,
|
||||||
|
)
|
||||||
|
from sickdays._day import parse_iso, resolve, to_iso, today
|
||||||
|
from sickdays._errors import (
|
||||||
|
AlreadySickError,
|
||||||
|
ConflictCheckUnavailableError,
|
||||||
|
FreeDayConflictError,
|
||||||
|
NotSickError,
|
||||||
|
PastDateError,
|
||||||
|
SickDayConflictError,
|
||||||
|
SickDayError,
|
||||||
|
UnreadablePoolError,
|
||||||
|
)
|
||||||
|
from sickdays._model import SickDay
|
||||||
|
from sickdays._paths import Paths
|
||||||
|
from sickdays._sync import SyncUnavailableError, sync, sync_quietly
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AlreadySickError",
|
||||||
|
"ConflictCheckUnavailableError",
|
||||||
|
"FreeDayConflictError",
|
||||||
|
"FreePredicate",
|
||||||
|
"NotSickError",
|
||||||
|
"PastDateError",
|
||||||
|
"Paths",
|
||||||
|
"SickDay",
|
||||||
|
"SickDayConflictError",
|
||||||
|
"SickDayError",
|
||||||
|
"SyncUnavailableError",
|
||||||
|
"UnreadablePoolError",
|
||||||
|
"actor_id",
|
||||||
|
"add_sick_day",
|
||||||
|
"ensure_not_free",
|
||||||
|
"ensure_not_sick",
|
||||||
|
"free_day_predicate",
|
||||||
|
"import_legacy",
|
||||||
|
"is_sick_day",
|
||||||
|
"load",
|
||||||
|
"parse_iso",
|
||||||
|
"remove_sick_day",
|
||||||
|
"resolve",
|
||||||
|
"sick_days",
|
||||||
|
"sync",
|
||||||
|
"sync_quietly",
|
||||||
|
"to_iso",
|
||||||
|
"today",
|
||||||
|
]
|
||||||
10
sickdays/sickdays/__main__.py
Normal file
10
sickdays/sickdays/__main__.py
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
|
"""``python -m sickdays``: the same command as the ``sickdays`` script."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from sickdays._cli import main
|
||||||
|
|
||||||
|
sys.exit(main())
|
||||||
178
sickdays/sickdays/_api.py
Normal file
178
sickdays/sickdays/_api.py
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
|
"""Ask, add, remove, import -- the whole public surface.
|
||||||
|
|
||||||
|
:func:`is_sick_day` is the one every gate calls, and it is deliberately the
|
||||||
|
cheapest thing here: one local file read, no network, no lock. It never
|
||||||
|
raises: an unreadable pool is logged and reads as "not sick", so the failure
|
||||||
|
mode is "the gates apply", never "everything is switched off".
|
||||||
|
|
||||||
|
There is no cap, no budget and no justification. Every day is normal until
|
||||||
|
someone marks it sick, and only today or a later day can be marked or
|
||||||
|
un-marked -- never a past one.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from crdt_sync import Hlc, load_device_identity
|
||||||
|
|
||||||
|
from sickdays._audit import record_event
|
||||||
|
from sickdays._conflict import ensure_not_free, free_day_predicate
|
||||||
|
from sickdays._day import to_iso, today
|
||||||
|
from sickdays._errors import AlreadySickError, NotSickError, PastDateError
|
||||||
|
from sickdays._model import (
|
||||||
|
SickDay,
|
||||||
|
build_record,
|
||||||
|
is_sick_record,
|
||||||
|
latest_hlc,
|
||||||
|
sick_dates,
|
||||||
|
)
|
||||||
|
from sickdays._paths import Paths, resolve_paths
|
||||||
|
from sickdays._store import read_pool, read_pool_strict, write_pool
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Iterable
|
||||||
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from crdt_sync import Log
|
||||||
|
|
||||||
|
from sickdays._conflict import FreePredicate
|
||||||
|
|
||||||
|
|
||||||
|
def load(log_path: Path | None = None) -> Log:
|
||||||
|
"""Read the pool. Missing reads as empty; unreadable is logged, then empty."""
|
||||||
|
return read_pool(log_path if log_path is not None else resolve_paths(None).log)
|
||||||
|
|
||||||
|
|
||||||
|
def actor_id(paths: Paths | None = None) -> str:
|
||||||
|
"""This install's persisted device uuid, created on first call."""
|
||||||
|
return load_device_identity(resolve_paths(paths).device_id).device_id
|
||||||
|
|
||||||
|
|
||||||
|
def is_sick_day(
|
||||||
|
day: date | None = None,
|
||||||
|
*,
|
||||||
|
log_path: Path | None = None,
|
||||||
|
now: date | None = None,
|
||||||
|
) -> bool:
|
||||||
|
"""Whether ``day`` (default: today) is a sick day. Never raises."""
|
||||||
|
target = day if day is not None else today(now)
|
||||||
|
record = load(log_path).get(to_iso(target))
|
||||||
|
return record is not None and is_sick_record(record)
|
||||||
|
|
||||||
|
|
||||||
|
def sick_days(*, log_path: Path | None = None) -> frozenset[date]:
|
||||||
|
"""Every date currently marked sick, past and future."""
|
||||||
|
return sick_dates(load(log_path))
|
||||||
|
|
||||||
|
|
||||||
|
def _refuse_past(day: date, reference: date, verb: str) -> None:
|
||||||
|
if day < reference:
|
||||||
|
msg = (
|
||||||
|
f"{to_iso(day)} has already passed -- sick days can only be "
|
||||||
|
f"{verb} for today or later"
|
||||||
|
)
|
||||||
|
raise PastDateError(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def _write(log: Log, entry: SickDay, resolved: Paths) -> None:
|
||||||
|
"""Stamp ``entry`` with this node's next tick and persist the pool."""
|
||||||
|
tick = Hlc.new_tick(entry.actor, latest_hlc(log, entry.actor))
|
||||||
|
log[to_iso(entry.day)] = build_record(entry, tick)
|
||||||
|
write_pool(resolved.log, log)
|
||||||
|
|
||||||
|
|
||||||
|
def add_sick_day(
|
||||||
|
day: date,
|
||||||
|
*,
|
||||||
|
is_free: FreePredicate | None = None,
|
||||||
|
paths: Paths | None = None,
|
||||||
|
now: date | None = None,
|
||||||
|
) -> SickDay:
|
||||||
|
"""Mark ``day`` sick, everywhere, for every gate.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
day: The day. Today or later.
|
||||||
|
is_free: "Is this a free day?"; ``None`` asks freedays, imported
|
||||||
|
lazily (:func:`sickdays._conflict.free_day_predicate`).
|
||||||
|
paths: Where the pool, uuid and audit trail live.
|
||||||
|
now: Reference date, for tests.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The stored entry.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
PastDateError: ``day`` has already passed.
|
||||||
|
AlreadySickError: ``day`` is already a sick day.
|
||||||
|
FreeDayConflictError: ``day`` is a free day.
|
||||||
|
ConflictCheckUnavailableError: The free-day pool cannot be checked.
|
||||||
|
UnreadablePoolError: The local pool exists but cannot be parsed.
|
||||||
|
"""
|
||||||
|
resolved = resolve_paths(paths)
|
||||||
|
_refuse_past(day, today(now), "added")
|
||||||
|
log = read_pool_strict(resolved.log)
|
||||||
|
existing = log.get(to_iso(day))
|
||||||
|
if existing is not None and is_sick_record(existing):
|
||||||
|
msg = f"{to_iso(day)} is already a sick day"
|
||||||
|
raise AlreadySickError(msg)
|
||||||
|
ensure_not_free(day, is_free if is_free is not None else free_day_predicate())
|
||||||
|
entry = SickDay(day=day, is_sick=True, actor=actor_id(resolved))
|
||||||
|
_write(log, entry, resolved)
|
||||||
|
record_event("add", day, actor=entry.actor, paths=resolved)
|
||||||
|
return entry
|
||||||
|
|
||||||
|
|
||||||
|
def remove_sick_day(
|
||||||
|
day: date,
|
||||||
|
*,
|
||||||
|
paths: Paths | None = None,
|
||||||
|
now: date | None = None,
|
||||||
|
) -> SickDay:
|
||||||
|
"""Make ``day`` a normal day again. Today or later only.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
PastDateError: ``day`` has already passed.
|
||||||
|
NotSickError: ``day`` is not a sick day.
|
||||||
|
UnreadablePoolError: The local pool exists but cannot be parsed.
|
||||||
|
"""
|
||||||
|
resolved = resolve_paths(paths)
|
||||||
|
_refuse_past(day, today(now), "removed")
|
||||||
|
log = read_pool_strict(resolved.log)
|
||||||
|
existing = log.get(to_iso(day))
|
||||||
|
if existing is None or not is_sick_record(existing):
|
||||||
|
msg = f"{to_iso(day)} is not a sick day"
|
||||||
|
raise NotSickError(msg)
|
||||||
|
entry = SickDay(day=day, is_sick=False, actor=actor_id(resolved))
|
||||||
|
_write(log, entry, resolved)
|
||||||
|
record_event("remove", day, actor=entry.actor, paths=resolved)
|
||||||
|
return entry
|
||||||
|
|
||||||
|
|
||||||
|
def import_legacy(days: Iterable[date], *, paths: Paths | None = None) -> list[date]:
|
||||||
|
"""Migration only: record ``days`` as sick, past dates included.
|
||||||
|
|
||||||
|
The one writer that may write the past -- used once, for screen-locker's
|
||||||
|
``sick_history.json`` (2026-06-21, 2026-07-24), both of which predate
|
||||||
|
every gate start. No free-day check: those dates predate freedays.
|
||||||
|
Idempotent: a day already sick is skipped and gets no audit line.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The days actually written, ascending.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
UnreadablePoolError: The local pool exists but cannot be parsed.
|
||||||
|
"""
|
||||||
|
resolved = resolve_paths(paths)
|
||||||
|
log = read_pool_strict(resolved.log)
|
||||||
|
node = actor_id(resolved)
|
||||||
|
written: list[date] = []
|
||||||
|
for day in sorted(set(days)):
|
||||||
|
existing = log.get(to_iso(day))
|
||||||
|
if existing is not None and is_sick_record(existing):
|
||||||
|
continue
|
||||||
|
_write(log, SickDay(day=day, is_sick=True, actor=node), resolved)
|
||||||
|
record_event("import", day, actor=node, paths=resolved)
|
||||||
|
written.append(day)
|
||||||
|
return written
|
||||||
66
sickdays/sickdays/_audit.py
Normal file
66
sickdays/sickdays/_audit.py
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
|
"""Append-only, HMAC-signed trail of every add, remove and legacy import.
|
||||||
|
|
||||||
|
Nothing reads this to make a decision -- the CRDT log is the truth. It
|
||||||
|
exists so "when did I mark that day, and from which device" is answerable
|
||||||
|
later, and so a hand-edited line is detectable rather than invisible.
|
||||||
|
|
||||||
|
Signing is best-effort, as in freedays: with no key the line is written
|
||||||
|
unsigned, because an unsigned audit line is a much smaller problem than a
|
||||||
|
sick day that could not be taken because a key file was missing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
from sickdays._day import to_iso
|
||||||
|
from sickdays._hmac import generate_key, sign
|
||||||
|
from sickdays._paths import Paths, resolve_paths
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def record_event(
|
||||||
|
action: str,
|
||||||
|
day: date,
|
||||||
|
*,
|
||||||
|
actor: str,
|
||||||
|
paths: Paths | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Append one signed line describing a change to the pool.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
action: ``"add"``, ``"remove"`` or ``"import"``.
|
||||||
|
day: The day that changed.
|
||||||
|
actor: The device id that made the change.
|
||||||
|
paths: Where the trail and its signing key live.
|
||||||
|
"""
|
||||||
|
resolved = resolve_paths(paths)
|
||||||
|
entry: dict[str, Any] = {
|
||||||
|
"action": action,
|
||||||
|
"day": to_iso(day),
|
||||||
|
"actor": actor,
|
||||||
|
"at": datetime.now(tz=UTC).isoformat(),
|
||||||
|
}
|
||||||
|
key_target = resolved.hmac_key
|
||||||
|
if not key_target.exists():
|
||||||
|
# First run on this machine: generating the key here means the trail
|
||||||
|
# is signed from its first line, with no setup step to skip.
|
||||||
|
generate_key(key_target)
|
||||||
|
entry["hmac"] = sign(entry, key_file=key_target)
|
||||||
|
target = resolved.audit
|
||||||
|
try:
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with target.open("a", encoding="utf-8") as handle:
|
||||||
|
handle.write(json.dumps(entry, sort_keys=True) + "\n")
|
||||||
|
except OSError as exc:
|
||||||
|
# The change is already committed to the log; an unwritable trail
|
||||||
|
# must never be the reason it fails.
|
||||||
|
_logger.warning("could not append to the sick-day audit trail: %s", exc)
|
||||||
134
sickdays/sickdays/_cli.py
Normal file
134
sickdays/sickdays/_cli.py
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
|
"""The ``sickdays`` command: mark, un-mark and list sick days.
|
||||||
|
|
||||||
|
Every day is a normal day until marked here (or on the phone). There is no
|
||||||
|
budget and no reason to give; the one rule is the calendar -- today or later
|
||||||
|
only -- plus the cross-pool check: a free day cannot also be a sick day.
|
||||||
|
|
||||||
|
``sync-quiet`` is what the timer runs: it reports success even with no
|
||||||
|
network, because a unit that goes red whenever the laptop is offline is a
|
||||||
|
unit you learn to ignore.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from sickdays._api import add_sick_day, is_sick_day, remove_sick_day, sick_days
|
||||||
|
from sickdays._cli_output import emit, emit_error
|
||||||
|
from sickdays._day import resolve, to_iso, today
|
||||||
|
from sickdays._errors import SickDayError
|
||||||
|
from sickdays._sync import SyncUnavailableError, sync, sync_quietly
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
_EXIT_OK = 0
|
||||||
|
_EXIT_REFUSED = 1
|
||||||
|
_EXIT_BAD_INPUT = 2
|
||||||
|
_DAY_HELP = "YYYY-MM-DD, or 'today'/'tomorrow' (default: today)"
|
||||||
|
|
||||||
|
|
||||||
|
def _build_parser() -> argparse.ArgumentParser:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
prog="sickdays",
|
||||||
|
description=(
|
||||||
|
"One shared pool of sick days every gate respects. No cap, no "
|
||||||
|
"reason; today or later only."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
sub = parser.add_subparsers(dest="command", required=True)
|
||||||
|
add = sub.add_parser("add", help="mark a day sick (today or later)")
|
||||||
|
add.add_argument("day", nargs="?", default="today", help=_DAY_HELP)
|
||||||
|
remove = sub.add_parser("remove", help="make a day normal again")
|
||||||
|
remove.add_argument("day", nargs="?", default="today", help=_DAY_HELP)
|
||||||
|
sub.add_parser("list", help="every sick day, oldest first")
|
||||||
|
check = sub.add_parser(
|
||||||
|
"check", help="exit 0 if the day is sick, 1 if not (for scripts)"
|
||||||
|
)
|
||||||
|
check.add_argument("day", nargs="?", default="today", help=_DAY_HELP)
|
||||||
|
sub.add_parser("sync", help="pull and push the pool now")
|
||||||
|
sub.add_parser(
|
||||||
|
"sync-quiet",
|
||||||
|
help="sync, but exit 0 when the network or credential is unavailable",
|
||||||
|
)
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def _do_add(raw_day: str) -> int:
|
||||||
|
entry = add_sick_day(resolve(raw_day))
|
||||||
|
emit(f"{to_iso(entry.day)} is now a sick day")
|
||||||
|
return _EXIT_OK
|
||||||
|
|
||||||
|
|
||||||
|
def _do_remove(raw_day: str) -> int:
|
||||||
|
entry = remove_sick_day(resolve(raw_day))
|
||||||
|
emit(f"{to_iso(entry.day)} is a normal day again")
|
||||||
|
return _EXIT_OK
|
||||||
|
|
||||||
|
|
||||||
|
def _do_list() -> int:
|
||||||
|
days = sorted(sick_days())
|
||||||
|
if not days:
|
||||||
|
emit("no sick days")
|
||||||
|
return _EXIT_OK
|
||||||
|
reference = today()
|
||||||
|
for day in days:
|
||||||
|
emit(f"{to_iso(day)}{' (today)' if day == reference else ''}")
|
||||||
|
return _EXIT_OK
|
||||||
|
|
||||||
|
|
||||||
|
def _do_check(raw_day: str) -> int:
|
||||||
|
day = resolve(raw_day)
|
||||||
|
sick = is_sick_day(day)
|
||||||
|
emit(f"{to_iso(day)}: {'sick day' if sick else 'normal day'}")
|
||||||
|
return _EXIT_OK if sick else _EXIT_REFUSED
|
||||||
|
|
||||||
|
|
||||||
|
def _do_sync() -> int:
|
||||||
|
sync()
|
||||||
|
emit("pool synced")
|
||||||
|
return _EXIT_OK
|
||||||
|
|
||||||
|
|
||||||
|
def _do_sync_quiet() -> int:
|
||||||
|
emit("pool synced" if sync_quietly() else "pool not synced (no remote available)")
|
||||||
|
return _EXIT_OK
|
||||||
|
|
||||||
|
|
||||||
|
def _dispatch(args: argparse.Namespace) -> int:
|
||||||
|
if args.command == "add":
|
||||||
|
return _do_add(args.day)
|
||||||
|
if args.command == "remove":
|
||||||
|
return _do_remove(args.day)
|
||||||
|
if args.command == "list":
|
||||||
|
return _do_list()
|
||||||
|
if args.command == "check":
|
||||||
|
return _do_check(args.day)
|
||||||
|
if args.command == "sync":
|
||||||
|
return _do_sync()
|
||||||
|
return _do_sync_quiet()
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: Sequence[str] | None = None) -> int:
|
||||||
|
"""Parse ``argv`` and run one subcommand.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
``0`` on success, ``1`` for a refused or negative answer, ``2`` for
|
||||||
|
input that could not be understood.
|
||||||
|
"""
|
||||||
|
args = _build_parser().parse_args(argv)
|
||||||
|
try:
|
||||||
|
return _dispatch(args)
|
||||||
|
except ValueError as exc:
|
||||||
|
emit_error(f"error: {exc}")
|
||||||
|
return _EXIT_BAD_INPUT
|
||||||
|
except (SickDayError, SyncUnavailableError) as exc:
|
||||||
|
emit_error(f"error: {exc}")
|
||||||
|
return _EXIT_REFUSED
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
22
sickdays/sickdays/_cli_output.py
Normal file
22
sickdays/sickdays/_cli_output.py
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
|
"""The one stdout/stderr sink every ``sickdays`` subcommand writes through.
|
||||||
|
|
||||||
|
A thin wrapper over ``sys.stdout.write`` (the shape diet-guard and home-guard
|
||||||
|
already use) so genuine CLI output does not trip ruff's ``T201`` without a
|
||||||
|
suppression. Kept deliberately trivial: ``T201`` is *auto-fixable*, and its
|
||||||
|
unsafe fix deletes the whole ``print`` call -- output and all.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def emit(text: str = "") -> None:
|
||||||
|
"""Write one line to stdout."""
|
||||||
|
sys.stdout.write(f"{text}\n")
|
||||||
|
|
||||||
|
|
||||||
|
def emit_error(text: str) -> None:
|
||||||
|
"""Write one line to stderr."""
|
||||||
|
sys.stderr.write(f"{text}\n")
|
||||||
93
sickdays/sickdays/_conflict.py
Normal file
93
sickdays/sickdays/_conflict.py
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
|
"""The one rule shared with freedays: a date is never in both pools.
|
||||||
|
|
||||||
|
The design is one-way on purpose. freedays never imports sickdays, and
|
||||||
|
sickdays never imports freedays at module level -- so neither package can
|
||||||
|
form an import cycle, and ``import sickdays`` costs nothing extra in a gate.
|
||||||
|
Instead:
|
||||||
|
|
||||||
|
* **Adding a sick day** asks a predicate ``is_free(day) -> bool``.
|
||||||
|
:func:`sickdays.add_sick_day` uses :func:`free_day_predicate` by default,
|
||||||
|
which imports ``freedays`` *lazily*, at call time, by name. A phone port or
|
||||||
|
a test passes its own predicate.
|
||||||
|
* **Marking a free day** is freedays' write, so its callers (CLIs, the phone,
|
||||||
|
the lock screen) call :func:`ensure_not_sick` first. That needs no import
|
||||||
|
of freedays at all.
|
||||||
|
|
||||||
|
If freedays cannot be imported, the add is *refused*
|
||||||
|
(:class:`ConflictCheckUnavailableError`): the invariant is hard, and "could
|
||||||
|
not check" must not pass as "not free".
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
import importlib
|
||||||
|
from typing import TYPE_CHECKING, cast
|
||||||
|
|
||||||
|
from sickdays._day import to_iso
|
||||||
|
from sickdays._errors import (
|
||||||
|
ConflictCheckUnavailableError,
|
||||||
|
FreeDayConflictError,
|
||||||
|
SickDayConflictError,
|
||||||
|
)
|
||||||
|
from sickdays._model import is_sick_record
|
||||||
|
from sickdays._paths import resolve_paths
|
||||||
|
from sickdays._store import read_pool
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from sickdays._paths import Paths
|
||||||
|
|
||||||
|
#: "Is this date a free day?" -- freedays' ``is_free_day`` or a stand-in.
|
||||||
|
FreePredicate = Callable[["date"], bool]
|
||||||
|
|
||||||
|
|
||||||
|
def free_day_predicate() -> FreePredicate:
|
||||||
|
"""Return ``freedays.is_free_day``, imported now rather than at load time.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ConflictCheckUnavailableError: freedays is not importable here.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
module = importlib.import_module("freedays")
|
||||||
|
except ImportError as exc:
|
||||||
|
msg = (
|
||||||
|
"cannot check the free-day pool (freedays is not installed), "
|
||||||
|
"so the sick day was not added"
|
||||||
|
)
|
||||||
|
raise ConflictCheckUnavailableError(msg) from exc
|
||||||
|
return cast("FreePredicate", module.is_free_day)
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_not_free(day: date, is_free: FreePredicate) -> None:
|
||||||
|
"""Refuse a sick day on a date the free-day pool already holds.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
FreeDayConflictError: ``day`` is a free day.
|
||||||
|
"""
|
||||||
|
if is_free(day):
|
||||||
|
msg = (
|
||||||
|
f"{to_iso(day)} is a free day -- release it with "
|
||||||
|
"'freedays release' before marking it sick"
|
||||||
|
)
|
||||||
|
raise FreeDayConflictError(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_not_sick(day: date, *, paths: Paths | None = None) -> None:
|
||||||
|
"""Refuse a free day on a date the sick-day pool already holds.
|
||||||
|
|
||||||
|
For freedays' callers, before ``freedays.mark``. Reads the local pool
|
||||||
|
only; an unreadable pool reads as "not sick", like every other reader.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
SickDayConflictError: ``day`` is a sick day.
|
||||||
|
"""
|
||||||
|
record = read_pool(resolve_paths(paths).log).get(to_iso(day))
|
||||||
|
if record is not None and is_sick_record(record):
|
||||||
|
msg = (
|
||||||
|
f"{to_iso(day)} is a sick day -- remove it with "
|
||||||
|
"'sickdays remove' before marking it free"
|
||||||
|
)
|
||||||
|
raise SickDayConflictError(msg)
|
||||||
56
sickdays/sickdays/_constants.py
Normal file
56
sickdays/sickdays/_constants.py
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
|
"""On-disk locations, remote paths and record field names.
|
||||||
|
|
||||||
|
Every path here is a *default*. Public functions take the corresponding
|
||||||
|
argument and only fall back to these when it is ``None``, so tests can point
|
||||||
|
the whole package at a tmp_path and a bug in this package can never write
|
||||||
|
into real user state. See ``sickdays/tests/conftest.py``, which redirects
|
||||||
|
these as a second line of defence.
|
||||||
|
|
||||||
|
Nothing here is shared with freedays. The two pools are different things
|
||||||
|
with different names, so they never share a directory, a file or a remote
|
||||||
|
node -- a sync bug in one can never touch the other.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Final
|
||||||
|
|
||||||
|
APP_NAME: Final = "sickdays"
|
||||||
|
|
||||||
|
STATE_DIR: Final = Path.home() / ".local" / "share" / "sickdays"
|
||||||
|
|
||||||
|
#: The CRDT log itself: ISO date -> Record(state=sick|cleared).
|
||||||
|
LOG_PATH: Final = STATE_DIR / "sick_days.json"
|
||||||
|
|
||||||
|
#: crdt-sync's per-device push/pull bookkeeping. Never merged, purely local.
|
||||||
|
SYNC_STATE_PATH: Final = STATE_DIR / "sync_state.json"
|
||||||
|
|
||||||
|
#: This install's persisted uuid. Never a fixed "pc"/"phone" constant, so two
|
||||||
|
#: machines can never collide on one device directory.
|
||||||
|
DEVICE_ID_PATH: Final = STATE_DIR / "device_id"
|
||||||
|
|
||||||
|
#: Append-only HMAC-signed record of every add/remove/import, for
|
||||||
|
#: after-the-fact review. Not consulted by any decision -- the log is the truth.
|
||||||
|
AUDIT_PATH: Final = STATE_DIR / "audit.jsonl"
|
||||||
|
|
||||||
|
#: Signing key for the audit trail, generated on first use. User-owned, as in
|
||||||
|
#: freedays: the signature answers "was this line edited after the fact" for
|
||||||
|
#: the person's own review, it does not defend a budget (there is none).
|
||||||
|
HMAC_KEY_FILE: Final = STATE_DIR / "hmac.key"
|
||||||
|
|
||||||
|
#: Remote layout, mirroring every other app: one directory per device under
|
||||||
|
#: a prefix, each device writing only its own file.
|
||||||
|
SYNC_PATH_PREFIX: Final = "sickdays-sync/devices"
|
||||||
|
SYNC_FILENAME: Final = "sick_days.json"
|
||||||
|
|
||||||
|
#: Field names inside a Record.
|
||||||
|
FIELD_STATE: Final = "state"
|
||||||
|
FIELD_ACTOR: Final = "actor"
|
||||||
|
|
||||||
|
#: Values of FIELD_STATE. Removing flips the field rather than tombstoning
|
||||||
|
#: the record: crdt-sync deletes are monotonic (OR-merged), so a tombstoned
|
||||||
|
#: date could never be marked sick again.
|
||||||
|
STATE_SICK: Final = "sick"
|
||||||
|
STATE_CLEARED: Final = "cleared"
|
||||||
78
sickdays/sickdays/_day.py
Normal file
78
sickdays/sickdays/_day.py
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
|
"""What "today" means, in one place.
|
||||||
|
|
||||||
|
The apps freedays serves disagreed about this before it existed:
|
||||||
|
screen-locker's lock chain computed the day in UTC while the tool that wrote
|
||||||
|
its skip file computed it in *local* time, so a skip added near midnight
|
||||||
|
could name a date the chain never matched. Sick days are a human-facing
|
||||||
|
calendar concept -- if it is Tuesday where the person is standing, it is
|
||||||
|
Tuesday -- so this package is local-time throughout and nothing else is
|
||||||
|
allowed a second opinion.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, date, datetime, timedelta
|
||||||
|
|
||||||
|
ISO_FORMAT = "%Y-%m-%d"
|
||||||
|
|
||||||
|
|
||||||
|
def today(now: date | None = None) -> date:
|
||||||
|
"""Return the current local calendar date.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
now: Override for tests and for callers that already resolved a
|
||||||
|
reference date. ``None`` reads the system clock in local time.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The local calendar date.
|
||||||
|
"""
|
||||||
|
return now if now is not None else datetime.now(tz=UTC).astimezone().date()
|
||||||
|
|
||||||
|
|
||||||
|
def parse_iso(text: str) -> date:
|
||||||
|
"""Parse ``YYYY-MM-DD`` into a :class:`~datetime.date`.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: The date string.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The parsed date.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If ``text`` is not a valid ``YYYY-MM-DD`` date. The
|
||||||
|
message names the offending input, because the usual way to see
|
||||||
|
this is a typo on the command line.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return date.fromisoformat(text.strip())
|
||||||
|
except ValueError as exc:
|
||||||
|
msg = f"not a YYYY-MM-DD date: {text!r}"
|
||||||
|
raise ValueError(msg) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def to_iso(day: date) -> str:
|
||||||
|
"""Render ``day`` as ``YYYY-MM-DD``."""
|
||||||
|
return day.strftime(ISO_FORMAT)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve(text: str, *, now: date | None = None) -> date:
|
||||||
|
"""Parse a user-supplied day, accepting ``today`` and ``tomorrow``.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: A ``YYYY-MM-DD`` date, or the words ``today``/``tomorrow``.
|
||||||
|
now: Reference date for the relative words.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The resolved date.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If ``text`` is neither a keyword nor a valid date.
|
||||||
|
"""
|
||||||
|
cleaned = text.strip().lower()
|
||||||
|
reference = today(now)
|
||||||
|
if cleaned == "today":
|
||||||
|
return reference
|
||||||
|
if cleaned == "tomorrow":
|
||||||
|
return reference + timedelta(days=1)
|
||||||
|
return parse_iso(text)
|
||||||
57
sickdays/sickdays/_errors.py
Normal file
57
sickdays/sickdays/_errors.py
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
|
"""Why an add, a remove or a free-day mark was refused.
|
||||||
|
|
||||||
|
Each of these is a *policy* refusal with a message meant to be shown to the
|
||||||
|
person who typed the command. None of them indicates a bug, so callers are
|
||||||
|
expected to catch :class:`SickDayError` and print ``str(exc)`` rather than
|
||||||
|
let a traceback out.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
class SickDayError(Exception):
|
||||||
|
"""Base class for every policy refusal in this package."""
|
||||||
|
|
||||||
|
|
||||||
|
class PastDateError(SickDayError):
|
||||||
|
"""A past day can be neither added nor removed.
|
||||||
|
|
||||||
|
Without this a gate that already fired and was already failed could be
|
||||||
|
erased after the fact -- or a day that was rightly excused could be
|
||||||
|
un-excused retroactively. Only :func:`sickdays.import_legacy` writes past
|
||||||
|
dates, once, from screen-locker's old history.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class AlreadySickError(SickDayError):
|
||||||
|
"""The day is already a sick day, so adding it again would be a no-op."""
|
||||||
|
|
||||||
|
|
||||||
|
class NotSickError(SickDayError):
|
||||||
|
"""The day is not a sick day, so there is nothing to remove."""
|
||||||
|
|
||||||
|
|
||||||
|
class FreeDayConflictError(SickDayError):
|
||||||
|
"""The day is a free day; a date cannot be in both pools."""
|
||||||
|
|
||||||
|
|
||||||
|
class SickDayConflictError(SickDayError):
|
||||||
|
"""The day is a sick day, so it cannot also be marked free."""
|
||||||
|
|
||||||
|
|
||||||
|
class ConflictCheckUnavailableError(SickDayError):
|
||||||
|
"""The free-day pool could not be consulted, so the add is refused.
|
||||||
|
|
||||||
|
"A date is never in both pools" is a hard invariant, so not being able
|
||||||
|
to check it is a refusal, not a pass.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class UnreadablePoolError(SickDayError):
|
||||||
|
"""The local pool file exists but cannot be read or parsed.
|
||||||
|
|
||||||
|
Readers treat that as "no sick days" (fail closed: the gates apply).
|
||||||
|
Writers refuse instead: rewriting the file from an empty pool would
|
||||||
|
silently destroy every entry the unreadable file still holds.
|
||||||
|
"""
|
||||||
84
sickdays/sickdays/_hmac.py
Normal file
84
sickdays/sickdays/_hmac.py
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
|
"""Signing the audit trail, using nothing but the standard library.
|
||||||
|
|
||||||
|
The same scheme as freedays' ``_hmac`` (HMAC-SHA256 over compact, sorted-key
|
||||||
|
JSON), copied rather than imported: sickdays and freedays must never import
|
||||||
|
each other's internals, and ``gatelock.log_integrity`` -- where the scheme
|
||||||
|
came from -- imports ``tkinter`` through ``gatelock/__init__``, which would
|
||||||
|
make ``import sickdays`` fail on any machine without Tk.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import secrets
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_KEY_BYTES = 32
|
||||||
|
|
||||||
|
|
||||||
|
def load_key(key_file: Path) -> bytes | None:
|
||||||
|
"""Return the key bytes at ``key_file``, or None if unreadable."""
|
||||||
|
try:
|
||||||
|
return key_file.read_bytes().strip()
|
||||||
|
except OSError:
|
||||||
|
_logger.warning("cannot read the sick-day signing key from %s", key_file)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def generate_key(key_file: Path) -> bytes | None:
|
||||||
|
"""Create a key at ``key_file`` with owner-only permissions.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The new key bytes, or None if it could not be written.
|
||||||
|
"""
|
||||||
|
key = secrets.token_bytes(_KEY_BYTES)
|
||||||
|
try:
|
||||||
|
key_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
key_file.write_bytes(key)
|
||||||
|
key_file.chmod(0o600)
|
||||||
|
except OSError:
|
||||||
|
_logger.warning("cannot write the sick-day signing key to %s", key_file)
|
||||||
|
return None
|
||||||
|
return key
|
||||||
|
|
||||||
|
|
||||||
|
def _payload(entry: dict[str, Any]) -> bytes:
|
||||||
|
return json.dumps(entry, sort_keys=True, separators=(",", ":")).encode()
|
||||||
|
|
||||||
|
|
||||||
|
def sign(entry: dict[str, Any], *, key_file: Path) -> str | None:
|
||||||
|
"""Return the hex HMAC of ``entry``, or None when the key is unavailable.
|
||||||
|
|
||||||
|
``entry`` must not already contain a ``hmac`` field.
|
||||||
|
"""
|
||||||
|
key = load_key(key_file)
|
||||||
|
if key is None:
|
||||||
|
return None
|
||||||
|
return hmac.new(key, _payload(entry), hashlib.sha256).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def verify(entry: dict[str, Any], *, key_file: Path) -> bool:
|
||||||
|
"""Whether ``entry``'s stored ``hmac`` matches its contents.
|
||||||
|
|
||||||
|
False when the field is missing or not a string, and when the key cannot
|
||||||
|
be read -- "cannot check" and "does not match" are the same answer to the
|
||||||
|
only question a caller asks, which is whether to trust the line.
|
||||||
|
"""
|
||||||
|
stored = entry.get("hmac")
|
||||||
|
if not isinstance(stored, str):
|
||||||
|
return False
|
||||||
|
key = load_key(key_file)
|
||||||
|
if key is None:
|
||||||
|
return False
|
||||||
|
without = {name: value for name, value in entry.items() if name != "hmac"}
|
||||||
|
expected = hmac.new(key, _payload(without), hashlib.sha256).hexdigest()
|
||||||
|
return hmac.compare_digest(stored, expected)
|
||||||
120
sickdays/sickdays/_model.py
Normal file
120
sickdays/sickdays/_model.py
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
|
"""Translating between a sick day and a crdt-sync ``Record``.
|
||||||
|
|
||||||
|
The record id *is* the ISO date, so two devices marking the same day produce
|
||||||
|
the same record and converge instead of accumulating duplicates.
|
||||||
|
|
||||||
|
Adding and removing are a last-writer-wins flip of the ``state`` field, every
|
||||||
|
field stamped with one hybrid logical clock tick. Merging two copies keeps,
|
||||||
|
per field, the one with the greater clock -- so the most recent add or
|
||||||
|
remove of a date wins on every device, and a removed day can be added again
|
||||||
|
(and removed again) any number of times. A tombstone (``Record.deleted``)
|
||||||
|
would not allow that: crdt-sync OR-merges it, so a deleted date could never
|
||||||
|
come back. Nothing here ever sets it, and a tombstoned record reads as "not
|
||||||
|
sick".
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from crdt_sync import Hlc, Record
|
||||||
|
|
||||||
|
from sickdays._constants import FIELD_ACTOR, FIELD_STATE, STATE_CLEARED, STATE_SICK
|
||||||
|
from sickdays._day import parse_iso, to_iso
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from crdt_sync import Log
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SickDay:
|
||||||
|
"""One day's entry, as the rest of the package wants to see it.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
day: The calendar date.
|
||||||
|
is_sick: Whether the day is currently a sick day.
|
||||||
|
actor: Which device last changed it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
day: date
|
||||||
|
is_sick: bool
|
||||||
|
actor: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
def _field_str(record: Record, name: str) -> str:
|
||||||
|
field = record.fields.get(name)
|
||||||
|
if field is None:
|
||||||
|
return ""
|
||||||
|
value = field[0]
|
||||||
|
return value if isinstance(value, str) else ""
|
||||||
|
|
||||||
|
|
||||||
|
def is_sick_record(record: Record) -> bool:
|
||||||
|
"""Whether ``record`` currently marks its day sick.
|
||||||
|
|
||||||
|
Anything not understood -- a tombstone, a missing or unknown state --
|
||||||
|
reads as "not sick": the gates apply.
|
||||||
|
"""
|
||||||
|
if record.deleted:
|
||||||
|
return False
|
||||||
|
return _field_str(record, FIELD_STATE) == STATE_SICK
|
||||||
|
|
||||||
|
|
||||||
|
def to_sick_day(record: Record) -> SickDay | None:
|
||||||
|
"""Build a :class:`SickDay` from ``record``, or None if its id is junk."""
|
||||||
|
try:
|
||||||
|
day = parse_iso(record.id)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return SickDay(
|
||||||
|
day=day,
|
||||||
|
is_sick=is_sick_record(record),
|
||||||
|
actor=_field_str(record, FIELD_ACTOR),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def sick_dates(log: Log) -> frozenset[date]:
|
||||||
|
"""Every date ``log`` currently marks sick (junk ids skipped)."""
|
||||||
|
days: set[date] = set()
|
||||||
|
for record in log.values():
|
||||||
|
entry = to_sick_day(record)
|
||||||
|
if entry is not None and entry.is_sick:
|
||||||
|
days.add(entry.day)
|
||||||
|
return frozenset(days)
|
||||||
|
|
||||||
|
|
||||||
|
def latest_hlc(log: Log, node_id: str) -> Hlc | None:
|
||||||
|
"""Return the greatest clock this node has issued anywhere in ``log``.
|
||||||
|
|
||||||
|
Feeding this back into :meth:`Hlc.new_tick` keeps the node's clock
|
||||||
|
monotonic across process restarts, so a fresh process never issues a
|
||||||
|
tick that loses to its own earlier write.
|
||||||
|
"""
|
||||||
|
ticks = [
|
||||||
|
hlc
|
||||||
|
for record in log.values()
|
||||||
|
for _, hlc in record.fields.values()
|
||||||
|
if hlc.node_id == node_id
|
||||||
|
]
|
||||||
|
return max(ticks) if ticks else None
|
||||||
|
|
||||||
|
|
||||||
|
def build_record(entry: SickDay, hlc: Hlc) -> Record:
|
||||||
|
"""Return the record expressing ``entry``, every field stamped ``hlc``.
|
||||||
|
|
||||||
|
One tick for every field, so a concurrent add and remove of one day
|
||||||
|
resolve together rather than interleaving state from one device and the
|
||||||
|
actor from the other.
|
||||||
|
"""
|
||||||
|
state = STATE_SICK if entry.is_sick else STATE_CLEARED
|
||||||
|
return Record(
|
||||||
|
id=to_iso(entry.day),
|
||||||
|
fields={
|
||||||
|
FIELD_STATE: (state, hlc),
|
||||||
|
FIELD_ACTOR: (entry.actor, hlc),
|
||||||
|
},
|
||||||
|
)
|
||||||
61
sickdays/sickdays/_paths.py
Normal file
61
sickdays/sickdays/_paths.py
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
|
"""Every file this package owns, as one value that can be swapped wholesale.
|
||||||
|
|
||||||
|
The same seam freedays uses: one bundle, so ``Paths.under(tmp_path)``
|
||||||
|
redirects everything at once and a file added to this class later is
|
||||||
|
redirected by construction rather than by remembering to update a list.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from sickdays._constants import (
|
||||||
|
AUDIT_PATH,
|
||||||
|
DEVICE_ID_PATH,
|
||||||
|
HMAC_KEY_FILE,
|
||||||
|
LOG_PATH,
|
||||||
|
SYNC_STATE_PATH,
|
||||||
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Paths:
|
||||||
|
"""Where the pool, the device id, the audit trail and sync state live."""
|
||||||
|
|
||||||
|
log: Path
|
||||||
|
device_id: Path
|
||||||
|
audit: Path
|
||||||
|
hmac_key: Path
|
||||||
|
sync_state: Path
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def default(cls) -> Paths:
|
||||||
|
"""The real, per-user locations under ``~/.local/share/sickdays``."""
|
||||||
|
return cls(
|
||||||
|
log=LOG_PATH,
|
||||||
|
device_id=DEVICE_ID_PATH,
|
||||||
|
audit=AUDIT_PATH,
|
||||||
|
hmac_key=HMAC_KEY_FILE,
|
||||||
|
sync_state=SYNC_STATE_PATH,
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def under(cls, directory: Path) -> Paths:
|
||||||
|
"""Every file, relocated under ``directory``. The test seam."""
|
||||||
|
return cls(
|
||||||
|
log=directory / "sick_days.json",
|
||||||
|
device_id=directory / "device_id",
|
||||||
|
audit=directory / "audit.jsonl",
|
||||||
|
hmac_key=directory / "hmac.key",
|
||||||
|
sync_state=directory / "sync_state.json",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_paths(paths: Paths | None) -> Paths:
|
||||||
|
"""Return ``paths``, or the real locations when it is ``None``."""
|
||||||
|
return paths if paths is not None else Paths.default()
|
||||||
74
sickdays/sickdays/_store.py
Normal file
74
sickdays/sickdays/_store.py
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
|
"""Reading and writing the pool file, failing closed and loudly.
|
||||||
|
|
||||||
|
``crdt_sync.read_log`` is deliberately not used for reading: it turns a
|
||||||
|
corrupt file into an empty log *silently*, and lets a ``PermissionError``
|
||||||
|
escape. A gate asking "is today a sick day?" must never raise, and an
|
||||||
|
unreadable pool must be visible in the journal -- it silently re-arms every
|
||||||
|
lock and alarm, which is the safe direction but a confusing one.
|
||||||
|
|
||||||
|
So there are two readers. :func:`read_pool` is for gates: anything wrong
|
||||||
|
reads as "no sick days" and is logged. :func:`read_pool_strict` is for
|
||||||
|
writers: a file that exists but cannot be parsed raises, because rewriting
|
||||||
|
it from an empty pool would destroy whatever it still holds.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from crdt_sync import load_log, write_log
|
||||||
|
|
||||||
|
from sickdays._errors import UnreadablePoolError
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from crdt_sync import Log
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _read(path: Path) -> Log:
|
||||||
|
"""Parse ``path``; ``{}`` when missing. Raises on anything else."""
|
||||||
|
try:
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
except FileNotFoundError:
|
||||||
|
return {}
|
||||||
|
except OSError as exc:
|
||||||
|
msg = f"the sick-day pool {path} cannot be read: {exc}"
|
||||||
|
raise UnreadablePoolError(msg) from exc
|
||||||
|
try:
|
||||||
|
return load_log(text)
|
||||||
|
except (ValueError, KeyError, TypeError, AttributeError) as exc:
|
||||||
|
msg = f"the sick-day pool {path} is corrupt: {exc!r}"
|
||||||
|
raise UnreadablePoolError(msg) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def read_pool(path: Path) -> Log:
|
||||||
|
"""Return the pool at ``path``; empty (and logged) when it is unusable.
|
||||||
|
|
||||||
|
A missing file is the normal "no sick day was ever taken" and is not
|
||||||
|
logged. Anything else -- unreadable, unparsable, wrong shape -- is a
|
||||||
|
warning, and reads as *no sick days*: the gates apply.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return _read(path)
|
||||||
|
except UnreadablePoolError as exc:
|
||||||
|
_logger.warning("%s -- treating every day as NOT a sick day", exc)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def read_pool_strict(path: Path) -> Log:
|
||||||
|
"""Return the pool at ``path``, or raise if it exists but is unusable.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
UnreadablePoolError: The file exists but cannot be read or parsed.
|
||||||
|
"""
|
||||||
|
return _read(path)
|
||||||
|
|
||||||
|
|
||||||
|
def write_pool(path: Path, log: Log) -> None:
|
||||||
|
"""Write ``log`` to ``path`` atomically (crdt-sync's temp-then-rename)."""
|
||||||
|
write_log(path, log)
|
||||||
145
sickdays/sickdays/_sync.py
Normal file
145
sickdays/sickdays/_sync.py
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
|
"""Pushing and pulling the pool, so a sick day marked on the phone lands on the PC.
|
||||||
|
|
||||||
|
Sync is a *separate* operation from every decision in :mod:`sickdays._api`.
|
||||||
|
Nothing that decides whether a gate fires ever waits on this: a gate reads
|
||||||
|
the last-merged local file and moves on, so an unreachable network makes the
|
||||||
|
pool stale rather than making a lock hang.
|
||||||
|
|
||||||
|
The remote is Firebase RTDB, matching every other app in the fleet, with one
|
||||||
|
directory per device -- no two devices write the same node, so there is
|
||||||
|
nothing to conflict and convergence is the CRDT layer's job.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from crdt_sync import (
|
||||||
|
ConfigError,
|
||||||
|
FileSyncStateStore,
|
||||||
|
FirebaseAuthError,
|
||||||
|
LogCodec,
|
||||||
|
RemoteSyncError,
|
||||||
|
RevisionTracking,
|
||||||
|
SyncTarget,
|
||||||
|
dump_log,
|
||||||
|
firebase_client_for,
|
||||||
|
load_log,
|
||||||
|
sync_log,
|
||||||
|
)
|
||||||
|
|
||||||
|
from sickdays._api import actor_id, load
|
||||||
|
from sickdays._constants import (
|
||||||
|
APP_NAME,
|
||||||
|
SYNC_FILENAME,
|
||||||
|
SYNC_PATH_PREFIX,
|
||||||
|
)
|
||||||
|
from sickdays._paths import Paths, resolve_paths
|
||||||
|
from sickdays._store import write_pool
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from crdt_sync import Log, RemoteStore
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class SyncUnavailableError(Exception):
|
||||||
|
"""No usable remote right now -- unconfigured, offline, or rejected.
|
||||||
|
|
||||||
|
Deliberately not a subclass of anything in :mod:`sickdays._errors`: this
|
||||||
|
is never a policy refusal, and callers treat it the way they treat a
|
||||||
|
network timeout.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def get_client() -> RemoteStore:
|
||||||
|
"""Return a signed-in Firebase client, or raise :class:`SyncUnavailableError`.
|
||||||
|
|
||||||
|
``ConfigError`` subclasses ``Exception`` directly rather than
|
||||||
|
``RemoteSyncError``, so both are caught explicitly -- assuming one covers
|
||||||
|
the other has bitten two other apps in this fleet already.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return firebase_client_for(APP_NAME)
|
||||||
|
except ConfigError as exc:
|
||||||
|
msg = f"sick-day sync is not configured on this machine: {exc}"
|
||||||
|
raise SyncUnavailableError(msg) from exc
|
||||||
|
except FirebaseAuthError as exc:
|
||||||
|
msg = f"sick-day sync credentials were rejected: {exc}"
|
||||||
|
raise SyncUnavailableError(msg) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def sync(
|
||||||
|
*,
|
||||||
|
client: RemoteStore | None = None,
|
||||||
|
paths: Paths | None = None,
|
||||||
|
) -> Log:
|
||||||
|
"""Run one pull/merge/push tick and persist the merged pool.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client: Override for the remote; ``None`` builds the Firebase one.
|
||||||
|
paths: Where the pool, uuid and sync bookkeeping live.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The merged pool, as pushed.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
SyncUnavailableError: If no remote could be reached or authenticated.
|
||||||
|
"""
|
||||||
|
resolved = resolve_paths(paths)
|
||||||
|
target_client = client if client is not None else get_client()
|
||||||
|
node = actor_id(resolved)
|
||||||
|
local = load(resolved.log)
|
||||||
|
state_path = resolved.sync_state
|
||||||
|
try:
|
||||||
|
merged = sync_log(
|
||||||
|
SyncTarget(
|
||||||
|
client=target_client,
|
||||||
|
device_id=node,
|
||||||
|
path_prefix=SYNC_PATH_PREFIX,
|
||||||
|
),
|
||||||
|
local,
|
||||||
|
LogCodec(
|
||||||
|
encode=dump_log,
|
||||||
|
decode=load_log,
|
||||||
|
filename=SYNC_FILENAME,
|
||||||
|
commit_message="sickdays: update pool",
|
||||||
|
),
|
||||||
|
RevisionTracking(state_store=FileSyncStateStore(state_path)),
|
||||||
|
)
|
||||||
|
except RemoteSyncError as exc:
|
||||||
|
msg = f"sick-day sync could not reach the remote: {exc}"
|
||||||
|
raise SyncUnavailableError(msg) from exc
|
||||||
|
write_pool(resolved.log, merged)
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
def sync_quietly(*, paths: Paths | None = None) -> bool:
|
||||||
|
"""Sync, swallowing every failure. Returns whether it actually ran.
|
||||||
|
|
||||||
|
For callers on a timer that want a fresher pool but must not fail, and
|
||||||
|
must not say anything, when the sync cannot happen.
|
||||||
|
|
||||||
|
The broad catch is deliberate and is the whole point of this function.
|
||||||
|
Today ``FirebaseAuthError`` happens to subclass ``RemoteSyncError`` and
|
||||||
|
``requests``' ``JSONDecodeError`` happens to subclass
|
||||||
|
``RequestException``, so the narrow handler above would in fact catch an
|
||||||
|
expired refresh token or a malformed payload. But that is an upstream
|
||||||
|
class hierarchy, not a promise to this package -- and a background sync
|
||||||
|
whose contract is "never fail" must not have that contract hold only by
|
||||||
|
coincidence. Anything unexpected is logged with its traceback, so a real
|
||||||
|
bug is loud in the journal while the timer stays green.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
sync(paths=paths)
|
||||||
|
except SyncUnavailableError as exc:
|
||||||
|
# The expected case -- offline, or not configured yet. Debug level:
|
||||||
|
# this is normal on a laptop and must not fill the journal.
|
||||||
|
_logger.debug("sick-day sync skipped: %s", exc)
|
||||||
|
return False
|
||||||
|
except Exception: # pylint: disable=broad-exception-caught
|
||||||
|
_logger.exception("sick-day sync failed unexpectedly")
|
||||||
|
return False
|
||||||
|
return True
|
||||||
2
sickdays/sickdays/tests/__init__.py
Normal file
2
sickdays/sickdays/tests/__init__.py
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
|
"""Tests for :mod:`sickdays`."""
|
||||||
50
sickdays/sickdays/tests/conftest.py
Normal file
50
sickdays/sickdays/tests/conftest.py
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
|
"""Redirect every on-disk path, and the free-day pool, away from real state.
|
||||||
|
|
||||||
|
Every writer resolves its paths through :func:`sickdays._paths.resolve_paths`,
|
||||||
|
so redirecting that one function in each module that imports it moves all of
|
||||||
|
them, including any file added later.
|
||||||
|
|
||||||
|
The cross-pool check would otherwise import the real freedays and read the
|
||||||
|
real ``~/.local/share/freedays`` pool, so it is stubbed to "nothing is free"
|
||||||
|
for every test; tests of the check itself pass their own predicate.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from sickdays._paths import Paths
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_MODULES = ("sickdays._api", "sickdays._audit", "sickdays._conflict", "sickdays._sync")
|
||||||
|
|
||||||
|
|
||||||
|
def _never_free(_day: date) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _redirect_state(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||||
|
"""Point every default path at ``tmp_path`` before any test runs."""
|
||||||
|
state = tmp_path / "state"
|
||||||
|
state.mkdir()
|
||||||
|
redirected = Paths.under(state)
|
||||||
|
|
||||||
|
def _resolve(paths: Paths | None) -> Paths:
|
||||||
|
return paths if paths is not None else redirected
|
||||||
|
|
||||||
|
for module in _MODULES:
|
||||||
|
monkeypatch.setattr(f"{module}.resolve_paths", _resolve)
|
||||||
|
monkeypatch.setattr("sickdays._api.free_day_predicate", lambda: _never_free)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def state_dir(tmp_path: Path) -> Path:
|
||||||
|
"""The redirected state directory, for tests that assert on files."""
|
||||||
|
return tmp_path / "state"
|
||||||
146
sickdays/sickdays/tests/test_api.py
Normal file
146
sickdays/sickdays/tests/test_api.py
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
|
"""Adding, removing, listing and the one-time legacy import."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
import json
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from sickdays._api import (
|
||||||
|
actor_id,
|
||||||
|
add_sick_day,
|
||||||
|
import_legacy,
|
||||||
|
is_sick_day,
|
||||||
|
load,
|
||||||
|
remove_sick_day,
|
||||||
|
sick_days,
|
||||||
|
)
|
||||||
|
from sickdays._errors import (
|
||||||
|
AlreadySickError,
|
||||||
|
FreeDayConflictError,
|
||||||
|
NotSickError,
|
||||||
|
PastDateError,
|
||||||
|
)
|
||||||
|
from sickdays._paths import Paths
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
TODAY = date(2026, 10, 16)
|
||||||
|
TOMORROW = date(2026, 10, 17)
|
||||||
|
YESTERDAY = date(2026, 10, 15)
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_day_is_normal_by_default() -> None:
|
||||||
|
assert not is_sick_day(TODAY)
|
||||||
|
assert sick_days() == frozenset()
|
||||||
|
|
||||||
|
|
||||||
|
def test_adding_today_makes_it_a_sick_day() -> None:
|
||||||
|
entry = add_sick_day(TODAY, now=TODAY)
|
||||||
|
assert entry.is_sick
|
||||||
|
assert is_sick_day(TODAY)
|
||||||
|
assert is_sick_day(now=TODAY)
|
||||||
|
|
||||||
|
|
||||||
|
def test_there_is_no_cap() -> None:
|
||||||
|
for offset in range(60):
|
||||||
|
add_sick_day(date.fromordinal(TODAY.toordinal() + offset), now=TODAY)
|
||||||
|
assert len(sick_days()) == 60
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_past_day_cannot_be_added() -> None:
|
||||||
|
with pytest.raises(PastDateError, match="already passed"):
|
||||||
|
add_sick_day(YESTERDAY, now=TODAY)
|
||||||
|
assert not is_sick_day(YESTERDAY)
|
||||||
|
|
||||||
|
|
||||||
|
def test_adding_twice_is_refused() -> None:
|
||||||
|
add_sick_day(TOMORROW, now=TODAY)
|
||||||
|
with pytest.raises(AlreadySickError):
|
||||||
|
add_sick_day(TOMORROW, now=TODAY)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_free_day_cannot_become_a_sick_day() -> None:
|
||||||
|
with pytest.raises(FreeDayConflictError, match="free day"):
|
||||||
|
add_sick_day(TOMORROW, is_free=lambda _day: True, now=TODAY)
|
||||||
|
assert not is_sick_day(TOMORROW)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_default_check_asks_the_free_day_pool(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
asked: list[date] = []
|
||||||
|
|
||||||
|
def _free(day: date) -> bool:
|
||||||
|
asked.append(day)
|
||||||
|
return True
|
||||||
|
|
||||||
|
monkeypatch.setattr("sickdays._api.free_day_predicate", lambda: _free)
|
||||||
|
with pytest.raises(FreeDayConflictError):
|
||||||
|
add_sick_day(TOMORROW, now=TODAY)
|
||||||
|
assert asked == [TOMORROW]
|
||||||
|
|
||||||
|
|
||||||
|
def test_removing_makes_the_day_normal_again() -> None:
|
||||||
|
add_sick_day(TOMORROW, now=TODAY)
|
||||||
|
entry = remove_sick_day(TOMORROW, now=TODAY)
|
||||||
|
assert not entry.is_sick
|
||||||
|
assert not is_sick_day(TOMORROW)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_removed_day_can_be_added_again() -> None:
|
||||||
|
add_sick_day(TODAY, now=TODAY)
|
||||||
|
remove_sick_day(TODAY, now=TODAY)
|
||||||
|
add_sick_day(TODAY, now=TODAY)
|
||||||
|
assert is_sick_day(TODAY)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_past_day_cannot_be_removed() -> None:
|
||||||
|
import_legacy([YESTERDAY])
|
||||||
|
with pytest.raises(PastDateError, match="removed"):
|
||||||
|
remove_sick_day(YESTERDAY, now=TODAY)
|
||||||
|
assert is_sick_day(YESTERDAY)
|
||||||
|
|
||||||
|
|
||||||
|
def test_removing_a_normal_day_is_refused() -> None:
|
||||||
|
with pytest.raises(NotSickError):
|
||||||
|
remove_sick_day(TOMORROW, now=TODAY)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_legacy_import_writes_past_days() -> None:
|
||||||
|
days = [date(2026, 7, 24), date(2026, 6, 21)]
|
||||||
|
assert import_legacy(days) == sorted(days)
|
||||||
|
assert sick_days() == frozenset(days)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_legacy_import_is_idempotent(state_dir: Path) -> None:
|
||||||
|
import_legacy([date(2026, 6, 21)])
|
||||||
|
assert import_legacy([date(2026, 6, 21), date(2026, 6, 21)]) == []
|
||||||
|
lines = (state_dir / "audit.jsonl").read_text(encoding="utf-8").splitlines()
|
||||||
|
assert [json.loads(line)["action"] for line in lines] == ["import"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_change_is_audited(state_dir: Path) -> None:
|
||||||
|
add_sick_day(TODAY, now=TODAY)
|
||||||
|
remove_sick_day(TODAY, now=TODAY)
|
||||||
|
lines = (state_dir / "audit.jsonl").read_text(encoding="utf-8").splitlines()
|
||||||
|
assert [json.loads(line)["action"] for line in lines] == ["add", "remove"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_actor_is_this_installs_uuid() -> None:
|
||||||
|
entry = add_sick_day(TODAY, now=TODAY)
|
||||||
|
assert entry.actor == actor_id()
|
||||||
|
assert len(entry.actor) == 36
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_explicit_pool_is_honoured(tmp_path: Path) -> None:
|
||||||
|
paths = Paths.under(tmp_path / "elsewhere")
|
||||||
|
add_sick_day(TOMORROW, paths=paths, now=TODAY)
|
||||||
|
assert is_sick_day(TOMORROW, log_path=paths.log)
|
||||||
|
assert sick_days(log_path=paths.log) == frozenset({TOMORROW})
|
||||||
|
assert not is_sick_day(TOMORROW)
|
||||||
|
assert load(paths.log)
|
||||||
89
sickdays/sickdays/tests/test_audit.py
Normal file
89
sickdays/sickdays/tests/test_audit.py
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
|
"""The audit trail: best-effort, signed, and never in the way."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import replace
|
||||||
|
from datetime import date
|
||||||
|
import json
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from sickdays._audit import record_event
|
||||||
|
from sickdays._hmac import verify
|
||||||
|
from sickdays._paths import Paths
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
DAY = date(2026, 12, 24)
|
||||||
|
|
||||||
|
|
||||||
|
def _lines(path: Path) -> list[dict[str, object]]:
|
||||||
|
return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines()]
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_event_is_appended_with_its_details(state_dir: Path) -> None:
|
||||||
|
record_event("add", DAY, actor="device-1")
|
||||||
|
(entry,) = _lines(state_dir / "audit.jsonl")
|
||||||
|
assert entry["action"] == "add"
|
||||||
|
assert entry["day"] == "2026-12-24"
|
||||||
|
assert entry["actor"] == "device-1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_events_accumulate_rather_than_overwrite(state_dir: Path) -> None:
|
||||||
|
record_event("add", DAY, actor="device-1")
|
||||||
|
record_event("remove", DAY, actor="device-1")
|
||||||
|
assert len(_lines(state_dir / "audit.jsonl")) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_signing_key_is_generated_on_first_use(state_dir: Path) -> None:
|
||||||
|
key = state_dir / "hmac.key"
|
||||||
|
assert not key.exists()
|
||||||
|
record_event("add", DAY, actor="device-1")
|
||||||
|
assert key.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_written_entry_verifies_against_the_key(state_dir: Path) -> None:
|
||||||
|
record_event("add", DAY, actor="device-1")
|
||||||
|
(entry,) = _lines(state_dir / "audit.jsonl")
|
||||||
|
assert verify(entry, key_file=state_dir / "hmac.key")
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_tampered_entry_stops_verifying(state_dir: Path) -> None:
|
||||||
|
record_event("add", DAY, actor="device-1")
|
||||||
|
(entry,) = _lines(state_dir / "audit.jsonl")
|
||||||
|
entry["day"] = "2026-12-25"
|
||||||
|
assert not verify(entry, key_file=state_dir / "hmac.key")
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_existing_key_is_reused_rather_than_regenerated(state_dir: Path) -> None:
|
||||||
|
record_event("add", DAY, actor="device-1")
|
||||||
|
original = (state_dir / "hmac.key").read_bytes()
|
||||||
|
record_event("remove", DAY, actor="device-1")
|
||||||
|
assert (state_dir / "hmac.key").read_bytes() == original
|
||||||
|
|
||||||
|
|
||||||
|
def test_explicit_paths_are_honoured(tmp_path: Path) -> None:
|
||||||
|
elsewhere = tmp_path / "elsewhere"
|
||||||
|
record_event("add", DAY, actor="device-1", paths=Paths.under(elsewhere))
|
||||||
|
(entry,) = _lines(elsewhere / "audit.jsonl")
|
||||||
|
assert verify(entry, key_file=elsewhere / "hmac.key")
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_unwritable_trail_warns_but_never_raises(
|
||||||
|
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||||
|
) -> None:
|
||||||
|
"""A sick day must be addable even when its audit line cannot be written."""
|
||||||
|
blocked = tmp_path / "a-file" / "audit.jsonl"
|
||||||
|
blocked.parent.write_text("I am a file, not a directory", encoding="utf-8")
|
||||||
|
with caplog.at_level("WARNING"):
|
||||||
|
record_event(
|
||||||
|
"add",
|
||||||
|
DAY,
|
||||||
|
actor="device-1",
|
||||||
|
paths=replace(Paths.under(tmp_path), audit=blocked),
|
||||||
|
)
|
||||||
|
assert "could not append" in caplog.text
|
||||||
117
sickdays/sickdays/tests/test_cli.py
Normal file
117
sickdays/sickdays/tests/test_cli.py
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
|
"""The command line: add, remove, list, check, sync."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
import runpy
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from sickdays._cli import main
|
||||||
|
from sickdays._day import to_iso, today
|
||||||
|
from sickdays._sync import SyncUnavailableError
|
||||||
|
from sickdays.tests.test_sync import FakeRemote
|
||||||
|
|
||||||
|
|
||||||
|
def _future() -> str:
|
||||||
|
return to_iso(today() + timedelta(days=30))
|
||||||
|
|
||||||
|
|
||||||
|
def _refuse() -> None:
|
||||||
|
msg = "no credentials on this machine"
|
||||||
|
raise SyncUnavailableError(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_on_an_empty_pool(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
assert main(["list"]) == 0
|
||||||
|
assert "no sick days" in capsys.readouterr().out
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_defaults_to_today(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
assert main(["add"]) == 0
|
||||||
|
assert f"{to_iso(today())} is now a sick day" in capsys.readouterr().out
|
||||||
|
assert main(["check"]) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_marks_today(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
main(["add", "today"])
|
||||||
|
main(["add", _future()])
|
||||||
|
capsys.readouterr()
|
||||||
|
assert main(["list"]) == 0
|
||||||
|
out = capsys.readouterr().out.splitlines()
|
||||||
|
assert out == [f"{to_iso(today())} (today)", _future()]
|
||||||
|
|
||||||
|
|
||||||
|
def test_remove_makes_the_day_normal(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
main(["add", "tomorrow"])
|
||||||
|
assert main(["remove", "tomorrow"]) == 0
|
||||||
|
assert "normal day again" in capsys.readouterr().out
|
||||||
|
assert main(["check", "tomorrow"]) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_past_day_is_refused(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
yesterday = to_iso(today() - timedelta(days=1))
|
||||||
|
assert main(["add", yesterday]) == 1
|
||||||
|
assert "already passed" in capsys.readouterr().err
|
||||||
|
assert main(["remove", yesterday]) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_removing_a_normal_day_is_refused(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
assert main(["remove", _future()]) == 1
|
||||||
|
assert "not a sick day" in capsys.readouterr().err
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_free_day_is_refused(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr("sickdays._api.free_day_predicate", lambda: lambda _d: True)
|
||||||
|
assert main(["add", _future()]) == 1
|
||||||
|
assert "is a free day" in capsys.readouterr().err
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_unparsable_date_exits_two(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
assert main(["add", "next tuesday"]) == 2
|
||||||
|
assert "not a YYYY-MM-DD date" in capsys.readouterr().err
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_missing_subcommand_is_rejected() -> None:
|
||||||
|
with pytest.raises(SystemExit):
|
||||||
|
main([])
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_reports_success(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr("sickdays._sync.firebase_client_for", lambda _app: FakeRemote())
|
||||||
|
assert main(["sync"]) == 0
|
||||||
|
assert "pool synced" in capsys.readouterr().out
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_failure_exits_one(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr("sickdays._sync.get_client", _refuse)
|
||||||
|
assert main(["sync"]) == 1
|
||||||
|
assert "no credentials" in capsys.readouterr().err
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_quiet_exits_zero_either_way(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr("sickdays._sync.firebase_client_for", lambda _app: FakeRemote())
|
||||||
|
assert main(["sync-quiet"]) == 0
|
||||||
|
assert "pool synced" in capsys.readouterr().out
|
||||||
|
monkeypatch.setattr("sickdays._sync.get_client", _refuse)
|
||||||
|
assert main(["sync-quiet"]) == 0
|
||||||
|
assert "no remote available" in capsys.readouterr().out
|
||||||
|
|
||||||
|
|
||||||
|
def test_python_dash_m_runs_the_cli(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr("sys.argv", ["sickdays", "list"])
|
||||||
|
with pytest.raises(SystemExit) as exited:
|
||||||
|
runpy.run_module("sickdays", run_name="__main__")
|
||||||
|
assert exited.value.code == 0
|
||||||
|
assert "no sick days" in capsys.readouterr().out
|
||||||
65
sickdays/sickdays/tests/test_conflict.py
Normal file
65
sickdays/sickdays/tests/test_conflict.py
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
|
"""A date is never in both pools: the lazy free-day check and its mirror."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from sickdays._api import add_sick_day
|
||||||
|
from sickdays._conflict import ensure_not_free, ensure_not_sick, free_day_predicate
|
||||||
|
from sickdays._errors import (
|
||||||
|
ConflictCheckUnavailableError,
|
||||||
|
FreeDayConflictError,
|
||||||
|
SickDayConflictError,
|
||||||
|
)
|
||||||
|
from sickdays._paths import Paths
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
TODAY = date(2026, 10, 16)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_predicate_is_freedays_own(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
fake = types.ModuleType("freedays")
|
||||||
|
|
||||||
|
def _is_free_day(day: date) -> bool:
|
||||||
|
return day == TODAY
|
||||||
|
|
||||||
|
vars(fake)["is_free_day"] = _is_free_day
|
||||||
|
monkeypatch.setitem(sys.modules, "freedays", fake)
|
||||||
|
predicate = free_day_predicate()
|
||||||
|
assert predicate(TODAY)
|
||||||
|
assert not predicate(date(2026, 10, 17))
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_freedays_means_the_add_is_refused(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setitem(sys.modules, "freedays", None)
|
||||||
|
with pytest.raises(ConflictCheckUnavailableError, match="not installed"):
|
||||||
|
free_day_predicate()
|
||||||
|
|
||||||
|
|
||||||
|
def test_ensure_not_free_passes_a_normal_day() -> None:
|
||||||
|
ensure_not_free(TODAY, lambda _day: False)
|
||||||
|
with pytest.raises(FreeDayConflictError, match="freedays release"):
|
||||||
|
ensure_not_free(TODAY, lambda _day: True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_sick_day_cannot_be_marked_free() -> None:
|
||||||
|
ensure_not_sick(TODAY)
|
||||||
|
add_sick_day(TODAY, now=TODAY)
|
||||||
|
with pytest.raises(SickDayConflictError, match="sickdays remove"):
|
||||||
|
ensure_not_sick(TODAY)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ensure_not_sick_honours_explicit_paths(tmp_path: Path) -> None:
|
||||||
|
elsewhere = Paths.under(tmp_path / "elsewhere")
|
||||||
|
add_sick_day(TODAY, paths=elsewhere, now=TODAY)
|
||||||
|
ensure_not_sick(TODAY)
|
||||||
|
with pytest.raises(SickDayConflictError):
|
||||||
|
ensure_not_sick(TODAY, paths=elsewhere)
|
||||||
50
sickdays/sickdays/tests/test_day.py
Normal file
50
sickdays/sickdays/tests/test_day.py
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
|
"""Date handling: the one place allowed an opinion about "today"."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from sickdays._day import parse_iso, resolve, to_iso, today
|
||||||
|
|
||||||
|
|
||||||
|
def test_today_reads_the_clock_when_not_overridden() -> None:
|
||||||
|
assert isinstance(today(), date)
|
||||||
|
|
||||||
|
|
||||||
|
def test_today_returns_the_override_untouched() -> None:
|
||||||
|
fixed = date(2026, 3, 4)
|
||||||
|
assert today(fixed) == fixed
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_iso_round_trips() -> None:
|
||||||
|
assert to_iso(parse_iso("2026-12-24")) == "2026-12-24"
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_iso_tolerates_surrounding_whitespace() -> None:
|
||||||
|
assert parse_iso(" 2026-12-24 ") == date(2026, 12, 24)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("bad", ["24-12-2026", "2026-13-01", "tomorrow", ""])
|
||||||
|
def test_parse_iso_rejects_junk_and_names_it(bad: str) -> None:
|
||||||
|
with pytest.raises(ValueError, match="not a YYYY-MM-DD date"):
|
||||||
|
parse_iso(bad)
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_understands_today() -> None:
|
||||||
|
assert resolve("today", now=date(2026, 3, 4)) == date(2026, 3, 4)
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_understands_tomorrow_across_a_month_boundary() -> None:
|
||||||
|
assert resolve("TOMORROW", now=date(2026, 3, 31)) == date(2026, 4, 1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_falls_through_to_a_plain_date() -> None:
|
||||||
|
assert resolve("2026-12-24", now=date(2026, 3, 4)) == date(2026, 12, 24)
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_rejects_junk() -> None:
|
||||||
|
with pytest.raises(ValueError, match="not a YYYY-MM-DD date"):
|
||||||
|
resolve("next tuesday", now=date(2026, 3, 4))
|
||||||
97
sickdays/sickdays/tests/test_hmac.py
Normal file
97
sickdays/sickdays/tests/test_hmac.py
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
|
"""Signing, and the ways it is allowed to fail."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from sickdays._hmac import generate_key, load_key, sign, verify
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_ENTRY = {"action": "mark", "day": "2026-12-24", "actor": "device-1"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_generated_key_is_owner_only(tmp_path: Path) -> None:
|
||||||
|
"""A signing key the rest of the machine can read is not much of one."""
|
||||||
|
key_file = tmp_path / "hmac.key"
|
||||||
|
generate_key(key_file)
|
||||||
|
assert key_file.stat().st_mode & 0o077 == 0
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def test_two_generated_keys_differ(tmp_path: Path) -> None:
|
||||||
|
first = generate_key(tmp_path / "a.key")
|
||||||
|
second = generate_key(tmp_path / "b.key")
|
||||||
|
assert first != second
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_key_that_cannot_be_written_returns_none(tmp_path: Path) -> None:
|
||||||
|
blocked = tmp_path / "a-file" / "hmac.key"
|
||||||
|
blocked.parent.write_text("I am a file, not a directory", encoding="utf-8")
|
||||||
|
assert generate_key(blocked) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_missing_key_reads_as_none(tmp_path: Path) -> None:
|
||||||
|
assert load_key(tmp_path / "absent.key") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_signing_and_verifying_agree(tmp_path: Path) -> None:
|
||||||
|
key_file = tmp_path / "hmac.key"
|
||||||
|
generate_key(key_file)
|
||||||
|
signature = sign(_ENTRY, key_file=key_file)
|
||||||
|
assert signature is not None
|
||||||
|
assert verify({**_ENTRY, "hmac": signature}, key_file=key_file)
|
||||||
|
|
||||||
|
|
||||||
|
def test_signing_is_stable_across_key_order(tmp_path: Path) -> None:
|
||||||
|
"""Sorted-key JSON: the same fields sign the same however they arrive."""
|
||||||
|
key_file = tmp_path / "hmac.key"
|
||||||
|
generate_key(key_file)
|
||||||
|
reordered = dict(reversed(list(_ENTRY.items())))
|
||||||
|
assert sign(_ENTRY, key_file=key_file) == sign(reordered, key_file=key_file)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_tampered_field_fails_verification(tmp_path: Path) -> None:
|
||||||
|
key_file = tmp_path / "hmac.key"
|
||||||
|
generate_key(key_file)
|
||||||
|
signature = sign(_ENTRY, key_file=key_file)
|
||||||
|
tampered = {**_ENTRY, "day": "2026-12-25", "hmac": signature}
|
||||||
|
assert not verify(tampered, key_file=key_file)
|
||||||
|
|
||||||
|
|
||||||
|
def test_another_key_fails_verification(tmp_path: Path) -> None:
|
||||||
|
mine = tmp_path / "mine.key"
|
||||||
|
theirs = tmp_path / "theirs.key"
|
||||||
|
generate_key(mine)
|
||||||
|
generate_key(theirs)
|
||||||
|
signature = sign(_ENTRY, key_file=mine)
|
||||||
|
assert not verify({**_ENTRY, "hmac": signature}, key_file=theirs)
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_entry_without_a_signature_does_not_verify(tmp_path: Path) -> None:
|
||||||
|
key_file = tmp_path / "hmac.key"
|
||||||
|
generate_key(key_file)
|
||||||
|
assert not verify(dict(_ENTRY), key_file=key_file)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_non_string_signature_does_not_verify(tmp_path: Path) -> None:
|
||||||
|
key_file = tmp_path / "hmac.key"
|
||||||
|
generate_key(key_file)
|
||||||
|
assert not verify({**_ENTRY, "hmac": 17}, key_file=key_file)
|
||||||
|
|
||||||
|
|
||||||
|
def test_verification_without_a_key_is_a_no(tmp_path: Path) -> None:
|
||||||
|
""" "Cannot check" and "does not match" are the same answer to the caller."""
|
||||||
|
assert not verify({**_ENTRY, "hmac": "deadbeef"}, key_file=tmp_path / "absent.key")
|
||||||
|
|
||||||
|
|
||||||
|
def test_signing_without_a_key_returns_none(tmp_path: Path) -> None:
|
||||||
|
assert sign(_ENTRY, key_file=tmp_path / "absent.key") is None
|
||||||
70
sickdays/sickdays/tests/test_model.py
Normal file
70
sickdays/sickdays/tests/test_model.py
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
|
"""Records, and how two devices' copies of a date converge."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from crdt_sync import Hlc, Record, merge_logs
|
||||||
|
|
||||||
|
from sickdays._model import (
|
||||||
|
SickDay,
|
||||||
|
build_record,
|
||||||
|
is_sick_record,
|
||||||
|
latest_hlc,
|
||||||
|
sick_dates,
|
||||||
|
to_sick_day,
|
||||||
|
)
|
||||||
|
|
||||||
|
DAY = date(2026, 10, 20)
|
||||||
|
|
||||||
|
|
||||||
|
def _tick(node: str, wall_ms: int) -> Hlc:
|
||||||
|
return Hlc(wall_time_ms=wall_ms, counter=0, node_id=node)
|
||||||
|
|
||||||
|
|
||||||
|
def _record(*, sick: bool, node: str, wall_ms: int) -> Record:
|
||||||
|
return build_record(SickDay(DAY, is_sick=sick, actor=node), _tick(node, wall_ms))
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_record_round_trips() -> None:
|
||||||
|
record = _record(sick=True, node="pc", wall_ms=1)
|
||||||
|
assert to_sick_day(record) == SickDay(DAY, is_sick=True, actor="pc")
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_later_removal_on_another_device_wins_everywhere() -> None:
|
||||||
|
added = {"2026-10-20": _record(sick=True, node="pc", wall_ms=1)}
|
||||||
|
removed = {"2026-10-20": _record(sick=False, node="phone", wall_ms=2)}
|
||||||
|
assert sick_dates(merge_logs(added, removed)) == frozenset()
|
||||||
|
assert sick_dates(merge_logs(removed, added)) == frozenset()
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_later_re_add_wins_over_an_earlier_removal() -> None:
|
||||||
|
removed = {"2026-10-20": _record(sick=False, node="pc", wall_ms=2)}
|
||||||
|
added_again = {"2026-10-20": _record(sick=True, node="phone", wall_ms=3)}
|
||||||
|
assert sick_dates(merge_logs(removed, added_again)) == {DAY}
|
||||||
|
assert sick_dates(merge_logs(added_again, removed)) == {DAY}
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_tombstone_never_reads_as_sick() -> None:
|
||||||
|
record = _record(sick=True, node="pc", wall_ms=1)
|
||||||
|
tombstoned = Record(id=record.id, fields=record.fields, deleted=True)
|
||||||
|
assert not is_sick_record(tombstoned)
|
||||||
|
|
||||||
|
|
||||||
|
def test_junk_ids_and_odd_values_are_tolerated() -> None:
|
||||||
|
junk = Record(id="not-a-date", fields={"state": ("sick", _tick("pc", 1))})
|
||||||
|
assert to_sick_day(junk) is None
|
||||||
|
odd = Record(id="2026-10-20", fields={"state": (7, _tick("pc", 1))})
|
||||||
|
assert not is_sick_record(odd)
|
||||||
|
assert to_sick_day(odd) == SickDay(DAY, is_sick=False, actor="")
|
||||||
|
assert sick_dates({"x": junk, "y": odd}) == frozenset()
|
||||||
|
|
||||||
|
|
||||||
|
def test_latest_hlc_is_per_node() -> None:
|
||||||
|
log = {
|
||||||
|
"a": _record(sick=True, node="pc", wall_ms=5),
|
||||||
|
"b": _record(sick=True, node="phone", wall_ms=9),
|
||||||
|
}
|
||||||
|
assert latest_hlc(log, "pc") == _tick("pc", 5)
|
||||||
|
assert latest_hlc(log, "tablet") is None
|
||||||
60
sickdays/sickdays/tests/test_paths.py
Normal file
60
sickdays/sickdays/tests/test_paths.py
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
|
"""The path bundle itself, exercised unpatched.
|
||||||
|
|
||||||
|
The autouse redirect in ``conftest`` replaces ``resolve_paths`` *as imported
|
||||||
|
by* ``_api``/``_audit``/``_sync``. It does not touch ``sickdays._paths``, so
|
||||||
|
the functions below are the real ones -- which is the point: the seam every
|
||||||
|
other test relies on has to be tested somewhere, and that somewhere cannot
|
||||||
|
be behind the seam.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from sickdays._constants import (
|
||||||
|
AUDIT_PATH,
|
||||||
|
DEVICE_ID_PATH,
|
||||||
|
HMAC_KEY_FILE,
|
||||||
|
LOG_PATH,
|
||||||
|
STATE_DIR,
|
||||||
|
SYNC_STATE_PATH,
|
||||||
|
)
|
||||||
|
from sickdays._paths import Paths, resolve_paths
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_defaults_are_the_real_per_user_locations() -> None:
|
||||||
|
defaults = Paths.default()
|
||||||
|
assert defaults.log == LOG_PATH
|
||||||
|
assert defaults.device_id == DEVICE_ID_PATH
|
||||||
|
assert defaults.audit == AUDIT_PATH
|
||||||
|
assert defaults.hmac_key == HMAC_KEY_FILE
|
||||||
|
assert defaults.sync_state == SYNC_STATE_PATH
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_default_lives_under_one_state_directory() -> None:
|
||||||
|
"""Nothing may escape ``~/.local/share/sickdays``."""
|
||||||
|
for path in vars(Paths.default()).values():
|
||||||
|
assert isinstance(path, Path)
|
||||||
|
assert path.parent == STATE_DIR
|
||||||
|
|
||||||
|
|
||||||
|
def test_under_relocates_every_single_file(tmp_path: Path) -> None:
|
||||||
|
"""The property the test redirect depends on: no file is left behind."""
|
||||||
|
relocated = Paths.under(tmp_path)
|
||||||
|
for path in vars(relocated).values():
|
||||||
|
assert path.parent == tmp_path
|
||||||
|
|
||||||
|
|
||||||
|
def test_under_gives_each_file_a_distinct_name(tmp_path: Path) -> None:
|
||||||
|
names = [path.name for path in vars(Paths.under(tmp_path)).values()]
|
||||||
|
assert len(names) == len(set(names))
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_returns_the_bundle_it_was_given(tmp_path: Path) -> None:
|
||||||
|
explicit = Paths.under(tmp_path)
|
||||||
|
assert resolve_paths(explicit) is explicit
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_falls_back_to_the_defaults() -> None:
|
||||||
|
assert resolve_paths(None) == Paths.default()
|
||||||
76
sickdays/sickdays/tests/test_store.py
Normal file
76
sickdays/sickdays/tests/test_store.py
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
|
"""An unusable pool fails closed for readers, and refuses writers, loudly."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from sickdays._api import add_sick_day, import_legacy, is_sick_day, remove_sick_day
|
||||||
|
from sickdays._errors import UnreadablePoolError
|
||||||
|
from sickdays._store import read_pool, read_pool_strict
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
TODAY = date(2026, 10, 16)
|
||||||
|
|
||||||
|
CORRUPT_PAYLOADS = (
|
||||||
|
"{not json",
|
||||||
|
"[1, 2, 3]",
|
||||||
|
'{"2026-10-16": {"fields": 5}}',
|
||||||
|
'{"2026-10-16": {"id": "2026-10-16", "fields": {}}}',
|
||||||
|
(
|
||||||
|
'{"2026-10-16": {"id": "x", "fields": {"state": ["sick", "nope"]}, '
|
||||||
|
'"deleted": false}}'
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("payload", CORRUPT_PAYLOADS)
|
||||||
|
def test_a_corrupt_pool_reads_as_not_sick_and_is_logged(
|
||||||
|
payload: str, state_dir: Path, caplog: pytest.LogCaptureFixture
|
||||||
|
) -> None:
|
||||||
|
(state_dir / "sick_days.json").write_text(payload, encoding="utf-8")
|
||||||
|
with caplog.at_level("WARNING"):
|
||||||
|
assert not is_sick_day(TODAY)
|
||||||
|
assert "NOT a sick day" in caplog.text
|
||||||
|
assert "corrupt" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_unreadable_pool_reads_as_not_sick_and_is_logged(
|
||||||
|
state_dir: Path, caplog: pytest.LogCaptureFixture
|
||||||
|
) -> None:
|
||||||
|
(state_dir / "sick_days.json").mkdir()
|
||||||
|
with caplog.at_level("WARNING"):
|
||||||
|
assert not is_sick_day(TODAY)
|
||||||
|
assert "cannot be read" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_missing_pool_is_silent(
|
||||||
|
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||||
|
) -> None:
|
||||||
|
with caplog.at_level("DEBUG"):
|
||||||
|
assert read_pool(tmp_path / "absent.json") == {}
|
||||||
|
assert not caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_strict_reading_raises_instead(tmp_path: Path) -> None:
|
||||||
|
target = tmp_path / "pool.json"
|
||||||
|
target.write_text("{not json", encoding="utf-8")
|
||||||
|
with pytest.raises(UnreadablePoolError, match="corrupt"):
|
||||||
|
read_pool_strict(target)
|
||||||
|
|
||||||
|
|
||||||
|
def test_writers_refuse_rather_than_overwrite_a_corrupt_pool(state_dir: Path) -> None:
|
||||||
|
pool = state_dir / "sick_days.json"
|
||||||
|
pool.write_text("{not json", encoding="utf-8")
|
||||||
|
with pytest.raises(UnreadablePoolError):
|
||||||
|
add_sick_day(TODAY, now=TODAY)
|
||||||
|
with pytest.raises(UnreadablePoolError):
|
||||||
|
remove_sick_day(TODAY, now=TODAY)
|
||||||
|
with pytest.raises(UnreadablePoolError):
|
||||||
|
import_legacy([TODAY])
|
||||||
|
assert pool.read_text(encoding="utf-8") == "{not json"
|
||||||
214
sickdays/sickdays/tests/test_sync.py
Normal file
214
sickdays/sickdays/tests/test_sync.py
Normal file
@ -0,0 +1,214 @@
|
|||||||
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
|
"""Sync: convergence across devices, and silence when the network is not there."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from crdt_sync import ConfigError, FirebaseAuthError, RemoteSyncError, dump_log
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from sickdays._api import add_sick_day, is_sick_day, load, remove_sick_day
|
||||||
|
from sickdays._paths import Paths
|
||||||
|
from sickdays._store import write_pool
|
||||||
|
from sickdays._sync import SyncUnavailableError, get_client, sync, sync_quietly
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
TODAY = date(2026, 10, 16)
|
||||||
|
LATER = date(2026, 12, 24)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeRemote:
|
||||||
|
"""An in-memory RemoteStore: a dict with the four methods sync calls."""
|
||||||
|
|
||||||
|
def __init__(self, files: dict[str, str] | None = None) -> None:
|
||||||
|
self.files: dict[str, str] = dict(files or {})
|
||||||
|
|
||||||
|
def list_directory(self, path: str) -> list[str]:
|
||||||
|
prefix = f"{path}/"
|
||||||
|
return sorted(
|
||||||
|
{
|
||||||
|
name[len(prefix) :].split("/", 1)[0]
|
||||||
|
for name in self.files
|
||||||
|
if name.startswith(prefix)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_file_text(self, path: str) -> str | None:
|
||||||
|
return self.files.get(path)
|
||||||
|
|
||||||
|
def put_file_text(self, path: str, text: str, *, message: str) -> None:
|
||||||
|
del message
|
||||||
|
self.files[path] = text
|
||||||
|
|
||||||
|
def delete_file(self, path: str, *, message: str = "") -> None:
|
||||||
|
del message
|
||||||
|
self.files.pop(path, None)
|
||||||
|
|
||||||
|
def can_access_remote(self) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class BrokenRemote(FakeRemote):
|
||||||
|
"""A remote that fails the way an offline one does."""
|
||||||
|
|
||||||
|
def list_directory(self, path: str) -> list[str]:
|
||||||
|
msg = f"cannot reach {path}"
|
||||||
|
raise RemoteSyncError(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_local_day_is_pushed_to_the_remote() -> None:
|
||||||
|
add_sick_day(LATER, now=TODAY)
|
||||||
|
remote = FakeRemote()
|
||||||
|
sync(client=remote)
|
||||||
|
assert any("sickdays-sync/devices/" in path for path in remote.files)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_peers_day_arrives_and_becomes_sick_locally(state_dir: Path) -> None:
|
||||||
|
peer_log = {}
|
||||||
|
# Build a peer's pushed log by marking into a separate pool file.
|
||||||
|
add_sick_day(LATER, now=TODAY)
|
||||||
|
peer_log = load()
|
||||||
|
remote = FakeRemote(
|
||||||
|
{"sickdays-sync/devices/other-device/sick_days.json": dump_log(peer_log)}
|
||||||
|
)
|
||||||
|
# Start over with an empty local pool, then pull.
|
||||||
|
write_pool(Paths.under(state_dir).log, {})
|
||||||
|
assert not is_sick_day(LATER)
|
||||||
|
sync(client=remote)
|
||||||
|
assert is_sick_day(LATER)
|
||||||
|
|
||||||
|
|
||||||
|
def test_syncing_twice_changes_nothing() -> None:
|
||||||
|
add_sick_day(LATER, now=TODAY)
|
||||||
|
remote = FakeRemote()
|
||||||
|
first = sync(client=remote)
|
||||||
|
second = sync(client=remote)
|
||||||
|
assert first == second
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_corrupt_peer_push_is_skipped_rather_than_fatal() -> None:
|
||||||
|
add_sick_day(LATER, now=TODAY)
|
||||||
|
remote = FakeRemote(
|
||||||
|
{"sickdays-sync/devices/broken/sick_days.json": "{not json at all"}
|
||||||
|
)
|
||||||
|
sync(client=remote)
|
||||||
|
assert is_sick_day(LATER)
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_unreachable_remote_raises_sync_unavailable() -> None:
|
||||||
|
with pytest.raises(SyncUnavailableError, match="could not reach"):
|
||||||
|
sync(client=BrokenRemote())
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_quietly_reports_failure_without_raising(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
||||||
|
) -> None:
|
||||||
|
def _refuse() -> None:
|
||||||
|
msg = "not configured"
|
||||||
|
raise SyncUnavailableError(msg)
|
||||||
|
|
||||||
|
monkeypatch.setattr("sickdays._sync.get_client", _refuse)
|
||||||
|
with caplog.at_level("DEBUG"):
|
||||||
|
assert sync_quietly() is False
|
||||||
|
assert "sync skipped" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_quietly_reports_success(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setattr("sickdays._sync.get_client", FakeRemote)
|
||||||
|
assert sync_quietly() is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_unconfigured_machine_cannot_sync(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
def _unconfigured(_app: str) -> None:
|
||||||
|
msg = "no config file"
|
||||||
|
raise ConfigError(msg)
|
||||||
|
|
||||||
|
monkeypatch.setattr("sickdays._sync.firebase_client_for", _unconfigured)
|
||||||
|
with pytest.raises(SyncUnavailableError, match="not configured"):
|
||||||
|
get_client()
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_rejected_credential_cannot_sync(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
def _rejected(_app: str) -> None:
|
||||||
|
msg = "bad password"
|
||||||
|
raise FirebaseAuthError(msg)
|
||||||
|
|
||||||
|
monkeypatch.setattr("sickdays._sync.firebase_client_for", _rejected)
|
||||||
|
with pytest.raises(SyncUnavailableError, match="rejected"):
|
||||||
|
get_client()
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_configured_client_is_used_when_none_is_passed(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
remote = FakeRemote()
|
||||||
|
monkeypatch.setattr("sickdays._sync.firebase_client_for", lambda _app: remote)
|
||||||
|
add_sick_day(LATER, now=TODAY)
|
||||||
|
sync()
|
||||||
|
assert remote.files
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_pool_can_be_pointed_elsewhere_wholesale(tmp_path: Path) -> None:
|
||||||
|
elsewhere = tmp_path / "other"
|
||||||
|
elsewhere.mkdir()
|
||||||
|
paths = Paths.under(elsewhere)
|
||||||
|
add_sick_day(LATER, now=TODAY, paths=paths)
|
||||||
|
sync(client=FakeRemote(), paths=paths)
|
||||||
|
assert is_sick_day(LATER, log_path=paths.log)
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_expired_credential_mid_sync_does_not_fail_the_timer(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""A refresh token expiring during the tick, not at client construction."""
|
||||||
|
|
||||||
|
class ExpiringRemote(FakeRemote):
|
||||||
|
def list_directory(self, path: str) -> list[str]:
|
||||||
|
msg = f"refresh token rejected while listing {path}"
|
||||||
|
raise FirebaseAuthError(msg)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"sickdays._sync.firebase_client_for", lambda _app: ExpiringRemote()
|
||||||
|
)
|
||||||
|
assert sync_quietly() is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_unexpected_error_is_logged_with_a_traceback_and_swallowed(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
||||||
|
) -> None:
|
||||||
|
"""The contract is "never fail", and it must not hold only by coincidence.
|
||||||
|
|
||||||
|
FirebaseAuthError happens to subclass RemoteSyncError today. Something
|
||||||
|
that does not -- a bug, a changed upstream hierarchy -- must still leave
|
||||||
|
the timer green rather than turning it red every 15 minutes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
class BrokenRemote(FakeRemote):
|
||||||
|
def list_directory(self, path: str) -> list[str]:
|
||||||
|
msg = f"something nobody predicted, at {path}"
|
||||||
|
raise RuntimeError(msg)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"sickdays._sync.firebase_client_for", lambda _app: BrokenRemote()
|
||||||
|
)
|
||||||
|
with caplog.at_level("ERROR"):
|
||||||
|
assert sync_quietly() is False
|
||||||
|
assert "failed unexpectedly" in caplog.text
|
||||||
|
assert "RuntimeError" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_removal_on_one_device_reaches_the_other() -> None:
|
||||||
|
"""The point of the state flip: un-marking survives a merge."""
|
||||||
|
remote = FakeRemote()
|
||||||
|
add_sick_day(LATER, now=TODAY)
|
||||||
|
sync(client=remote)
|
||||||
|
pushed = next(iter(remote.files))
|
||||||
|
# The peer pushed the same log; then this device removes the day.
|
||||||
|
remote.files["sickdays-sync/devices/phone/sick_days.json"] = remote.files[pushed]
|
||||||
|
remove_sick_day(LATER, now=TODAY)
|
||||||
|
sync(client=remote)
|
||||||
|
assert not is_sick_day(LATER)
|
||||||
19
sickdays/systemd/sickdays-sync.service
Normal file
19
sickdays/systemd/sickdays-sync.service
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Pull and push the shared sick-day pool
|
||||||
|
Documentation=file:///home/kuhy/src/utils/sickdays/README.md
|
||||||
|
# Without a network this exits 0 having done nothing, by design -- the gates
|
||||||
|
# read the last-merged local file and a stale pool is never a reason to fail.
|
||||||
|
After=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
# `sync-quiet` rather than `sync`: this unit must never report failure just
|
||||||
|
# because the machine is offline or the credential has not been set up yet.
|
||||||
|
# A real bug still shows up in the journal via the library's own logging.
|
||||||
|
ExecStart=%h/.local/share/sickdays/venv/bin/sickdays sync-quiet
|
||||||
|
|
||||||
|
# A hung network call must not leave a unit running until the next tick.
|
||||||
|
TimeoutStartSec=120
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
19
sickdays/systemd/sickdays-sync.timer
Normal file
19
sickdays/systemd/sickdays-sync.timer
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Sync the shared sick-day pool periodically
|
||||||
|
Documentation=file:///home/kuhy/src/utils/sickdays/README.md
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
# Every 15 minutes. A sick day marked on the phone should reach the PC well
|
||||||
|
# before the next gate fires, and the gates that run most often (wake-alarm
|
||||||
|
# every minute, the lockers every 5) read the *local* file, so this interval
|
||||||
|
# bounds staleness rather than gating anything.
|
||||||
|
OnBootSec=2min
|
||||||
|
OnUnitActiveSec=15min
|
||||||
|
|
||||||
|
# A laptop that was asleep at the scheduled tick should sync on waking, not
|
||||||
|
# silently wait another 15 minutes -- the overnight case is exactly when a
|
||||||
|
# day gets marked from bed and needs to be there by morning.
|
||||||
|
Persistent=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
Loading…
Reference in New Issue
Block a user