mirror of
https://github.com/kuhyx/steam-backlog-enforcer.git
synced 2026-11-10 20:27:03 +01:00
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
143 lines
5.1 KiB
Python
143 lines
5.1 KiB
Python
"""One loopback status-API read, shared by the workout and LeetCode bonuses.
|
|
|
|
Both bonuses ask a sibling daemon's ``/api/status`` endpoint over loopback and
|
|
reuse a successful answer for a minute. The transport and the cache policy
|
|
are identical, so they live here once; each bonus keeps only the field it
|
|
reads out of the payload.
|
|
|
|
Uses ``http.client`` rather than ``urllib.request`` deliberately, matching
|
|
``screen_locker._http_workout_fetch``: it takes a host and a path rather than a
|
|
URL, so there is no scheme for a bad config value to turn into a ``file://``
|
|
read, and no lint waiver is needed to say so.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import http.client
|
|
import json
|
|
import logging
|
|
import time
|
|
from typing import TYPE_CHECKING, Any, Final
|
|
from urllib.parse import urlsplit
|
|
|
|
if TYPE_CHECKING:
|
|
from pathlib import Path
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Loopback, so a short timeout is generous. The daemon ticks frequently and
|
|
# must never block on this.
|
|
_TIMEOUT_SECONDS: Final = 2.0
|
|
|
|
# A successful answer is reused for this long. The daemon would otherwise make
|
|
# one HTTP call per tick, and the answer changes at most a few times a day. The
|
|
# cost is that a workout logged now raises the budget within a minute rather
|
|
# than instantly -- invisible next to a two-hour swing. A caller that knows
|
|
# which file the answer is derived from also passes its stat stamp, and a
|
|
# changed stamp drops the answer at once (see :func:`file_stamp`).
|
|
_CACHE_TTL_SECONDS: Final = 60.0
|
|
|
|
# ``(st_ino, st_mtime_ns, st_size)`` of a source file; ``None`` when absent.
|
|
type Stamp = tuple[int, int, int] | None
|
|
|
|
# Last stat error per path, so an unreadable file warns once, not every tick.
|
|
_stat_errors: dict[Path, str] = {}
|
|
|
|
|
|
def file_stamp(path: Path) -> Stamp:
|
|
"""Cheap identity of ``path``'s current contents, for cache invalidation.
|
|
|
|
The inode is part of it because writers replace these files atomically:
|
|
a rename within the same mtime tick still changes the inode.
|
|
|
|
Returns:
|
|
The stamp, or ``None`` when the file does not exist -- an ordinary
|
|
state (no workout log yet, no book registered), compared like any
|
|
other stamp. Any other stat failure also yields ``None``, warned once
|
|
per distinct error so a 3 s loop cannot flood the journal.
|
|
"""
|
|
try:
|
|
st = path.stat()
|
|
except FileNotFoundError:
|
|
_stat_errors.pop(path, None)
|
|
return None
|
|
except OSError as exc:
|
|
if _stat_errors.get(path) != str(exc):
|
|
_stat_errors[path] = str(exc)
|
|
logger.warning(
|
|
"Cannot stat %s (%s) -- its cached answer now expires only "
|
|
"on the %ds TTL instead of on change.",
|
|
path,
|
|
exc,
|
|
int(_CACHE_TTL_SECONDS),
|
|
)
|
|
return None
|
|
_stat_errors.pop(path, None)
|
|
return (st.st_ino, st.st_mtime_ns, st.st_size)
|
|
|
|
|
|
def get_status(url: str) -> dict[str, Any]:
|
|
"""GET a status endpoint and return its JSON body.
|
|
|
|
Args:
|
|
url: The status endpoint, e.g. ``http://127.0.0.1:8770/api/status``.
|
|
|
|
Raises:
|
|
OSError: The endpoint could not be reached.
|
|
ValueError: The response was not usable JSON, or not a 200.
|
|
"""
|
|
split = urlsplit(url)
|
|
conn = http.client.HTTPConnection(
|
|
split.hostname or "127.0.0.1",
|
|
split.port or 80,
|
|
timeout=_TIMEOUT_SECONDS,
|
|
)
|
|
try:
|
|
conn.request("GET", split.path or "/api/status")
|
|
resp = conn.getresponse()
|
|
body = resp.read()
|
|
if resp.status != http.HTTPStatus.OK:
|
|
msg = f"status {resp.status} {resp.reason}"
|
|
raise ValueError(msg)
|
|
finally:
|
|
conn.close()
|
|
payload: dict[str, Any] = json.loads(body)
|
|
return payload
|
|
|
|
|
|
class AnswerCache:
|
|
"""Successful answers by URL, each good for :data:`_CACHE_TTL_SECONDS`.
|
|
|
|
Only *successes* are stored. A failure is retried on the very next tick, so
|
|
a restarted status server takes effect immediately instead of serving the
|
|
floor for another minute. An answer stored with a :data:`Stamp` is also
|
|
dropped as soon as the caller presents a different one.
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
"""Start empty."""
|
|
self._entries: dict[str, tuple[float, bool, Stamp]] = {}
|
|
|
|
def clear(self) -> None:
|
|
"""Drop every cached answer, forcing the next call to re-ask."""
|
|
self._entries.clear()
|
|
|
|
def fresh(self, url: str, *, stamp: Stamp = None) -> bool | None:
|
|
"""The cached answer for ``url`` if within its TTL and ``stamp``."""
|
|
cached = self._entries.get(url)
|
|
if cached is None or cached[2] != stamp:
|
|
return None
|
|
if time.monotonic() - cached[0] < _CACHE_TTL_SECONDS:
|
|
return cached[1]
|
|
return None
|
|
|
|
def store(self, url: str, *, answer: bool, stamp: Stamp = None) -> bool:
|
|
"""Remember ``answer`` for ``url`` as of now, and hand it back.
|
|
|
|
``stamp`` must be taken *before* the answer was fetched: a write that
|
|
lands mid-fetch then leaves the stored stamp stale, so the next call
|
|
re-asks instead of trusting a pre-write answer for a whole TTL.
|
|
"""
|
|
self._entries[url] = (time.monotonic(), answer, stamp)
|
|
return answer
|