gatelock 0.10.0: night_lockdown.evening_lockdown_today, so earner gates stop re-arming after tonight's lockdown
Some checks failed
dependency freshness / dependencies on newest stable (push) Has been cancelled
file length / file-length (push) Has been cancelled
gatelock pre-commit / pre-commit (push) Has been cancelled
gatelock 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 (1M context) <noreply@anthropic.com>
This commit is contained in:
Krzysztof Rudnicki 2026-10-17 18:23:48 +02:00
parent 49a401debd
commit eef2d46f32
4 changed files with 207 additions and 1 deletions

View File

@ -72,6 +72,14 @@ leetcode-guard and book-guard all call it; the policy itself stays in
wake-alarm. Path and retry budget are arguments so each consumer's tests keep
redirecting their own constant.
`gatelock.night_lockdown.evening_lockdown_today` answers "has tonight's
evening night-lockdown already happened?" from night-lockdown's
`/var/lib/night-lockdown/locked_at_epoch` (local date today, local time
>= 12:00; the 04:01 morning re-lock does not count). Earner gates
(screen-locker's workout lock, leetcode-guard, book-guard) opt in and do not
re-arm for the rest of such a day; diet-guard does not. Missing or garbage
means "no lockdown" (garbage warns), so the gate locks as before.
## Development
```bash

View File

@ -0,0 +1,90 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Has tonight's evening night-lockdown already happened?
night-lockdown (digital-wellbeing, ``night-lockdown-enter.sh``) stamps
``/var/lib/night-lockdown/locked_at_epoch`` with a unix epoch every time it
locks, and never removes it. Earner gates (workout, LeetCode, reading) that
opt in call :func:`evening_lockdown_today` and do not re-arm their lock for
the rest of a day whose evening lockdown already fired: once a late credit
lifted that lockdown, re-locking behind a task gate the same evening would
make the finished task impossible to submit.
Only an EVENING lock counts -- the stamp's local date is today and its local
time is at or after 12:00. The 04:01 re-lock of the morning window is the
night that began yesterday, not tonight's.
Fail open to "no lockdown" (the gate locks as before): a missing stamp is the
normal state on a fresh machine and stays quiet; an unreadable or garbage
stamp is a warning, because then the signal itself is broken.
Opt-in by design: a gate that is not an earner (diet-guard) never calls this.
"""
from __future__ import annotations
from datetime import UTC, datetime, time
import logging
from pathlib import Path
from typing import Final
_logger = logging.getLogger(__name__)
NIGHT_LOCKDOWN_STAMP: Final = Path("/var/lib/night-lockdown/locked_at_epoch")
"""Where night-lockdown-enter.sh writes the epoch of its latest lock."""
EVENING_FROM: Final = time(12, 0)
"""A same-day lock at or after this local time is the evening lockdown."""
def _read_stamp(path: Path) -> datetime | None:
"""The stamp as a local instant, or None when absent or unusable."""
try:
text = path.read_text(encoding="ascii")
except FileNotFoundError:
_logger.debug("No night-lockdown stamp at %s", path)
return None
except (OSError, UnicodeDecodeError) as exc:
_logger.warning(
"Cannot read night-lockdown stamp %s (%s); treating as no lockdown",
path,
exc,
)
return None
try:
return datetime.fromtimestamp(int(text.strip()), tz=UTC).astimezone()
except (ValueError, OverflowError, OSError) as exc:
_logger.warning(
"Night-lockdown stamp %s is not a unix epoch (%r: %s); "
"treating as no lockdown",
path,
text[:40],
exc,
)
return None
def evening_lockdown_today(
path: Path = NIGHT_LOCKDOWN_STAMP,
now: datetime | None = None,
) -> datetime | None:
"""When today's evening lockdown locked, or None if it has not yet.
Truthy exactly when the stamp's local date is ``now``'s and its local
time is at or after :data:`EVENING_FROM` (and not in the future), so it
reads as a predicate while still giving callers the time to quote.
"""
now = (now or datetime.now(tz=UTC)).astimezone()
locked_at = _read_stamp(path)
if locked_at is None:
return None
if locked_at.date() != now.date() or locked_at.time() < EVENING_FROM:
return None
if locked_at > now:
_logger.warning(
"Night-lockdown stamp %s is in the future (%s > %s); ignoring it",
path,
locked_at.isoformat(),
now.isoformat(),
)
return None
return locked_at

View File

@ -0,0 +1,108 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""gatelock.night_lockdown -- has tonight's evening lockdown already happened?"""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
import logging
from typing import TYPE_CHECKING
import pytest
from gatelock.night_lockdown import NIGHT_LOCKDOWN_STAMP, evening_lockdown_today
if TYPE_CHECKING:
from pathlib import Path
NOW = datetime(2026, 10, 17, 22, 30).astimezone()
@pytest.fixture
def stamp(tmp_path: Path) -> Path:
"""Where the tests write the stamp -- never the real root-owned one."""
return tmp_path / "locked_at_epoch"
def _write_local(stamp: Path, when: str) -> None:
"""Stamp the epoch of a LOCAL wall-clock time, as night-lockdown does."""
epoch = int(datetime.fromisoformat(when).astimezone().timestamp())
stamp.write_text(f"{epoch}\n")
def test_default_path_is_the_one_night_lockdown_writes() -> None:
assert str(NIGHT_LOCKDOWN_STAMP) == "/var/lib/night-lockdown/locked_at_epoch"
def test_evening_lock_today_counts(stamp: Path) -> None:
_write_local(stamp, "2026-10-17T18:00")
got = evening_lockdown_today(stamp, NOW)
assert got == datetime(2026, 10, 17, 18, 0).astimezone()
def test_morning_relock_today_does_not_count(
stamp: Path, caplog: pytest.LogCaptureFixture
) -> None:
# The 04:01 re-lock is the night that started yesterday, not tonight's.
_write_local(stamp, "2026-10-17T04:01")
with caplog.at_level(logging.DEBUG, logger="gatelock.night_lockdown"):
assert evening_lockdown_today(stamp, NOW) is None
assert not [r for r in caplog.records if r.levelno >= logging.WARNING]
def test_noon_is_the_first_evening_minute(stamp: Path) -> None:
_write_local(stamp, "2026-10-17T12:00")
assert evening_lockdown_today(stamp, NOW) is not None
def test_just_before_noon_is_still_morning(stamp: Path) -> None:
_write_local(stamp, "2026-10-17T11:59:59")
assert evening_lockdown_today(stamp, NOW) is None
def test_yesterdays_evening_lock_does_not_count(stamp: Path) -> None:
_write_local(stamp, "2026-10-16T21:00")
assert evening_lockdown_today(stamp, NOW) is None
def test_lock_later_than_now_does_not_count(stamp: Path) -> None:
# Clock trouble: a stamp from the future is not a lockdown that happened.
_write_local(stamp, "2026-10-17T23:00")
assert evening_lockdown_today(stamp, NOW) is None
def test_missing_file_is_quiet(stamp: Path, caplog: pytest.LogCaptureFixture) -> None:
with caplog.at_level(logging.DEBUG, logger="gatelock.night_lockdown"):
assert evening_lockdown_today(stamp, NOW) is None
assert not [r for r in caplog.records if r.levelno >= logging.WARNING]
@pytest.mark.parametrize("content", ["garbage", "", " \n", "12.5", "1e9"])
def test_garbage_warns(
stamp: Path, caplog: pytest.LogCaptureFixture, content: str
) -> None:
stamp.write_text(content)
with caplog.at_level(logging.WARNING, logger="gatelock.night_lockdown"):
assert evening_lockdown_today(stamp, NOW) is None
assert any(r.levelno == logging.WARNING for r in caplog.records)
def test_absurd_epoch_warns(stamp: Path, caplog: pytest.LogCaptureFixture) -> None:
stamp.write_text("9" * 30)
with caplog.at_level(logging.WARNING, logger="gatelock.night_lockdown"):
assert evening_lockdown_today(stamp, NOW) is None
assert any(r.levelno == logging.WARNING for r in caplog.records)
def test_unreadable_warns(stamp: Path, caplog: pytest.LogCaptureFixture) -> None:
stamp.mkdir() # reading a directory is an OSError that is not "missing"
with caplog.at_level(logging.WARNING, logger="gatelock.night_lockdown"):
assert evening_lockdown_today(stamp, NOW) is None
assert any(r.levelno == logging.WARNING for r in caplog.records)
def test_now_defaults_to_the_local_clock(stamp: Path) -> None:
# A stamp a minute ago counts exactly when "a minute ago" is today, >=12:00.
ago = datetime.now(tz=UTC).astimezone() - timedelta(minutes=1)
stamp.write_text(str(int(ago.timestamp())))
expected = ago.date() == (ago + timedelta(minutes=1)).date() and ago.hour >= 12
assert (evening_lockdown_today(stamp) is not None) is expected

View File

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "gatelock"
version = "0.9.0"
version = "0.10.0"
description = "Shared per-output lock-window + HMAC log-integrity backend for blocking-overlay apps"
requires-python = ">=3.14"
dependencies = [] # pure stdlib — tkinter is bundled with Python