holds 0.1.0: declared time-boxed holds
Some checks failed
dependency freshness / dependencies on newest stable (push) Has been cancelled
file length / file-length (push) Has been cancelled
holds pre-commit / pre-commit (push) Has been cancelled
holds tests / test (push) Has been cancelled
md-naming / markdown naming convention (push) Has been cancelled
palette drift / palette (push) Has been cancelled
repo-contract / repo contract (push) Has been cancelled
shared gate tests / gate unit tests (push) Has been cancelled

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWVSoczW2NHQd887FUm
This commit is contained in:
Krzysztof Rudnicki 2026-11-03 20:25:10 +01:00
parent 22fdf2c344
commit 26079c34c2
16 changed files with 976 additions and 0 deletions

73
.github/workflows/holds-precommit.yml vendored Normal file
View File

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

34
.github/workflows/holds-tests.yml vendored Normal file
View File

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

View File

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

20
holds/README.md Normal file
View File

@ -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`.

12
holds/holds/__init__.py Normal file
View File

@ -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"]

76
holds/holds/_api.py Normal file
View File

@ -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])

54
holds/holds/_model.py Normal file
View File

@ -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"])),
)

60
holds/holds/_rules.py Normal file
View File

@ -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-<slug>``; 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

55
holds/holds/_store.py Normal file
View File

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

0
holds/holds/py.typed Normal file
View File

View File

@ -0,0 +1,2 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Tests for the holds package."""

View File

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

View File

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

13
holds/install.sh Executable file
View File

@ -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__)'

173
holds/pyproject.toml Normal file
View File

@ -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"]

10
holds/requirements.txt Normal file
View File

@ -0,0 +1,10 @@
# Exact pins, enforced by the shared dependency-freshness gate: a `>=`
# floor is satisfied by anything newer, so it tells a checker nothing
# about whether this repo is actually current.
bandit[toml]==1.9.4
mypy==2.4.1
pre-commit==4.7.0
pylint==4.1.3
pytest==9.2.0
pytest-cov==7.1.0
ruff==0.17.3