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

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:
Krzysztof Rudnicki 2026-09-07 15:08:22 +02:00
parent 92d7151bdf
commit ef69619597
27 changed files with 2460 additions and 0 deletions

View 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
View 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

View 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
View 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
```

View 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
View 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,
)

View 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)

View 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
View 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())

View 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")

View 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
View 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)

View 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
View 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),
},
)

View 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
View 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

View File

@ -0,0 +1 @@
"""Tests for :mod:`freedays`."""

View 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"

View 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)

View 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

View 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([])

View 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))

View 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

View 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()

View 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
View 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
View 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