mirror of
https://github.com/kuhyx/steam-backlog-enforcer.git
synced 2026-11-10 19:27:04 +01:00
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
169 lines
5.6 KiB
Python
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)
|