mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 21:07:15 +01:00
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
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:
parent
49a401debd
commit
eef2d46f32
@ -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
|
||||
|
||||
90
gatelock/gatelock/night_lockdown.py
Normal file
90
gatelock/gatelock/night_lockdown.py
Normal 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
|
||||
108
gatelock/gatelock/tests/test_night_lockdown.py
Normal file
108
gatelock/gatelock/tests/test_night_lockdown.py
Normal 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
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user