Skip to content

Commit e26703d

Browse files
authored
Refactored async alerts. (#1586)
Replaced async_alert() and async_update_prompt() with a single function called add_alert(). This new function is thread-safe and does not require you to acquire a mutex before calling it like the previous functions did.
1 parent e38f0f7 commit e26703d

File tree

7 files changed

+417
-171
lines changed

7 files changed

+417
-171
lines changed

CHANGELOG.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,9 @@ prompt is displayed.
5757
- Changed `StatementParser.parse_command_only()` to return a `PartialStatement` object.
5858
- Renamed `Macro.arg_list` to `Macro.args`.
5959
- Removed `terminal_utils.py` since `prompt-toolkit` provides this functionality.
60+
- Replaced `async_alert()` and `async_update_prompt()` with a single function called
61+
`add_alert()`. This new function is thread-safe and does not require you to acquire a mutex
62+
before calling it like the previous functions did.
6063
- Enhancements
6164
- New `cmd2.Cmd` parameters
6265
- **auto_suggest**: (boolean) if `True`, provide fish shell style auto-suggestions. These
@@ -66,8 +69,6 @@ prompt is displayed.
6669
displaying realtime status information while the prompt is displayed, see the
6770
`cmd2.Cmd2.get_bottom_toolbar` method that can be overridden as well as the updated
6871
`getting_started.py` example
69-
- Added `cmd2.Cmd._in_prompt` flag that is set to `True` when the prompt is displayed and the
70-
application is waiting for user input
7172
- New `cmd2.Cmd` methods
7273
- **get_bottom_toolbar**: populates bottom toolbar if `bottom_toolbar` is `True`
7374
- **get_rprompt**: override to populate right prompt

cmd2/cmd2.py

Lines changed: 155 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -39,15 +39,23 @@
3939
import sys
4040
import tempfile
4141
import threading
42+
import time
4243
from code import InteractiveConsole
43-
from collections import namedtuple
44+
from collections import (
45+
deque,
46+
namedtuple,
47+
)
4448
from collections.abc import (
4549
Callable,
4650
Iterable,
4751
Mapping,
4852
MutableSequence,
4953
Sequence,
5054
)
55+
from dataclasses import (
56+
dataclass,
57+
field,
58+
)
5159
from types import FrameType
5260
from typing import (
5361
IO,
@@ -60,6 +68,7 @@
6068
)
6169

6270
import rich.box
71+
from prompt_toolkit import print_formatted_text
6372
from prompt_toolkit.application import get_app
6473
from rich.console import (
6574
Group,
@@ -177,6 +186,7 @@ def __init__(self, msg: str = '') -> None:
177186
Cmd2Completer,
178187
Cmd2History,
179188
Cmd2Lexer,
189+
pt_filter_style,
180190
)
181191
from .utils import (
182192
Settable,
@@ -273,6 +283,23 @@ def remove(self, command_method: CommandFunc) -> None:
273283
del self._parsers[full_method_name]
274284

275285

286+
@dataclass(kw_only=True)
287+
class AsyncAlert:
288+
"""Contents of an asynchonous alert which display while user is at prompt.
289+
290+
:param msg: an optional message to be printed above the prompt.
291+
:param prompt: an optional string to dynamically replace the current prompt.
292+
293+
:ivar timestamp: monotonic creation time of the alert. If an alert was created
294+
before the current prompt was rendered, the prompt update is ignored
295+
to avoid a stale display but the msg will still be displayed.
296+
"""
297+
298+
msg: str | None = None
299+
prompt: str | None = None
300+
timestamp: float = field(default_factory=time.monotonic, init=False)
301+
302+
276303
class Cmd:
277304
"""An easy but powerful framework for writing line-oriented command interpreters.
278305
@@ -370,7 +397,7 @@ def __init__(
370397
self._initialize_plugin_system()
371398

372399
# Configure a few defaults
373-
self.prompt = Cmd.DEFAULT_PROMPT
400+
self.prompt: str = Cmd.DEFAULT_PROMPT
374401
self.intro = intro
375402

376403
# What to use for standard input
@@ -587,6 +614,14 @@ def __init__(
587614
# Command parsers for this Cmd instance.
588615
self._command_parsers: _CommandParsers = _CommandParsers(self)
589616

617+
# Members related to printing asychronous alerts
618+
self._alert_queue: deque[AsyncAlert] = deque()
619+
self._alert_condition = threading.Condition()
620+
self._alert_allowed = False
621+
self._alert_shutdown = False
622+
self._alert_thread: threading.Thread | None = None
623+
self._alert_prompt_timestamp: float = 0.0 # Uses time.monotonic()
624+
590625
# Add functions decorated to be subcommands
591626
self._register_subcommands(self)
592627

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

25902625
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).
2626+
"""Ran just before the command is executed by [cmd2.Cmd.onecmd][] and after adding it to history (cmd Hook method).
25922627
25932628
:param statement: subclass of str which also contains the parsed input
25942629
:return: a potentially modified version of the input Statement object
@@ -3200,9 +3235,9 @@ def _read_raw_input(
32003235
) -> str:
32013236
"""Execute the low-level input read from either a terminal or a redirected stream.
32023237
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`.
3238+
If input is coming from a TTY, it uses `prompt_toolkit` to render a
3239+
UI with completion and `patch_stdout` protection. Otherwise it performs
3240+
a direct line read from `stdin`.
32063241
32073242
:param prompt: the prompt text or a callable that returns the prompt.
32083243
:param session: the PromptSession instance to use for reading.
@@ -3214,6 +3249,8 @@ def _read_raw_input(
32143249
# Check if the session is configured for interactive terminal use.
32153250
if not isinstance(session.input, DummyInput):
32163251
with patch_stdout():
3252+
if not callable(prompt):
3253+
prompt = pt_filter_style(prompt)
32173254
return session.prompt(prompt, completer=completer, **prompt_kwargs)
32183255

32193256
# We're not at a terminal, so we're likely reading from a file or a pipe.
@@ -3321,6 +3358,60 @@ def read_input(
33213358

33223359
return self._read_raw_input(prompt, temp_session, completer_to_use)
33233360

3361+
def _process_alerts(self) -> None:
3362+
"""Background worker that processes queued alerts and dynamic prompt updates."""
3363+
while True:
3364+
with self._alert_condition:
3365+
# Wait until we have alerts and are allowed to display them, or shutdown is signaled.
3366+
self._alert_condition.wait_for(
3367+
lambda: (len(self._alert_queue) > 0 and self._alert_allowed) or self._alert_shutdown
3368+
)
3369+
3370+
# Shutdown immediately even if we have alerts.
3371+
if self._alert_shutdown:
3372+
break
3373+
3374+
# Hold the condition lock while printing to block command execution. This
3375+
# prevents async alerts from printing once a command starts.
3376+
3377+
# Print all alerts at once to reduce flicker.
3378+
alert_text = "\n".join(alert.msg for alert in self._alert_queue if alert.msg)
3379+
3380+
# Find the latest prompt update among all pending alerts.
3381+
latest_prompt = None
3382+
for alert in reversed(self._alert_queue):
3383+
if (
3384+
alert.prompt is not None
3385+
and alert.prompt != self.prompt
3386+
and alert.timestamp > self._alert_prompt_timestamp
3387+
):
3388+
latest_prompt = alert.prompt
3389+
self._alert_prompt_timestamp = alert.timestamp
3390+
break
3391+
3392+
# Clear the alerts
3393+
self._alert_queue.clear()
3394+
3395+
if alert_text:
3396+
if not self._at_continuation_prompt and latest_prompt is not None:
3397+
# Update prompt now so patch_stdout can redraw it immediately.
3398+
self.prompt = latest_prompt
3399+
3400+
# Print the alert messages above the prompt.
3401+
with patch_stdout():
3402+
print_formatted_text(pt_filter_style(alert_text))
3403+
3404+
if self._at_continuation_prompt and latest_prompt is not None:
3405+
# Update state only. The onscreen prompt won't change until the next prompt starts.
3406+
self.prompt = latest_prompt
3407+
3408+
elif latest_prompt is not None:
3409+
self.prompt = latest_prompt
3410+
3411+
# Refresh UI immediately unless at a continuation prompt.
3412+
if not self._at_continuation_prompt:
3413+
get_app().invalidate()
3414+
33243415
def _read_command_line(self, prompt: str) -> str:
33253416
"""Read the next command line from the input stream.
33263417
@@ -3331,19 +3422,43 @@ def _read_command_line(self, prompt: str) -> str:
33313422
"""
33323423

33333424
# Use dynamic prompt if the prompt matches self.prompt
3334-
def get_prompt() -> ANSI | str:
3335-
return ANSI(self.prompt)
3425+
def get_prompt() -> str | ANSI:
3426+
return pt_filter_style(self.prompt)
33363427

33373428
prompt_to_use: Callable[[], ANSI | str] | ANSI | str = ANSI(prompt)
33383429
if prompt == self.prompt:
33393430
prompt_to_use = get_prompt
33403431

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-
)
3432+
def _pre_prompt() -> None:
3433+
"""Run standard pre-prompt processing and activate the background alerter."""
3434+
self.pre_prompt()
3435+
3436+
# Record when this prompt was rendered.
3437+
self._alert_prompt_timestamp = time.monotonic()
3438+
3439+
# Start alerter thread if it's not already running.
3440+
if self._alert_thread is None or not self._alert_thread.is_alive():
3441+
self._alert_allowed = False
3442+
self._alert_shutdown = False
3443+
self._alert_thread = threading.Thread(target=self._process_alerts, daemon=True)
3444+
self._alert_thread.start()
3445+
3446+
# Allow alerts to be printed now that we are at a prompt.
3447+
with self._alert_condition:
3448+
self._alert_allowed = True
3449+
self._alert_condition.notify_all()
3450+
3451+
try:
3452+
return self._read_raw_input(
3453+
prompt=prompt_to_use,
3454+
session=self.session,
3455+
completer=self.completer,
3456+
pre_run=_pre_prompt,
3457+
)
3458+
finally:
3459+
# Ensure no alerts print while not at a prompt.
3460+
with self._alert_condition:
3461+
self._alert_allowed = False
33473462

33483463
def _cmdloop(self) -> None:
33493464
"""Repeatedly issue a prompt, accept input, parse it, and dispatch to apporpriate commands.
@@ -3371,7 +3486,18 @@ def _cmdloop(self) -> None:
33713486
# Run the command along with all associated pre and post hooks
33723487
stop = self.onecmd_plus_hooks(line)
33733488
finally:
3374-
pass
3489+
with self.sigint_protection:
3490+
# Shut down the alert thread.
3491+
if self._alert_thread is not None:
3492+
with self._alert_condition:
3493+
self._alert_shutdown = True
3494+
self._alert_condition.notify_all()
3495+
3496+
# The thread is event-driven and stays suspended until notified.
3497+
# We join with a 1 second timeout as a safety measure. If it hangs,
3498+
# the daemon status allows the OS to reap it on exit.
3499+
self._alert_thread.join(timeout=1.0)
3500+
self._alert_thread = None
33753501

33763502
#############################################################
33773503
# Parsers and functions for alias command and subcommands
@@ -5207,66 +5333,25 @@ def do__relative_run_script(self, args: argparse.Namespace) -> bool | None:
52075333
# self.last_result will be set by do_run_script()
52085334
return self.do_run_script(su.quote(relative_path))
52095335

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.
5336+
def add_alert(self, *, msg: str | None = None, prompt: str | None = None) -> None:
5337+
"""Queue an asynchronous alert to be displayed when the prompt is active.
52155338
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.
5339+
Examples:
5340+
add_alert(msg="System error!") # Print message only
5341+
add_alert(prompt="user@host> ") # Update prompt only
5342+
add_alert(msg="Done", prompt="> ") # Update both
52185343
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.
5344+
:param msg: an optional message to be printed above the prompt.
5345+
:param prompt: an optional string to dynamically replace the current prompt.
52225346
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.
52285347
"""
5348+
if msg is None and prompt is None:
5349+
return
52295350

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.
5268-
"""
5269-
self.async_alert('', new_prompt)
5351+
with self._alert_condition:
5352+
alert = AsyncAlert(msg=msg, prompt=prompt)
5353+
self._alert_queue.append(alert)
5354+
self._alert_condition.notify_all()
52705355

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

0 commit comments

Comments
 (0)