utils/focus_project/focus_project/ledger.py
Krzysztof kuhy Rudnicki 88e4be5042
Some checks are pending
code-runner pre-commit / pre-commit (push) Waiting to run
code-runner tests / test (push) Waiting to run
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
focus-project pre-commit / pre-commit (push) Waiting to run
focus-project tests / test (push) Waiting to run
leetcode-coach pre-commit / pre-commit (push) Waiting to run
leetcode-coach tests / test (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
sql-drills pre-commit / pre-commit (push) Waiting to run
sql-drills tests / test (push) Waiting to run
focus_project: carry overtime, v0.6.0
Minutes past a day's target bank (max 60) and pay on the next project day; status shows real minutes (121/120) plus carried_in/carry_out; carry ledger rows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWYGW5NZL5uVpnqmNKo
2026-11-02 15:14:59 +01:00

223 lines
6.9 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki
"""The signed credit ledger the ``focus`` earner reads.
Row contract (earned_time's "tutor row contract"): ``detail.minutes`` is the
positive increment the row pays, ``entry_id`` is
``<project>-<YYYYMMDD>-m<n>`` with ``n`` the day's cumulative credited
minutes AFTER the row (so ids never overlap and a retry rewrites the same
id), ``day`` is the local date, ``detail.ended_at`` is when the last paid
minute ended and never passes the day's end. The only writer is the
harvester; every write is read back and verified.
"""
from __future__ import annotations
import fcntl
import json
import logging
import os
from pathlib import Path
import tempfile
import time
from typing import TYPE_CHECKING, Any, Final
import earned_time
from focus_project._paths import data_dir
from focus_project.sources import day_bounds
if TYPE_CHECKING:
from datetime import date
from focus_project._model import Project
CREDIT: Final = "credit"
KEY_ENV: Final = "FOCUS_PROJECT_KEY"
_REAL_KEY: Final = Path("/etc/workout-locker/hmac.key")
_LOG = logging.getLogger(__name__)
class LedgerError(Exception):
"""The ledger exists but is not a ledger: never silently emptied."""
def key_path() -> Path:
"""The signing key: ``$FOCUS_PROJECT_KEY`` or the shared root-owned key."""
override = os.environ.get(KEY_ENV)
return Path(override) if override else _REAL_KEY
def ledger_path() -> Path:
"""The ledger file inside the data dir."""
return data_dir() / "ledger.json"
def read_key() -> bytes | None:
"""The signing key, or None if unreadable or empty (logged)."""
try:
return key_path().read_bytes().strip() or None
except OSError as exc:
_LOG.warning("signing key %s unreadable: %s", key_path(), exc)
return None
def read_rows(path: Path) -> list[Any]:
"""The raw ``entries`` array; a missing file is empty.
Raises:
LedgerError: unreadable, not JSON, or no entries array.
"""
try:
raw = json.loads(path.read_text(encoding="utf-8"))
except FileNotFoundError:
return []
except (OSError, ValueError) as exc:
msg = f"{path} is unreadable: {exc}"
raise LedgerError(msg) from exc
rows = raw.get("entries") if isinstance(raw, dict) else None
if not isinstance(rows, list):
msg = f"{path} has no entries array"
raise LedgerError(msg)
return rows
def _write_rows(path: Path, rows: list[Any]) -> None:
"""Atomically replace the ledger: readers see the old file or the new one."""
path.parent.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile(
"w", encoding="utf-8", dir=path.parent, suffix=".tmp", delete=False
) as handle:
json.dump({"version": 1, "entries": rows}, handle, indent=1)
handle.flush()
os.fsync(handle.fileno())
Path(handle.name).replace(path)
def paid_minutes(
rows: list[Any],
key: bytes,
project_id: str,
day: date,
*,
carry: bool | None = None,
) -> int:
"""Minutes already paid for ``project_id`` on ``day`` by verified rows.
``carry`` picks one class (True: banked-overtime rows, False: own-work
rows); None sums both. A repeated ``entry_id`` pays once, at its smallest
count.
"""
by_id: dict[str, int] = {}
for row in rows:
if not (isinstance(row, dict) and row.get("kind") == CREDIT):
continue
detail = row.get("detail")
if not isinstance(detail, dict):
continue
minutes = detail.get("minutes")
if (
row.get("day") != day.isoformat()
or detail.get("project") != project_id
or not isinstance(minutes, int)
or isinstance(minutes, bool)
or minutes < 1
or (carry is not None and bool(detail.get("carry")) != carry)
or not earned_time.verified(row, key)
):
continue
eid = str(row.get("entry_id"))
by_id[eid] = min(by_id.get(eid, minutes), minutes)
return sum(by_id.values())
def build_row( # noqa: PLR0913
project_id: str,
day: date,
total: int,
minutes: int,
key: bytes,
*,
now: float,
carry: bool = False,
first_carry: bool = True,
) -> dict[str, Any]:
"""The signed row paying ``minutes`` that bring the day's total to ``total``.
A ``carry`` row pays banked overtime: ``<project>-<YYYYMMDD>-carry``, or
``-carry-m<total>`` when it tops up an earlier carry row (late data for
the day before grew the bank).
"""
ended = min(now, day_bounds(day)[1] - 1)
stem = f"{project_id}-{day:%Y%m%d}"
if not carry:
entry_id = f"{stem}-m{total}"
else:
entry_id = f"{stem}-carry" if first_carry else f"{stem}-carry-m{total}"
row: dict[str, Any] = {
"kind": CREDIT,
"entry_id": entry_id,
"day": day.isoformat(),
"created_at": float(now),
"detail": {
"project": project_id,
"minutes": int(minutes),
"total": int(total),
"ended_at": float(ended),
**({"carry": True} if carry else {}),
},
}
row["hmac"] = str(earned_time.entry_signature(row, key))
return row
def credit_to(
project: Project,
day: date,
total: int,
*,
now: float | None = None,
carry: bool = False,
) -> int:
"""Pay the day up to ``total`` cumulative minutes; return minutes added.
``carry`` pays the banked-overtime class and counts only its own rows;
the default pays (and counts) own work.
Idempotent: nothing is written when the ledger already pays ``total`` or
more. Raises LedgerError (unreadable ledger, unreadable key, unverifiable
write) so the harvester can fail loudly instead of crediting nothing quietly.
"""
key = read_key()
if key is None:
msg = f"signing key {key_path()} unreadable"
raise LedgerError(msg)
path = ledger_path()
path.parent.mkdir(parents=True, exist_ok=True)
with path.with_suffix(".lock").open("w") as lock:
fcntl.flock(lock, fcntl.LOCK_EX)
rows = read_rows(path)
paid = paid_minutes(rows, key, project.id, day, carry=carry)
if total <= paid:
return 0
row = build_row(
project.id,
day,
total,
total - paid,
key,
now=now or time.time(),
carry=carry,
first_carry=paid == 0,
)
if any(
isinstance(r, dict) and r.get("entry_id") == row["entry_id"] for r in rows
):
msg = f"entry_id {row['entry_id']} already present but unverified"
raise LedgerError(msg)
_write_rows(path, [*rows, row])
stored = read_rows(path)
if paid_minutes(stored, key, project.id, day, carry=carry) != total:
msg = f"row {row['entry_id']} not found verified on read-back"
raise LedgerError(msg)
return total - paid