mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 17:47:24 +01:00
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
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:
parent
22fdf2c344
commit
26079c34c2
73
.github/workflows/holds-precommit.yml
vendored
Normal file
73
.github/workflows/holds-precommit.yml
vendored
Normal 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
34
.github/workflows/holds-tests.yml
vendored
Normal 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
|
||||
146
holds/.pre-commit-config.yaml
Normal file
146
holds/.pre-commit-config.yaml
Normal 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
20
holds/README.md
Normal 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
12
holds/holds/__init__.py
Normal 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
76
holds/holds/_api.py
Normal 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
54
holds/holds/_model.py
Normal 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
60
holds/holds/_rules.py
Normal 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
55
holds/holds/_store.py
Normal 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
0
holds/holds/py.typed
Normal file
2
holds/holds/tests/__init__.py
Normal file
2
holds/holds/tests/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""Tests for the holds package."""
|
||||
19
holds/holds/tests/conftest.py
Normal file
19
holds/holds/tests/conftest.py
Normal 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
|
||||
229
holds/holds/tests/test_api.py
Normal file
229
holds/holds/tests/test_api.py
Normal 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
13
holds/install.sh
Executable 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
173
holds/pyproject.toml
Normal 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
10
holds/requirements.txt
Normal 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
|
||||
Loading…
Reference in New Issue
Block a user