steam-backlog-enforcer/steam_backlog_enforcer/_prompter.py
2026-10-10 16:07:42 +02:00

169 lines
5.6 KiB
Python

"""Questions a command asks its user, answered by a terminal or the web UI.
Every ``input()`` in the package goes through :func:`current_prompter`. The
CLI prompter reads stdin exactly as the commands used to; a web job installs
one that emits a ``prompt`` event and waits for the answer the server writes
(see :mod:`steam_backlog_enforcer.jobs._job_prompter`).
Typed-phrase confirmations take their text from
:func:`steam_backlog_enforcer._friction.expected_phrase`, so the CLI, the job
runner and the daemon all demand the same sentence.
"""
from __future__ import annotations
from contextlib import contextmanager
from contextvars import ContextVar
from dataclasses import dataclass
import sys
from typing import TYPE_CHECKING, Protocol, runtime_checkable
from steam_backlog_enforcer._echo import _echo
from steam_backlog_enforcer._friction import expected_phrase, phrase_matches
if TYPE_CHECKING:
from collections.abc import Iterator, Sequence
@dataclass(frozen=True)
class PromptOption:
"""One answer to a ``choice`` prompt.
Attributes:
value: What the prompter returns when this option is chosen.
label: The text shown for it.
detail: Optional secondary text (the UI shows it under the label).
"""
value: str
label: str
detail: str | None = None
class PromptAbortedError(Exception):
"""The user declined, or never answered, a prompt the command needs."""
class Prompter(Protocol):
"""Asks the user something and returns the answer."""
@property
def interactive(self) -> bool:
"""Whether a human can answer at all (a piped CLI cannot)."""
def choice(self, message: str, options: Sequence[PromptOption]) -> str:
"""Return the ``value`` of the option the user picked."""
def confirm(self, message: str, *, default: bool) -> bool:
"""Return the user's yes/no answer."""
def phrase(self, message: str, expected: str) -> bool:
"""Return whether the user typed *expected* exactly."""
def text(self, message: str) -> str:
"""Return a line of free text, stripped."""
@runtime_checkable
class GamePicker(Protocol):
"""A prompter that can show the whole library to pick a game from.
Optional on top of :class:`Prompter`: the CLI has no browser and keeps
its text search (:func:`steam_backlog_enforcer._own_pick.prompt_own_pick`).
"""
def game(self, message: str, app_ids: Sequence[int]) -> int | None:
"""Return the picked app id (one of *app_ids*), or ``None`` to go back."""
class CliPrompter:
"""Reads answers from stdin; the CLI's behaviour before prompters existed."""
@property
def interactive(self) -> bool:
"""True when stdin is a terminal."""
return sys.stdin.isatty()
def choice(self, message: str, options: Sequence[PromptOption]) -> str:
"""Print a numbered list and loop until a valid number is typed."""
for i, option in enumerate(options, 1):
_echo(f" {i}. {option.label}")
while True:
raw = input(f"{message}: ")
try:
idx = int(raw)
except ValueError:
_echo(f"Invalid input: {raw!r}")
continue
if idx < 1 or idx > len(options):
_echo(f"Out of range: {idx}")
continue
return options[idx - 1].value
def confirm(self, message: str, *, default: bool) -> bool:
"""Ask ``[Y/n]``; an empty answer or end of input means *default*."""
hint = "[Y/n]" if default else "[y/N]"
while True:
try:
answer = input(f"{message} {hint}: ").strip().lower()
except EOFError:
return default
if not answer:
return default
if answer in {"y", "yes"}:
return True
if answer in {"n", "no"}:
return False
_echo(" Please answer 'y' or 'n'.")
def phrase(self, message: str, expected: str) -> bool:
"""Ask for the phrase once; a mismatch is a "no".
The CLI has already printed what is at stake before asking, so only
the phrase itself is shown; *message* is for front ends without that
preamble.
"""
del message
return phrase_matches(expected, input(f'Type "{expected}" to confirm: '))
def text(self, message: str) -> str:
"""Ask for one line of text."""
return input(f"{message}: ").strip()
_ACTIVE: ContextVar[Prompter | None] = ContextVar("prompter", default=None)
_CLI = CliPrompter()
def current_prompter() -> Prompter:
"""Return the prompter the running command should ask."""
return _ACTIVE.get() or _CLI
@contextmanager
def use_prompter(prompter: Prompter) -> Iterator[Prompter]:
"""Make *prompter* the active one for the duration of the context."""
token = _ACTIVE.set(prompter)
try:
yield prompter
finally:
_ACTIVE.reset(token)
def confirm_phrase(command: str, message: str, **fields: object) -> bool:
"""Ask for *command*'s friction phrase and say whether it was typed.
Args:
command: Key into :data:`steam_backlog_enforcer._friction.FRICTION`.
message: What is being confirmed, for front ends that show it.
**fields: The template's slots (``game_name``, ``count``, ...).
Raises:
KeyError: If *command* has no phrase; a missing phrase must never
silently mean "confirmed".
"""
expected = expected_phrase(command, **fields)
if expected is None:
raise KeyError(command)
return current_prompter().phrase(message, expected)