Skip to content

Commit 775931e

Browse files
committed
Refactored async alerts to use a Queue.
1 parent e38f0f7 commit 775931e

2 files changed

Lines changed: 155 additions & 144 deletions

File tree

cmd2/cmd2.py

Lines changed: 103 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,12 @@
3535
import inspect
3636
import os
3737
import pydoc
38+
import queue
3839
import re
3940
import sys
4041
import tempfile
4142
import threading
43+
import time
4244
from code import InteractiveConsole
4345
from collections import namedtuple
4446
from collections.abc import (
@@ -48,6 +50,10 @@
4850
MutableSequence,
4951
Sequence,
5052
)
53+
from dataclasses import (
54+
dataclass,
55+
field,
56+
)
5157
from types import FrameType
5258
from typing import (
5359
IO,
@@ -60,7 +66,6 @@
6066
)
6167

6268
import rich.box
63-
from prompt_toolkit.application import get_app
6469
from rich.console import (
6570
Group,
6671
RenderableType,
@@ -273,6 +278,19 @@ def remove(self, command_method: CommandFunc) -> None:
273278
del self._parsers[full_method_name]
274279

275280

281+
@dataclass(kw_only=True)
282+
class AsyncAlert:
283+
"""Contents of an asynchonous alert which display while user is at prompt.
284+
285+
:param msg: an optional message to be printed above the prompt.
286+
:param prompt: an optional string to dynamically replace the current prompt.
287+
"""
288+
289+
msg: str | None = None
290+
prompt: str | None = None
291+
timestamp: float = field(default_factory=time.monotonic, init=False)
292+
293+
276294
class Cmd:
277295
"""An easy but powerful framework for writing line-oriented command interpreters.
278296
@@ -587,6 +605,13 @@ def __init__(
587605
# Command parsers for this Cmd instance.
588606
self._command_parsers: _CommandParsers = _CommandParsers(self)
589607

608+
# Members related to printing asychronous alerts
609+
self.alert_queue: queue.Queue[AsyncAlert] = queue.Queue()
610+
self._alerter_gate = threading.Event()
611+
self._alerter_shutdown = threading.Event()
612+
self._process_alerts_thread: threading.Thread | None = None
613+
self._prompt_drawn_at: float = 0.0 # Uses time.monotonic()
614+
590615
# Add functions decorated to be subcommands
591616
self._register_subcommands(self)
592617

@@ -2588,7 +2613,7 @@ def pre_prompt(self) -> None:
25882613
"""Ran just before the prompt is displayed (and after the event loop has started)."""
25892614

25902615
def precmd(self, statement: Statement | str) -> Statement:
2591-
"""Ran just before the command is executed by [cmd2.Cmd.onecmd][] and after adding it to history (cmd Hook method).
2616+
"""Ran just before the command is executed by [cmd2.Cmd.onecmd][] and after adding it to history (cmd Hook method).
25922617
25932618
:param statement: subclass of str which also contains the parsed input
25942619
:return: a potentially modified version of the input Statement object
@@ -3200,9 +3225,9 @@ def _read_raw_input(
32003225
) -> str:
32013226
"""Execute the low-level input read from either a terminal or a redirected stream.
32023227
3203-
If the session is interactive (TTY), it uses `prompt_toolkit` to render a
3204-
rich UI with completion and `patch_stdout` protection. If non-interactive
3205-
(Pipe/File), it performs a direct line read from `stdin`.
3228+
If input is coming from a TTY, it uses `prompt_toolkit` to render a
3229+
UI with completion and `patch_stdout` protection. Otherwise it performs
3230+
a direct line read from `stdin`.
32063231
32073232
:param prompt: the prompt text or a callable that returns the prompt.
32083233
:param session: the PromptSession instance to use for reading.
@@ -3321,6 +3346,42 @@ def read_input(
33213346

33223347
return self._read_raw_input(prompt, temp_session, completer_to_use)
33233348

3349+
def _process_alerts(self) -> None:
3350+
"""Background worker that processes queued alerts and prompt updates.
3351+
3352+
This loop waits for the prompt gate to open, ensuring that background
3353+
messages and UI refreshes only occur while the user is at an
3354+
interactive prompt, avoiding interference with active commands.
3355+
"""
3356+
while not self._alerter_shutdown.is_set():
3357+
try:
3358+
# Wait for an alert
3359+
alert = self.alert_queue.get(timeout=0.5)
3360+
3361+
# Block if not at a prompt
3362+
while not self._alerter_gate.is_set():
3363+
if self._alerter_shutdown.is_set():
3364+
return
3365+
self._alerter_gate.wait(timeout=0.1)
3366+
3367+
# Print and update
3368+
with patch_stdout():
3369+
if alert.msg:
3370+
print(alert.msg)
3371+
3372+
# Only update if the alert was generated after the current prompt was drawn on the screen.
3373+
if (alert.prompt is not None and
3374+
alert.prompt != self.prompt and
3375+
alert.timestamp > self._prompt_drawn_at): # fmt: skip
3376+
self.prompt = alert.prompt
3377+
3378+
# Don't update the UI if we are at a continuation prompt.
3379+
if not self._at_continuation_prompt:
3380+
self.session.app.invalidate()
3381+
3382+
except queue.Empty: # noqa: PERF203
3383+
continue
3384+
33243385
def _read_command_line(self, prompt: str) -> str:
33253386
"""Read the next command line from the input stream.
33263387
@@ -3331,19 +3392,39 @@ def _read_command_line(self, prompt: str) -> str:
33313392
"""
33323393

33333394
# Use dynamic prompt if the prompt matches self.prompt
3334-
def get_prompt() -> ANSI | str:
3395+
def get_prompt() -> ANSI:
33353396
return ANSI(self.prompt)
33363397

33373398
prompt_to_use: Callable[[], ANSI | str] | ANSI | str = ANSI(prompt)
33383399
if prompt == self.prompt:
33393400
prompt_to_use = get_prompt
33403401

3341-
return self._read_raw_input(
3342-
prompt=prompt_to_use,
3343-
session=self.session,
3344-
completer=self.completer,
3345-
pre_run=self.pre_prompt,
3346-
)
3402+
def _pre_prompt() -> None:
3403+
"""Run standard pre-prompt processing and activate the background alerter."""
3404+
self.pre_prompt()
3405+
3406+
# Record exactly when the user was presented with this prompt
3407+
self._prompt_drawn_at = time.monotonic()
3408+
3409+
# Start alerter thread if it's not already running
3410+
if self._process_alerts_thread is None or not self._process_alerts_thread.is_alive():
3411+
self._alerter_shutdown.clear()
3412+
self._process_alerts_thread = threading.Thread(target=self._process_alerts, daemon=True)
3413+
self._process_alerts_thread.start()
3414+
3415+
# Allow alerts to be printed
3416+
self._alerter_gate.set()
3417+
3418+
try:
3419+
return self._read_raw_input(
3420+
prompt=prompt_to_use,
3421+
session=self.session,
3422+
completer=self.completer,
3423+
pre_run=_pre_prompt,
3424+
)
3425+
finally:
3426+
# Ensure no alerts print while the command is processing
3427+
self._alerter_gate.clear()
33473428

33483429
def _cmdloop(self) -> None:
33493430
"""Repeatedly issue a prompt, accept input, parse it, and dispatch to apporpriate commands.
@@ -5207,66 +5288,19 @@ def do__relative_run_script(self, args: argparse.Namespace) -> bool | None:
52075288
# self.last_result will be set by do_run_script()
52085289
return self.do_run_script(su.quote(relative_path))
52095290

5210-
def async_alert(self, alert_msg: str, new_prompt: str | None = None) -> None:
5211-
"""Display an important message to the user while they are at a command line prompt.
5212-
5213-
To the user it appears as if an alert message is printed above the prompt and their
5214-
current input text and cursor location is left alone.
5215-
5216-
This function checks self._in_prompt to ensure a prompt is on screen.
5217-
If the main thread is not at the prompt, a RuntimeError is raised.
5291+
def add_alert(self, *, msg: str | None = None, prompt: str | None = None) -> None:
5292+
"""Thread-safe method to request UI updates.
52185293
5219-
This function is only needed when you need to print an alert or update the prompt while the
5220-
main thread is blocking at the prompt. Therefore, this should never be called from the main
5221-
thread. Doing so will raise a RuntimeError.
5294+
:param msg: an optional message to be printed above the prompt.
5295+
:param prompt: an optional string to dynamically replace the current prompt.
52225296
5223-
:param alert_msg: the message to display to the user
5224-
:param new_prompt: If you also want to change the prompt that is displayed, then include it here.
5225-
See async_update_prompt() docstring for guidance on updating a prompt.
5226-
:raises RuntimeError: if called from the main thread.
5227-
:raises RuntimeError: if main thread is not currently at the prompt.
5228-
"""
5229-
5230-
# Check if prompt is currently displayed and waiting for user input
5231-
def _alert() -> None:
5232-
if new_prompt is not None:
5233-
self.prompt = new_prompt
5234-
5235-
if alert_msg:
5236-
# Since we are running in the loop, patch_stdout context manager from read_input
5237-
# should be active (if tty), or at least we are in the main thread.
5238-
print(alert_msg)
5239-
5240-
if hasattr(self, 'session'):
5241-
# Invalidate to force prompt update
5242-
get_app().invalidate()
5243-
5244-
# Schedule the alert to run on the main thread's event loop
5245-
try:
5246-
get_app().loop.call_soon_threadsafe(_alert) # type: ignore[union-attr]
5247-
except AttributeError:
5248-
# Fallback if loop is not accessible (e.g. prompt not running or session not initialized)
5249-
# This shouldn't happen if _in_prompt is True, unless prompt exited concurrently.
5250-
raise RuntimeError("Event loop not available") from None
5251-
5252-
def async_update_prompt(self, new_prompt: str) -> None: # pragma: no cover
5253-
"""Update the command line prompt while the user is still typing at it.
5254-
5255-
This is good for alerting the user to system changes dynamically in between commands.
5256-
For instance you could alter the color of the prompt to indicate a system status or increase a
5257-
counter to report an event. If you do alter the actual text of the prompt, it is best to keep
5258-
the prompt the same width as what's on screen. Otherwise the user's input text will be shifted
5259-
and the update will not be seamless.
5260-
5261-
If user is at a continuation prompt while entering a multiline command, the onscreen prompt will
5262-
not change. However, self.prompt will still be updated and display immediately after the multiline
5263-
line command completes.
5264-
5265-
:param new_prompt: what to change the prompt to
5266-
:raises RuntimeError: if called from the main thread.
5267-
:raises RuntimeError: if main thread is not currently at the prompt.
5297+
1. print an alert: add_alert(msg="System error!")
5298+
2. print and update prompt: add_alert(msg="Logged in", prompt="user@host> ")
5299+
3. update prompt only: add_alert(prompt="waiting> ")
52685300
"""
5269-
self.async_alert('', new_prompt)
5301+
if msg is not None or prompt is not None:
5302+
alert = AsyncAlert(msg=msg, prompt=prompt)
5303+
self.alert_queue.put(alert)
52705304

52715305
@staticmethod
52725306
def set_window_title(title: str) -> None: # pragma: no cover

0 commit comments

Comments
 (0)