mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-07 23:47:54 +08:00
core/prompting: let a host notify and answer a waiting prompt
A prompt that blocks on the human now announces itself through one process-wide hook: `prompt_wait(kind, title, detail)` wraps `ask`, `ask_secret`, `choose` and `confirm` and yields the answer source the host offers. `kind` names the prompt family (`ask`, `secret`, `choose`, `confirm`, and the CLI's own `policy` / `access` / `skills` / `limit`) so a host can pick a surface, while `title` stays short enough to show anywhere and `detail` carries one line the caller has already redacted. No hook installed means no behaviour change, which keeps library hosts and the test suite exactly as they were. The read side of the seam is `read_line_with_timeout`: an optional `source` ends the read with the host's answer (echoed on the prompt line like a typed one) and a `None` timeout now means "wait until a line or an answer arrives" instead of giving up. Stdin keeps priority, so a human typing at the terminal is never raced by a notification answer that shows up at the same moment. `reset_prompting` clears the hook along with the output hook and the pause factory.
This commit is contained in:
+141
-42
@@ -117,6 +117,66 @@ def _break_output() -> None:
|
|||||||
hook()
|
hook()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PromptWait:
|
||||||
|
"""A prompt that is about to block on the human.
|
||||||
|
|
||||||
|
``kind`` names the prompt family (``confirm``, ``ask``, ``secret``,
|
||||||
|
``choose``, ``policy``, ``access``, ``skills``, ``limit``) so the host can
|
||||||
|
tailor what it shows and which answers it offers; ``title`` is short and
|
||||||
|
safe to surface anywhere, while ``detail`` carries one optional line the
|
||||||
|
caller has already redacted.
|
||||||
|
"""
|
||||||
|
|
||||||
|
kind: str
|
||||||
|
title: str
|
||||||
|
detail: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class PromptAnswerSource(Protocol):
|
||||||
|
"""Answers a waiting prompt may take from a host surface (a notification)."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def supports_actions(self) -> bool:
|
||||||
|
"""Whether :meth:`take` can answer this prompt at all."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def take(self) -> str | None:
|
||||||
|
"""Take the next host answer without blocking (``allow`` / ``deny`` / reply text)."""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
type PromptWaitHook = Callable[[PromptWait], AbstractContextManager[PromptAnswerSource | None]]
|
||||||
|
|
||||||
|
# Hook run *while* a prompt blocks on the human. It yields the answer source the
|
||||||
|
# read consults (``None`` = notify only), so a host can reach someone who has
|
||||||
|
# switched to another window; without a hook, prompting behaves as before.
|
||||||
|
_wait_hook: PromptWaitHook | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def set_prompt_wait_hook(hook: PromptWaitHook | None) -> None:
|
||||||
|
"""Install the hook run while a prompt blocks (None clears it)."""
|
||||||
|
global _wait_hook # noqa: PLW0603
|
||||||
|
_wait_hook = hook
|
||||||
|
|
||||||
|
|
||||||
|
def get_prompt_wait_hook() -> PromptWaitHook | None:
|
||||||
|
"""The installed prompt-wait hook, if any."""
|
||||||
|
return _wait_hook
|
||||||
|
|
||||||
|
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def prompt_wait(kind: str, title: str, detail: str = "") -> Generator[PromptAnswerSource | None]:
|
||||||
|
"""Announce a blocking prompt to the host; yields its answer source (or None)."""
|
||||||
|
hook = _wait_hook
|
||||||
|
if hook is None:
|
||||||
|
yield None
|
||||||
|
return
|
||||||
|
with hook(PromptWait(kind, title, detail)) as source:
|
||||||
|
yield source
|
||||||
|
|
||||||
|
|
||||||
def _readline_input(prompt: str, *, completions: Sequence[str] | None = None) -> str:
|
def _readline_input(prompt: str, *, completions: Sequence[str] | None = None) -> str:
|
||||||
"""``input()`` with optional Tab completion via readline when available."""
|
"""``input()`` with optional Tab completion via readline when available."""
|
||||||
try:
|
try:
|
||||||
@@ -157,45 +217,60 @@ type PollFn = Callable[
|
|||||||
|
|
||||||
def _read_line_with_timeout_posix(
|
def _read_line_with_timeout_posix(
|
||||||
prompt: str,
|
prompt: str,
|
||||||
timeout: float,
|
timeout: float | None,
|
||||||
*,
|
*,
|
||||||
stream: TextIO,
|
stream: TextIO,
|
||||||
poll: PollFn,
|
poll: PollFn,
|
||||||
|
source: PromptAnswerSource | None = None,
|
||||||
) -> str | None:
|
) -> str | None:
|
||||||
"""Blocking line read on POSIX: poll stdin, then read one line on data.
|
"""Blocking line read on POSIX: poll stdin, then read one line on data.
|
||||||
|
|
||||||
Returns the line (without trailing newline), ``""`` for an empty Enter, or
|
Returns the line (without trailing newline), ``""`` for an empty Enter, or
|
||||||
``None`` when the timeout elapses before any line is complete (EOF too).
|
``None`` when the timeout elapses before any line is complete (EOF too); a
|
||||||
Raises :class:`PromptCancelledError` when :func:`prompt_cancelled` turns true
|
``None`` *timeout* waits until a line or a *source* answer arrives. Raises
|
||||||
while waiting (the poll loop notices within one interval).
|
:class:`PromptCancelledError` when :func:`prompt_cancelled` turns true while
|
||||||
|
waiting (the poll loop notices within one interval).
|
||||||
"""
|
"""
|
||||||
_ = sys.stdout.write(prompt)
|
_ = sys.stdout.write(prompt)
|
||||||
_ = sys.stdout.flush()
|
_ = sys.stdout.flush()
|
||||||
deadline = time.monotonic() + timeout
|
deadline = None if timeout is None else time.monotonic() + timeout
|
||||||
while True:
|
while True:
|
||||||
remaining = deadline - time.monotonic()
|
if deadline is None:
|
||||||
if remaining <= 0:
|
remaining = 0.1
|
||||||
return None
|
else:
|
||||||
|
remaining = min(deadline - time.monotonic(), 0.1)
|
||||||
|
if remaining <= 0:
|
||||||
|
return None
|
||||||
if prompt_cancelled():
|
if prompt_cancelled():
|
||||||
raise PromptCancelledError
|
raise PromptCancelledError
|
||||||
ready, _, _ = poll([stream], [], [], min(remaining, 0.1))
|
ready, _, _ = poll([stream], [], [], remaining)
|
||||||
if not ready:
|
if ready:
|
||||||
continue
|
line = stream.readline()
|
||||||
line = stream.readline()
|
if line == "":
|
||||||
if line == "":
|
return None # EOF
|
||||||
return None # EOF
|
return line.rstrip("\n")
|
||||||
return line.rstrip("\n")
|
if source is not None:
|
||||||
|
answer = source.take()
|
||||||
|
if answer is not None:
|
||||||
|
_ = sys.stdout.write(f"{answer}\n")
|
||||||
|
_ = sys.stdout.flush()
|
||||||
|
return answer
|
||||||
|
|
||||||
|
|
||||||
def _read_line_with_timeout_windows(prompt: str, timeout: float) -> str | None:
|
def _read_line_with_timeout_windows(
|
||||||
|
prompt: str,
|
||||||
|
timeout: float | None,
|
||||||
|
*,
|
||||||
|
source: PromptAnswerSource | None = None,
|
||||||
|
) -> str | None:
|
||||||
"""Blocking line read on Windows via msvcrt char polling (best-effort)."""
|
"""Blocking line read on Windows via msvcrt char polling (best-effort)."""
|
||||||
import msvcrt
|
import msvcrt
|
||||||
|
|
||||||
_ = sys.stdout.write(prompt)
|
_ = sys.stdout.write(prompt)
|
||||||
_ = sys.stdout.flush()
|
_ = sys.stdout.flush()
|
||||||
deadline = time.monotonic() + timeout
|
deadline = None if timeout is None else time.monotonic() + timeout
|
||||||
buf: list[str] = []
|
buf: list[str] = []
|
||||||
while time.monotonic() < deadline:
|
while deadline is None or time.monotonic() < deadline:
|
||||||
if prompt_cancelled():
|
if prompt_cancelled():
|
||||||
raise PromptCancelledError
|
raise PromptCancelledError
|
||||||
if msvcrt.kbhit():
|
if msvcrt.kbhit():
|
||||||
@@ -214,28 +289,47 @@ def _read_line_with_timeout_windows(prompt: str, timeout: float) -> str | None:
|
|||||||
buf.append(ch)
|
buf.append(ch)
|
||||||
_ = sys.stdout.write(ch)
|
_ = sys.stdout.write(ch)
|
||||||
_ = sys.stdout.flush()
|
_ = sys.stdout.flush()
|
||||||
else:
|
continue
|
||||||
time.sleep(0.05)
|
if source is not None:
|
||||||
|
answer = source.take()
|
||||||
|
if answer is not None:
|
||||||
|
_ = sys.stdout.write(f"{answer}\n")
|
||||||
|
_ = sys.stdout.flush()
|
||||||
|
return answer
|
||||||
|
time.sleep(0.05)
|
||||||
_ = sys.stdout.write("\n")
|
_ = sys.stdout.write("\n")
|
||||||
_ = sys.stdout.flush()
|
_ = sys.stdout.flush()
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def read_line_with_timeout(prompt: str, timeout: float) -> str | None:
|
def read_line_with_timeout(
|
||||||
"""Read one line, blocking at most ``timeout`` seconds.
|
prompt: str,
|
||||||
|
timeout: float | None,
|
||||||
|
*,
|
||||||
|
source: PromptAnswerSource | None = None,
|
||||||
|
) -> str | None:
|
||||||
|
"""Read one line, blocking at most ``timeout`` seconds (``None`` = no limit).
|
||||||
|
|
||||||
Returns the typed line (without trailing newline), ``""`` for an empty
|
Returns the typed line (without trailing newline), ``""`` for an empty
|
||||||
Enter, or ``None`` when the timeout elapses first (EOF too). Ctrl+C
|
Enter, or ``None`` when the timeout elapses first (EOF too). With a
|
||||||
propagates as :class:`KeyboardInterrupt`; :func:`cancel_prompt_read` (the
|
*source*, its answers end the read as well — the answer is echoed on the
|
||||||
SIGINT target of an off-loop prompt read) raises
|
prompt line like a typed one. Ctrl+C propagates as
|
||||||
:class:`PromptCancelledError` instead. POSIX polls stdin via ``select``;
|
:class:`KeyboardInterrupt`; :func:`cancel_prompt_read` (the SIGINT target of
|
||||||
Windows uses ``msvcrt`` char polling (best-effort).
|
an off-loop prompt read) raises :class:`PromptCancelledError` instead.
|
||||||
|
POSIX polls stdin via ``select``; Windows uses ``msvcrt`` char polling
|
||||||
|
(best-effort).
|
||||||
"""
|
"""
|
||||||
if timeout < 0:
|
if timeout is not None and timeout < 0:
|
||||||
timeout = 0.0
|
timeout = 0.0
|
||||||
if sys.platform == "win32":
|
if sys.platform == "win32":
|
||||||
return _read_line_with_timeout_windows(prompt, timeout)
|
return _read_line_with_timeout_windows(prompt, timeout, source=source)
|
||||||
return _read_line_with_timeout_posix(prompt, timeout, stream=sys.stdin, poll=cast("PollFn", select.select))
|
return _read_line_with_timeout_posix(
|
||||||
|
prompt,
|
||||||
|
timeout,
|
||||||
|
stream=sys.stdin,
|
||||||
|
poll=cast("PollFn", select.select),
|
||||||
|
source=source,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ClickPromptBackend:
|
class ClickPromptBackend:
|
||||||
@@ -345,11 +439,12 @@ def get_prompt_backend() -> PromptBackend:
|
|||||||
|
|
||||||
|
|
||||||
def reset_prompting() -> None:
|
def reset_prompting() -> None:
|
||||||
"""Restore the default backend; clear the pause/output hooks and cancel flag (tests)."""
|
"""Restore the default backend; clear the pause/output/wait hooks and cancel flag (tests)."""
|
||||||
global _backend, _pause_factory, _before_output # noqa: PLW0603
|
global _backend, _pause_factory, _before_output, _wait_hook # noqa: PLW0603
|
||||||
_backend = ClickPromptBackend()
|
_backend = ClickPromptBackend()
|
||||||
_pause_factory = None
|
_pause_factory = None
|
||||||
_before_output = None
|
_before_output = None
|
||||||
|
_wait_hook = None
|
||||||
_prompt_cancel.clear()
|
_prompt_cancel.clear()
|
||||||
|
|
||||||
|
|
||||||
@@ -415,8 +510,9 @@ def ask(
|
|||||||
if not backend.is_interactive() and default is None:
|
if not backend.is_interactive() and default is None:
|
||||||
msg = f"non-interactive: cannot prompt for {prompt!r}"
|
msg = f"non-interactive: cannot prompt for {prompt!r}"
|
||||||
raise NonInteractiveError(msg)
|
raise NonInteractiveError(msg)
|
||||||
backend.secho(prompt, fg="yellow")
|
with prompt_wait("ask", prompt):
|
||||||
return backend.read_line("Answer", default=default, completions=completions).strip()
|
backend.secho(prompt, fg="yellow")
|
||||||
|
return backend.read_line("Answer", default=default, completions=completions).strip()
|
||||||
|
|
||||||
|
|
||||||
def ask_secret(prompt: str) -> str:
|
def ask_secret(prompt: str) -> str:
|
||||||
@@ -428,8 +524,9 @@ def ask_secret(prompt: str) -> str:
|
|||||||
if not backend.is_interactive():
|
if not backend.is_interactive():
|
||||||
msg = f"non-interactive: cannot prompt for secret {prompt!r}"
|
msg = f"non-interactive: cannot prompt for secret {prompt!r}"
|
||||||
raise NonInteractiveError(msg)
|
raise NonInteractiveError(msg)
|
||||||
backend.secho(prompt, fg="yellow")
|
with prompt_wait("secret", prompt):
|
||||||
return backend.read_secret_line("Secret")
|
backend.secho(prompt, fg="yellow")
|
||||||
|
return backend.read_secret_line("Secret")
|
||||||
|
|
||||||
|
|
||||||
def choose(
|
def choose(
|
||||||
@@ -461,11 +558,12 @@ def choose(
|
|||||||
raise NonInteractiveError(msg)
|
raise NonInteractiveError(msg)
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
raw = backend.read_line(
|
with prompt_wait("choose", prompt):
|
||||||
"Choice",
|
raw = backend.read_line(
|
||||||
default=default_display,
|
"Choice",
|
||||||
completions=completions,
|
default=default_display,
|
||||||
).strip()
|
completions=completions,
|
||||||
|
).strip()
|
||||||
if not raw:
|
if not raw:
|
||||||
if default is not None:
|
if default is not None:
|
||||||
return default
|
return default
|
||||||
@@ -489,7 +587,8 @@ def confirm(prompt: str, *, default: bool = False) -> bool:
|
|||||||
if not backend.is_interactive():
|
if not backend.is_interactive():
|
||||||
return default
|
return default
|
||||||
backend.echo()
|
backend.echo()
|
||||||
return backend.confirm(prompt, default=default)
|
with prompt_wait("confirm", prompt):
|
||||||
|
return backend.confirm(prompt, default=default)
|
||||||
|
|
||||||
|
|
||||||
def form(
|
def form(
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ from plyngent.prompting import (
|
|||||||
NonInteractiveBackend,
|
NonInteractiveBackend,
|
||||||
NonInteractiveError,
|
NonInteractiveError,
|
||||||
PromptCancelledError,
|
PromptCancelledError,
|
||||||
|
PromptWait,
|
||||||
_read_line_with_timeout_posix,
|
_read_line_with_timeout_posix,
|
||||||
ask,
|
ask,
|
||||||
|
ask_secret,
|
||||||
cancel_prompt_read,
|
cancel_prompt_read,
|
||||||
choose,
|
choose,
|
||||||
configure_prompting,
|
configure_prompting,
|
||||||
@@ -21,12 +23,15 @@ from plyngent.prompting import (
|
|||||||
form,
|
form,
|
||||||
get_prompt_backend,
|
get_prompt_backend,
|
||||||
get_prompt_output_hook,
|
get_prompt_output_hook,
|
||||||
|
get_prompt_wait_hook,
|
||||||
prompt_cancelled,
|
prompt_cancelled,
|
||||||
|
prompt_wait,
|
||||||
read_line_with_timeout,
|
read_line_with_timeout,
|
||||||
reset_prompting,
|
reset_prompting,
|
||||||
run_cancellable_prompt_async,
|
run_cancellable_prompt_async,
|
||||||
run_prompt_async,
|
run_prompt_async,
|
||||||
set_prompt_output_hook,
|
set_prompt_output_hook,
|
||||||
|
set_prompt_wait_hook,
|
||||||
temporary_backend,
|
temporary_backend,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -279,3 +284,101 @@ def test_prompt_output_hook_runs_before_chrome(capsys: pytest.CaptureFixture[str
|
|||||||
assert calls == ["break", "break"]
|
assert calls == ["break", "break"]
|
||||||
assert get_prompt_output_hook() is None
|
assert get_prompt_output_hook() is None
|
||||||
assert "box" in capsys.readouterr().out
|
assert "box" in capsys.readouterr().out
|
||||||
|
|
||||||
|
|
||||||
|
class _AnswerSource:
|
||||||
|
"""Stand-in for a host surface that answers a waiting prompt."""
|
||||||
|
|
||||||
|
supports_actions = True
|
||||||
|
|
||||||
|
def __init__(self, answer: str | None) -> None:
|
||||||
|
self.answers = [answer] if answer is not None else []
|
||||||
|
|
||||||
|
def take(self) -> str | None:
|
||||||
|
return self.answers.pop(0) if self.answers else None
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_line_with_timeout_takes_source_answer(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
"""A host answer ends the read even though stdin never becomes readable."""
|
||||||
|
stream = io.StringIO()
|
||||||
|
try:
|
||||||
|
got = _read_line_with_timeout_posix("p> ", 5.0, stream=stream, poll=_never_poll, source=_AnswerSource("allow"))
|
||||||
|
finally:
|
||||||
|
stream.close()
|
||||||
|
assert got == "allow"
|
||||||
|
# The answer is echoed on the prompt line, so the terminal is not left mid-line.
|
||||||
|
assert "allow" in capsys.readouterr().out
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_line_with_timeout_prefers_stdin_over_source() -> None:
|
||||||
|
"""A human typing at the terminal wins over a pending host answer."""
|
||||||
|
stream = io.StringIO("typed\n")
|
||||||
|
try:
|
||||||
|
got = _read_line_with_timeout_posix("p> ", 5.0, stream=stream, poll=_ready_poll, source=_AnswerSource("allow"))
|
||||||
|
finally:
|
||||||
|
stream.close()
|
||||||
|
assert got == "typed"
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_line_with_timeout_without_deadline() -> None:
|
||||||
|
"""``timeout=None`` waits until an answer arrives instead of giving up."""
|
||||||
|
stream = io.StringIO()
|
||||||
|
try:
|
||||||
|
got = _read_line_with_timeout_posix("p> ", None, stream=stream, poll=_never_poll, source=_AnswerSource("deny"))
|
||||||
|
finally:
|
||||||
|
stream.close()
|
||||||
|
assert got == "deny"
|
||||||
|
|
||||||
|
|
||||||
|
def test_prompt_wait_without_hook_yields_none() -> None:
|
||||||
|
"""Without a hook the seam is a no-op (library hosts keep the old behaviour)."""
|
||||||
|
assert get_prompt_wait_hook() is None
|
||||||
|
with prompt_wait("confirm", "Allow?") as source:
|
||||||
|
assert source is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_prompt_wait_hook_wraps_ask() -> None:
|
||||||
|
"""The CLI hook sees one PromptWait per prompt and is exited afterwards."""
|
||||||
|
seen: list[PromptWait] = []
|
||||||
|
entered: list[str] = []
|
||||||
|
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def hook(wait: PromptWait) -> Generator[None]:
|
||||||
|
seen.append(wait)
|
||||||
|
entered.append("in")
|
||||||
|
try:
|
||||||
|
yield None
|
||||||
|
finally:
|
||||||
|
entered.append("out")
|
||||||
|
|
||||||
|
set_prompt_wait_hook(hook)
|
||||||
|
backend = ScriptedBackend(["hi"])
|
||||||
|
with temporary_backend(backend):
|
||||||
|
assert ask("Name?") == "hi"
|
||||||
|
assert [(wait.kind, wait.title) for wait in seen] == [("ask", "Name?")]
|
||||||
|
assert entered == ["in", "out"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_prompt_wait_hook_reports_the_prompt_family() -> None:
|
||||||
|
"""Each entry point announces its own kind so a host can pick a surface."""
|
||||||
|
kinds: list[str] = []
|
||||||
|
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def hook(wait: PromptWait) -> Generator[None]:
|
||||||
|
kinds.append(wait.kind)
|
||||||
|
yield None
|
||||||
|
|
||||||
|
set_prompt_wait_hook(hook)
|
||||||
|
backend = ScriptedBackend(["hello"], confirms=[True], secrets=["s3cret"])
|
||||||
|
with temporary_backend(backend):
|
||||||
|
_ = ask_secret("Token?")
|
||||||
|
_ = choose("Pick", ["a", "b"])
|
||||||
|
_ = confirm("Go?")
|
||||||
|
assert kinds == ["secret", "choose", "confirm"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_reset_prompting_clears_the_wait_hook() -> None:
|
||||||
|
set_prompt_wait_hook(lambda wait: contextlib.nullcontext(None))
|
||||||
|
assert get_prompt_wait_hook() is not None
|
||||||
|
reset_prompting()
|
||||||
|
assert get_prompt_wait_hook() is None
|
||||||
|
|||||||
Reference in New Issue
Block a user