diff --git a/.github/workflows/freedays-precommit.yml b/.github/workflows/freedays-precommit.yml new file mode 100644 index 0000000..2aa16be --- /dev/null +++ b/.github/workflows/freedays-precommit.yml @@ -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 diff --git a/.github/workflows/freedays-tests.yml b/.github/workflows/freedays-tests.yml new file mode 100644 index 0000000..81da099 --- /dev/null +++ b/.github/workflows/freedays-tests.yml @@ -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 diff --git a/freedays/.pre-commit-config.yaml b/freedays/.pre-commit-config.yaml new file mode 100644 index 0000000..00bf4c5 --- /dev/null +++ b/freedays/.pre-commit-config.yaml @@ -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 diff --git a/freedays/README.md b/freedays/README.md new file mode 100644 index 0000000..8563f54 --- /dev/null +++ b/freedays/README.md @@ -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 +``` diff --git a/freedays/freedays/__init__.py b/freedays/freedays/__init__.py new file mode 100644 index 0000000..4433ae9 --- /dev/null +++ b/freedays/freedays/__init__.py @@ -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", +] diff --git a/freedays/freedays/_api.py b/freedays/freedays/_api.py new file mode 100644 index 0000000..ea9803d --- /dev/null +++ b/freedays/freedays/_api.py @@ -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, + ) diff --git a/freedays/freedays/_audit.py b/freedays/freedays/_audit.py new file mode 100644 index 0000000..c78dc1a --- /dev/null +++ b/freedays/freedays/_audit.py @@ -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) diff --git a/freedays/freedays/_budget.py b/freedays/freedays/_budget.py new file mode 100644 index 0000000..30da3db --- /dev/null +++ b/freedays/freedays/_budget.py @@ -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 diff --git a/freedays/freedays/_cli.py b/freedays/freedays/_cli.py new file mode 100644 index 0000000..83d48c4 --- /dev/null +++ b/freedays/freedays/_cli.py @@ -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()) diff --git a/freedays/freedays/_cli_output.py b/freedays/freedays/_cli_output.py new file mode 100644 index 0000000..3968e1a --- /dev/null +++ b/freedays/freedays/_cli_output.py @@ -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") diff --git a/freedays/freedays/_constants.py b/freedays/freedays/_constants.py new file mode 100644 index 0000000..1d78297 --- /dev/null +++ b/freedays/freedays/_constants.py @@ -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" diff --git a/freedays/freedays/_day.py b/freedays/freedays/_day.py new file mode 100644 index 0000000..d9303bb --- /dev/null +++ b/freedays/freedays/_day.py @@ -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) diff --git a/freedays/freedays/_errors.py b/freedays/freedays/_errors.py new file mode 100644 index 0000000..a4296e8 --- /dev/null +++ b/freedays/freedays/_errors.py @@ -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.""" diff --git a/freedays/freedays/_model.py b/freedays/freedays/_model.py new file mode 100644 index 0000000..094a49b --- /dev/null +++ b/freedays/freedays/_model.py @@ -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), + }, + ) diff --git a/freedays/freedays/_paths.py b/freedays/freedays/_paths.py new file mode 100644 index 0000000..9c358b1 --- /dev/null +++ b/freedays/freedays/_paths.py @@ -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() diff --git a/freedays/freedays/_sync.py b/freedays/freedays/_sync.py new file mode 100644 index 0000000..72d5b09 --- /dev/null +++ b/freedays/freedays/_sync.py @@ -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 diff --git a/freedays/freedays/tests/__init__.py b/freedays/freedays/tests/__init__.py new file mode 100644 index 0000000..1333fa0 --- /dev/null +++ b/freedays/freedays/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for :mod:`freedays`.""" diff --git a/freedays/freedays/tests/conftest.py b/freedays/freedays/tests/conftest.py new file mode 100644 index 0000000..ab2d085 --- /dev/null +++ b/freedays/freedays/tests/conftest.py @@ -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" diff --git a/freedays/freedays/tests/test_api.py b/freedays/freedays/tests/test_api.py new file mode 100644 index 0000000..e20a7b6 --- /dev/null +++ b/freedays/freedays/tests/test_api.py @@ -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) diff --git a/freedays/freedays/tests/test_audit.py b/freedays/freedays/tests/test_audit.py new file mode 100644 index 0000000..22d20fb --- /dev/null +++ b/freedays/freedays/tests/test_audit.py @@ -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 diff --git a/freedays/freedays/tests/test_cli.py b/freedays/freedays/tests/test_cli.py new file mode 100644 index 0000000..1193876 --- /dev/null +++ b/freedays/freedays/tests/test_cli.py @@ -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([]) diff --git a/freedays/freedays/tests/test_day.py b/freedays/freedays/tests/test_day.py new file mode 100644 index 0000000..bce53ee --- /dev/null +++ b/freedays/freedays/tests/test_day.py @@ -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)) diff --git a/freedays/freedays/tests/test_model.py b/freedays/freedays/tests/test_model.py new file mode 100644 index 0000000..325e5b1 --- /dev/null +++ b/freedays/freedays/tests/test_model.py @@ -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 diff --git a/freedays/freedays/tests/test_paths.py b/freedays/freedays/tests/test_paths.py new file mode 100644 index 0000000..ad51b05 --- /dev/null +++ b/freedays/freedays/tests/test_paths.py @@ -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() diff --git a/freedays/freedays/tests/test_sync.py b/freedays/freedays/tests/test_sync.py new file mode 100644 index 0000000..e1cd60b --- /dev/null +++ b/freedays/freedays/tests/test_sync.py @@ -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) diff --git a/freedays/pyproject.toml b/freedays/pyproject.toml new file mode 100644 index 0000000..e869e9e --- /dev/null +++ b/freedays/pyproject.toml @@ -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"] diff --git a/freedays/requirements.txt b/freedays/requirements.txt new file mode 100644 index 0000000..2777833 --- /dev/null +++ b/freedays/requirements.txt @@ -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