steam-backlog-enforcer/steam_backlog_enforcer/_leetcode_bonus.py
Krzysztof kuhy Rudnicki eb47067617 budget: wake on screen-locker's log.json; stat stamps drop stale earner answers
The enforce loop now waits on an inotify watch of log.json's directory
(_file_wake, ctypes, no new dependency) instead of a plain sleep, so a
credited workout moves the gaming budget within the tick. Earner answers
(workout, LeetCode, reading) are memoised with the source file's
(inode, mtime_ns, size) stamp, so a rewritten file re-asks at once instead
of after the 60 s TTL. Any inotify setup or read failure warns and falls
back to the plain sleep; the stamp still catches the change next tick.

Tests: conftest stubs the wake factory and points workout_log_path at a
tmp path, so no test ever watches the real screen-locker tree.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018RHwVPyiiQMPh4M3JQwQku
2026-10-09 18:52:58 +02:00

138 lines
5.1 KiB
Python

"""Whether a LeetCode problem was solved today, worth an hour of budget.
Deliberately shaped like :mod:`steam_backlog_enforcer._workout_budget`, because
the two earners must behave identically where it matters:
**Fail closed.** Any failure to get an answer -- missing ledger, unparsable
JSON, unreadable integrity key, dead fallback endpoint -- yields *no bonus*,
logged at warning and reported through :mod:`_bonus_incident`. Failing open
would let the coupling be defeated by deleting a file.
**Independent of the workout term.** Nothing here can raise or lower the
workout bonus. The two answers are read separately and summed by
``resolve_budget``, which is what "must not interfere with the workout" means
in code.
**Two transports, one fact.** The ledger is read directly first: it answers
even when leetcode-guard is not running, which is most of the day. The loopback
endpoint is the fallback, and covers a renamed or misconfigured ledger path
rather than a permission problem -- this daemon runs as root, so it already has
strictly more access than the endpoint does. Both carry the same staleness (see
below); the fallback is not a fresher source.
**Known staleness, accepted.** leetcode-guard only writes credits from its lock
window's poll loop, so a *voluntary extra* solve made after the day is settled
never reaches the ledger and earns nothing. The bonus tracks what the day's
gate run recorded.
Reading and verifying the ledger itself lives in
:mod:`steam_backlog_enforcer._leetcode_ledger`; this module owns the two
transports, the memo, and the incident on total failure.
"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import TYPE_CHECKING
from steam_backlog_enforcer._bonus_incident import report_leetcode_incident
from steam_backlog_enforcer._leetcode_ledger import read_ledger_solved_today
from steam_backlog_enforcer._status_api import AnswerCache, file_stamp, get_status
if TYPE_CHECKING:
from steam_backlog_enforcer.config import Config
logger = logging.getLogger(__name__)
_cache = AnswerCache()
def reset_cache() -> None:
"""Drop the cached answer, forcing the next call to re-ask."""
_cache.clear()
def _fetch_leetcode_today(url: str) -> bool:
"""Read ``leetcode.solved_today`` from leetcode-guard's status API.
Args:
url: The status endpoint, e.g. ``http://127.0.0.1:8771/api/status``.
Returns:
Whether an accepted submission is recorded for today.
Raises:
OSError: The endpoint could not be reached.
ValueError: The response was not usable JSON, not a 200, or said it
could not check.
KeyError: The payload lacked the expected fields.
"""
block = get_status(url)["leetcode"]
if not block["checked"]:
# The server answered, but said it could not look. That is not a "no".
msg = f"leetcode-guard could not check: {block.get('reason', 'no reason')}"
raise ValueError(msg)
return bool(block["solved_today"])
def leetcode_solved_today(config: Config) -> bool | None:
"""Whether an accepted LeetCode submission is recorded for today.
Args:
config: Loaded user configuration, for the ledger path and the URL.
Returns:
True or False when either transport answered, ``None`` when neither
could. ``None`` is deliberately distinct from ``False`` so the caller
can log which one happened -- and so an unreadable ledger raises an
incident while an honest "not solved yet" does not.
"""
url = config.leetcode_status_url
path = Path(config.leetcode_ledger_path).expanduser()
# A new credit row rewrites the ledger; its stamp drops the memo at once.
stamp = file_stamp(path)
cached = _cache.fresh(url, stamp=stamp)
if cached is not None:
return cached
answer = read_ledger_solved_today(path)
if answer is None:
answer = _fallback_to_endpoint(url, path)
if answer is None:
return None
return _cache.store(url, answer=answer, stamp=stamp)
def _fallback_to_endpoint(url: str, path: Path) -> bool | None:
"""Ask leetcode-guard's status API after the ledger read failed.
Args:
url: The status endpoint.
path: The ledger that could not be read, for the incident message.
Returns:
The answer, or ``None`` when this transport failed too.
"""
try:
answer = _fetch_leetcode_today(url)
except OSError as exc:
report_leetcode_incident(
f"could not read the ledger at {path}, and could not reach "
f"leetcode-guard's status API at {url} ({exc})",
fix="systemctl --user status leetcode-guard-web",
)
return None
except (ValueError, KeyError, TypeError) as exc:
report_leetcode_incident(
f"could not read the ledger at {path}, and leetcode-guard's status "
f"API at {url} returned something unusable ({exc})",
fix="journalctl --user -u leetcode-guard-web -n 50",
)
return None
logger.warning(
"LeetCode ledger at %s was unreadable; used the status API instead", path
)
return answer