steam-backlog-enforcer/steam_backlog_enforcer/_friction.py
Krzysztof kuhy Rudnicki e4c61442c2
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
buy-dlc: fixed-length phrase the daemon accepts (was an unfillable {minutes} slot)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 16:11:32 +02:00

77 lines
2.7 KiB
Python

"""Typed-phrase friction shared by the CLI, the web job runner and the daemon.
One table, so a phrase cannot be strict in one front end and lax in another.
The phrase is checked wherever the action is actually carried out (the job
subprocess or the root daemon), never only in the browser: a check that
``curl`` can skip is decoration, not friction.
Templates use ``str.format`` fields; the caller supplies every field the
template names (``days``, ``minutes``, ``game_name``, ``count``, ``backup_id``).
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Final
from steam_backlog_enforcer._store_window import DEFAULT_WINDOW_MINUTES
@dataclass(frozen=True)
class Friction:
"""The friction attached to one command.
Attributes:
phrase_template: Text the user must type, with ``{field}`` slots.
countdown_seconds: Armed wait with heartbeats before commit (0 = none).
"""
phrase_template: str
countdown_seconds: int = 0
GAMING_RESET_COUNTDOWN_SECONDS: Final = 300
_STORE_WINDOW_PHRASE: Final = "unblock the store for {minutes} minutes"
FRICTION: Final[dict[str, Friction]] = {
"gaming-reset": Friction(
"reset today's gaming budget", GAMING_RESET_COUNTDOWN_SECONDS
),
"block-gaming": Friction("block all gaming for {days} days"),
"unblock": Friction(_STORE_WINDOW_PHRASE),
# buy-dlc takes no params: it is always the default-length window, and the
# daemon checks it as an ``unblock`` of that length. So its phrase is that
# exact sentence with the number already in it, not a ``{minutes}`` slot
# that nothing could fill.
"buy-dlc": Friction(_STORE_WINDOW_PHRASE.format(minutes=DEFAULT_WINDOW_MINUTES)),
"gaming-unblock": Friction("force release playtime mounts"),
"abandon-pick": Friction("abandon {game_name}"),
"pick-manual": Friction("lock in {game_name}"),
"add-exception": Friction("request exception for {game_name}"),
"uninstall": Friction("uninstall {count} games"),
"reset": Friction("wipe all enforcer state"),
"restore-backup": Friction("restore backup {backup_id}"),
}
def expected_phrase(command: str, **fields: object) -> str | None:
"""Return the exact phrase *command* requires, or ``None`` if it has none.
Raises:
KeyError: If the template names a field missing from *fields*.
"""
friction = FRICTION.get(command)
if friction is None:
return None
return friction.phrase_template.format(**fields)
def phrase_matches(expected: str, typed: str) -> bool:
"""Compare a typed phrase to the expected one.
Only surrounding whitespace is forgiven. Case and inner spacing must
match: retyping the sentence exactly is the point.
"""
return typed.strip() == expected