From 26079c34c208e887bb4017772fbbe5cfbab37c0f Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Tue, 3 Nov 2026 20:25:10 +0100 Subject: [PATCH] holds 0.1.0: declared time-boxed holds Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WzxdWVSoczW2NHQd887FUm --- .github/workflows/holds-precommit.yml | 73 ++++++++ .github/workflows/holds-tests.yml | 34 ++++ holds/.pre-commit-config.yaml | 146 ++++++++++++++++ holds/README.md | 20 +++ holds/holds/__init__.py | 12 ++ holds/holds/_api.py | 76 +++++++++ holds/holds/_model.py | 54 ++++++ holds/holds/_rules.py | 60 +++++++ holds/holds/_store.py | 55 +++++++ holds/holds/py.typed | 0 holds/holds/tests/__init__.py | 2 + holds/holds/tests/conftest.py | 19 +++ holds/holds/tests/test_api.py | 229 ++++++++++++++++++++++++++ holds/install.sh | 13 ++ holds/pyproject.toml | 173 +++++++++++++++++++ holds/requirements.txt | 10 ++ 16 files changed, 976 insertions(+) create mode 100644 .github/workflows/holds-precommit.yml create mode 100644 .github/workflows/holds-tests.yml create mode 100644 holds/.pre-commit-config.yaml create mode 100644 holds/README.md create mode 100644 holds/holds/__init__.py create mode 100644 holds/holds/_api.py create mode 100644 holds/holds/_model.py create mode 100644 holds/holds/_rules.py create mode 100644 holds/holds/_store.py create mode 100644 holds/holds/py.typed create mode 100644 holds/holds/tests/__init__.py create mode 100644 holds/holds/tests/conftest.py create mode 100644 holds/holds/tests/test_api.py create mode 100755 holds/install.sh create mode 100644 holds/pyproject.toml create mode 100644 holds/requirements.txt diff --git a/.github/workflows/holds-precommit.yml b/.github/workflows/holds-precommit.yml new file mode 100644 index 0000000..90e8eb2 --- /dev/null +++ b/.github/workflows/holds-precommit.yml @@ -0,0 +1,73 @@ +name: holds pre-commit + +on: + push: + branches: [main] + # The workflow's own file counts as holds: editing only this file would + # otherwise trigger nothing, so a fix to the job itself could never prove + # itself. + paths: ['holds/**', '.github/workflows/holds-precommit.yml'] + pull_request: + branches: [main] + paths: ['holds/**', '.github/workflows/holds-precommit.yml'] + +jobs: + pre-commit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.x" + # Only pre-commit itself. Every linting hook runs in its own isolated + # env -- ruff, mypy, pylint, bandit and codespell each come from their + # own repo+rev, and the local hooks need nothing beyond bash and + # python3 -- so installing the full requirements.txt spent 9s a run on + # packages no pre-commit-stage hook imports. Verified by running all + # three configs green in a venv holding pre-commit and nothing else. + # The pin is read out of requirements.txt rather than repeated here, so + # it cannot drift from what the dependency-freshness gate governs. + - name: Install pre-commit + run: | + pip install "$(grep '^pre-commit==' holds/requirements.txt)" + + # Keyed on THIS package's config. pre-commit/action@v3.0.1 caches on + # hashFiles('.pre-commit-config.yaml') -- the repo ROOT's -- so all three + # package jobs shared one key, raced to overwrite it with hook sets built + # from four different configs, and editing a per-package config did not + # invalidate it. The cache was therefore silently wrong rather than + # missing, and the hooks reinstalled from scratch on every run (38-49s). + - uses: actions/cache@v4 + with: + path: ~/.cache/pre-commit + key: >- + pre-commit-holds-${{ env.pythonLocation }}-${{ + hashFiles('holds/.pre-commit-config.yaml') }} + # Prefix fallback, or this fix would evaporate on the very + # event that motivated it: bumping a hook rev changes the + # exact key, and a total miss means reinstalling every hook + # again. pre-commit's store is keyed internally by hook + # repo+rev, so a superseded entry is ignored disk rather + # than a wrong hook, and cache still re-saves under the new + # exact key after a prefix-only hit. + restore-keys: pre-commit-holds-${{ env.pythonLocation }}- + # mypy re-analyses the stdlib and every third-party stub from scratch + # without this, which measured 10s of the hook step in CI against 0.25s + # warm locally -- a 19MB cache dir does the whole difference. Keyed on + # the config, not the sources: mypy's own cache metadata records the + # interpreter, the flag set and a hash per source file, so it re-checks + # what changed and cannot pass stale code. Deliberately NOT keyed on + # github.sha -- that re-saves 19MB x3 every commit and would evict the + # 68MB hook caches under the repo's 10GB LRU budget. A fixed key keeps + # the expensive part, the dependency analysis, which is what costs 10s. + - uses: actions/cache@v4 + with: + path: .mypy_cache + key: >- + mypy-holds-${{ env.pythonLocation }}-${{ + hashFiles('holds/.pre-commit-config.yaml') }} + restore-keys: mypy-holds-${{ env.pythonLocation }}- + - name: Run the hooks + run: >- + pre-commit run --config holds/.pre-commit-config.yaml + --all-files --show-diff-on-failure diff --git a/.github/workflows/holds-tests.yml b/.github/workflows/holds-tests.yml new file mode 100644 index 0000000..0c4e81b --- /dev/null +++ b/.github/workflows/holds-tests.yml @@ -0,0 +1,34 @@ +name: holds tests + +on: + push: + branches: [main] + # The workflow's own file counts as holds: editing only this file would + # otherwise trigger nothing, so a fix to the job itself could never prove + # itself. + paths: ['holds/**', '.github/workflows/holds-tests.yml'] + pull_request: + branches: [main] + paths: ['holds/**', '.github/workflows/holds-tests.yml'] + +jobs: + test: + runs-on: ubuntu-latest + defaults: + run: + working-directory: holds + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.x" + cache: pip + cache-dependency-path: holds/requirements.txt + + - name: Install dependencies + run: pip install -r requirements.txt && pip install --no-deps -e . + + - name: Run tests with coverage + run: python -m pytest -v diff --git a/holds/.pre-commit-config.yaml b/holds/.pre-commit-config.yaml new file mode 100644 index 0000000..83e2d23 --- /dev/null +++ b/holds/.pre-commit-config.yaml @@ -0,0 +1,146 @@ +# Pre-commit Configuration for holds +# 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 ~/src/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 holds/.pre-commit-config.yaml --all-files +files: ^holds/ + +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 holds + 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.17.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: v4.0.8 + hooks: + - id: pylint + args: + - --rcfile=holds/pyproject.toml + - --fail-under=10 + - --jobs=4 + additional_dependencies: + - pytest + # Kept in step with pyproject.toml, so pylint resolves the crdt_sync + # imports instead of scoring every one of them as import-error. + - crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.2#subdirectory=crdt-sync + + - repo: https://github.com/PyCQA/bandit + rev: 1.9.4 + hooks: + - id: bandit + args: + - -c + - holds/pyproject.toml + - --severity-level=high + - --confidence-level=medium + - --skip=B113 + additional_dependencies: ["bandit[toml]"] + exclude: ^(holds/holds/tests/|.*test.*\.py$) + + - repo: local + hooks: + - id: pytest-coverage + name: pytest with coverage enforcement + entry: scripts/run_subproject_tests.sh holds + 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/holds/README.md b/holds/README.md new file mode 100644 index 0000000..bbbb009 --- /dev/null +++ b/holds/README.md @@ -0,0 +1,20 @@ +# holds + +Declared time-boxed holds (contract v1): while a hold is active every gate +(focus-guard, diet-guard, screen-locker prompts) stands down so the PC stays +usable, e.g. for a proctored test. Stdlib only; gates import it from the system python. + +```python +import holds + +holds.active(now=None) # Hold | None (corrupt file -> None, logged: fail closed) +holds.covers("diet-guard") # bool; v1: every gate +holds.next_change(now=None) # datetime | None: active hold's end, else next start +holds.add(start, end, "reason of 10+ chars") # raises HoldsError on a ratchet breach +holds.remove(hold_id) # only before it starts +``` + +Ratchet (R11): each hold <= 15 h, <= 3 per ISO week (by start), start >= now + 30 min, +reason >= 10 chars, no overlaps, whole-minute times, existing holds are never edited. +Data: `$HOLDS_DATA` or `~/.local/share/holds/holds.json`, local naive times. +Install: `./install.sh` (editable, `/usr/bin/python3`). Tests: `python3 -m pytest`. diff --git a/holds/holds/__init__.py b/holds/holds/__init__.py new file mode 100644 index 0000000..f9901b3 --- /dev/null +++ b/holds/holds/__init__.py @@ -0,0 +1,12 @@ +# Copyright (c) 2026 Krzysztof Rudnicki +"""Declared time-boxed holds: while one is active every gate stands down. + +Stdlib only; gates import it from the system python. Contract v1. +""" + +from __future__ import annotations + +from holds._api import active, add, covers, next_change, remove +from holds._model import Hold, HoldsError + +__all__ = ["Hold", "HoldsError", "active", "add", "covers", "next_change", "remove"] diff --git a/holds/holds/_api.py b/holds/holds/_api.py new file mode 100644 index 0000000..48d7dc8 --- /dev/null +++ b/holds/holds/_api.py @@ -0,0 +1,76 @@ +# Copyright (c) 2026 Krzysztof Rudnicki +"""Public API: query (fails closed) and mutate (ratchet-checked) holds.""" + +from __future__ import annotations + +from datetime import datetime +import logging + +from holds import _store +from holds._model import Hold, HoldsError, naive +from holds._rules import make_id, validate + +_LOG = logging.getLogger("holds") + + +def _now(now: datetime | None) -> datetime: + return naive(now) if now is not None else datetime.now() + + +def _safe_load() -> list[Hold]: + """Fail CLOSED: an untrustworthy file means no hold (gates act normally).""" + try: + return _store.load() + except HoldsError: + _LOG.exception("ignoring hold file; gates behave as normal") + return [] + + +def active(now: datetime | None = None) -> Hold | None: + """The hold covering ``now`` (earliest start if hand-edits overlap), or None.""" + moment = _now(now) + hits = [h for h in _safe_load() if h.contains(moment)] + return min(hits, key=lambda h: h.start) if hits else None + + +def covers(gate: str, now: datetime | None = None) -> bool: + """Whether ``gate`` stands down at ``now``. v1: every gate does.""" + del gate # kept for future per-gate holds + return active(now) is not None + + +def next_change(now: datetime | None = None) -> datetime | None: + """When the active hold ends, else when the next one starts, else None.""" + moment = _now(now) + current = active(moment) + if current is not None: + return current.end + upcoming = [h.start for h in _safe_load() if h.start > moment] + return min(upcoming) if upcoming else None + + +def add( + start: datetime, end: datetime, reason: str, now: datetime | None = None +) -> Hold: + """Declare a hold. Raises HoldsError on any ratchet violation.""" + moment = _now(now) + begin, finish = naive(start), naive(end) + existing = _store.load() + clean = validate(existing, begin, finish, reason, moment) + hold = Hold(make_id(existing, begin, clean), begin, finish, clean, moment) + _store.save([*existing, hold]) + return hold + + +def remove(hold_id: str, now: datetime | None = None) -> None: + """Delete a hold that has not started yet.""" + moment = _now(now) + existing = _store.load() + target = next((h for h in existing if h.id == hold_id), None) + if target is None: + msg = f"unknown hold: {hold_id}" + raise HoldsError(msg) + if target.start <= moment: + msg = "hold already started; it cannot be removed" + raise HoldsError(msg) + _store.save([h for h in existing if h.id != hold_id]) diff --git a/holds/holds/_model.py b/holds/holds/_model.py new file mode 100644 index 0000000..df6b857 --- /dev/null +++ b/holds/holds/_model.py @@ -0,0 +1,54 @@ +# Copyright (c) 2026 Krzysztof Rudnicki +"""The ``Hold`` record and its JSON row form (local, naive timestamps).""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime + + +class HoldsError(Exception): + """A hold was refused by the ratchet rules, or the file cannot be trusted.""" + + +def naive(moment: datetime) -> datetime: + """Return ``moment`` as local wall-clock time without a tzinfo.""" + if moment.tzinfo is not None: + return moment.astimezone().replace(tzinfo=None) + return moment + + +@dataclass(frozen=True) +class Hold: + """One declared window during which every gate stands down.""" + + id: str + start: datetime + end: datetime + reason: str + created: datetime + + def contains(self, now: datetime) -> bool: + """Half-open: active from ``start`` up to, not including, ``end``.""" + return self.start <= now < self.end + + def to_row(self) -> dict[str, str]: + """Serialise as the contract row (start/end are whole minutes).""" + return { + "id": self.id, + "start": self.start.isoformat(timespec="minutes"), + "end": self.end.isoformat(timespec="minutes"), + "reason": self.reason, + "created": self.created.isoformat(timespec="seconds"), + } + + @classmethod + def from_row(cls, row: dict[str, str]) -> Hold: + """Parse a row; raises KeyError/TypeError/ValueError when malformed.""" + return cls( + id=str(row["id"]), + start=naive(datetime.fromisoformat(row["start"])), + end=naive(datetime.fromisoformat(row["end"])), + reason=str(row["reason"]), + created=naive(datetime.fromisoformat(row["created"])), + ) diff --git a/holds/holds/_rules.py b/holds/holds/_rules.py new file mode 100644 index 0000000..5264900 --- /dev/null +++ b/holds/holds/_rules.py @@ -0,0 +1,60 @@ +# Copyright (c) 2026 Krzysztof Rudnicki +"""Ratchet rules for new holds (R11): only ever stricter, never an escape hatch.""" + +from __future__ import annotations + +from datetime import datetime, timedelta +import re + +from holds._model import Hold, HoldsError + +MAX_DURATION = timedelta(hours=15) +MAX_PER_WEEK = 3 +MIN_LEAD = timedelta(minutes=30) +MIN_REASON = 10 + + +def _week(moment: datetime) -> tuple[int, int]: + iso = moment.isocalendar() + return iso.year, iso.week + + +def validate( + existing: list[Hold], start: datetime, end: datetime, reason: str, now: datetime +) -> str: + """Raise HoldsError on any violation; return the stripped reason.""" + if start.second or start.microsecond or end.second or end.microsecond: + msg = "start and end must be whole minutes" + raise HoldsError(msg) + if end <= start: + msg = "end must be after start" + raise HoldsError(msg) + if end - start > MAX_DURATION: + msg = "a hold may last at most 15 h" + raise HoldsError(msg) + if start < now + MIN_LEAD: + msg = "a hold must start at least 30 min from now" + raise HoldsError(msg) + clean = reason.strip() + if len(clean) < MIN_REASON: + msg = f"reason must be at least {MIN_REASON} chars" + raise HoldsError(msg) + if any(h.start < end and start < h.end for h in existing): + msg = "hold would overlap an existing hold" + raise HoldsError(msg) + if sum(_week(h.start) == _week(start) for h in existing) >= MAX_PER_WEEK: + msg = f"at most {MAX_PER_WEEK} holds per ISO week" + raise HoldsError(msg) + return clean + + +def make_id(existing: list[Hold], start: datetime, reason: str) -> str: + """``YYYYMMDDTHHMM-``; unique because holds never overlap.""" + slug = re.sub(r"[^a-z0-9]+", "-", reason.lower()).strip("-")[:40].strip("-") + base = f"{start:%Y%m%dT%H%M}-{slug or 'hold'}" + taken = {h.id for h in existing} + candidate, n = base, 2 + while candidate in taken: + candidate = f"{base}-{n}" + n += 1 + return candidate diff --git a/holds/holds/_store.py b/holds/holds/_store.py new file mode 100644 index 0000000..e22f836 --- /dev/null +++ b/holds/holds/_store.py @@ -0,0 +1,55 @@ +# Copyright (c) 2026 Krzysztof Rudnicki +"""Read and write the hold file. The path is resolved on every call.""" + +from __future__ import annotations + +import json +import os +from pathlib import Path + +from holds._model import Hold, HoldsError + +_ENV = "HOLDS_DATA" + + +def data_path() -> Path: + """``$HOLDS_DATA`` or ``~/.local/share/holds/holds.json``.""" + override = os.environ.get(_ENV) + if override: + return Path(override) + return Path.home() / ".local" / "share" / "holds" / "holds.json" + + +def load() -> list[Hold]: + """All holds; a missing file is empty, anything unreadable raises.""" + path = data_path() + try: + raw = path.read_text(encoding="utf-8") + except FileNotFoundError: + return [] + except OSError as exc: + msg = f"hold file unreadable: {path}: {exc}" + raise HoldsError(msg) from exc + try: + rows = json.loads(raw)["holds"] + except (KeyError, TypeError, ValueError) as exc: + msg = f"hold file unreadable: {path}: {exc!r}" + raise HoldsError(msg) from exc + if not isinstance(rows, list): + msg = f"hold file unreadable: {path}: 'holds' is not a list" + raise HoldsError(msg) + try: + return [Hold.from_row(row) for row in rows] + except (KeyError, TypeError, ValueError) as exc: + msg = f"hold file unreadable: {path}: {exc!r}" + raise HoldsError(msg) from exc + + +def save(items: list[Hold]) -> None: + """Atomically replace the file with ``items`` sorted by start.""" + path = data_path() + path.parent.mkdir(parents=True, exist_ok=True) + rows = [h.to_row() for h in sorted(items, key=lambda h: h.start)] + tmp = path.with_name(path.name + ".tmp") + tmp.write_text(json.dumps({"holds": rows}, indent=2) + "\n", encoding="utf-8") + tmp.replace(path) diff --git a/holds/holds/py.typed b/holds/holds/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/holds/holds/tests/__init__.py b/holds/holds/tests/__init__.py new file mode 100644 index 0000000..11a8201 --- /dev/null +++ b/holds/holds/tests/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) 2026 Krzysztof Rudnicki +"""Tests for the holds package.""" diff --git a/holds/holds/tests/conftest.py b/holds/holds/tests/conftest.py new file mode 100644 index 0000000..2291f81 --- /dev/null +++ b/holds/holds/tests/conftest.py @@ -0,0 +1,19 @@ +# Copyright (c) 2026 Krzysztof Rudnicki +"""Point the hold file at ``tmp_path`` for every test, so none can touch live state.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +if TYPE_CHECKING: + from pathlib import Path + + +@pytest.fixture(autouse=True) +def data_file(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path: + """Redirect the data file; the path is resolved per call, so this is enough.""" + path = tmp_path / "state" / "holds.json" + monkeypatch.setenv("HOLDS_DATA", str(path)) + return path diff --git a/holds/holds/tests/test_api.py b/holds/holds/tests/test_api.py new file mode 100644 index 0000000..e33971d --- /dev/null +++ b/holds/holds/tests/test_api.py @@ -0,0 +1,229 @@ +# Copyright (c) 2026 Krzysztof Rudnicki +"""Contract tests for the public ``holds`` API.""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +import json +import logging +from typing import TYPE_CHECKING + +import pytest + +import holds + +if TYPE_CHECKING: + from pathlib import Path + +NOW = datetime(2026, 11, 3, 21, 0) +WED = datetime(2026, 11, 4, 17, 0) +WED_END = datetime(2026, 11, 4, 20, 0) +REASON = "HackerRank proctored test" + + +def _real(delta: timedelta) -> datetime: + """A whole-minute moment ``delta`` from the real clock.""" + return (datetime.now() + delta).replace(second=0, microsecond=0) + + +def _seed(path: Path, rows: list[dict[str, str]]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps({"holds": rows}), encoding="utf-8") + + +def _row(start: str, end: str, hid: str = "a") -> dict[str, str]: + return { + "id": hid, + "start": start, + "end": end, + "reason": "some long reason", + "created": "2026-11-01T10:00:00", + } + + +def test_add_then_active_half_open(data_file: Path) -> None: + hold = holds.add(WED, WED_END, REASON, now=NOW) + assert hold.id == "20261104T1700-hackerrank-proctored-test" + assert hold.created == NOW + assert holds.active(WED - timedelta(minutes=1)) is None + assert holds.active(WED) == hold + assert holds.active(datetime(2026, 11, 4, 19, 59)) == hold + assert holds.active(WED_END) is None + stored = json.loads(data_file.read_text(encoding="utf-8")) + assert stored["holds"][0]["start"] == "2026-11-04T17:00" + assert stored["holds"][0]["created"] == "2026-11-03T21:00:00" + + +def test_covers_ignores_gate_in_v1() -> None: + holds.add(WED, WED_END, REASON, now=NOW) + assert holds.covers("focus-guard", WED) is True + assert holds.covers("diet-guard", WED_END) is False + + +def test_active_defaults_to_real_clock(monkeypatch: pytest.MonkeyPatch) -> None: + assert holds.active() is None + assert holds.next_change() is None + assert holds.covers("x") is False + with pytest.raises(holds.HoldsError, match="30 min"): + holds.add(_real(timedelta(minutes=5)), _real(timedelta(hours=1)), REASON) + + +def test_aware_datetimes_become_local_naive() -> None: + aware = WED.astimezone().astimezone(UTC) + hold = holds.add( + aware, aware + timedelta(hours=3), REASON, now=NOW.astimezone().astimezone(UTC) + ) + assert hold.start == WED + assert holds.active(aware + timedelta(hours=1)) == hold + + +def test_next_change_start_end_none() -> None: + first = holds.add(WED, WED_END, REASON, now=NOW) + second = holds.add( + datetime(2026, 11, 5), datetime(2026, 11, 5, 14, 30), "fallback window", now=NOW + ) + assert holds.next_change(NOW) == first.start + assert holds.next_change(WED) == first.end + assert holds.next_change(WED_END) == second.start + assert holds.next_change(datetime(2026, 11, 6)) is None + + +@pytest.mark.parametrize( + ("start", "end", "reason", "match"), + [ + (WED, WED + timedelta(hours=15, minutes=1), REASON, "15 h"), + (WED, WED, REASON, "after start"), + (NOW + timedelta(minutes=29), NOW + timedelta(hours=2), REASON, "30 min"), + (WED, WED_END, "too short", "10 char"), + (WED, WED_END, " padded ", "10 char"), + (WED + timedelta(seconds=5), WED_END, REASON, "whole minutes"), + ], +) +def test_add_rejects_ratchet_violations( + start: datetime, end: datetime, reason: str, match: str +) -> None: + with pytest.raises(holds.HoldsError, match=match): + holds.add(start, end, reason, now=NOW) + assert holds.active(start) is None + + +def test_add_exact_limits_pass() -> None: + start = NOW + timedelta(minutes=30) + holds.add(start, start + timedelta(hours=15), REASON, now=NOW) + + +def test_add_rejects_overlap_but_allows_touching() -> None: + holds.add(WED, WED_END, REASON, now=NOW) + for s, e in [ + (WED_END - timedelta(hours=1), WED_END + timedelta(hours=1)), + (WED - timedelta(hours=1), WED + timedelta(hours=1)), + (WED + timedelta(minutes=30), WED + timedelta(hours=1)), + ]: + with pytest.raises(holds.HoldsError, match="overlap"): + holds.add(s, e, REASON, now=NOW) + holds.add(WED_END, WED_END + timedelta(hours=1), REASON, now=NOW) + + +def test_add_caps_three_per_iso_week_but_not_next_week() -> None: + for day in (4, 5, 6): + start = datetime(2026, 11, day, 9) + holds.add(start, start + timedelta(hours=1), REASON, now=NOW) + start = datetime(2026, 11, 7, 9) + with pytest.raises(holds.HoldsError, match="3 holds"): + holds.add(start, start + timedelta(hours=1), REASON, now=NOW) + nxt = datetime(2026, 11, 9, 9) # Monday, ISO week 46 + holds.add(nxt, nxt + timedelta(hours=1), REASON, now=NOW) + + +def test_remove_only_before_start() -> None: + hold = holds.add(WED, WED_END, REASON, now=NOW) + with pytest.raises(holds.HoldsError, match="already started"): + holds.remove(hold.id, now=WED) + holds.remove(hold.id, now=NOW) + assert holds.active(WED) is None + with pytest.raises(holds.HoldsError, match="unknown"): + holds.remove(hold.id, now=NOW) + + +def test_remove_defaults_to_real_clock() -> None: + start = _real(timedelta(days=1)) + hold = holds.add(start, start + timedelta(hours=1), REASON) + holds.remove(hold.id) + assert holds.next_change() is None + + +def test_ids_stay_unique_and_slug_is_bounded() -> None: + long_reason = "!!! " + "word " * 30 + first = holds.add(WED, WED_END, long_reason, now=NOW) + assert len(first.id) <= 60 + assert first.id.startswith("20261104T1700-word-word") + symbols = holds.add( + datetime(2026, 11, 5), datetime(2026, 11, 5, 1), "!!!!!!!!!!!!", now=NOW + ) + assert symbols.id == "20261105T0000-hold" + + +def test_default_path_under_home( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.delenv("HOLDS_DATA") + monkeypatch.setenv("HOME", str(tmp_path)) + holds.add(WED, WED_END, REASON, now=NOW) + assert (tmp_path / ".local/share/holds/holds.json").is_file() + + +@pytest.mark.parametrize( + "content", + [ + "{not json", + "[]", + '{"holds": {}}', + '{"holds": [{"id": "x"}]}', + json.dumps({"holds": [_row("nope", "2026-11-04T20:00")]}), + '{"holds": [1]}', + ], +) +def test_corrupt_file_fails_closed_and_logs( + data_file: Path, caplog: pytest.LogCaptureFixture, content: str +) -> None: + data_file.parent.mkdir(parents=True) + data_file.write_text(content, encoding="utf-8") + with caplog.at_level(logging.ERROR, logger="holds"): + assert holds.active(WED) is None + assert holds.covers("g", WED) is False + assert holds.next_change(WED) is None + assert "holds" in caplog.text + with pytest.raises(holds.HoldsError, match="unreadable"): + holds.add(WED, WED_END, REASON, now=NOW) + assert data_file.read_text(encoding="utf-8") == content + + +def test_unreadable_file_fails_closed(data_file: Path) -> None: + data_file.mkdir(parents=True) # a directory where the file should be + assert holds.active(WED) is None + + +def test_missing_file_is_empty_without_error(caplog: pytest.LogCaptureFixture) -> None: + assert holds.active(WED) is None + assert caplog.text == "" + + +def test_hand_edited_overlap_picks_earliest_start(data_file: Path) -> None: + _seed( + data_file, + [ + _row("2026-11-04T18:00", "2026-11-04T21:00", "b"), + _row("2026-11-04T17:00", "2026-11-04T19:00", "a"), + ], + ) + found = holds.active(datetime(2026, 11, 4, 18, 30)) + assert found is not None + assert found.id == "a" + + +def test_make_id_suffixes_on_collision() -> None: + from holds._rules import make_id + + taken = holds.Hold("20261104T1700-x", WED, WED_END, REASON, NOW) + twice = holds.Hold("20261104T1700-x-2", WED, WED_END, REASON, NOW) + assert make_id([taken, twice], WED, "x") == "20261104T1700-x-3" diff --git a/holds/install.sh b/holds/install.sh new file mode 100755 index 0000000..946653a --- /dev/null +++ b/holds/install.sh @@ -0,0 +1,13 @@ +#!/bin/bash +# Editable user install so /usr/bin/python3 (the guards' user units) can +# `import holds`. Stdlib only; idempotent. The hold library is read by every gate +# from the system python, so it must import there without a venv. +set -euo pipefail + +REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly REPO_DIR +readonly SYSTEM_PYTHON="/usr/bin/python3" + +"$SYSTEM_PYTHON" -m pip install --user --no-deps --break-system-packages -e "$REPO_DIR" +cd / +"$SYSTEM_PYTHON" -c 'import holds; print(holds.__file__)' diff --git a/holds/pyproject.toml b/holds/pyproject.toml new file mode 100644 index 0000000..415ff3d --- /dev/null +++ b/holds/pyproject.toml @@ -0,0 +1,173 @@ +[build-system] +requires = ["setuptools>=61"] +build-backend = "setuptools.build_meta" + +[project] +name = "holds" +version = "0.1.0" +description = "Declared time-boxed holds: while one is active every gate stands down so the PC stays usable (proctored tests)" +requires-python = ">=3.14" +# Stdlib only on purpose: guards import it from the system python. +dependencies = [] + +[tool.setuptools.packages.find] +include = ["holds*"] + +[tool.setuptools.package-data] +holds = ["py.typed"] + +[tool.ruff] +target-version = "py314" +include = ["*.py", "**/*.py"] +exclude = [".git", ".venv", "__pycache__", "build", "dist", ".eggs"] + +[tool.ruff.lint] +select = ["ALL"] +ignore = [ + # Holds are local wall-clock windows by contract (naive datetimes, Europe/Warsaw). + "DTZ", + "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 = ["holds"] + +[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] +# The repo root on the path, so pylint resolves the package's own imports the +# same way pytest does instead of reporting import-error for them. +init-hook = "import sys; sys.path.insert(0, 'holds')" +analyse-fallback-blocks = true +persistent = true +jobs = 0 +py-version = "3.14" +ignore = [".venv", "__pycache__"] +# Test suites intentionally use patterns (protected-access, fixture-arg +# shadowing) that don't apply to source code; ruff's per-file-ignores carry +# the equivalent exemptions, so tests stay linted -- by ruff. This is the same +# source-only pylint scope wake-alarm / diet-guard / testsAndMisc use. It has +# to be `ignore-paths`, not `ignore = ["tests"]`: `ignore` only matches the +# basename of what pylint is *given*, and pre-commit hands it explicit file +# paths, so a directory name in `ignore` never fires under the hook. +ignore-paths = ["^(.*/)?tests/.*$"] +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 = ["holds/tests"] +python_files = ["test_*.py", "*_test.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] +addopts = [ + "-v", + "--strict-markers", + "--strict-config", + "-ra", + "--cov=holds", + "--cov-branch", + "--cov-report=term-missing", + "--cov-report=lcov", +] +filterwarnings = [ + "error", + "ignore::DeprecationWarning", + "default::pytest.PytestUnraisableExceptionWarning", +] + +[tool.coverage.run] +source = ["holds"] +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/holds/requirements.txt b/holds/requirements.txt new file mode 100644 index 0000000..d69c161 --- /dev/null +++ b/holds/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.4.1 +pre-commit==4.7.0 +pylint==4.1.3 +pytest==9.2.0 +pytest-cov==7.1.0 +ruff==0.17.3