from __future__ import annotations import contextlib import io from typing import TYPE_CHECKING import pytest from plyngent.prompting import ( ChoiceOption, FormField, NonInteractiveBackend, NonInteractiveError, PromptCancelledError, _read_line_with_timeout_posix, ask, cancel_prompt_read, choose, configure_prompting, confirm, form, prompt_cancelled, read_line_with_timeout, reset_prompting, run_cancellable_prompt_async, run_prompt_async, temporary_backend, ) if TYPE_CHECKING: from collections.abc import Generator, Iterator @pytest.fixture(autouse=True) def reset_prompting_state() -> Iterator[None]: """Prompting state (backend, SIGINT guard, cancel flag) is process-global.""" reset_prompting() yield reset_prompting() def _ready_poll(r, w, x, t): return (r, w, x) def _never_poll(r, w, x, t): return ([], [], []) def test_read_line_with_timeout_reads_line() -> None: stream = io.StringIO("hello\n") assert _read_line_with_timeout_posix("p> ", 5.0, stream=stream, poll=_ready_poll) == "hello" def test_read_line_with_timeout_empty_enter() -> None: stream = io.StringIO("\n") assert _read_line_with_timeout_posix("p> ", 5.0, stream=stream, poll=_ready_poll) == "" def test_read_line_with_timeout_timeout_returns_none() -> None: stream = io.StringIO() assert _read_line_with_timeout_posix("p> ", 0.01, stream=stream, poll=_never_poll) is None def test_read_line_with_timeout_eof_returns_none() -> None: stream = io.StringIO("") assert _read_line_with_timeout_posix("p> ", 5.0, stream=stream, poll=_ready_poll) is None def test_read_line_with_timeout_negative_timeout() -> None: assert read_line_with_timeout("p> ", -1) is None def test_read_line_with_timeout_cancelled() -> None: """A cancel request (SIGINT during an off-loop prompt) ends the read.""" stream = io.StringIO() cancel_prompt_read() try: assert prompt_cancelled() with pytest.raises(PromptCancelledError): _read_line_with_timeout_posix("p> ", 5.0, stream=stream, poll=_never_poll) finally: stream.close() async def test_run_prompt_async_clears_cancel_flag() -> None: """A stale cancel request must not leak into the next prompt read.""" cancel_prompt_read() with temporary_backend(NonInteractiveBackend()): assert await run_prompt_async(lambda: "ok") == "ok" assert not prompt_cancelled() async def test_run_prompt_async_passes_cancel_hook_only_when_cancellable() -> None: """The CLI guard gets the read's cancel hook for poll-based reads only.""" seen: list[object] = [] @contextlib.contextmanager def _guard(cancel: object) -> Generator[None]: seen.append(cancel) yield with temporary_backend(NonInteractiveBackend()): # temporary_backend resets the guard, so install it inside. configure_prompting(pause_factory=_guard) assert await run_cancellable_prompt_async(lambda: "a") == "a" assert await run_prompt_async(lambda: "b") == "b" assert seen == [cancel_prompt_read, None] class ScriptedBackend: """Deterministic backend for unit tests.""" def __init__( self, lines: list[str], *, confirms: list[bool] | None = None, secrets: list[str] | None = None, ) -> None: self.lines = list(lines) self.confirms = list(confirms or []) self.secrets = list(secrets or []) self.interactive = True self.echoes: list[str] = [] def is_interactive(self) -> bool: return self.interactive def read_line( self, prompt: str, *, default: str | None = None, completions: object = None, ) -> str: del prompt, completions if self.lines: return self.lines.pop(0) if default is not None: return default msg = "no scripted lines left" raise NonInteractiveError(msg) def read_secret_line(self, prompt: str) -> str: del prompt if self.secrets: return self.secrets.pop(0) msg = "no scripted secrets left" raise NonInteractiveError(msg) def confirm(self, prompt: str, *, default: bool = False) -> bool: del prompt if self.confirms: return self.confirms.pop(0) return default def echo(self, message: str = "", *, err: bool = False) -> None: del err self.echoes.append(message) def secho(self, message: str, *, fg: str | None = None, err: bool = False) -> None: del fg, err self.echoes.append(message) def test_ask_free_text() -> None: backend = ScriptedBackend(["hello world"]) with temporary_backend(backend): assert ask("Name?") == "hello world" def test_ask_secret() -> None: from plyngent.prompting import ask_secret backend = ScriptedBackend([], secrets=["s3cret"]) with temporary_backend(backend): assert ask_secret("Password?") == "s3cret" def test_ask_default_when_empty_uses_backend_default() -> None: backend = ScriptedBackend([]) with temporary_backend(backend): assert ask("Name?", default="anon") == "anon" def test_choose_by_index() -> None: backend = ScriptedBackend(["2"]) with temporary_backend(backend): assert ( choose( "Pick", [ ChoiceOption(label="a", value="A"), ChoiceOption(label="b", value="B"), ], allow_custom=False, ) == "B" ) def test_choose_by_label() -> None: backend = ScriptedBackend(["keep"]) with temporary_backend(backend): assert choose("Pick", ["keep", "abort"], allow_custom=False) == "keep" def test_choose_custom_text() -> None: backend = ScriptedBackend(["something else"]) with temporary_backend(backend): assert choose("Pick", ["a", "b"], allow_custom=True) == "something else" def test_choose_rejects_custom_when_disabled() -> None: backend = ScriptedBackend(["nope", "1"]) with temporary_backend(backend): assert choose("Pick", ["a", "b"], allow_custom=False) == "a" def test_confirm_yes() -> None: backend = ScriptedBackend([], confirms=[True]) with temporary_backend(backend): assert confirm("ok?", default=False) is True def test_form_with_confirm() -> None: backend = ScriptedBackend(["alice", "2"], confirms=[True]) with temporary_backend(backend): answers = form( "Profile", [ FormField(name="name", prompt="Name?"), FormField( name="role", prompt="Role?", options=["dev", "ops"], allow_custom=False, ), ], confirm_submit=True, ) assert answers == {"name": "alice", "role": "ops"} def test_non_interactive_ask_requires_default() -> None: with temporary_backend(NonInteractiveBackend()), pytest.raises(NonInteractiveError): _ = ask("Name?") def test_non_interactive_ask_uses_default() -> None: with temporary_backend(NonInteractiveBackend()): assert ask("Name?", default="x") == "x" def test_non_interactive_confirm_uses_default() -> None: with temporary_backend(NonInteractiveBackend()): assert confirm("ok?", default=False) is False assert confirm("ok?", default=True) is True