steam-backlog-enforcer/steam_backlog_enforcer/_leetcode_bonus.py
Krzysztof kuhy Rudnicki 2d45515665 pylint: fold the remaining split-era duplicates back into single owners
The 250-line split left function-sized copies behind: the confidence-only
batch fetch was the search half of _fetch_batch verbatim (now
_search_batch, and _SearchCtx carries one _HLTBExtras instead of two of
its dicts); the workout and LeetCode bonuses each had the same loopback
GET and answer cache (now _status_api); the poll-count refetch lived in
both _cmd_done and _polls_reporting (now refetch_poll_counts); the
candidate-selection prelude in both scanning and _scanning_assign; the
/etc/hosts rewrite loop in both block and unblock (now
rewrite_hosts_lines with a per-line rule); the timed HLTB fetch and its
rate line; and the whitelist constants each module only half used.
_hltb_cached's dead facade constants go too. Tests patch where the code
now lives.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HHCbkJWXHjtTUaqvnHRAqA
2026-09-12 20:49:12 +02:00

136 lines
4.9 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, 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
cached = _cache.fresh(url)
if cached is not None:
return cached
path = Path(config.leetcode_ledger_path).expanduser()
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)
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