mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 19:07:17 +01:00
Add freedays: one shared pool of days when every gate stands down
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
freedays pre-commit / pre-commit (push) Waiting to run
freedays tests / test (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
shared gate tests / gate unit tests (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
freedays pre-commit / pre-commit (push) Waiting to run
freedays tests / test (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
Five apps (diet-guard, wake-alarm, screen-locker, leetcode-guard, home-guard) each enforce something daily, and none of them had a way to say "not today". screen-locker was the only one with any notion of a day off, and it had two overlapping ones: a budgeted sick day designed to be hard to take, and an undocumented scheduled_skips.json that no other app could see. This is the second thing generalised, not the first. Free days are pre-declared, carry no justification, allow consecutive runs, and are capped only by an annual budget -- 35 per calendar year, resetting Jan 1. Sick days stay in screen-locker exactly as they are: their rolling windows (1/7d, 3/30d, 10/90d) exist precisely to forbid the consecutive runs a holiday is made of, so merging the two policies would have had to ruin one of them. One global pool: a free day is free for every app at once, so there is nothing per-app to mark and nothing per-app to forget. The integration is one call, `freedays.is_free_day()`, which reads a single local JSON file and never the network -- a gate fires from a systemd timer and a hung sync must not be able to hold one open or shut. It returns False for anything it cannot parse, so an unreadable pool means the normal rules apply rather than everything switching off. Two design points worth keeping: - Un-marking flips a `state` field rather than tombstoning the record. crdt-sync deletes are monotonic, so a tombstoned date could never be marked free again. - `consumed` is a sticky flag. Releasing a day that has not arrived refunds it; releasing today does not, because those hours were already unguarded. It is OR-ed against the stored value on every write so two devices either side of midnight cannot hand a spent day back. Timezone is local, decided in _day.today() and nowhere else. The apps disagreed about this before: screen-locker's lock chain computes the day in UTC while add_scheduled_skip.py uses local time, so a skip added near midnight could name a date the chain never matched. 94 tests, 100% branch coverage, no suppressions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
92d7151bdf
commit
ef69619597
29
.github/workflows/freedays-precommit.yml
vendored
Normal file
29
.github/workflows/freedays-precommit.yml
vendored
Normal file
@ -0,0 +1,29 @@
|
||||
name: freedays pre-commit
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
# The workflow's own file counts as freedays: editing only this file would
|
||||
# otherwise trigger nothing, so a fix to the job itself could never prove
|
||||
# itself.
|
||||
paths: ['freedays/**', '.github/workflows/freedays-precommit.yml']
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths: ['freedays/**', '.github/workflows/freedays-precommit.yml']
|
||||
|
||||
jobs:
|
||||
pre-commit:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.x"
|
||||
- name: Install sibling packages
|
||||
run: pip install ./gatelock ./crdt-sync
|
||||
- name: Install dependencies
|
||||
working-directory: freedays
|
||||
run: pip install -r requirements.txt
|
||||
- uses: pre-commit/action@v3.0.1
|
||||
with:
|
||||
extra_args: --config freedays/.pre-commit-config.yaml --all-files
|
||||
41
.github/workflows/freedays-tests.yml
vendored
Normal file
41
.github/workflows/freedays-tests.yml
vendored
Normal file
@ -0,0 +1,41 @@
|
||||
name: freedays tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
# The workflow's own file counts as freedays: editing only this file would
|
||||
# otherwise trigger nothing, so a fix to the job itself could never prove
|
||||
# itself.
|
||||
paths: ['freedays/**', '.github/workflows/freedays-tests.yml']
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths: ['freedays/**', '.github/workflows/freedays-tests.yml']
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: freedays
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.x"
|
||||
|
||||
# freedays imports gatelock (audit signing) and crdt-sync (the merge
|
||||
# scheme and the Firebase transport). Both 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.
|
||||
- name: Install sibling packages
|
||||
run: pip install ../gatelock ../crdt-sync
|
||||
|
||||
- name: Install dependencies
|
||||
run: pip install -r requirements.txt && pip install --no-deps -e .
|
||||
|
||||
# No display needed: freedays draws nothing. It is policy, a JSON file
|
||||
# and a CLI.
|
||||
- name: Run tests with coverage
|
||||
run: python -m pytest -v
|
||||
143
freedays/.pre-commit-config.yaml
Normal file
143
freedays/.pre-commit-config.yaml
Normal file
@ -0,0 +1,143 @@
|
||||
# Pre-commit Configuration for freedays
|
||||
# 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 ~/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 freedays/.pre-commit-config.yaml --all-files
|
||||
files: ^freedays/
|
||||
|
||||
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 freedays
|
||||
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.15.2
|
||||
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: v3.3.2
|
||||
hooks:
|
||||
- id: pylint
|
||||
args:
|
||||
- --rcfile=freedays/pyproject.toml
|
||||
- --fail-under=8.0
|
||||
- --jobs=4
|
||||
additional_dependencies:
|
||||
- pytest
|
||||
|
||||
- repo: https://github.com/PyCQA/bandit
|
||||
rev: 1.9.4
|
||||
hooks:
|
||||
- id: bandit
|
||||
args:
|
||||
- -c
|
||||
- freedays/pyproject.toml
|
||||
- --severity-level=high
|
||||
- --confidence-level=medium
|
||||
- --skip=B113
|
||||
additional_dependencies: ["bandit[toml]"]
|
||||
exclude: ^(freedays/freedays/tests/|.*test.*\.py$)
|
||||
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: pytest-coverage
|
||||
name: pytest with coverage enforcement
|
||||
entry: scripts/run_subproject_tests.sh freedays
|
||||
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
|
||||
92
freedays/README.md
Normal file
92
freedays/README.md
Normal file
@ -0,0 +1,92 @@
|
||||
# freedays
|
||||
|
||||
One shared pool of pre-declared days on which **every** gate app stands down.
|
||||
|
||||
Mark a day. Nothing locks, nothing rings, nothing asks you for a photo, and
|
||||
nothing mentions it afterwards.
|
||||
|
||||
## For a gate app
|
||||
|
||||
The entire integration is one call:
|
||||
|
||||
```python
|
||||
import freedays
|
||||
|
||||
if freedays.is_free_day():
|
||||
return # no lock, no dialog, no alarm -- and say nothing
|
||||
```
|
||||
|
||||
`is_free_day()` reads one local JSON file. It never touches the network, so
|
||||
it cannot block a systemd-fired gate, and it returns `False` for anything it
|
||||
cannot parse — an unreadable pool means *the normal rules apply*, never that
|
||||
everything is switched off.
|
||||
|
||||
## For a person
|
||||
|
||||
```console
|
||||
$ freedays status
|
||||
2026: 33 of 35 free days left
|
||||
booked ahead: 2026-12-24
|
||||
|
||||
$ freedays mark 2026-12-24 --reason "travelling"
|
||||
$ freedays mark today
|
||||
$ freedays release 2026-12-24
|
||||
$ freedays check tomorrow # exit 0 if free, 1 if not -- for scripts
|
||||
$ freedays sync # pull and push now
|
||||
```
|
||||
|
||||
## The rules
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Budget** | 35 per calendar year, resetting Jan 1. Unused days do not carry over. |
|
||||
| **One pool** | A free day is free for *every* app at once. There is nothing per-app to mark, and nothing per-app to forget. |
|
||||
| **Declared, not backdated** | Today or later. A past day cannot be marked, so a gate you already failed cannot be erased afterwards. |
|
||||
| **Consecutive is fine** | Take a fortnight in a row if you want. Nothing here rate-limits you. |
|
||||
| **No reason required** | `--reason` is stored if given and never asked for. |
|
||||
| **Releasing refunds only the future** | Give back a day that has not arrived and it returns to the pool. Give back today, and it does not: those hours were already unguarded. |
|
||||
| **Silent** | No notification, no low-budget warning, no prompt when a gate fires. You find out by asking. |
|
||||
|
||||
## Free days are not sick days
|
||||
|
||||
screen-locker's sick days stay exactly where they are, with their rolling
|
||||
windows, their 120-character justifications and their escalating countdown.
|
||||
They are *designed to be hard to take*.
|
||||
|
||||
These are the opposite, and merging the two would have to ruin one of them:
|
||||
rolling windows forbid consecutive days, which is the entire point of a
|
||||
holiday. So the policies stay separate. Only the storage is shared.
|
||||
|
||||
## How it syncs
|
||||
|
||||
Firebase RTDB, one directory per device under `freedays-sync/devices/`, the
|
||||
same shape every other app in this fleet uses. No two devices write the same
|
||||
node, so nothing can conflict; convergence is the CRDT layer's job.
|
||||
|
||||
Marking is a last-writer-wins flip of a `state` field rather than a create
|
||||
and a delete, because crdt-sync tombstones are monotonic — deleting the
|
||||
record would burn that date permanently, and a released day has to be
|
||||
markable again.
|
||||
|
||||
`consumed` is a deliberately sticky flag: once a day has arrived while free
|
||||
it stays spent, even if two devices disagree about what "today" is across a
|
||||
midnight boundary.
|
||||
|
||||
## Timezone
|
||||
|
||||
Local, everywhere, decided in `_day.today()` and nowhere else.
|
||||
|
||||
This package exists partly because the apps disagreed about it: screen-locker
|
||||
computed the day in UTC in its lock chain while the tool that wrote its skip
|
||||
file used local time, so a skip added near midnight could name a date the
|
||||
chain never matched. A free day is a human calendar day — if it is Tuesday
|
||||
where you are standing, it is Tuesday.
|
||||
|
||||
## Development
|
||||
|
||||
```console
|
||||
$ pip install ../gatelock ../crdt-sync
|
||||
$ pip install -r requirements.txt && pip install --no-deps -e .
|
||||
$ python -m pytest # 100% branch coverage is enforced
|
||||
$ pre-commit run --config .pre-commit-config.yaml --all-files
|
||||
```
|
||||
84
freedays/freedays/__init__.py
Normal file
84
freedays/freedays/__init__.py
Normal file
@ -0,0 +1,84 @@
|
||||
"""One shared pool of pre-declared days on which every gate app stands down.
|
||||
|
||||
The whole point of this package fits in one call. A gate app asks::
|
||||
|
||||
import freedays
|
||||
|
||||
if freedays.is_free_day():
|
||||
return # no lock, no dialog, no alarm -- say nothing
|
||||
|
||||
That is the entire integration. It reads one local file, never the network,
|
||||
and answers ``False`` for anything it cannot parse, so the failure mode is
|
||||
"the normal rules apply", never "everything is switched off".
|
||||
|
||||
Two properties are deliberate and load-bearing:
|
||||
|
||||
* **Nothing here ever initiates.** No notification, no warning when the pool
|
||||
runs low, no prompt when a gate fires. The pool is consulted silently and
|
||||
reported only when someone runs ``freedays status``.
|
||||
* **One global pool.** A free day is free for every app at once. There is no
|
||||
per-app dimension to mark, and none to forget to mark.
|
||||
|
||||
Free days are *not* sick days. Sick days stay where they are, with their
|
||||
rolling windows and their justification friction, because they exist to be
|
||||
hard to take. These exist to be easy: declared in advance, no reason
|
||||
required, consecutive runs allowed, capped only by an annual budget.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from freedays._api import (
|
||||
Status,
|
||||
actor_id,
|
||||
is_free_day,
|
||||
load,
|
||||
lookup,
|
||||
mark,
|
||||
release,
|
||||
save,
|
||||
status,
|
||||
)
|
||||
from freedays._budget import free_days_in_year, is_exhausted, remaining, spent
|
||||
from freedays._constants import DEFAULT_ANNUAL_BUDGET
|
||||
from freedays._day import parse_iso, resolve, to_iso, today
|
||||
from freedays._errors import (
|
||||
AlreadyFreeError,
|
||||
BudgetExhaustedError,
|
||||
FreeDayError,
|
||||
NotFreeError,
|
||||
PastDateError,
|
||||
)
|
||||
from freedays._model import FreeDay
|
||||
from freedays._paths import Paths
|
||||
from freedays._sync import SyncUnavailableError, sync, sync_quietly
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_ANNUAL_BUDGET",
|
||||
"AlreadyFreeError",
|
||||
"BudgetExhaustedError",
|
||||
"FreeDay",
|
||||
"FreeDayError",
|
||||
"NotFreeError",
|
||||
"PastDateError",
|
||||
"Paths",
|
||||
"Status",
|
||||
"SyncUnavailableError",
|
||||
"actor_id",
|
||||
"free_days_in_year",
|
||||
"is_exhausted",
|
||||
"is_free_day",
|
||||
"load",
|
||||
"lookup",
|
||||
"mark",
|
||||
"parse_iso",
|
||||
"release",
|
||||
"remaining",
|
||||
"resolve",
|
||||
"save",
|
||||
"spent",
|
||||
"status",
|
||||
"sync",
|
||||
"sync_quietly",
|
||||
"to_iso",
|
||||
"today",
|
||||
]
|
||||
243
freedays/freedays/_api.py
Normal file
243
freedays/freedays/_api.py
Normal file
@ -0,0 +1,243 @@
|
||||
"""Ask, mark, release, report -- the whole public surface.
|
||||
|
||||
:func:`is_free_day` is the one every gate app calls, and it is deliberately
|
||||
the cheapest thing here: a single local file read, no network, no lock, no
|
||||
clock beyond today's date. A gate fires from a systemd timer, and a sync
|
||||
that hangs must never be able to hold one open or hold one shut.
|
||||
|
||||
Nothing in this module prompts, warns, or notifies. The pool is consulted
|
||||
silently and reported only when someone asks for it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from crdt_sync import Hlc, load_device_identity, read_log, write_log
|
||||
|
||||
from freedays._audit import record_event
|
||||
from freedays._budget import (
|
||||
free_days_in_year,
|
||||
is_exhausted,
|
||||
remaining,
|
||||
spent_days_in_year,
|
||||
)
|
||||
from freedays._constants import DEFAULT_ANNUAL_BUDGET
|
||||
from freedays._day import to_iso, today
|
||||
from freedays._errors import (
|
||||
AlreadyFreeError,
|
||||
BudgetExhaustedError,
|
||||
NotFreeError,
|
||||
PastDateError,
|
||||
)
|
||||
from freedays._model import (
|
||||
FreeDay,
|
||||
build_record,
|
||||
is_consumed_record,
|
||||
is_free_record,
|
||||
latest_hlc,
|
||||
to_free_day,
|
||||
)
|
||||
from freedays._paths import Paths, resolve_paths
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
from crdt_sync import Log, Record
|
||||
|
||||
|
||||
def load(log_path: Path | None = None) -> Log:
|
||||
"""Read the pool from disk. A missing or corrupt file reads as empty.
|
||||
|
||||
Takes a bare path rather than a :class:`Paths`: this and
|
||||
:func:`is_free_day` are what every gate app calls, and asking a gate to
|
||||
assemble a path bundle to answer one question is the wrong trade.
|
||||
"""
|
||||
return read_log(log_path if log_path is not None else resolve_paths(None).log)
|
||||
|
||||
|
||||
def save(log: Log, log_path: Path | None = None) -> None:
|
||||
"""Write the pool to disk atomically."""
|
||||
write_log(log_path if log_path is not None else resolve_paths(None).log, 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 lookup(
|
||||
day: date | None = None,
|
||||
*,
|
||||
log_path: Path | None = None,
|
||||
now: date | None = None,
|
||||
) -> FreeDay | None:
|
||||
"""Return the entry for ``day``, or None if that day was never touched."""
|
||||
target = day if day is not None else today(now)
|
||||
record = load(log_path).get(to_iso(target))
|
||||
return to_free_day(record) if record is not None else None
|
||||
|
||||
|
||||
def is_free_day(
|
||||
day: date | None = None,
|
||||
*,
|
||||
log_path: Path | None = None,
|
||||
now: date | None = None,
|
||||
) -> bool:
|
||||
"""Whether the gates stand down on ``day`` (default: today).
|
||||
|
||||
This is the integration point every app calls. It reads one local file
|
||||
and nothing else, so it cannot block, and it answers False for anything
|
||||
it does not understand -- an unreadable pool means the normal rules
|
||||
apply, never that everything is switched off.
|
||||
"""
|
||||
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_free_record(record)
|
||||
|
||||
|
||||
def _next_hlc(log: Log, node_id: str) -> Hlc:
|
||||
return Hlc.new_tick(node_id, latest_hlc(log, node_id))
|
||||
|
||||
|
||||
def _stays_consumed(existing: Record | None, day: date, reference: date) -> bool:
|
||||
"""Whether the day is spent: it has arrived, or was already marked spent.
|
||||
|
||||
OR-ing against the existing value is what keeps the flag sticky. Two
|
||||
devices either side of midnight can disagree about whether a day has
|
||||
arrived, and without this the one that thinks it is still tomorrow would
|
||||
write ``False`` over the other's ``True`` and hand back a day that was
|
||||
already used.
|
||||
"""
|
||||
already = existing is not None and is_consumed_record(existing)
|
||||
return already or day <= reference
|
||||
|
||||
|
||||
def mark(
|
||||
day: date,
|
||||
*,
|
||||
reason: str = "",
|
||||
paths: Paths | None = None,
|
||||
now: date | None = None,
|
||||
budget: int = DEFAULT_ANNUAL_BUDGET,
|
||||
) -> FreeDay:
|
||||
"""Mark ``day`` free, everywhere, for every app.
|
||||
|
||||
Args:
|
||||
day: The day to take. Today or later.
|
||||
reason: Optional free text. Never required.
|
||||
paths: Where the pool, uuid and audit trail live.
|
||||
now: Reference date, for tests.
|
||||
budget: Days granted in ``day``'s calendar year.
|
||||
|
||||
Returns:
|
||||
The stored entry.
|
||||
|
||||
Raises:
|
||||
PastDateError: If ``day`` has already passed.
|
||||
AlreadyFreeError: If ``day`` is already free.
|
||||
BudgetExhaustedError: If that year has no days left.
|
||||
"""
|
||||
resolved = resolve_paths(paths)
|
||||
reference = today(now)
|
||||
if day < reference:
|
||||
msg = (
|
||||
f"{to_iso(day)} has already passed -- free days are declared, not backdated"
|
||||
)
|
||||
raise PastDateError(msg)
|
||||
log = load(resolved.log)
|
||||
existing = log.get(to_iso(day))
|
||||
if existing is not None and is_free_record(existing):
|
||||
msg = f"{to_iso(day)} is already a free day"
|
||||
raise AlreadyFreeError(msg)
|
||||
if is_exhausted(log, day.year, budget=budget):
|
||||
msg = f"no free days left in {day.year} -- all {budget} are spent"
|
||||
raise BudgetExhaustedError(msg)
|
||||
|
||||
node = actor_id(resolved)
|
||||
entry = FreeDay(
|
||||
day=day,
|
||||
is_free=True,
|
||||
consumed=_stays_consumed(existing, day, reference),
|
||||
actor=node,
|
||||
reason=reason,
|
||||
)
|
||||
log[to_iso(day)] = build_record(entry, _next_hlc(log, node))
|
||||
save(log, resolved.log)
|
||||
record_event("mark", day, actor=node, reason=reason, paths=resolved)
|
||||
return entry
|
||||
|
||||
|
||||
def release(
|
||||
day: date,
|
||||
*,
|
||||
paths: Paths | None = None,
|
||||
now: date | None = None,
|
||||
) -> FreeDay:
|
||||
"""Give ``day`` back, and refund it only if it has not arrived yet.
|
||||
|
||||
Releasing a future day returns it to the pool. Releasing today or a past
|
||||
day restores the gates from here on but does not refund it: those hours
|
||||
were already unguarded, and pretending otherwise would make the count
|
||||
mean nothing.
|
||||
|
||||
Raises:
|
||||
NotFreeError: If ``day`` is not currently a free day.
|
||||
"""
|
||||
resolved = resolve_paths(paths)
|
||||
reference = today(now)
|
||||
log = load(resolved.log)
|
||||
existing = log.get(to_iso(day))
|
||||
if existing is None or not is_free_record(existing):
|
||||
msg = f"{to_iso(day)} is not a free day"
|
||||
raise NotFreeError(msg)
|
||||
|
||||
node = actor_id(resolved)
|
||||
entry = FreeDay(
|
||||
day=day,
|
||||
is_free=False,
|
||||
consumed=_stays_consumed(existing, day, reference),
|
||||
actor=node,
|
||||
)
|
||||
log[to_iso(day)] = build_record(entry, _next_hlc(log, node))
|
||||
save(log, resolved.log)
|
||||
record_event("release", day, actor=node, paths=resolved)
|
||||
return entry
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Status:
|
||||
"""A year's worth of pool state, for someone who went looking for it."""
|
||||
|
||||
year: int
|
||||
budget: int
|
||||
spent: int
|
||||
left: int
|
||||
today_is_free: bool
|
||||
upcoming: list[date] = field(default_factory=list)
|
||||
taken: list[date] = field(default_factory=list)
|
||||
|
||||
|
||||
def status(
|
||||
*,
|
||||
year: int | None = None,
|
||||
log_path: Path | None = None,
|
||||
now: date | None = None,
|
||||
budget: int = DEFAULT_ANNUAL_BUDGET,
|
||||
) -> Status:
|
||||
"""Summarise ``year`` (default: the current one). Never called on its own."""
|
||||
reference = today(now)
|
||||
target_year = year if year is not None else reference.year
|
||||
log = load(log_path)
|
||||
taken = spent_days_in_year(log, target_year)
|
||||
return Status(
|
||||
year=target_year,
|
||||
budget=budget,
|
||||
spent=len(taken),
|
||||
left=remaining(log, target_year, budget=budget),
|
||||
today_is_free=is_free_day(reference, log_path=log_path),
|
||||
upcoming=[d for d in free_days_in_year(log, target_year) if d > reference],
|
||||
taken=taken,
|
||||
)
|
||||
74
freedays/freedays/_audit.py
Normal file
74
freedays/freedays/_audit.py
Normal file
@ -0,0 +1,74 @@
|
||||
"""Append-only, HMAC-signed trail of every mark and release.
|
||||
|
||||
Nothing reads this to make a decision -- the CRDT log is the truth, and this
|
||||
file is deliberately not consulted by :mod:`freedays._api`. It exists so that
|
||||
"when did I take that day, and from which device" is answerable later, and so
|
||||
that a hand-edited entry is detectable rather than invisible.
|
||||
|
||||
Signing is best-effort on purpose. If the key is missing the entry is still
|
||||
written, unsigned: an unsigned audit line is a much smaller problem than a
|
||||
free day that could not be claimed because a key file was absent.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
import json
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from gatelock.log_integrity import compute_entry_hmac, generate_hmac_key
|
||||
|
||||
from freedays._day import to_iso
|
||||
from freedays._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,
|
||||
reason: str = "",
|
||||
paths: Paths | None = None,
|
||||
) -> None:
|
||||
"""Append one signed line describing a change to the pool.
|
||||
|
||||
Args:
|
||||
action: ``"mark"`` or ``"release"``.
|
||||
day: The day that changed.
|
||||
actor: The device id that made the change.
|
||||
reason: Optional free text supplied by the person.
|
||||
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,
|
||||
"reason": reason,
|
||||
"at": datetime.now(tz=UTC).isoformat(),
|
||||
}
|
||||
key_target = resolved.hmac_key
|
||||
if not key_target.exists():
|
||||
# First run on this machine. Generating it here rather than in an
|
||||
# installer means the trail is signed from the very first entry, and
|
||||
# that gatelock never gets to warn about a key that was simply never
|
||||
# created yet.
|
||||
key_target.parent.mkdir(parents=True, exist_ok=True)
|
||||
generate_hmac_key(key_target)
|
||||
key_target.chmod(0o600)
|
||||
entry["hmac"] = compute_entry_hmac(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:
|
||||
# An unwritable audit trail must never be the reason a day cannot be
|
||||
# taken -- the caller has already committed the change to the log.
|
||||
_logger.warning("could not append to the free-day audit trail: %s", exc)
|
||||
70
freedays/freedays/_budget.py
Normal file
70
freedays/freedays/_budget.py
Normal file
@ -0,0 +1,70 @@
|
||||
"""Calendar-year accounting for the pool.
|
||||
|
||||
One global pool, shared by every app: a day is either free everywhere or
|
||||
free nowhere. The budget resets on Jan 1 and unused days do not carry over,
|
||||
which is the employer-leave model people already have in their heads --
|
||||
deliberately *not* a rolling window, because "how many do I have left"
|
||||
should be answerable without knowing what you did eleven months ago.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from freedays._constants import DEFAULT_ANNUAL_BUDGET
|
||||
from freedays._model import is_consumed_record, is_free_record, to_free_day
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datetime import date
|
||||
|
||||
from crdt_sync import Log
|
||||
|
||||
|
||||
def free_days_in_year(log: Log, year: int) -> list[date]:
|
||||
"""Return every currently-free date in ``year``, ascending."""
|
||||
days: list[date] = []
|
||||
for record in log.values():
|
||||
if not is_free_record(record):
|
||||
continue
|
||||
free_day = to_free_day(record)
|
||||
if free_day is not None and free_day.day.year == year:
|
||||
days.append(free_day.day)
|
||||
return sorted(days)
|
||||
|
||||
|
||||
def spent_days_in_year(log: Log, year: int) -> list[date]:
|
||||
"""Every date in ``year`` that counts against the budget, ascending.
|
||||
|
||||
Two kinds count: days still marked free, and days that were released
|
||||
*after* they had already arrived. The second kind is why this is not
|
||||
simply :func:`free_days_in_year` -- taking Monday off and clearing the
|
||||
entry on Tuesday does not give Monday back.
|
||||
"""
|
||||
days: list[date] = []
|
||||
for record in log.values():
|
||||
free_day = to_free_day(record)
|
||||
if free_day is None or free_day.day.year != year:
|
||||
continue
|
||||
if is_free_record(record) or is_consumed_record(record):
|
||||
days.append(free_day.day)
|
||||
return sorted(days)
|
||||
|
||||
|
||||
def spent(log: Log, year: int) -> int:
|
||||
"""How many of ``year``'s free days are gone -- claimed or already used."""
|
||||
return len(spent_days_in_year(log, year))
|
||||
|
||||
|
||||
def remaining(log: Log, year: int, *, budget: int = DEFAULT_ANNUAL_BUDGET) -> int:
|
||||
"""How many free days ``year`` has left, never below zero.
|
||||
|
||||
Clamped at zero so a budget lowered after the fact -- or a log merged
|
||||
from a device that had a larger one -- reads as "none left" rather than
|
||||
as a negative number that some caller will eventually treat as a count.
|
||||
"""
|
||||
return max(0, budget - spent(log, year))
|
||||
|
||||
|
||||
def is_exhausted(log: Log, year: int, *, budget: int = DEFAULT_ANNUAL_BUDGET) -> bool:
|
||||
"""Whether ``year`` has no free days left to claim."""
|
||||
return remaining(log, year, budget=budget) <= 0
|
||||
132
freedays/freedays/_cli.py
Normal file
132
freedays/freedays/_cli.py
Normal file
@ -0,0 +1,132 @@
|
||||
"""The ``freedays`` command: the only thing that ever talks about free days.
|
||||
|
||||
Every subcommand is pull, never push. Nothing here runs on a timer, nothing
|
||||
notifies, and no gate ever invokes it to nag -- the apps consult
|
||||
:func:`freedays.is_free_day` silently and say nothing either way. If you
|
||||
want to know where the pool stands, you ask; otherwise it is invisible.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from freedays._api import is_free_day as _is_free_day
|
||||
from freedays._api import mark, release, status
|
||||
from freedays._cli_output import emit, emit_error
|
||||
from freedays._constants import DEFAULT_ANNUAL_BUDGET
|
||||
from freedays._day import resolve, to_iso
|
||||
from freedays._errors import FreeDayError
|
||||
from freedays._sync import SyncUnavailableError, sync
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
_EXIT_OK = 0
|
||||
_EXIT_REFUSED = 1
|
||||
_EXIT_BAD_INPUT = 2
|
||||
|
||||
|
||||
def _build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="freedays",
|
||||
description=(
|
||||
"One shared pool of days on which every gate app stands down. "
|
||||
f"{DEFAULT_ANNUAL_BUDGET} per calendar year, resetting Jan 1."
|
||||
),
|
||||
)
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
show = sub.add_parser("status", help="how many days are left this year")
|
||||
show.add_argument("--year", type=int, default=None)
|
||||
|
||||
check = sub.add_parser(
|
||||
"check", help="exit 0 if the day is free, 1 if it is not (for scripts)"
|
||||
)
|
||||
check.add_argument("day", nargs="?", default="today")
|
||||
|
||||
take = sub.add_parser("mark", help="take a day off, today or later")
|
||||
take.add_argument("day", help="YYYY-MM-DD, or 'today'/'tomorrow'")
|
||||
take.add_argument("--reason", default="", help="optional, never required")
|
||||
|
||||
give = sub.add_parser("release", help="give a day back")
|
||||
give.add_argument("day", help="YYYY-MM-DD, or 'today'/'tomorrow'")
|
||||
|
||||
sub.add_parser("sync", help="pull and push the pool now")
|
||||
return parser
|
||||
|
||||
|
||||
def _show_status(year: int | None) -> int:
|
||||
current = status(year=year)
|
||||
emit(f"{current.year}: {current.left} of {current.budget} free days left")
|
||||
if current.today_is_free:
|
||||
emit("today is a free day -- every gate is standing down")
|
||||
if current.upcoming:
|
||||
upcoming = ", ".join(to_iso(day) for day in current.upcoming)
|
||||
emit(f"booked ahead: {upcoming}")
|
||||
return _EXIT_OK
|
||||
|
||||
|
||||
def _do_check(raw_day: str) -> int:
|
||||
day = resolve(raw_day)
|
||||
free = _is_free_day(day)
|
||||
emit(f"{to_iso(day)}: {'free' if free else 'normal rules apply'}")
|
||||
return _EXIT_OK if free else _EXIT_REFUSED
|
||||
|
||||
|
||||
def _do_mark(raw_day: str, reason: str) -> int:
|
||||
entry = mark(resolve(raw_day), reason=reason)
|
||||
left = status(year=entry.day.year).left
|
||||
emit(f"{to_iso(entry.day)} is now a free day ({left} left in {entry.day.year})")
|
||||
return _EXIT_OK
|
||||
|
||||
|
||||
def _do_release(raw_day: str) -> int:
|
||||
entry = release(resolve(raw_day))
|
||||
left = status(year=entry.day.year).left
|
||||
refunded = (
|
||||
"not refunded (the day had already started)" if entry.consumed else "refunded"
|
||||
)
|
||||
emit(f"{to_iso(entry.day)} released, {refunded} -- {left} left in {entry.day.year}")
|
||||
return _EXIT_OK
|
||||
|
||||
|
||||
def _do_sync() -> int:
|
||||
sync()
|
||||
emit("pool synced")
|
||||
return _EXIT_OK
|
||||
|
||||
|
||||
def _dispatch(args: argparse.Namespace) -> int:
|
||||
if args.command == "status":
|
||||
return _show_status(args.year)
|
||||
if args.command == "check":
|
||||
return _do_check(args.day)
|
||||
if args.command == "mark":
|
||||
return _do_mark(args.day, args.reason)
|
||||
if args.command == "release":
|
||||
return _do_release(args.day)
|
||||
return _do_sync()
|
||||
|
||||
|
||||
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 (FreeDayError, SyncUnavailableError) as exc:
|
||||
emit_error(f"error: {exc}")
|
||||
return _EXIT_REFUSED
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
21
freedays/freedays/_cli_output.py
Normal file
21
freedays/freedays/_cli_output.py
Normal file
@ -0,0 +1,21 @@
|
||||
"""The one stdout/stderr sink every ``freedays`` 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")
|
||||
69
freedays/freedays/_constants.py
Normal file
69
freedays/freedays/_constants.py
Normal file
@ -0,0 +1,69 @@
|
||||
"""On-disk locations, remote paths and the one policy number that matters.
|
||||
|
||||
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 ``freedays/tests/conftest.py``, which redirects
|
||||
these as a second line of defence.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
|
||||
APP_NAME: Final = "freedays"
|
||||
|
||||
#: How many free days a calendar year grants. Resets on Jan 1; unused days do
|
||||
#: not carry over. 35 sits in the middle of the 30-40 band this was specced
|
||||
#: against -- roughly a week off per quarter plus a scattering of single days.
|
||||
DEFAULT_ANNUAL_BUDGET: Final = 35
|
||||
|
||||
STATE_DIR: Final = Path.home() / ".local" / "share" / "freedays"
|
||||
|
||||
#: The CRDT log itself: date -> Record(state=free|cleared).
|
||||
LOG_PATH: Final = STATE_DIR / "free_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 mark/unmark, for after-the-fact
|
||||
#: review. Not consulted by any decision -- the log above is the truth.
|
||||
AUDIT_PATH: Final = STATE_DIR / "audit.jsonl"
|
||||
|
||||
#: Signing key for the audit trail, generated on first use.
|
||||
#:
|
||||
#: User-owned, unlike screen-locker's root-owned key under /etc. That key
|
||||
#: defends the sick-day budget *against the person using the machine*, so it
|
||||
#: has to live somewhere they cannot rewrite. Free days are the opposite:
|
||||
#: they are theirs to take, no justification, no gatekeeper. The signature
|
||||
#: here only answers "was this line edited after the fact", for their own
|
||||
#: review -- so a per-user key is the right strength, and it means signing
|
||||
#: works without root instead of warning on every single mark.
|
||||
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 = "freedays-sync/devices"
|
||||
SYNC_FILENAME: Final = "free_days.json"
|
||||
|
||||
#: Field names inside a Record.
|
||||
FIELD_STATE: Final = "state"
|
||||
FIELD_ACTOR: Final = "actor"
|
||||
FIELD_REASON: Final = "reason"
|
||||
|
||||
#: Sticky "this day arrived while it was free, so it is spent". Releasing a
|
||||
#: *future* day refunds it -- it never happened. Releasing today or a past
|
||||
#: day does not: the gates already stood down. Nothing ever writes this back
|
||||
#: to false, which is what makes it monotonic under last-writer-wins merges.
|
||||
FIELD_CONSUMED: Final = "consumed"
|
||||
|
||||
#: Values of FIELD_STATE. Un-marking flips the field rather than tombstoning
|
||||
#: the record: crdt-sync deletes are deliberately monotonic, so a tombstoned
|
||||
#: date could never be marked free again.
|
||||
STATE_FREE: Final = "free"
|
||||
STATE_CLEARED: Final = "cleared"
|
||||
77
freedays/freedays/_day.py
Normal file
77
freedays/freedays/_day.py
Normal file
@ -0,0 +1,77 @@
|
||||
"""What "today" means, in one place.
|
||||
|
||||
The apps this package 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. Free 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)
|
||||
34
freedays/freedays/_errors.py
Normal file
34
freedays/freedays/_errors.py
Normal file
@ -0,0 +1,34 @@
|
||||
"""Why a mark or un-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 them and print ``str(exc)`` rather than let a traceback
|
||||
out.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class FreeDayError(Exception):
|
||||
"""Base class for every policy refusal in this package."""
|
||||
|
||||
|
||||
class PastDateError(FreeDayError):
|
||||
"""A day in the past cannot be marked free.
|
||||
|
||||
This is the one real abuse vector in an otherwise frictionless system:
|
||||
without it, a gate that already fired and was already failed could be
|
||||
erased after the fact.
|
||||
"""
|
||||
|
||||
|
||||
class BudgetExhaustedError(FreeDayError):
|
||||
"""This calendar year has no free days left."""
|
||||
|
||||
|
||||
class AlreadyFreeError(FreeDayError):
|
||||
"""The day is already marked free, so marking it again would be a no-op."""
|
||||
|
||||
|
||||
class NotFreeError(FreeDayError):
|
||||
"""The day is not marked free, so there is nothing to release."""
|
||||
131
freedays/freedays/_model.py
Normal file
131
freedays/freedays/_model.py
Normal file
@ -0,0 +1,131 @@
|
||||
"""Translating between a free 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. Marking and
|
||||
un-marking are a last-writer-wins flip of one field rather than a create and
|
||||
a delete, because crdt-sync tombstones are monotonic -- deleting the record
|
||||
would burn that date permanently.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from crdt_sync import Hlc, Record
|
||||
|
||||
from freedays._constants import (
|
||||
FIELD_ACTOR,
|
||||
FIELD_CONSUMED,
|
||||
FIELD_REASON,
|
||||
FIELD_STATE,
|
||||
STATE_CLEARED,
|
||||
STATE_FREE,
|
||||
)
|
||||
from freedays._day import parse_iso, to_iso
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datetime import date
|
||||
|
||||
from crdt_sync import Log
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FreeDay:
|
||||
"""One day's entry, as the rest of the package wants to see it.
|
||||
|
||||
Attributes:
|
||||
day: The calendar date.
|
||||
is_free: Whether the day currently stands the gates down.
|
||||
consumed: Whether the day arrived while free, and so stays spent even
|
||||
after being released.
|
||||
actor: Which device last changed it.
|
||||
reason: Optional free text. Never required -- a free day carries no
|
||||
justification burden by design.
|
||||
"""
|
||||
|
||||
day: date
|
||||
is_free: bool
|
||||
consumed: bool = False
|
||||
actor: str = ""
|
||||
reason: 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_consumed_record(record: Record) -> bool:
|
||||
"""Whether ``record``'s day is spent regardless of its current state."""
|
||||
field = record.fields.get(FIELD_CONSUMED)
|
||||
return bool(field[0]) if field is not None else False
|
||||
|
||||
|
||||
def is_free_record(record: Record) -> bool:
|
||||
"""Whether ``record`` currently marks its day free.
|
||||
|
||||
A tombstoned record counts as not free: nothing in this package creates
|
||||
one, but a hand-edited or future-format log might, and "not free" is the
|
||||
safe reading of a record we do not understand.
|
||||
"""
|
||||
if record.deleted:
|
||||
return False
|
||||
return _field_str(record, FIELD_STATE) == STATE_FREE
|
||||
|
||||
|
||||
def to_free_day(record: Record) -> FreeDay | None:
|
||||
"""Build a :class:`FreeDay` from ``record``, or None if its id is junk."""
|
||||
try:
|
||||
day = parse_iso(record.id)
|
||||
except ValueError:
|
||||
return None
|
||||
return FreeDay(
|
||||
day=day,
|
||||
is_free=is_free_record(record),
|
||||
consumed=is_consumed_record(record),
|
||||
actor=_field_str(record, FIELD_ACTOR),
|
||||
reason=_field_str(record, FIELD_REASON),
|
||||
)
|
||||
|
||||
|
||||
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, which is what stops a fresh process
|
||||
from issuing 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: FreeDay, hlc: Hlc) -> Record:
|
||||
"""Return the record expressing ``entry``, stamped with ``hlc``.
|
||||
|
||||
Every field carries the same tick, so a concurrent mark and un-mark of
|
||||
one day resolve together rather than interleaving into a state from one
|
||||
device and a reason from the other.
|
||||
|
||||
``entry.consumed`` is only ever ``True`` when the caller has already
|
||||
established the day has arrived; no caller writes it back to ``False``
|
||||
over a ``True``, which is what keeps it sticky across merges.
|
||||
"""
|
||||
state = STATE_FREE if entry.is_free else STATE_CLEARED
|
||||
return Record(
|
||||
id=to_iso(entry.day),
|
||||
fields={
|
||||
FIELD_STATE: (state, hlc),
|
||||
FIELD_CONSUMED: (entry.consumed, hlc),
|
||||
FIELD_ACTOR: (entry.actor, hlc),
|
||||
FIELD_REASON: (entry.reason, hlc),
|
||||
},
|
||||
)
|
||||
66
freedays/freedays/_paths.py
Normal file
66
freedays/freedays/_paths.py
Normal file
@ -0,0 +1,66 @@
|
||||
"""Every file this package owns, as one value that can be swapped wholesale.
|
||||
|
||||
Threading five separate ``*_path`` keyword arguments through the API was the
|
||||
first shape of this, and it had a specific failure mode: a test suite has to
|
||||
redirect *every* one of them, and a module that starts importing a sixth
|
||||
silently keeps writing to real user state while the suite still looks green.
|
||||
That has happened before in this fleet and cost a live log file.
|
||||
|
||||
One bundle means one seam. ``Paths.under(tmp_path)`` redirects everything at
|
||||
once, and a new file added to this class is redirected by construction
|
||||
rather than by remembering to update a list somewhere else.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from freedays._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/freedays``."""
|
||||
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 / "free_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()
|
||||
128
freedays/freedays/_sync.py
Normal file
128
freedays/freedays/_sync.py
Normal file
@ -0,0 +1,128 @@
|
||||
"""Pushing and pulling the pool, so a day taken on the phone lands on the PC.
|
||||
|
||||
Sync is a *separate* operation from every decision in :mod:`freedays._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 freedays._api import actor_id, load, save
|
||||
from freedays._constants import (
|
||||
APP_NAME,
|
||||
SYNC_FILENAME,
|
||||
SYNC_PATH_PREFIX,
|
||||
)
|
||||
from freedays._paths import Paths, resolve_paths
|
||||
|
||||
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:`freedays._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"free-day sync is not configured on this machine: {exc}"
|
||||
raise SyncUnavailableError(msg) from exc
|
||||
except FirebaseAuthError as exc:
|
||||
msg = f"free-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="freedays: update pool",
|
||||
),
|
||||
RevisionTracking(state_store=FileSyncStateStore(state_path)),
|
||||
)
|
||||
except RemoteSyncError as exc:
|
||||
msg = f"free-day sync could not reach the remote: {exc}"
|
||||
raise SyncUnavailableError(msg) from exc
|
||||
save(merged, resolved.log)
|
||||
return merged
|
||||
|
||||
|
||||
def sync_quietly(*, paths: Paths | None = None) -> bool:
|
||||
"""Sync, swallowing unavailability. Returns whether it actually ran.
|
||||
|
||||
For callers on a timer that want a fresher pool but must not fail, and
|
||||
must not say anything, if the network is down.
|
||||
"""
|
||||
try:
|
||||
sync(paths=paths)
|
||||
except SyncUnavailableError as exc:
|
||||
_logger.debug("free-day sync skipped: %s", exc)
|
||||
return False
|
||||
return True
|
||||
1
freedays/freedays/tests/__init__.py
Normal file
1
freedays/freedays/tests/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Tests for :mod:`freedays`."""
|
||||
44
freedays/freedays/tests/conftest.py
Normal file
44
freedays/freedays/tests/conftest.py
Normal file
@ -0,0 +1,44 @@
|
||||
"""Redirect every on-disk path away from real user state, for every test.
|
||||
|
||||
This is one ``monkeypatch`` of one function, and that is the whole point.
|
||||
An earlier version patched five separate module attributes by name, which
|
||||
meant a module that started importing a sixth path constant would keep
|
||||
writing to ``~/.local/share/freedays`` while the suite still looked green --
|
||||
the exact way a test run in this fleet once wrote 27 fake entries into a
|
||||
live log.
|
||||
|
||||
Because every writer resolves its paths through
|
||||
:func:`freedays._paths.resolve_paths`, redirecting that one function moves
|
||||
all of them, including any file added later.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
|
||||
from freedays._paths import Paths
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@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 ("freedays._api", "freedays._audit", "freedays._sync"):
|
||||
monkeypatch.setattr(f"{module}.resolve_paths", _resolve)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def state_dir(tmp_path: Path) -> Path:
|
||||
"""The redirected state directory, for tests that assert on files."""
|
||||
return tmp_path / "state"
|
||||
199
freedays/freedays/tests/test_api.py
Normal file
199
freedays/freedays/tests/test_api.py
Normal file
@ -0,0 +1,199 @@
|
||||
"""Marking, releasing, and the budget arithmetic behind both."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, timedelta
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
|
||||
from freedays._api import (
|
||||
actor_id,
|
||||
is_free_day,
|
||||
load,
|
||||
lookup,
|
||||
mark,
|
||||
release,
|
||||
save,
|
||||
status,
|
||||
)
|
||||
from freedays._errors import (
|
||||
AlreadyFreeError,
|
||||
BudgetExhaustedError,
|
||||
NotFreeError,
|
||||
PastDateError,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
TODAY = date(2026, 9, 7)
|
||||
TOMORROW = date(2026, 9, 8)
|
||||
YESTERDAY = date(2026, 9, 6)
|
||||
|
||||
|
||||
def test_an_untouched_day_is_not_free() -> None:
|
||||
assert not is_free_day(TODAY, now=TODAY)
|
||||
|
||||
|
||||
def test_marking_a_day_makes_it_free() -> None:
|
||||
mark(TODAY, now=TODAY)
|
||||
assert is_free_day(TODAY, now=TODAY)
|
||||
|
||||
|
||||
def test_is_free_day_defaults_to_today() -> None:
|
||||
mark(TODAY, now=TODAY)
|
||||
assert is_free_day(now=TODAY)
|
||||
|
||||
|
||||
def test_marking_one_day_does_not_free_its_neighbour() -> None:
|
||||
mark(TODAY, now=TODAY)
|
||||
assert not is_free_day(TOMORROW, now=TODAY)
|
||||
|
||||
|
||||
def test_an_unreadable_pool_means_the_normal_rules_apply(tmp_path: Path) -> None:
|
||||
corrupt = tmp_path / "corrupt.json"
|
||||
corrupt.write_text("{ this is not json", encoding="utf-8")
|
||||
assert not is_free_day(TODAY, log_path=corrupt, now=TODAY)
|
||||
|
||||
|
||||
def test_a_reason_is_stored_but_never_required() -> None:
|
||||
mark(TODAY, reason="wedding", now=TODAY)
|
||||
entry = lookup(TODAY, now=TODAY)
|
||||
assert entry is not None
|
||||
assert entry.reason == "wedding"
|
||||
|
||||
|
||||
def test_marking_without_a_reason_is_fine() -> None:
|
||||
entry = mark(TOMORROW, now=TODAY)
|
||||
assert entry.reason == ""
|
||||
|
||||
|
||||
def test_lookup_defaults_to_today_and_returns_none_for_an_untouched_day() -> None:
|
||||
assert lookup(now=TODAY) is None
|
||||
|
||||
|
||||
def test_a_past_day_cannot_be_marked() -> None:
|
||||
with pytest.raises(PastDateError, match="already passed"):
|
||||
mark(YESTERDAY, now=TODAY)
|
||||
|
||||
|
||||
def test_marking_an_already_free_day_is_refused() -> None:
|
||||
mark(TOMORROW, now=TODAY)
|
||||
with pytest.raises(AlreadyFreeError, match="already a free day"):
|
||||
mark(TOMORROW, now=TODAY)
|
||||
|
||||
|
||||
def test_the_annual_budget_is_enforced() -> None:
|
||||
for offset in range(3):
|
||||
mark(date(2026, 10, 1 + offset), now=TODAY, budget=3)
|
||||
with pytest.raises(BudgetExhaustedError, match="no free days left in 2026"):
|
||||
mark(date(2026, 11, 1), now=TODAY, budget=3)
|
||||
|
||||
|
||||
def test_the_budget_is_per_calendar_year_of_the_day_being_marked() -> None:
|
||||
for offset in range(3):
|
||||
mark(date(2026, 10, 1 + offset), now=TODAY, budget=3)
|
||||
# 2027 has its own allowance; Jan 1 resets it.
|
||||
assert mark(date(2027, 1, 2), now=TODAY, budget=3).is_free
|
||||
|
||||
|
||||
def test_consecutive_days_are_allowed() -> None:
|
||||
"""A fortnight in a row -- the whole reason these are not sick days."""
|
||||
for offset in range(14):
|
||||
mark(date(2026, 10, 1) + timedelta(days=offset), now=TODAY)
|
||||
assert is_free_day(date(2026, 10, 14), now=TODAY)
|
||||
|
||||
|
||||
def test_releasing_a_day_that_was_never_free_is_refused() -> None:
|
||||
with pytest.raises(NotFreeError, match="not a free day"):
|
||||
release(TOMORROW, now=TODAY)
|
||||
|
||||
|
||||
def test_releasing_an_already_released_day_is_refused() -> None:
|
||||
mark(TOMORROW, now=TODAY)
|
||||
release(TOMORROW, now=TODAY)
|
||||
with pytest.raises(NotFreeError):
|
||||
release(TOMORROW, now=TODAY)
|
||||
|
||||
|
||||
def test_releasing_a_future_day_refunds_it() -> None:
|
||||
mark(TOMORROW, now=TODAY)
|
||||
assert status(now=TODAY).left == 34
|
||||
entry = release(TOMORROW, now=TODAY)
|
||||
assert not entry.consumed
|
||||
assert status(now=TODAY).left == 35
|
||||
|
||||
|
||||
def test_releasing_today_does_not_refund_it() -> None:
|
||||
mark(TODAY, now=TODAY)
|
||||
entry = release(TODAY, now=TODAY)
|
||||
assert entry.consumed
|
||||
assert not is_free_day(TODAY, now=TODAY)
|
||||
assert status(now=TODAY).left == 34
|
||||
|
||||
|
||||
def test_a_day_released_after_it_arrived_stays_spent() -> None:
|
||||
mark(TOMORROW, now=TODAY)
|
||||
# The day arrives, then is released the day after.
|
||||
entry = release(TOMORROW, now=date(2026, 9, 9))
|
||||
assert entry.consumed
|
||||
assert status(now=TODAY).left == 34
|
||||
|
||||
|
||||
def test_a_day_marked_today_is_immediately_consumed() -> None:
|
||||
assert mark(TODAY, now=TODAY).consumed
|
||||
|
||||
|
||||
def test_a_day_marked_ahead_is_not_yet_consumed() -> None:
|
||||
assert not mark(TOMORROW, now=TODAY).consumed
|
||||
|
||||
|
||||
def test_a_released_future_day_can_be_marked_again() -> None:
|
||||
mark(TOMORROW, now=TODAY)
|
||||
release(TOMORROW, now=TODAY)
|
||||
assert mark(TOMORROW, now=TODAY).is_free
|
||||
|
||||
|
||||
def test_re_marking_a_consumed_day_keeps_it_consumed() -> None:
|
||||
mark(TODAY, now=TODAY)
|
||||
release(TODAY, now=TODAY)
|
||||
assert mark(TODAY, now=TODAY).consumed
|
||||
|
||||
|
||||
def test_status_reports_the_year_the_budget_and_what_is_booked() -> None:
|
||||
mark(TODAY, now=TODAY)
|
||||
mark(date(2026, 12, 24), now=TODAY)
|
||||
current = status(now=TODAY)
|
||||
assert current.year == 2026
|
||||
assert current.budget == 35
|
||||
assert current.spent == 2
|
||||
assert current.left == 33
|
||||
assert current.today_is_free
|
||||
assert current.upcoming == [date(2026, 12, 24)]
|
||||
assert current.taken == [TODAY, date(2026, 12, 24)]
|
||||
|
||||
|
||||
def test_status_can_be_asked_about_another_year() -> None:
|
||||
mark(date(2027, 3, 1), now=TODAY)
|
||||
assert status(year=2027, now=TODAY).spent == 1
|
||||
assert status(year=2026, now=TODAY).spent == 0
|
||||
|
||||
|
||||
def test_status_left_never_goes_negative() -> None:
|
||||
mark(TOMORROW, now=TODAY)
|
||||
mark(date(2026, 9, 9), now=TODAY)
|
||||
assert status(now=TODAY, budget=1).left == 0
|
||||
|
||||
|
||||
def test_the_device_id_is_a_persisted_uuid_not_a_fixed_constant() -> None:
|
||||
first = actor_id()
|
||||
assert first not in {"pc", "phone", ""}
|
||||
assert actor_id() == first
|
||||
|
||||
|
||||
def test_save_and_load_round_trip(tmp_path: Path) -> None:
|
||||
mark(TOMORROW, now=TODAY)
|
||||
elsewhere = tmp_path / "copy.json"
|
||||
save(load(), elsewhere)
|
||||
assert is_free_day(TOMORROW, log_path=elsewhere, now=TODAY)
|
||||
90
freedays/freedays/tests/test_audit.py
Normal file
90
freedays/freedays/tests/test_audit.py
Normal file
@ -0,0 +1,90 @@
|
||||
"""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 gatelock.log_integrity import verify_entry_hmac
|
||||
|
||||
from freedays._audit import record_event
|
||||
from freedays._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("mark", DAY, actor="device-1", reason="wedding")
|
||||
(entry,) = _lines(state_dir / "audit.jsonl")
|
||||
assert entry["action"] == "mark"
|
||||
assert entry["day"] == "2026-12-24"
|
||||
assert entry["actor"] == "device-1"
|
||||
assert entry["reason"] == "wedding"
|
||||
|
||||
|
||||
def test_events_accumulate_rather_than_overwrite(state_dir: Path) -> None:
|
||||
record_event("mark", DAY, actor="device-1")
|
||||
record_event("release", 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("mark", DAY, actor="device-1")
|
||||
assert key.exists()
|
||||
|
||||
|
||||
def test_the_written_entry_verifies_against_the_key(state_dir: Path) -> None:
|
||||
record_event("mark", DAY, actor="device-1")
|
||||
(entry,) = _lines(state_dir / "audit.jsonl")
|
||||
assert verify_entry_hmac(entry, key_file=state_dir / "hmac.key")
|
||||
|
||||
|
||||
def test_a_tampered_entry_stops_verifying(state_dir: Path) -> None:
|
||||
record_event("mark", DAY, actor="device-1")
|
||||
(entry,) = _lines(state_dir / "audit.jsonl")
|
||||
entry["day"] = "2026-12-25"
|
||||
assert not verify_entry_hmac(entry, key_file=state_dir / "hmac.key")
|
||||
|
||||
|
||||
def test_an_existing_key_is_reused_rather_than_regenerated(state_dir: Path) -> None:
|
||||
record_event("mark", DAY, actor="device-1")
|
||||
original = (state_dir / "hmac.key").read_bytes()
|
||||
record_event("release", 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("mark", DAY, actor="device-1", paths=Paths.under(elsewhere))
|
||||
(entry,) = _lines(elsewhere / "audit.jsonl")
|
||||
assert verify_entry_hmac(entry, key_file=elsewhere / "hmac.key")
|
||||
|
||||
|
||||
def test_an_unwritable_trail_warns_but_never_raises(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A day must be takeable 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(
|
||||
"mark",
|
||||
DAY,
|
||||
actor="device-1",
|
||||
paths=replace(Paths.under(tmp_path), audit=blocked),
|
||||
)
|
||||
assert "could not append" in caplog.text
|
||||
132
freedays/freedays/tests/test_cli.py
Normal file
132
freedays/freedays/tests/test_cli.py
Normal file
@ -0,0 +1,132 @@
|
||||
"""The command line: the only surface that ever mentions free days."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from freedays._cli import main
|
||||
from freedays._day import to_iso, today
|
||||
from freedays._sync import SyncUnavailableError
|
||||
from freedays.tests.test_sync import FakeRemote
|
||||
|
||||
|
||||
def _future() -> str:
|
||||
return to_iso(today() + timedelta(days=30))
|
||||
|
||||
|
||||
def test_status_on_an_empty_pool(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
assert main(["status"]) == 0
|
||||
assert "35 of 35 free days left" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_check_exits_one_when_the_day_is_not_free(
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
assert main(["check"]) == 1
|
||||
assert "normal rules apply" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_mark_then_check_exits_zero(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
assert main(["mark", "today"]) == 0
|
||||
capsys.readouterr()
|
||||
assert main(["check", "today"]) == 0
|
||||
assert "free" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_mark_reports_what_is_left(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
assert main(["mark", "tomorrow"]) == 0
|
||||
assert "34 left" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_mark_accepts_an_optional_reason(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
assert main(["mark", _future(), "--reason", "wedding"]) == 0
|
||||
assert "is now a free day" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_marking_a_past_day_is_refused(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
yesterday = to_iso(today() - timedelta(days=1))
|
||||
assert main(["mark", yesterday]) == 1
|
||||
assert "already passed" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_an_unparsable_date_is_told_apart_from_a_refusal(
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
"""Exit 2 means "I did not understand", not "no"."""
|
||||
assert main(["mark", "next tuesday"]) == 2
|
||||
assert "not a YYYY-MM-DD date" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_release_of_a_future_day_says_it_was_refunded(
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
day = _future()
|
||||
main(["mark", day])
|
||||
capsys.readouterr()
|
||||
assert main(["release", day]) == 0
|
||||
out = capsys.readouterr().out
|
||||
assert "refunded" in out
|
||||
assert "not refunded" not in out
|
||||
|
||||
|
||||
def test_release_of_today_says_it_was_not_refunded(
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
main(["mark", "today"])
|
||||
capsys.readouterr()
|
||||
assert main(["release", "today"]) == 0
|
||||
assert "not refunded" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_releasing_a_day_that_is_not_free_is_refused(
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
assert main(["release", _future()]) == 1
|
||||
assert "not a free day" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_status_lists_days_booked_ahead(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
day = _future()
|
||||
main(["mark", day])
|
||||
capsys.readouterr()
|
||||
main(["status"])
|
||||
assert f"booked ahead: {day}" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_status_says_when_today_is_free(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
main(["mark", "today"])
|
||||
capsys.readouterr()
|
||||
main(["status"])
|
||||
assert "every gate is standing down" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_status_accepts_a_year(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
assert main(["status", "--year", "2030"]) == 0
|
||||
assert "2030: 35 of 35" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_sync_reports_success(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
monkeypatch.setattr("freedays._sync.firebase_client_for", lambda _app: FakeRemote())
|
||||
assert main(["sync"]) == 0
|
||||
assert "pool synced" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_sync_failure_is_reported_without_a_traceback(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
def _refuse() -> None:
|
||||
msg = "no credentials on this machine"
|
||||
raise SyncUnavailableError(msg)
|
||||
|
||||
monkeypatch.setattr("freedays._sync.get_client", _refuse)
|
||||
assert main(["sync"]) == 1
|
||||
assert "no credentials" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_a_missing_subcommand_is_rejected() -> None:
|
||||
with pytest.raises(SystemExit):
|
||||
main([])
|
||||
49
freedays/freedays/tests/test_day.py
Normal file
49
freedays/freedays/tests/test_day.py
Normal file
@ -0,0 +1,49 @@
|
||||
"""Date handling: the one place allowed an opinion about "today"."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
|
||||
from freedays._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))
|
||||
119
freedays/freedays/tests/test_model.py
Normal file
119
freedays/freedays/tests/test_model.py
Normal file
@ -0,0 +1,119 @@
|
||||
"""Record translation, and the CRDT properties the design leans on."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
from crdt_sync import Hlc, Record, merge_logs, merge_record
|
||||
|
||||
from freedays._constants import FIELD_CONSUMED, FIELD_STATE, STATE_FREE
|
||||
from freedays._model import (
|
||||
FreeDay,
|
||||
build_record,
|
||||
is_consumed_record,
|
||||
is_free_record,
|
||||
latest_hlc,
|
||||
to_free_day,
|
||||
)
|
||||
|
||||
DAY = date(2026, 12, 24)
|
||||
|
||||
|
||||
def _record(
|
||||
*, free: bool, consumed: bool = False, node: str = "a", ms: int = 1000
|
||||
) -> Record:
|
||||
return build_record(
|
||||
FreeDay(day=DAY, is_free=free, consumed=consumed, actor=node),
|
||||
Hlc(wall_time_ms=ms, counter=0, node_id=node),
|
||||
)
|
||||
|
||||
|
||||
def test_the_record_id_is_the_date_so_two_devices_converge() -> None:
|
||||
assert _record(free=True).id == "2026-12-24"
|
||||
|
||||
|
||||
def test_a_marked_record_reads_as_free() -> None:
|
||||
assert is_free_record(_record(free=True))
|
||||
|
||||
|
||||
def test_a_cleared_record_does_not_read_as_free() -> None:
|
||||
assert not is_free_record(_record(free=False))
|
||||
|
||||
|
||||
def test_a_tombstoned_record_is_never_free() -> None:
|
||||
tombstoned = Record(
|
||||
id="2026-12-24",
|
||||
fields={FIELD_STATE: (STATE_FREE, Hlc(1, 0, "a"))},
|
||||
deleted=True,
|
||||
)
|
||||
assert not is_free_record(tombstoned)
|
||||
|
||||
|
||||
def test_consumed_defaults_to_false_when_the_field_is_absent() -> None:
|
||||
bare = Record(id="2026-12-24", fields={})
|
||||
assert not is_consumed_record(bare)
|
||||
|
||||
|
||||
def test_to_free_day_reports_state_and_consumption() -> None:
|
||||
entry = to_free_day(_record(free=True, consumed=True))
|
||||
assert entry is not None
|
||||
assert entry.day == DAY
|
||||
assert entry.is_free
|
||||
assert entry.consumed
|
||||
|
||||
|
||||
def test_to_free_day_rejects_a_record_whose_id_is_not_a_date() -> None:
|
||||
assert to_free_day(Record(id="not-a-date", fields={})) is None
|
||||
|
||||
|
||||
def test_a_non_string_field_value_reads_as_empty() -> None:
|
||||
weird = Record(id="2026-12-24", fields={FIELD_STATE: (17, Hlc(1, 0, "a"))})
|
||||
entry = to_free_day(weird)
|
||||
assert entry is not None
|
||||
assert not entry.is_free
|
||||
|
||||
|
||||
def test_latest_hlc_is_none_for_a_node_that_has_written_nothing() -> None:
|
||||
assert latest_hlc({"2026-12-24": _record(free=True, node="a")}, "b") is None
|
||||
|
||||
|
||||
def test_latest_hlc_finds_this_nodes_greatest_tick() -> None:
|
||||
log = {
|
||||
"2026-12-24": _record(free=True, node="a", ms=5000),
|
||||
"2026-12-25": build_record(
|
||||
FreeDay(day=date(2026, 12, 25), is_free=True, actor="a"),
|
||||
Hlc(wall_time_ms=9000, counter=0, node_id="a"),
|
||||
),
|
||||
}
|
||||
found = latest_hlc(log, "a")
|
||||
assert found is not None
|
||||
assert found.wall_time_ms == 9000
|
||||
|
||||
|
||||
def test_marking_then_clearing_the_same_day_converges_to_the_later_write() -> None:
|
||||
marked = _record(free=True, node="a", ms=1000)
|
||||
cleared = _record(free=False, node="b", ms=2000)
|
||||
assert not is_free_record(merge_record(marked, cleared))
|
||||
assert not is_free_record(merge_record(cleared, marked))
|
||||
|
||||
|
||||
def test_a_cleared_day_can_be_marked_free_again() -> None:
|
||||
"""The reason un-marking flips a field instead of tombstoning."""
|
||||
cleared = _record(free=False, node="a", ms=1000)
|
||||
remarked = _record(free=True, node="b", ms=2000)
|
||||
assert is_free_record(merge_record(cleared, remarked))
|
||||
|
||||
|
||||
def test_merge_is_commutative_and_idempotent_over_a_log() -> None:
|
||||
left = {"2026-12-24": _record(free=True, node="a", ms=1000)}
|
||||
right = {"2026-12-25": _record(free=False, node="b", ms=2000)}
|
||||
assert merge_logs(left, right) == merge_logs(right, left)
|
||||
assert merge_logs(left, left) == left
|
||||
|
||||
|
||||
def test_consumed_survives_a_merge_with_a_later_unconsumed_write() -> None:
|
||||
"""Sticky consumption is what stops a spent day being handed back."""
|
||||
spent = _record(free=True, consumed=True, node="a", ms=1000)
|
||||
later = _record(free=False, consumed=True, node="b", ms=2000)
|
||||
merged = merge_record(spent, later)
|
||||
assert merged.fields[FIELD_CONSUMED][0] is True
|
||||
59
freedays/freedays/tests/test_paths.py
Normal file
59
freedays/freedays/tests/test_paths.py
Normal file
@ -0,0 +1,59 @@
|
||||
"""The path bundle itself, exercised unpatched.
|
||||
|
||||
The autouse redirect in ``conftest`` replaces ``resolve_paths`` *as imported
|
||||
by* ``_api``/``_audit``/``_sync``. It does not touch ``freedays._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 freedays._constants import (
|
||||
AUDIT_PATH,
|
||||
DEVICE_ID_PATH,
|
||||
HMAC_KEY_FILE,
|
||||
LOG_PATH,
|
||||
STATE_DIR,
|
||||
SYNC_STATE_PATH,
|
||||
)
|
||||
from freedays._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/freedays``."""
|
||||
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()
|
||||
161
freedays/freedays/tests/test_sync.py
Normal file
161
freedays/freedays/tests/test_sync.py
Normal file
@ -0,0 +1,161 @@
|
||||
"""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 freedays._api import is_free_day, load, mark
|
||||
from freedays._paths import Paths
|
||||
from freedays._sync import SyncUnavailableError, get_client, sync, sync_quietly
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
TODAY = date(2026, 9, 7)
|
||||
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:
|
||||
mark(LATER, now=TODAY)
|
||||
remote = FakeRemote()
|
||||
sync(client=remote)
|
||||
assert any("freedays-sync/devices/" in path for path in remote.files)
|
||||
|
||||
|
||||
def test_a_peers_day_arrives_and_becomes_free_locally() -> None:
|
||||
peer_log = {}
|
||||
# Build a peer's pushed log by marking into a separate pool file.
|
||||
mark(LATER, now=TODAY)
|
||||
peer_log = load()
|
||||
remote = FakeRemote(
|
||||
{"freedays-sync/devices/other-device/free_days.json": dump_log(peer_log)}
|
||||
)
|
||||
# Start over with an empty local pool, then pull.
|
||||
from freedays._api import save
|
||||
|
||||
save({})
|
||||
assert not is_free_day(LATER, now=TODAY)
|
||||
sync(client=remote)
|
||||
assert is_free_day(LATER, now=TODAY)
|
||||
|
||||
|
||||
def test_syncing_twice_changes_nothing() -> None:
|
||||
mark(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:
|
||||
mark(LATER, now=TODAY)
|
||||
remote = FakeRemote(
|
||||
{"freedays-sync/devices/broken/free_days.json": "{not json at all"}
|
||||
)
|
||||
sync(client=remote)
|
||||
assert is_free_day(LATER, now=TODAY)
|
||||
|
||||
|
||||
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("freedays._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("freedays._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("freedays._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("freedays._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("freedays._sync.firebase_client_for", lambda _app: remote)
|
||||
mark(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)
|
||||
mark(LATER, now=TODAY, paths=paths)
|
||||
sync(client=FakeRemote(), paths=paths)
|
||||
assert is_free_day(LATER, log_path=paths.log, now=TODAY)
|
||||
162
freedays/pyproject.toml
Normal file
162
freedays/pyproject.toml
Normal file
@ -0,0 +1,162 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=61"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "freedays"
|
||||
version = "0.1.0"
|
||||
description = "Shared pre-declared free-day pool: one global list of days on which every gate app stands down"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = [
|
||||
"gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.7.3#subdirectory=gatelock",
|
||||
"crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.1#subdirectory=crdt-sync",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
freedays = "freedays._cli:main"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["freedays*"]
|
||||
|
||||
[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 = ["freedays"]
|
||||
|
||||
[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]
|
||||
analyse-fallback-blocks = true
|
||||
persistent = true
|
||||
jobs = 0
|
||||
py-version = "3.14"
|
||||
ignore = [".venv", "__pycache__"]
|
||||
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 = ["freedays/tests"]
|
||||
python_files = ["test_*.py", "*_test.py"]
|
||||
python_classes = ["Test*"]
|
||||
python_functions = ["test_*"]
|
||||
addopts = [
|
||||
"-v",
|
||||
"--strict-markers",
|
||||
"--strict-config",
|
||||
"-ra",
|
||||
"--cov=freedays",
|
||||
"--cov-branch",
|
||||
"--cov-report=term-missing",
|
||||
"--cov-report=lcov",
|
||||
]
|
||||
filterwarnings = [
|
||||
"error",
|
||||
"ignore::DeprecationWarning",
|
||||
"default::pytest.PytestUnraisableExceptionWarning",
|
||||
]
|
||||
|
||||
[tool.coverage.run]
|
||||
source = ["freedays"]
|
||||
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
freedays/requirements.txt
Normal file
10
freedays/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.3.1
|
||||
pre-commit==4.6.2
|
||||
pylint==4.0.8
|
||||
pytest==9.1.1
|
||||
pytest-cov==7.1.0
|
||||
ruff==0.16.6
|
||||
Loading…
Reference in New Issue
Block a user