"""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. **Staleness.** leetcode-guard's one-minute harvester credits a solve within about a minute, even with no lock window up. A day paid ahead from banked credits counts like a solve (``earned_time`` >= 0.17.0, kuhy 2026-10-23). 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, or today was paid from banked credits. 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) # A day paid ahead from banked credits earns the hour like a solve # (leetcode-guard >= 2026-10-23); an older server has no such field. return bool(block["solved_today"]) or bool(block.get("settled_by_credit")) 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