mirror of
https://github.com/kuhyx/steam-backlog-enforcer.git
synced 2026-11-10 19:27:04 +01:00
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 (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint) (push) Waiting to run
Tests / test (push) Waiting to run
Tests / web-coverage (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
144 lines
4.8 KiB
Python
144 lines
4.8 KiB
Python
"""Any registered earner without a dedicated reader: its gate's signed ledger.
|
|
|
|
The workout, LeetCode and reading earners have their own modules (transports,
|
|
fallbacks, incidents). An earner registered in ``earned_time`` after them only
|
|
publishes HMAC-signed ``credit`` rows, so the shared reader is all it needs:
|
|
it joins the budget with no code here.
|
|
|
|
A *counted* gate (the Automation tutor, from ``earned_time.TUTOR_FROM``) pays
|
|
per unit -- each verified active minute (earned_time >= 0.8; a legacy
|
|
15-minute block row counts 15) -- so :func:`ledger_units` reads its unit
|
|
count for the day instead of a yes/no.
|
|
|
|
:func:`first_credits` feeds ``earned_time.resolve`` each penalised earner's
|
|
first real credit (earned_time >= 0.6), so a gate that never paid out costs
|
|
nothing yet: maturity can only delay a penalty, never add one.
|
|
|
|
Paths resolve under :data:`LEDGER_HOME` -- the unit sets ``HOME`` to the
|
|
desktop user's, and the test suite redirects this constant.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import date, datetime
|
|
import logging
|
|
from pathlib import Path
|
|
import time
|
|
from typing import TYPE_CHECKING, Final, cast
|
|
|
|
import earned_time
|
|
|
|
from steam_backlog_enforcer._leetcode_ledger import HMAC_KEY_FILE
|
|
from steam_backlog_enforcer._status_api import AnswerCache
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Callable
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
LEDGER_HOME: Final = Path.home()
|
|
|
|
# A unit count is reused for this long, like AnswerCache's yes/no: a block
|
|
# credited now raises the budget within a minute.
|
|
_UNITS_TTL_SECONDS: Final = 60.0
|
|
|
|
_cache = AnswerCache()
|
|
_units: dict[str, tuple[float, int]] = {}
|
|
|
|
|
|
def reset_cache() -> None:
|
|
"""Drop the cached answers, forcing the next call to re-read."""
|
|
_cache.clear()
|
|
_units.clear()
|
|
|
|
|
|
def registry_for(day: date) -> tuple[earned_time.Earner, ...]:
|
|
"""The earners in force on ``day``.
|
|
|
|
``earners_for`` (earned_time >= 0.5) switches the registry on
|
|
``TUTOR_FROM``; an older earned_time has one static registry.
|
|
"""
|
|
fn = getattr(earned_time, "earners_for", None)
|
|
return tuple(earned_time.EARNERS if fn is None else fn(day))
|
|
|
|
|
|
def first_credits(
|
|
registry: tuple[earned_time.Earner, ...], day: date
|
|
) -> dict[str, date | None] | None:
|
|
"""Each penalised earner's first real credit up to ``day``, for ``resolve``.
|
|
|
|
Keyed by name over ``registry`` -- the tuple the answers came from, so the
|
|
two ``automation`` earners (one per side of ``TUTOR_FROM``) never collide.
|
|
An earner without ``penalty_from``, a ledger or a matcher is left out:
|
|
``resolve`` then falls back to its ``confirmed_on`` (fail closed).
|
|
|
|
Returns:
|
|
The map, or ``None`` on an earned_time without ``maturity`` (< 0.6),
|
|
where the caller resolves exactly as before.
|
|
"""
|
|
maturity = cast(
|
|
"Callable[..., earned_time.Maturity] | None",
|
|
getattr(earned_time, "maturity", None),
|
|
)
|
|
if maturity is None:
|
|
return None
|
|
return {
|
|
item.name: maturity(
|
|
item, LEDGER_HOME / item.ledger, HMAC_KEY_FILE, day
|
|
).first_credit
|
|
for item in registry
|
|
if item.penalty_from is not None
|
|
and item.ledger is not None
|
|
and item.match is not None
|
|
}
|
|
|
|
|
|
def _no_ledger(earner: earned_time.Earner) -> None:
|
|
logger.warning(
|
|
"Earner %s has no ledger and no reader here; it earns nothing",
|
|
earner.label,
|
|
)
|
|
|
|
|
|
def ledger_units(earner: earned_time.Earner) -> int | None:
|
|
"""Today's verified units of a counted gate earner, memoised for a minute.
|
|
|
|
Returns:
|
|
The count (``0`` included), or ``None`` when the ledger or key could
|
|
not be read -- never "no units". ``max_units`` is applied by
|
|
``earned_time.resolve``.
|
|
"""
|
|
if earner.ledger is None:
|
|
_no_ledger(earner)
|
|
return None
|
|
now = time.monotonic()
|
|
cached = _units.get(earner.name)
|
|
if cached is not None and now - cached[0] < _UNITS_TTL_SECONDS:
|
|
return cached[1]
|
|
today = datetime.now().astimezone().date()
|
|
path = LEDGER_HOME / earner.ledger
|
|
units = earned_time.credit_units(earner, path, HMAC_KEY_FILE, today)
|
|
if units is None:
|
|
return None
|
|
_units[earner.name] = (now, units)
|
|
return units
|
|
|
|
|
|
def ledger_answer(earner: earned_time.Earner) -> bool | None:
|
|
"""Today's answer from ``earner``'s ledger, memoised like the other earners.
|
|
|
|
Returns:
|
|
True or False when the ledger could be read, ``None`` when it could
|
|
not or the earner has no ledger -- which is never "not done".
|
|
"""
|
|
if earner.ledger is None:
|
|
_no_ledger(earner)
|
|
return None
|
|
cached = _cache.fresh(earner.name)
|
|
if cached is not None:
|
|
return cached
|
|
answer = earned_time.done_today(earner, LEDGER_HOME / earner.ledger, HMAC_KEY_FILE)
|
|
if answer is None:
|
|
return None
|
|
return _cache.store(earner.name, answer=answer)
|