screen-locker/screen_locker/_leetcode_bonus.py
Krzysztof kuhy Rudnicki 6a07dc56aa
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
Tests / flutter-coverage (push) Waiting to run
Tests / web-coverage (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
Reading hour from book-guard: +1h shutdown; base 20->19 from 2026-10-01
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-26 19:02:24 +02:00

184 lines
6.6 KiB
Python

"""One extra shutdown hour on a day with an accepted LeetCode submission.
leetcode-guard never writes the shutdown config itself; it only publishes the
fact through its ledger, the same way ``steam-backlog-enforcer`` reads it for
an hour of gaming budget. The write has to live here because
:func:`~screen_locker._shutdown_base.reset_to_base_if_new_day` recomputes the
config from scratch on every new day -- a bonus written by another program
would be wiped by that reset, exactly as a pre-tick workout credit was on
2026-09-13. So the hour is a *term* of the derived target, not an increment
that survives by luck.
Three things are pinned deliberately, mirroring leetcode-guard's own
``solved_today`` contract:
**Only ``credit`` entries count.** A ``seen`` entry is first-run seeding worth
zero, and a ``charge`` proves only that the day was *settled* -- which also
happens from banked credit, from the escape hatch and from a classified
outage, so it can never stand in for a solve.
**The solve time is ``detail["submitted_at"]``, not ``day``.** ``day`` is the
*harvesting* run's date, and a problem solved at 23:50 and harvested the next
morning carries the next day's key. It earns for the day it was solved on,
which by then is over -- never for the next one.
**"Cannot check" is not "not solved".** An unreadable ledger or key yields
``None``, and the caller fails closed to no hour. Failing open would let the
coupling be defeated by deleting a file.
"""
from __future__ import annotations
from datetime import UTC, datetime, time
import json
import logging
from pathlib import Path
from typing import Final
from gatelock.log_integrity import verify_entry_hmac
from screen_locker._constants import HMAC_KEY_FILE
_logger: Final = logging.getLogger(__name__)
# Flat, once per day, regardless of how many problems were solved -- the
# workout is the harder thing and keeps the bigger, per-session reward.
LEETCODE_BONUS_HOURS: Final = 1
# leetcode-guard's real ledger (its DATA_DIR / "ledger.json"). Read-only from
# here; resolved at call time so the test suite's path redirect applies.
LEETCODE_LEDGER_FILE: Final = Path.home() / ".local/share/leetcode_guard/ledger.json"
_CREDIT: Final = "credit"
def _today_window(now: datetime | None = None) -> tuple[float, float]:
"""Local midnight and now, as unix seconds.
Args:
now: Stand-in for the current local time, for tests.
Returns:
The inclusive window a solve must fall in to count for today.
"""
moment = (now or datetime.now(tz=UTC)).astimezone()
midnight = datetime.combine(moment.date(), time.min, moment.tzinfo)
return midnight.timestamp(), moment.timestamp()
def _landed_today(entry: dict[str, object], *, window: tuple[float, float]) -> bool:
"""Whether this verified credit's accepted submission happened today.
Args:
entry: One ledger entry, already known to be a verified credit.
window: Local midnight and now, as unix seconds.
Returns:
Whether the solve falls inside today. Without a usable
``submitted_at`` the harvest ``day`` is the fallback: it runs late,
never early, so it can miss a late solve but never invent one.
"""
detail = entry.get("detail")
raw = detail.get("submitted_at") if isinstance(detail, dict) else None
if raw is not None:
try:
stamp = float(str(raw))
except ValueError:
_logger.warning(
"LeetCode credit %r has an unparsable submitted_at (%r); "
"falling back to its day key",
entry.get("entry_id"),
raw,
)
else:
start, end = window
return start <= stamp <= end
start, _ = window
today = datetime.fromtimestamp(start).astimezone().date().isoformat()
return entry.get("day") == today
def _read_entries(ledger_path: Path, label: str = "LeetCode") -> list[object] | None:
"""Read the ledger's entry list, or ``None`` when it cannot be read.
Args:
ledger_path: leetcode-guard's (or book-guard's) ledger file.
label: Whose ledger, for the log line.
Returns:
The raw entries, or ``None`` -- which is never "no entries".
"""
try:
raw = json.loads(ledger_path.read_text(encoding="utf-8"))
except OSError as exc:
_logger.warning("Cannot read the %s ledger at %s (%s)", label, ledger_path, exc)
return None
except ValueError as exc:
_logger.warning(
"%s ledger at %s is not valid JSON (%s)", label, ledger_path, exc
)
return None
rows = raw.get("entries") if isinstance(raw, dict) else None
if not isinstance(rows, list):
_logger.warning("%s ledger at %s has no entries array", label, ledger_path)
return None
return rows
def leetcode_solved_today(
ledger_path: Path | None = None,
*,
now: datetime | None = None,
) -> bool | None:
"""Whether a verified accepted submission landed today, locally.
Args:
ledger_path: The ledger to read. None resolves
:data:`LEETCODE_LEDGER_FILE` at call time.
now: Stand-in for the current local time, for tests.
Returns:
True or False when the ledger could be read and verified, ``None``
when it could not -- which is never "nothing was solved".
"""
target = LEETCODE_LEDGER_FILE if ledger_path is None else ledger_path
try:
key_usable = bool(HMAC_KEY_FILE.read_bytes().strip())
except OSError as exc:
_logger.warning("Cannot read the integrity key at %s (%s)", HMAC_KEY_FILE, exc)
return None
if not key_usable:
_logger.warning("Integrity key at %s is empty", HMAC_KEY_FILE)
return None
rows = _read_entries(target)
if rows is None:
return None
window = _today_window(now)
return any(
isinstance(row, dict)
and row.get("kind") == _CREDIT
and verify_entry_hmac(row, key_file=HMAC_KEY_FILE)
and _landed_today(row, window=window)
for row in rows
)
def leetcode_bonus_hours(ledger_path: Path | None = None) -> int:
"""The shutdown hours today's LeetCode solve has earned, failing closed.
Args:
ledger_path: The ledger to read. None resolves the real one.
Returns:
:data:`LEETCODE_BONUS_HOURS` on a day with a verified solve, else 0.
A ledger that cannot be checked is 0 too, and logged -- it must never
be mistaken for a confident "not solved", but it earns nothing either.
"""
solved = leetcode_solved_today(ledger_path)
if solved is None:
_logger.warning("LeetCode solve state could not be checked; no shutdown hour")
return 0
return LEETCODE_BONUS_HOURS if solved else 0