mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 19:07:17 +01:00
sickdays 0.2.0: per-locker keep set on a sick day
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
sickdays pre-commit / pre-commit (push) Waiting to run
sickdays tests / test (push) Waiting to run
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
sickdays pre-commit / pre-commit (push) Waiting to run
sickdays tests / test (push) Waiting to run
A sick day may keep some lockers active: a locker stands down iff the day is sick and the locker is not in that day's keep set. New record field `keep` = [sorted, de-duplicated locker ids, hlc]. - add/remove write state, actor and keep on one tick (keep=[] on a clear), so a stale keep cannot survive a clear + re-add. - set_kept_lockers re-stamps only keep and actor; state keeps its clock, so a keep edit never re-asserts sick over a concurrent clear. Ticks are issued after every clock already on the record (clock-skew safe). - Reading: no keep (0.1.0) or keep older than state -> keep nothing; malformed keep -> every locker kept (fail closed, logged); unknown ids ignored. - API: stands_down, kept_lockers, set_kept_lockers, add_sick_day(keep=), SickDay.keep, LOCKER_* / LOCKERS. is_sick_day unchanged. - CLI: add --keep, keep DAY [LOCKER ...], list shows keep, check --locker. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PAhbNszs5JKdtnD4uBi3xp
This commit is contained in:
parent
6f76ba7228
commit
0a51c9998d
@ -24,20 +24,48 @@ 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.
|
||||
|
||||
### Keeping some lockers on a sick day (0.2.0)
|
||||
|
||||
A sick day may **keep** some lockers active. A locker *stands down* on a day
|
||||
iff the day is sick **and** the locker is not in that day's keep set. A gate
|
||||
that is one of the lockers asks the narrower question:
|
||||
|
||||
```python
|
||||
if sickdays.stands_down(sickdays.LOCKER_LEETCODE): # default: today
|
||||
... # this locker is off today
|
||||
```
|
||||
|
||||
Locker ids (`LOCKER_*` constants): `workout`, `leetcode`, `book`, `diet`,
|
||||
`alarm`. `alarm` is one switch for the phone ring, the PC fallback ring, the
|
||||
RTC self-wake and the missed-alarm penalty.
|
||||
|
||||
`LOCKERS` is all five, in that order. `stands_down` never raises for a data
|
||||
problem (only `ValueError` for an id not in `LOCKERS`). Every doubt answers
|
||||
"the locks apply": an unreadable pool is not sick, a malformed keep set keeps
|
||||
every locker. `is_sick_day` is unchanged, so a gate still on the 0.1.0 API
|
||||
keeps working and treats every sick day as "everything stands down".
|
||||
|
||||
## For a person
|
||||
|
||||
```console
|
||||
$ sickdays add # today
|
||||
$ sickdays add 2026-10-20 # or 'tomorrow'
|
||||
$ sickdays add tomorrow --keep leetcode --keep workout # those two stay on
|
||||
$ sickdays keep 2026-10-20 diet # replace the kept set of a sick day
|
||||
$ sickdays keep 2026-10-20 # keep nothing: every locker stands down again
|
||||
$ sickdays remove 2026-10-20 # today or later only
|
||||
$ sickdays list # every sick day, oldest first
|
||||
2026-10-16 (today)
|
||||
2026-10-20 keep: diet
|
||||
$ sickdays check tomorrow # exit 0 if sick, 1 if not -- for scripts
|
||||
$ sickdays check --locker alarm # exit 0 iff that locker stands down today
|
||||
$ 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.
|
||||
`2` input not understood (a bad date, or a locker id not in `LOCKERS` --
|
||||
`--keep`, `keep` and `--locker` only accept the five ids).
|
||||
|
||||
## API
|
||||
|
||||
@ -45,12 +73,19 @@ refused (past day, already sick, not sick, a free day, sync unavailable),
|
||||
|---|---|
|
||||
| `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 |
|
||||
| `stands_down(locker, day=None, *, log_path=None, now=None) -> bool` | the per-locker gate question; never raises for data, `ValueError` for an unknown id |
|
||||
| `kept_lockers(day=None, *, log_path=None, now=None) -> frozenset[str]` | the day's kept lockers; empty when not sick |
|
||||
| `add_sick_day(day, *, keep=(), is_free=None, paths=None, now=None) -> SickDay` | today or later; refuses a free day; `ValueError` on an unknown locker |
|
||||
| `set_kept_lockers(day, keep, *, paths=None, now=None) -> SickDay` | replace an existing sick day's kept set; today or later |
|
||||
| `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 |
|
||||
|
||||
`SickDay` is `(day, is_sick, actor="", keep=frozenset())`. The locker ids are
|
||||
`LOCKER_WORKOUT`, `LOCKER_LEETCODE`, `LOCKER_BOOK`, `LOCKER_DIET`,
|
||||
`LOCKER_ALARM` and the tuple `LOCKERS`.
|
||||
|
||||
Refusals subclass `SickDayError`: `PastDateError`, `AlreadySickError`,
|
||||
`NotSickError`, `FreeDayConflictError`, `SickDayConflictError`,
|
||||
`ConflictCheckUnavailableError`, `UnreadablePoolError`. Writers refuse an
|
||||
@ -81,7 +116,7 @@ import cycle:
|
||||
|---|---|
|
||||
| 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` |
|
||||
| audit trail (HMAC-signed, append-only; actions `add`, `remove`, `keep`, `import`) | `~/.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.
|
||||
@ -110,7 +145,8 @@ The log is a JSON object keyed by ISO date. Each value is a crdt-sync record:
|
||||
"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>"]
|
||||
"actor": ["<uuid>", "2026-10-16T08:12:03.517Z-0000-<uuid>"],
|
||||
"keep": [["leetcode", "workout"], "2026-10-16T08:12:03.517Z-0000-<uuid>"]
|
||||
},
|
||||
"deleted": false,
|
||||
"deleted_hlc": null
|
||||
@ -134,11 +170,48 @@ The log is a JSON object keyed by ISO date. Each value is a crdt-sync record:
|
||||
crdt-sync OR-merges tombstones, so a deleted date could never be marked
|
||||
sick again. A tombstoned record reads as "not sick".
|
||||
|
||||
#### `keep` (0.2.0)
|
||||
|
||||
`keep` is `[<list of locker-id strings>, <hlc>]`. The list is **sorted**
|
||||
(plain string order) and **de-duplicated**, always: the rev is a SHA-256 of
|
||||
the exact JSON string, so two devices holding the same set must write the
|
||||
same bytes. Ids: `workout`, `leetcode`, `book`, `diet`, `alarm`.
|
||||
|
||||
Writing:
|
||||
|
||||
- **add** (mark sick) writes `state: "sick"`, `actor` and `keep` (default
|
||||
`[]`), all three with the **same** HLC tick, always. So a stale `keep` from
|
||||
another device cannot survive a clear and a re-add.
|
||||
- **remove** writes `state: "cleared"`, `actor` and `keep: []`, same tick.
|
||||
- **change kept lockers** on an existing sick day **does not touch `state`**:
|
||||
copy the record, stamp only `keep` and `actor` with one fresh tick, and
|
||||
leave `state` (and its HLC) as it was. Re-stamping `state` would make a
|
||||
keep edit re-assert "sick" over a concurrent clear. Only today or later,
|
||||
only on a day that is currently sick.
|
||||
- Every new tick is issued after both this device's last tick and the
|
||||
greatest HLC already on that day's record, so an edit never comes out
|
||||
older than the `state` it was made against.
|
||||
|
||||
Reading (a locker *stands down* iff the day is sick and the locker is not in
|
||||
the keep set), checked in this order:
|
||||
|
||||
1. Day not sick (no record, tombstone, state not `"sick"`, unreadable pool)
|
||||
-> nothing stands down; `keep` is not looked at.
|
||||
2. No `keep` field (every 0.1.0 record) -> keep nothing: every locker stands
|
||||
down.
|
||||
3. `keep`'s HLC is **older** than `state`'s HLC -> stale (an old 0.1.0
|
||||
client re-added the day) -> keep nothing. Equal HLCs are not stale.
|
||||
4. `keep`'s value is not a list, or holds a non-string element -> malformed
|
||||
-> fail closed: **every** locker is kept (nothing stands down), and a
|
||||
warning is logged. (A stale malformed value is just stale: rule 3 wins.)
|
||||
5. Otherwise the kept set is the list, with unknown string ids **ignored**
|
||||
(forward compatibility), not an error.
|
||||
|
||||
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.
|
||||
`consumed` or `reason` field. The phone must follow the `keep` rules above.
|
||||
|
||||
## Install
|
||||
|
||||
|
||||
@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "sickdays"
|
||||
version = "0.1.0"
|
||||
version = "0.2.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
|
||||
|
||||
@ -12,6 +12,12 @@ 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".
|
||||
|
||||
Since 0.2.0 a sick day may keep some lockers active, so a gate that is one
|
||||
of :data:`LOCKERS` asks the narrower question instead::
|
||||
|
||||
if sickdays.stands_down(sickdays.LOCKER_LEETCODE):
|
||||
... # this locker is off today
|
||||
|
||||
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
|
||||
@ -35,6 +41,14 @@ from sickdays._conflict import (
|
||||
ensure_not_sick,
|
||||
free_day_predicate,
|
||||
)
|
||||
from sickdays._constants import (
|
||||
LOCKER_ALARM,
|
||||
LOCKER_BOOK,
|
||||
LOCKER_DIET,
|
||||
LOCKER_LEETCODE,
|
||||
LOCKER_WORKOUT,
|
||||
LOCKERS,
|
||||
)
|
||||
from sickdays._day import parse_iso, resolve, to_iso, today
|
||||
from sickdays._errors import (
|
||||
AlreadySickError,
|
||||
@ -46,11 +60,18 @@ from sickdays._errors import (
|
||||
SickDayError,
|
||||
UnreadablePoolError,
|
||||
)
|
||||
from sickdays._keep_api import kept_lockers, set_kept_lockers, stands_down
|
||||
from sickdays._model import SickDay
|
||||
from sickdays._paths import Paths
|
||||
from sickdays._sync import SyncUnavailableError, sync, sync_quietly
|
||||
|
||||
__all__ = [
|
||||
"LOCKERS",
|
||||
"LOCKER_ALARM",
|
||||
"LOCKER_BOOK",
|
||||
"LOCKER_DIET",
|
||||
"LOCKER_LEETCODE",
|
||||
"LOCKER_WORKOUT",
|
||||
"AlreadySickError",
|
||||
"ConflictCheckUnavailableError",
|
||||
"FreeDayConflictError",
|
||||
@ -70,11 +91,14 @@ __all__ = [
|
||||
"free_day_predicate",
|
||||
"import_legacy",
|
||||
"is_sick_day",
|
||||
"kept_lockers",
|
||||
"load",
|
||||
"parse_iso",
|
||||
"remove_sick_day",
|
||||
"resolve",
|
||||
"set_kept_lockers",
|
||||
"sick_days",
|
||||
"stands_down",
|
||||
"sync",
|
||||
"sync_quietly",
|
||||
"to_iso",
|
||||
|
||||
@ -15,21 +15,17 @@ from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from crdt_sync import Hlc, load_device_identity
|
||||
from crdt_sync import 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._errors import AlreadySickError, NotSickError
|
||||
from sickdays._keep import normalise_keep
|
||||
from sickdays._model import SickDay, is_sick_record, sick_dates
|
||||
from sickdays._paths import Paths, resolve_paths
|
||||
from sickdays._store import read_pool, read_pool_strict, write_pool
|
||||
from sickdays._store import read_pool, read_pool_strict
|
||||
from sickdays._writes import refuse_past, write_entry
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterable
|
||||
@ -68,25 +64,10 @@ def sick_days(*, log_path: Path | None = None) -> frozenset[date]:
|
||||
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,
|
||||
*,
|
||||
keep: Iterable[str] = (),
|
||||
is_free: FreePredicate | None = None,
|
||||
paths: Paths | None = None,
|
||||
now: date | None = None,
|
||||
@ -95,6 +76,8 @@ def add_sick_day(
|
||||
|
||||
Args:
|
||||
day: The day. Today or later.
|
||||
keep: Lockers that stay active on the day (:data:`sickdays.LOCKERS`);
|
||||
every other locker stands down. Default: none -- all stand down.
|
||||
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.
|
||||
@ -104,23 +87,25 @@ def add_sick_day(
|
||||
The stored entry.
|
||||
|
||||
Raises:
|
||||
ValueError: ``keep`` names an unknown locker.
|
||||
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.
|
||||
"""
|
||||
kept = normalise_keep(keep)
|
||||
resolved = resolve_paths(paths)
|
||||
_refuse_past(day, today(now), "added")
|
||||
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)
|
||||
entry = SickDay(day=day, is_sick=True, actor=actor_id(resolved), keep=kept)
|
||||
write_entry(log, entry, resolved)
|
||||
record_event("add", day, actor=entry.actor, keep=kept, paths=resolved)
|
||||
return entry
|
||||
|
||||
|
||||
@ -138,14 +123,14 @@ def remove_sick_day(
|
||||
UnreadablePoolError: The local pool exists but cannot be parsed.
|
||||
"""
|
||||
resolved = resolve_paths(paths)
|
||||
_refuse_past(day, today(now), "removed")
|
||||
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)
|
||||
write_entry(log, entry, resolved)
|
||||
record_event("remove", day, actor=entry.actor, paths=resolved)
|
||||
return entry
|
||||
|
||||
@ -172,7 +157,7 @@ def import_legacy(days: Iterable[date], *, paths: Paths | None = None) -> list[d
|
||||
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)
|
||||
write_entry(log, SickDay(day=day, is_sick=True, actor=node), resolved)
|
||||
record_event("import", day, actor=node, paths=resolved)
|
||||
written.append(day)
|
||||
return written
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""Append-only, HMAC-signed trail of every add, remove and legacy import.
|
||||
"""Append-only, HMAC-signed trail of every add, remove, keep edit and 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
|
||||
@ -32,14 +32,17 @@ def record_event(
|
||||
day: date,
|
||||
*,
|
||||
actor: str,
|
||||
keep: frozenset[str] | None = None,
|
||||
paths: Paths | None = None,
|
||||
) -> None:
|
||||
"""Append one signed line describing a change to the pool.
|
||||
|
||||
Args:
|
||||
action: ``"add"``, ``"remove"`` or ``"import"``.
|
||||
action: ``"add"``, ``"remove"``, ``"keep"`` or ``"import"``.
|
||||
day: The day that changed.
|
||||
actor: The device id that made the change.
|
||||
keep: The day's kept lockers after the change, recorded (sorted)
|
||||
for an add or a keep edit; omitted from the line when ``None``.
|
||||
paths: Where the trail and its signing key live.
|
||||
"""
|
||||
resolved = resolve_paths(paths)
|
||||
@ -49,6 +52,8 @@ def record_event(
|
||||
"actor": actor,
|
||||
"at": datetime.now(tz=UTC).isoformat(),
|
||||
}
|
||||
if keep is not None:
|
||||
entry["keep"] = sorted(keep)
|
||||
key_target = resolved.hmac_key
|
||||
if not key_target.exists():
|
||||
# First run on this machine: generating the key here means the trail
|
||||
|
||||
@ -5,6 +5,10 @@ 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.
|
||||
|
||||
A sick day stands every locker down unless it *keeps* some (``--keep`` on
|
||||
``add``, or ``keep`` afterwards). Locker ids are argparse ``choices``, so a
|
||||
typo exits 2 instead of silently keeping nothing.
|
||||
|
||||
``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.
|
||||
@ -16,19 +20,28 @@ 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._api import add_sick_day, is_sick_day, load, remove_sick_day
|
||||
from sickdays._cli_output import emit, emit_error
|
||||
from sickdays._constants import LOCKERS
|
||||
from sickdays._day import resolve, to_iso, today
|
||||
from sickdays._errors import SickDayError
|
||||
from sickdays._keep_api import set_kept_lockers, stands_down
|
||||
from sickdays._model import sick_entries
|
||||
from sickdays._sync import SyncUnavailableError, sync, sync_quietly
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
from collections.abc import Callable, Sequence
|
||||
|
||||
_EXIT_OK = 0
|
||||
_EXIT_REFUSED = 1
|
||||
_EXIT_BAD_INPUT = 2
|
||||
_DAY_HELP = "YYYY-MM-DD, or 'today'/'tomorrow' (default: today)"
|
||||
_LOCKER_METAVAR = "LOCKER"
|
||||
|
||||
|
||||
def _keep_suffix(keep: frozenset[str]) -> str:
|
||||
"""``" keep: a, b"`` for a non-empty keep set, else nothing."""
|
||||
return f" keep: {', '.join(sorted(keep))}" if keep else ""
|
||||
|
||||
|
||||
def _build_parser() -> argparse.ArgumentParser:
|
||||
@ -42,13 +55,42 @@ def _build_parser() -> argparse.ArgumentParser:
|
||||
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)
|
||||
add.add_argument(
|
||||
"--keep",
|
||||
action="append",
|
||||
default=[],
|
||||
choices=LOCKERS,
|
||||
metavar=_LOCKER_METAVAR,
|
||||
help=f"keep this locker active (repeatable; one of {', '.join(LOCKERS)})",
|
||||
)
|
||||
keep = sub.add_parser(
|
||||
"keep", help="replace the lockers an existing sick day keeps active"
|
||||
)
|
||||
keep.add_argument("day", help="YYYY-MM-DD, or 'today'/'tomorrow'")
|
||||
keep.add_argument(
|
||||
"lockers",
|
||||
nargs="*",
|
||||
choices=LOCKERS,
|
||||
metavar=_LOCKER_METAVAR,
|
||||
help="lockers to keep; none stands every locker down again",
|
||||
)
|
||||
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",
|
||||
help=(
|
||||
"exit 0 if the day is sick (with --locker: if that locker stands "
|
||||
"down), 1 if not -- for scripts"
|
||||
),
|
||||
)
|
||||
check.add_argument("day", nargs="?", default="today", help=_DAY_HELP)
|
||||
check.add_argument(
|
||||
"--locker",
|
||||
choices=LOCKERS,
|
||||
metavar=_LOCKER_METAVAR,
|
||||
help="instead: exit 0 iff this locker stands down on the day, 1 if not",
|
||||
)
|
||||
sub.add_parser("sync", help="pull and push the pool now")
|
||||
sub.add_parser(
|
||||
"sync-quiet",
|
||||
@ -57,9 +99,18 @@ def _build_parser() -> argparse.ArgumentParser:
|
||||
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")
|
||||
def _do_add(raw_day: str, keep: list[str]) -> int:
|
||||
entry = add_sick_day(resolve(raw_day), keep=keep)
|
||||
emit(f"{to_iso(entry.day)} is now a sick day{_keep_suffix(entry.keep)}")
|
||||
return _EXIT_OK
|
||||
|
||||
|
||||
def _do_keep(raw_day: str, lockers: list[str]) -> int:
|
||||
entry = set_kept_lockers(resolve(raw_day), lockers)
|
||||
if entry.keep:
|
||||
emit(f"{to_iso(entry.day)}{_keep_suffix(entry.keep)}")
|
||||
else:
|
||||
emit(f"{to_iso(entry.day)}: every locker stands down")
|
||||
return _EXIT_OK
|
||||
|
||||
|
||||
@ -70,18 +121,23 @@ def _do_remove(raw_day: str) -> int:
|
||||
|
||||
|
||||
def _do_list() -> int:
|
||||
days = sorted(sick_days())
|
||||
if not days:
|
||||
entries = sick_entries(load())
|
||||
if not entries:
|
||||
emit("no sick days")
|
||||
return _EXIT_OK
|
||||
reference = today()
|
||||
for day in days:
|
||||
emit(f"{to_iso(day)}{' (today)' if day == reference else ''}")
|
||||
for entry in entries:
|
||||
marker = " (today)" if entry.day == reference else ""
|
||||
emit(f"{to_iso(entry.day)}{marker}{_keep_suffix(entry.keep)}")
|
||||
return _EXIT_OK
|
||||
|
||||
|
||||
def _do_check(raw_day: str) -> int:
|
||||
def _do_check(raw_day: str, locker: str | None) -> int:
|
||||
day = resolve(raw_day)
|
||||
if locker is not None:
|
||||
off = stands_down(locker, day)
|
||||
emit(f"{to_iso(day)}: {locker} {'stands down' if off else 'applies'}")
|
||||
return _EXIT_OK if off else _EXIT_REFUSED
|
||||
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
|
||||
@ -98,18 +154,15 @@ def _do_sync_quiet() -> int:
|
||||
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()
|
||||
_HANDLERS: dict[str, Callable[[argparse.Namespace], int]] = {
|
||||
"add": lambda args: _do_add(args.day, args.keep),
|
||||
"keep": lambda args: _do_keep(args.day, args.lockers),
|
||||
"remove": lambda args: _do_remove(args.day),
|
||||
"list": lambda _args: _do_list(),
|
||||
"check": lambda args: _do_check(args.day, args.locker),
|
||||
"sync": lambda _args: _do_sync(),
|
||||
"sync-quiet": lambda _args: _do_sync_quiet(),
|
||||
}
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
@ -121,7 +174,7 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||
"""
|
||||
args = _build_parser().parse_args(argv)
|
||||
try:
|
||||
return _dispatch(args)
|
||||
return _HANDLERS[args.command](args)
|
||||
except ValueError as exc:
|
||||
emit_error(f"error: {exc}")
|
||||
return _EXIT_BAD_INPUT
|
||||
|
||||
@ -48,9 +48,30 @@ SYNC_FILENAME: Final = "sick_days.json"
|
||||
#: Field names inside a Record.
|
||||
FIELD_STATE: Final = "state"
|
||||
FIELD_ACTOR: Final = "actor"
|
||||
#: The sorted, de-duplicated list of locker ids that stay active on the day
|
||||
#: (0.2.0). Absent on every 0.1.0 record, which reads as "keep nothing".
|
||||
FIELD_KEEP: Final = "keep"
|
||||
|
||||
#: 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"
|
||||
|
||||
#: Locker ids a sick day may keep active (``FIELD_KEEP``). A locker *stands
|
||||
#: down* on a day iff the day is sick and the locker is not in its keep set.
|
||||
#: These strings are wire format: the phone writes the same ones.
|
||||
LOCKER_WORKOUT: Final = "workout"
|
||||
LOCKER_LEETCODE: Final = "leetcode"
|
||||
LOCKER_BOOK: Final = "book"
|
||||
LOCKER_DIET: Final = "diet"
|
||||
#: The phone ring, the PC fallback ring, the RTC self-wake and the
|
||||
#: missed-alarm penalty -- one switch, because they are one alarm.
|
||||
LOCKER_ALARM: Final = "alarm"
|
||||
LOCKERS: Final = (
|
||||
LOCKER_WORKOUT,
|
||||
LOCKER_LEETCODE,
|
||||
LOCKER_BOOK,
|
||||
LOCKER_DIET,
|
||||
LOCKER_ALARM,
|
||||
)
|
||||
|
||||
110
sickdays/sickdays/_keep.py
Normal file
110
sickdays/sickdays/_keep.py
Normal file
@ -0,0 +1,110 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""The per-day keep set: which lockers stay active on a sick day.
|
||||
|
||||
The keep set is one more last-writer-wins field on the day's record
|
||||
(``FIELD_KEEP``), holding a **sorted, de-duplicated** JSON list of locker
|
||||
ids. Sorting is wire format, not tidiness: the RTDB rev is a SHA-256 of the
|
||||
exact serialised string, so two devices holding the same set must write the
|
||||
same bytes.
|
||||
|
||||
Reading is where the safety lives, and each rule is pinned by a test:
|
||||
|
||||
- no ``keep`` field (every 0.1.0 record) -- keep nothing: every locker
|
||||
stands down, exactly as before 0.2.0;
|
||||
- a ``keep`` stamped *older* than ``state`` -- stale (an old client re-added
|
||||
the day without writing it), so keep nothing;
|
||||
- a ``keep`` that is not a list of strings -- fail closed: every locker is
|
||||
kept, i.e. the locks apply, and a warning is logged;
|
||||
- an id this version does not know, inside an otherwise valid list -- ignored,
|
||||
so a newer phone can add a locker without breaking this reader.
|
||||
|
||||
Staleness is decided first: a superseded value is ignored whatever it holds.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from crdt_sync import Record
|
||||
|
||||
from sickdays._constants import FIELD_ACTOR, FIELD_KEEP, FIELD_STATE, LOCKERS
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterable
|
||||
|
||||
from crdt_sync import Hlc
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
_ALL: frozenset[str] = frozenset(LOCKERS)
|
||||
|
||||
|
||||
def validate_locker(locker: str) -> str:
|
||||
"""Return ``locker`` if it is a known id.
|
||||
|
||||
Raises:
|
||||
ValueError: ``locker`` is not one of :data:`sickdays.LOCKERS`.
|
||||
"""
|
||||
if locker not in _ALL:
|
||||
msg = f"unknown locker {locker!r}; expected one of {', '.join(LOCKERS)}"
|
||||
raise ValueError(msg)
|
||||
return locker
|
||||
|
||||
|
||||
def normalise_keep(keep: Iterable[str]) -> frozenset[str]:
|
||||
"""Validate every id in ``keep`` and return them as a set.
|
||||
|
||||
A bare string is refused rather than iterated character by character.
|
||||
|
||||
Raises:
|
||||
ValueError: An id is not one of :data:`sickdays.LOCKERS`.
|
||||
"""
|
||||
if isinstance(keep, str):
|
||||
msg = f"keep must be a collection of locker ids, not the string {keep!r}"
|
||||
raise ValueError(msg)
|
||||
return frozenset(validate_locker(locker) for locker in keep)
|
||||
|
||||
|
||||
def keep_wire(keep: frozenset[str]) -> list[str]:
|
||||
"""The on-the-wire value of ``keep``: sorted, so the bytes are stable."""
|
||||
return sorted(keep)
|
||||
|
||||
|
||||
def read_keep(record: Record) -> frozenset[str]:
|
||||
"""Which known lockers ``record``'s day keeps active (the rules above).
|
||||
|
||||
Only meaningful while the day is sick; callers check that first.
|
||||
"""
|
||||
field = record.fields.get(FIELD_KEEP)
|
||||
if field is None:
|
||||
return frozenset()
|
||||
value, hlc = field
|
||||
state = record.fields.get(FIELD_STATE)
|
||||
if state is not None and hlc < state[1]:
|
||||
return frozenset()
|
||||
if not isinstance(value, list) or not all(isinstance(v, str) for v in value):
|
||||
_logger.warning(
|
||||
"sick day %s has a malformed keep field %r -- every locker applies",
|
||||
record.id,
|
||||
value,
|
||||
)
|
||||
return _ALL
|
||||
return frozenset(value) & _ALL
|
||||
|
||||
|
||||
def with_keep(record: Record, keep: frozenset[str], *, actor: str, tick: Hlc) -> Record:
|
||||
"""Return ``record`` with only ``keep`` and ``actor`` re-stamped ``tick``.
|
||||
|
||||
``state`` keeps its own clock on purpose: re-stamping it would make a
|
||||
keep edit re-assert "sick" over a concurrent clear from another device.
|
||||
"""
|
||||
fields = dict(record.fields)
|
||||
fields[FIELD_KEEP] = (keep_wire(keep), tick)
|
||||
fields[FIELD_ACTOR] = (actor, tick)
|
||||
return Record(
|
||||
id=record.id,
|
||||
fields=fields,
|
||||
deleted=record.deleted,
|
||||
deleted_hlc=record.deleted_hlc,
|
||||
)
|
||||
108
sickdays/sickdays/_keep_api.py
Normal file
108
sickdays/sickdays/_keep_api.py
Normal file
@ -0,0 +1,108 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""Per-locker questions and the keep edit (0.2.0).
|
||||
|
||||
A sick day may keep some lockers active. A locker *stands down* on a day iff
|
||||
the day is sick **and** the locker is not in that day's keep set.
|
||||
:func:`stands_down` is what a gate asks; like :func:`sickdays.is_sick_day` it
|
||||
reads one local file and never raises for a data problem. An unreadable pool
|
||||
reads as "not sick" and a malformed keep set as "every locker kept" -- both
|
||||
mean the locks apply.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sickdays._api import actor_id, load
|
||||
from sickdays._audit import record_event
|
||||
from sickdays._day import to_iso, today
|
||||
from sickdays._errors import NotSickError
|
||||
from sickdays._keep import normalise_keep, read_keep, validate_locker
|
||||
from sickdays._model import SickDay, is_sick_record
|
||||
from sickdays._paths import resolve_paths
|
||||
from sickdays._store import read_pool_strict
|
||||
from sickdays._writes import refuse_past, write_keep
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterable
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
from sickdays._paths import Paths
|
||||
|
||||
|
||||
def kept_lockers(
|
||||
day: date | None = None,
|
||||
*,
|
||||
log_path: Path | None = None,
|
||||
now: date | None = None,
|
||||
) -> frozenset[str]:
|
||||
"""The lockers ``day`` (default: today) keeps active; empty when not sick.
|
||||
|
||||
Never raises: a malformed keep set is logged and reads as every locker.
|
||||
"""
|
||||
target = day if day is not None else today(now)
|
||||
record = load(log_path).get(to_iso(target))
|
||||
if record is None or not is_sick_record(record):
|
||||
return frozenset()
|
||||
return read_keep(record)
|
||||
|
||||
|
||||
def stands_down(
|
||||
locker: str,
|
||||
day: date | None = None,
|
||||
*,
|
||||
log_path: Path | None = None,
|
||||
now: date | None = None,
|
||||
) -> bool:
|
||||
"""Whether ``locker`` is switched off on ``day`` (default: today).
|
||||
|
||||
True iff the day is sick and ``locker`` is not in its keep set. Never
|
||||
raises for a data problem -- every one of them answers ``False``.
|
||||
|
||||
Raises:
|
||||
ValueError: ``locker`` is not one of :data:`sickdays.LOCKERS` (a
|
||||
programmer error, not a data problem).
|
||||
"""
|
||||
validate_locker(locker)
|
||||
target = day if day is not None else today(now)
|
||||
record = load(log_path).get(to_iso(target))
|
||||
if record is None or not is_sick_record(record):
|
||||
return False
|
||||
return locker not in read_keep(record)
|
||||
|
||||
|
||||
def set_kept_lockers(
|
||||
day: date,
|
||||
keep: Iterable[str],
|
||||
*,
|
||||
paths: Paths | None = None,
|
||||
now: date | None = None,
|
||||
) -> SickDay:
|
||||
"""Replace the kept lockers of an existing sick day. Today or later only.
|
||||
|
||||
Only ``keep`` and ``actor`` are re-stamped; ``state`` keeps its clock,
|
||||
so this edit can never re-assert "sick" over a concurrent clear. An empty
|
||||
``keep`` stands every locker down again.
|
||||
|
||||
Returns:
|
||||
The stored entry.
|
||||
|
||||
Raises:
|
||||
ValueError: ``keep`` names an unknown locker.
|
||||
PastDateError: ``day`` has already passed.
|
||||
NotSickError: ``day`` is not a sick day.
|
||||
UnreadablePoolError: The local pool exists but cannot be parsed.
|
||||
"""
|
||||
kept = normalise_keep(keep)
|
||||
resolved = resolve_paths(paths)
|
||||
refuse_past(day, today(now), "changed")
|
||||
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)
|
||||
actor = actor_id(resolved)
|
||||
write_keep(log, existing, kept, actor=actor, resolved=resolved)
|
||||
record_event("keep", day, actor=actor, keep=kept, paths=resolved)
|
||||
return SickDay(day=day, is_sick=True, actor=actor, keep=kept)
|
||||
@ -21,8 +21,15 @@ 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._constants import (
|
||||
FIELD_ACTOR,
|
||||
FIELD_KEEP,
|
||||
FIELD_STATE,
|
||||
STATE_CLEARED,
|
||||
STATE_SICK,
|
||||
)
|
||||
from sickdays._day import parse_iso, to_iso
|
||||
from sickdays._keep import keep_wire, read_keep
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datetime import date
|
||||
@ -38,11 +45,13 @@ class SickDay:
|
||||
day: The calendar date.
|
||||
is_sick: Whether the day is currently a sick day.
|
||||
actor: Which device last changed it.
|
||||
keep: The lockers that stay active on the day (empty unless sick).
|
||||
"""
|
||||
|
||||
day: date
|
||||
is_sick: bool
|
||||
actor: str = ""
|
||||
keep: frozenset[str] = frozenset()
|
||||
|
||||
|
||||
def _field_str(record: Record, name: str) -> str:
|
||||
@ -70,21 +79,34 @@ def to_sick_day(record: Record) -> SickDay | None:
|
||||
day = parse_iso(record.id)
|
||||
except ValueError:
|
||||
return None
|
||||
sick = is_sick_record(record)
|
||||
return SickDay(
|
||||
day=day,
|
||||
is_sick=is_sick_record(record),
|
||||
is_sick=sick,
|
||||
actor=_field_str(record, FIELD_ACTOR),
|
||||
keep=read_keep(record) if sick else frozenset(),
|
||||
)
|
||||
|
||||
|
||||
def sick_entries(log: Log) -> list[SickDay]:
|
||||
"""Every day ``log`` currently marks sick, oldest first (junk ids skipped)."""
|
||||
entries = (to_sick_day(record) for record in log.values())
|
||||
return sorted(
|
||||
(entry for entry in entries if entry is not None and entry.is_sick),
|
||||
key=lambda entry: entry.day,
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
return frozenset(entry.day for entry in sick_entries(log))
|
||||
|
||||
|
||||
def record_hlc(record: Record | None) -> Hlc | None:
|
||||
"""Return the greatest clock on any field of ``record`` (any node)."""
|
||||
if record is None or not record.fields:
|
||||
return None
|
||||
return max(hlc for _, hlc in record.fields.values())
|
||||
|
||||
|
||||
def latest_hlc(log: Log, node_id: str) -> Hlc | None:
|
||||
@ -108,13 +130,16 @@ def build_record(entry: SickDay, hlc: Hlc) -> Record:
|
||||
|
||||
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.
|
||||
actor from the other. ``keep`` is always written -- ``[]`` on a clear --
|
||||
so a keep set from before a clear can never survive a later re-add.
|
||||
"""
|
||||
state = STATE_SICK if entry.is_sick else STATE_CLEARED
|
||||
keep = keep_wire(entry.keep) if entry.is_sick else []
|
||||
return Record(
|
||||
id=to_iso(entry.day),
|
||||
fields={
|
||||
FIELD_STATE: (state, hlc),
|
||||
FIELD_ACTOR: (entry.actor, hlc),
|
||||
FIELD_KEEP: (keep, hlc),
|
||||
},
|
||||
)
|
||||
|
||||
71
sickdays/sickdays/_writes.py
Normal file
71
sickdays/sickdays/_writes.py
Normal file
@ -0,0 +1,71 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""The two ways a write reaches the pool: a whole record, or one field update.
|
||||
|
||||
A whole-record write (add, remove, import) stamps every field with one tick.
|
||||
A keep edit must not: it re-stamps only ``keep`` and ``actor``, leaving
|
||||
``state`` on its own clock (:func:`sickdays._keep.with_keep`).
|
||||
|
||||
Every tick is issued *after* both this node's last tick and every clock
|
||||
already on the day's record, so a write always beats what it was looking at,
|
||||
even when another device's wall clock runs ahead of this one's. Without that,
|
||||
a keep edit made under clock skew could be born older than ``state`` -- and a
|
||||
keep older than ``state`` reads as stale.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from crdt_sync import Hlc
|
||||
|
||||
from sickdays._day import to_iso
|
||||
from sickdays._errors import PastDateError
|
||||
from sickdays._keep import with_keep
|
||||
from sickdays._model import build_record, latest_hlc, record_hlc
|
||||
from sickdays._store import write_pool
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datetime import date
|
||||
|
||||
from crdt_sync import Log, Record
|
||||
|
||||
from sickdays._model import SickDay
|
||||
from sickdays._paths import Paths
|
||||
|
||||
|
||||
def refuse_past(day: date, reference: date, verb: str) -> None:
|
||||
"""Raise :class:`PastDateError` when ``day`` is before ``reference``."""
|
||||
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 next_tick(log: Log, actor: str, existing: Record | None) -> Hlc:
|
||||
"""This node's next clock, later than its own and ``existing``'s ticks."""
|
||||
seen = [t for t in (latest_hlc(log, actor), record_hlc(existing)) if t is not None]
|
||||
return Hlc.new_tick(actor, max(seen) if seen else None)
|
||||
|
||||
|
||||
def write_entry(log: Log, entry: SickDay, resolved: Paths) -> None:
|
||||
"""Replace the day's whole record with ``entry`` and persist the pool."""
|
||||
key = to_iso(entry.day)
|
||||
tick = next_tick(log, entry.actor, log.get(key))
|
||||
log[key] = build_record(entry, tick)
|
||||
write_pool(resolved.log, log)
|
||||
|
||||
|
||||
def write_keep(
|
||||
log: Log,
|
||||
existing: Record,
|
||||
keep: frozenset[str],
|
||||
*,
|
||||
actor: str,
|
||||
resolved: Paths,
|
||||
) -> None:
|
||||
"""Re-stamp only ``keep`` and ``actor`` on ``existing`` and persist."""
|
||||
tick = next_tick(log, actor, existing)
|
||||
log[existing.id] = with_keep(existing, keep, actor=actor, tick=tick)
|
||||
write_pool(resolved.log, log)
|
||||
@ -22,7 +22,13 @@ if TYPE_CHECKING:
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
_MODULES = ("sickdays._api", "sickdays._audit", "sickdays._conflict", "sickdays._sync")
|
||||
_MODULES = (
|
||||
"sickdays._api",
|
||||
"sickdays._audit",
|
||||
"sickdays._conflict",
|
||||
"sickdays._keep_api",
|
||||
"sickdays._sync",
|
||||
)
|
||||
|
||||
|
||||
def _never_free(_day: date) -> bool:
|
||||
|
||||
@ -115,3 +115,56 @@ def test_python_dash_m_runs_the_cli(
|
||||
runpy.run_module("sickdays", run_name="__main__")
|
||||
assert exited.value.code == 0
|
||||
assert "no sick days" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_add_keeps_lockers_and_list_shows_them(
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
assert main(["add", "today"]) == 0
|
||||
assert main(["add", _future(), "--keep", "workout", "--keep", "leetcode"]) == 0
|
||||
assert "sick day keep: leetcode, workout" in capsys.readouterr().out
|
||||
assert main(["list"]) == 0
|
||||
assert capsys.readouterr().out.splitlines() == [
|
||||
f"{to_iso(today())} (today)",
|
||||
f"{_future()} keep: leetcode, workout",
|
||||
]
|
||||
|
||||
|
||||
def test_an_unknown_locker_exits_two(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
with pytest.raises(SystemExit) as exited:
|
||||
main(["add", "--keep", "leetcod"])
|
||||
assert exited.value.code == 2
|
||||
assert "invalid choice" in capsys.readouterr().err
|
||||
with pytest.raises(SystemExit) as exited:
|
||||
main(["keep", _future(), "nope"])
|
||||
assert exited.value.code == 2
|
||||
|
||||
|
||||
def test_keep_replaces_the_set(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
main(["add", _future(), "--keep", "book"])
|
||||
assert main(["keep", _future(), "diet", "alarm"]) == 0
|
||||
assert f"{_future()} keep: alarm, diet" in capsys.readouterr().out
|
||||
assert main(["check", _future(), "--locker", "book"]) == 0
|
||||
assert "book stands down" in capsys.readouterr().out
|
||||
assert main(["check", _future(), "--locker", "diet"]) == 1
|
||||
assert "diet applies" in capsys.readouterr().out
|
||||
assert main(["keep", _future()]) == 0
|
||||
assert "every locker stands down" in capsys.readouterr().out
|
||||
assert main(["check", _future(), "--locker", "diet"]) == 0
|
||||
|
||||
|
||||
def test_keep_on_a_normal_or_past_day_is_refused(
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
assert main(["keep", _future(), "book"]) == 1
|
||||
assert "not a sick day" in capsys.readouterr().err
|
||||
yesterday = to_iso(today() - timedelta(days=1))
|
||||
assert main(["keep", yesterday]) == 1
|
||||
assert "already passed" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_check_with_a_locker_on_a_normal_day_exits_one(
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
assert main(["check", "--locker", "alarm"]) == 1
|
||||
assert "alarm applies" in capsys.readouterr().out
|
||||
|
||||
132
sickdays/sickdays/tests/test_keep.py
Normal file
132
sickdays/sickdays/tests/test_keep.py
Normal file
@ -0,0 +1,132 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""Reading a day's keep set: each wire-format rule, pinned."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from crdt_sync import Hlc, Record
|
||||
import pytest
|
||||
|
||||
from sickdays._constants import LOCKERS
|
||||
from sickdays._keep import (
|
||||
keep_wire,
|
||||
normalise_keep,
|
||||
read_keep,
|
||||
validate_locker,
|
||||
with_keep,
|
||||
)
|
||||
from sickdays._model import SickDay, build_record, record_hlc, to_sick_day
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Any
|
||||
|
||||
DAY = date(2026, 10, 20)
|
||||
|
||||
|
||||
def _tick(wall_ms: int, node: str = "pc") -> Hlc:
|
||||
return Hlc(wall_time_ms=wall_ms, counter=0, node_id=node)
|
||||
|
||||
|
||||
def _sick(keep: tuple[Any, Hlc] | None, state_at: int = 5) -> Record:
|
||||
fields: dict[str, tuple[Any, Hlc]] = {
|
||||
"state": ("sick", _tick(state_at)),
|
||||
"actor": ("pc", _tick(state_at)),
|
||||
}
|
||||
if keep is not None:
|
||||
fields["keep"] = keep
|
||||
return Record(id="2026-10-20", fields=fields)
|
||||
|
||||
|
||||
def test_a_0_1_0_record_without_keep_keeps_nothing() -> None:
|
||||
assert read_keep(_sick(None)) == frozenset()
|
||||
|
||||
|
||||
def test_a_keep_older_than_state_is_stale() -> None:
|
||||
assert read_keep(_sick((["leetcode"], _tick(4)))) == frozenset()
|
||||
|
||||
|
||||
def test_a_keep_from_the_same_tick_counts() -> None:
|
||||
assert read_keep(_sick((["leetcode"], _tick(5)))) == {"leetcode"}
|
||||
|
||||
|
||||
def test_a_later_keep_counts() -> None:
|
||||
assert read_keep(_sick((["book", "diet"], _tick(9)))) == {"book", "diet"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["leetcode", {"leetcode": True}, ["diet", 7], None])
|
||||
def test_a_malformed_keep_fails_closed_to_every_locker(
|
||||
value: object, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
with caplog.at_level(logging.WARNING):
|
||||
assert read_keep(_sick((value, _tick(5)))) == frozenset(LOCKERS)
|
||||
assert "malformed keep field" in caplog.text
|
||||
|
||||
|
||||
def test_a_stale_malformed_keep_is_still_just_stale() -> None:
|
||||
assert read_keep(_sick(("junk", _tick(1)))) == frozenset()
|
||||
|
||||
|
||||
def test_unknown_ids_are_ignored() -> None:
|
||||
record = _sick((["hologram", "leetcode"], _tick(5)))
|
||||
assert read_keep(record) == {"leetcode"}
|
||||
|
||||
|
||||
def test_a_keep_without_a_state_is_read_as_is() -> None:
|
||||
record = Record(id="2026-10-20", fields={"keep": (["book"], _tick(1))})
|
||||
assert read_keep(record) == {"book"}
|
||||
|
||||
|
||||
def test_validate_locker_names_the_choices() -> None:
|
||||
assert validate_locker("alarm") == "alarm"
|
||||
with pytest.raises(ValueError, match="workout, leetcode, book, diet, alarm"):
|
||||
validate_locker("leetcod")
|
||||
|
||||
|
||||
def test_normalise_deduplicates_and_refuses_junk() -> None:
|
||||
assert normalise_keep(["diet", "diet", "book"]) == {"book", "diet"}
|
||||
assert normalise_keep(()) == frozenset()
|
||||
with pytest.raises(ValueError, match="not the string"):
|
||||
normalise_keep("diet")
|
||||
with pytest.raises(ValueError, match="unknown locker"):
|
||||
normalise_keep(["diet", "nope"])
|
||||
|
||||
|
||||
def test_the_wire_value_is_sorted() -> None:
|
||||
assert keep_wire(frozenset({"workout", "alarm", "leetcode"})) == [
|
||||
"alarm",
|
||||
"leetcode",
|
||||
"workout",
|
||||
]
|
||||
|
||||
|
||||
def test_an_add_writes_keep_on_the_state_tick() -> None:
|
||||
entry = SickDay(DAY, is_sick=True, actor="pc", keep=frozenset({"diet", "book"}))
|
||||
record = build_record(entry, _tick(3))
|
||||
assert record.fields["keep"] == (["book", "diet"], _tick(3))
|
||||
assert record.fields["state"] == ("sick", _tick(3))
|
||||
assert to_sick_day(record) == entry
|
||||
|
||||
|
||||
def test_a_clear_writes_an_empty_keep_on_the_state_tick() -> None:
|
||||
entry = SickDay(DAY, is_sick=False, actor="pc", keep=frozenset({"diet"}))
|
||||
record = build_record(entry, _tick(3))
|
||||
assert record.fields["keep"] == ([], _tick(3))
|
||||
assert to_sick_day(record) == SickDay(DAY, is_sick=False, actor="pc")
|
||||
|
||||
|
||||
def test_with_keep_restamps_only_keep_and_actor() -> None:
|
||||
record = _sick((["book"], _tick(5)))
|
||||
edited = with_keep(record, frozenset({"alarm"}), actor="phone", tick=_tick(8))
|
||||
assert edited.fields["state"] == ("sick", _tick(5))
|
||||
assert edited.fields["keep"] == (["alarm"], _tick(8))
|
||||
assert edited.fields["actor"] == ("phone", _tick(8))
|
||||
assert record.fields["keep"] == (["book"], _tick(5))
|
||||
|
||||
|
||||
def test_record_hlc_is_the_greatest_field_clock() -> None:
|
||||
assert record_hlc(None) is None
|
||||
assert record_hlc(Record(id="x", fields={})) is None
|
||||
assert record_hlc(_sick((["book"], _tick(9, "phone")))) == _tick(9, "phone")
|
||||
202
sickdays/sickdays/tests/test_keep_api.py
Normal file
202
sickdays/sickdays/tests/test_keep_api.py
Normal file
@ -0,0 +1,202 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""stands_down, kept_lockers, add --keep and the keep edit, end to end."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
import json
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from crdt_sync import Hlc, Record, merge_logs
|
||||
import pytest
|
||||
|
||||
from sickdays._api import add_sick_day, is_sick_day, load, remove_sick_day
|
||||
from sickdays._constants import LOCKERS
|
||||
from sickdays._errors import NotSickError, PastDateError
|
||||
from sickdays._keep_api import kept_lockers, set_kept_lockers, stands_down
|
||||
from sickdays._store import write_pool
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
TODAY = date(2026, 10, 16)
|
||||
TOMORROW = date(2026, 10, 17)
|
||||
KEY = "2026-10-17"
|
||||
|
||||
#: A record exactly as 0.1.0 wrote the live 2026-10-16..20 days: no keep.
|
||||
V010_RECORD = {
|
||||
"id": KEY,
|
||||
"fields": {
|
||||
"state": ["sick", "2026-10-16T08:12:03.517Z-0000-phone"],
|
||||
"actor": ["phone", "2026-10-16T08:12:03.517Z-0000-phone"],
|
||||
},
|
||||
"deleted": False,
|
||||
"deleted_hlc": None,
|
||||
}
|
||||
|
||||
|
||||
def _pool(state_dir: Path) -> Path:
|
||||
return state_dir / "sick_days.json"
|
||||
|
||||
|
||||
def _raw(state_dir: Path, record: dict[str, object]) -> None:
|
||||
_pool(state_dir).write_text(json.dumps({KEY: record}), encoding="utf-8")
|
||||
|
||||
|
||||
def test_a_normal_day_stands_nothing_down() -> None:
|
||||
assert not any(stands_down(locker, TOMORROW) for locker in LOCKERS)
|
||||
assert kept_lockers(TOMORROW) == frozenset()
|
||||
|
||||
|
||||
def test_a_sick_day_with_no_keep_stands_everything_down() -> None:
|
||||
add_sick_day(TOMORROW, now=TODAY)
|
||||
assert all(stands_down(locker, TOMORROW) for locker in LOCKERS)
|
||||
assert kept_lockers(TOMORROW) == frozenset()
|
||||
|
||||
|
||||
def test_a_kept_locker_applies_and_the_rest_stand_down() -> None:
|
||||
entry = add_sick_day(TOMORROW, keep=["leetcode", "leetcode"], now=TODAY)
|
||||
assert entry.keep == {"leetcode"}
|
||||
assert not stands_down("leetcode", TOMORROW)
|
||||
assert stands_down("workout", TOMORROW)
|
||||
assert kept_lockers(TOMORROW) == {"leetcode"}
|
||||
assert is_sick_day(TOMORROW)
|
||||
|
||||
|
||||
def test_today_is_the_default_day() -> None:
|
||||
add_sick_day(TODAY, keep=["diet"], now=TODAY)
|
||||
assert stands_down("book", now=TODAY)
|
||||
assert not stands_down("diet", now=TODAY)
|
||||
assert kept_lockers(now=TODAY) == {"diet"}
|
||||
|
||||
|
||||
def test_the_add_writes_a_sorted_keep_on_one_tick(state_dir: Path) -> None:
|
||||
add_sick_day(TOMORROW, keep=["workout", "alarm"], now=TODAY)
|
||||
fields = json.loads(_pool(state_dir).read_text(encoding="utf-8"))[KEY]["fields"]
|
||||
assert fields["keep"][0] == ["alarm", "workout"]
|
||||
assert fields["keep"][1] == fields["state"][1] == fields["actor"][1]
|
||||
|
||||
|
||||
def test_an_unknown_locker_on_add_writes_nothing(state_dir: Path) -> None:
|
||||
with pytest.raises(ValueError, match="unknown locker"):
|
||||
add_sick_day(TOMORROW, keep=["leetcod"], now=TODAY)
|
||||
assert not _pool(state_dir).exists()
|
||||
|
||||
|
||||
def test_an_unknown_locker_question_is_a_programmer_error() -> None:
|
||||
with pytest.raises(ValueError, match="unknown locker"):
|
||||
stands_down("leetcod", TOMORROW)
|
||||
|
||||
|
||||
def test_a_0_1_0_record_stands_every_locker_down(state_dir: Path) -> None:
|
||||
_raw(state_dir, V010_RECORD)
|
||||
assert all(stands_down(locker, TOMORROW) for locker in LOCKERS)
|
||||
assert kept_lockers(TOMORROW) == frozenset()
|
||||
|
||||
|
||||
def test_a_malformed_keep_means_the_locks_apply(state_dir: Path) -> None:
|
||||
record = json.loads(json.dumps(V010_RECORD))
|
||||
record["fields"]["keep"] = ["leetcode", "2026-10-16T08:12:03.517Z-0000-phone"]
|
||||
_raw(state_dir, record)
|
||||
assert not any(stands_down(locker, TOMORROW) for locker in LOCKERS)
|
||||
assert kept_lockers(TOMORROW) == frozenset(LOCKERS)
|
||||
|
||||
|
||||
def test_an_unreadable_pool_stands_nothing_down(state_dir: Path) -> None:
|
||||
_pool(state_dir).write_text("{not json", encoding="utf-8")
|
||||
assert not stands_down("alarm", TOMORROW)
|
||||
assert kept_lockers(TOMORROW) == frozenset()
|
||||
|
||||
|
||||
def test_set_kept_lockers_replaces_the_set(state_dir: Path) -> None:
|
||||
add_sick_day(TOMORROW, keep=["book"], now=TODAY)
|
||||
entry = set_kept_lockers(TOMORROW, ["alarm", "diet"], now=TODAY)
|
||||
assert entry.keep == {"alarm", "diet"}
|
||||
assert kept_lockers(TOMORROW) == {"alarm", "diet"}
|
||||
assert stands_down("book", TOMORROW)
|
||||
set_kept_lockers(TOMORROW, [], now=TODAY)
|
||||
assert all(stands_down(locker, TOMORROW) for locker in LOCKERS)
|
||||
lines = (state_dir / "audit.jsonl").read_text(encoding="utf-8").splitlines()
|
||||
events = [json.loads(line) for line in lines]
|
||||
assert [(e["action"], e["keep"]) for e in events] == [
|
||||
("add", ["book"]),
|
||||
("keep", ["alarm", "diet"]),
|
||||
("keep", []),
|
||||
]
|
||||
|
||||
|
||||
def test_a_keep_edit_does_not_restamp_state(state_dir: Path) -> None:
|
||||
add_sick_day(TOMORROW, now=TODAY)
|
||||
before = load()[KEY].fields["state"]
|
||||
set_kept_lockers(TOMORROW, ["leetcode"], now=TODAY)
|
||||
after = load()[KEY]
|
||||
assert after.fields["state"] == before
|
||||
assert after.fields["keep"][1] > before[1]
|
||||
assert after.fields["actor"][1] == after.fields["keep"][1]
|
||||
|
||||
|
||||
def test_a_keep_edit_never_reasserts_sick_over_a_concurrent_clear() -> None:
|
||||
add_sick_day(TOMORROW, now=TODAY)
|
||||
added = load()
|
||||
set_kept_lockers(TOMORROW, ["leetcode"], now=TODAY)
|
||||
edited = load()
|
||||
state_tick = added[KEY].fields["state"][1]
|
||||
# "!" sorts before any uuid, so this clear is concurrent with, and
|
||||
# older than, the keep edit -- the case a re-stamped state would win.
|
||||
clear_tick = Hlc(state_tick.wall_time_ms, state_tick.counter + 1, "!")
|
||||
assert edited[KEY].fields["keep"][1] > clear_tick
|
||||
cleared = Record(
|
||||
id=KEY,
|
||||
fields={
|
||||
"state": ("cleared", clear_tick),
|
||||
"actor": ("phone", clear_tick),
|
||||
"keep": ([], clear_tick),
|
||||
},
|
||||
)
|
||||
merged = merge_logs(edited, {KEY: cleared})
|
||||
assert merged[KEY].fields["state"][0] == "cleared"
|
||||
|
||||
|
||||
def test_a_keep_edit_beats_a_state_from_a_clock_that_runs_ahead(
|
||||
state_dir: Path,
|
||||
) -> None:
|
||||
future = "2099-01-01T00:00:00.000Z-0000-phone"
|
||||
record = json.loads(json.dumps(V010_RECORD))
|
||||
record["fields"]["state"][1] = future
|
||||
record["fields"]["actor"][1] = future
|
||||
_raw(state_dir, record)
|
||||
set_kept_lockers(TOMORROW, ["workout"], now=TODAY)
|
||||
assert kept_lockers(TOMORROW) == {"workout"}
|
||||
assert not stands_down("workout", TOMORROW)
|
||||
|
||||
|
||||
def test_a_clear_and_re_add_drops_a_stale_keep_from_another_node() -> None:
|
||||
add_sick_day(TOMORROW, keep=["leetcode"], now=TODAY)
|
||||
old_copy = load()
|
||||
remove_sick_day(TOMORROW, now=TODAY)
|
||||
add_sick_day(TOMORROW, now=TODAY)
|
||||
merged = merge_logs(load(), old_copy)
|
||||
assert merged[KEY].fields["keep"][0] == []
|
||||
|
||||
|
||||
def test_a_0_1_0_re_add_makes_an_older_keep_stale(state_dir: Path) -> None:
|
||||
add_sick_day(TOMORROW, keep=["leetcode"], now=TODAY)
|
||||
newer = "2099-01-01T00:00:00.000Z-0000-oldphone"
|
||||
record = json.loads(json.dumps(V010_RECORD))
|
||||
record["fields"]["state"] = ["sick", newer]
|
||||
merged = merge_logs(load(), {KEY: Record.from_dict(record)})
|
||||
write_pool(_pool(state_dir), merged)
|
||||
assert stands_down("leetcode", TOMORROW)
|
||||
|
||||
|
||||
def test_the_keep_edit_refusals() -> None:
|
||||
with pytest.raises(NotSickError):
|
||||
set_kept_lockers(TOMORROW, ["book"], now=TODAY)
|
||||
with pytest.raises(PastDateError, match="changed"):
|
||||
set_kept_lockers(date(2026, 10, 15), ["book"], now=TODAY)
|
||||
add_sick_day(TOMORROW, now=TODAY)
|
||||
with pytest.raises(ValueError, match="unknown locker"):
|
||||
set_kept_lockers(TOMORROW, ["nope"], now=TODAY)
|
||||
remove_sick_day(TOMORROW, now=TODAY)
|
||||
with pytest.raises(NotSickError):
|
||||
set_kept_lockers(TOMORROW, ["book"], now=TODAY)
|
||||
Loading…
Reference in New Issue
Block a user