From 45a260dcff94767486ed39ffb9b5b822098f6cac Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Thu, 9 Jul 2026 15:57:01 -0400 Subject: [PATCH 1/9] Added ability to use Rich renderables as alert messages. --- cmd2/cmd2.py | 35 ++++++++++++++++++++++++++++++----- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index e48516191..acae17be2 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -313,6 +313,9 @@ class AsyncAlert: """Contents of an asynchronous alert which display while user is at prompt. :param msg: an optional message to be printed above the prompt. + :param soft_wrap: Enable soft wrap mode. This only applies with msg is not None. + Defaults to True. See print_to() docstring for more details on + this parameter. :param prompt: an optional string to dynamically replace the current prompt. :ivar timestamp: monotonic creation time of the alert. If an alert was created @@ -320,7 +323,8 @@ class AsyncAlert: to avoid a stale display, but its msg data will still be displayed. """ - msg: str | None = None + msg: RenderableType | None = None + soft_wrap: bool = True prompt: str | None = None timestamp: float = field(default_factory=time.monotonic, init=False) @@ -3657,7 +3661,19 @@ def _process_alerts(self) -> None: # prevents async alerts from printing once a command starts. # Print all alerts at once to reduce flicker. - alert_text = "\n".join(alert.msg for alert in self._alert_queue if alert.msg) + console = self._get_core_print_console( + file=self.stdout, + emoji=False, + markup=False, + highlight=False, + ) + + with console.capture() as capture: + for alert in self._alert_queue: + if alert.msg is not None: + console.print(alert.msg, soft_wrap=alert.soft_wrap) + + alert_text = capture.get() # Find the latest prompt update among all pending alerts. latest_prompt = None @@ -3681,7 +3697,7 @@ def _process_alerts(self) -> None: if alert_text: # Print the alert messages above the prompt. with patch_stdout(): - print_formatted_text(pt_filter_style(alert_text)) + print_formatted_text(ANSI(alert_text), end="") elif latest_prompt is not None: # Refresh UI immediately to show the new prompt @@ -5616,7 +5632,13 @@ def do__relative_run_script(self, args: argparse.Namespace) -> bool | None: # self.last_result will be set by do_run_script() return self.do_run_script(su.quote(relative_path)) - def add_alert(self, *, msg: str | None = None, prompt: str | None = None) -> None: + def add_alert( + self, + *, + msg: RenderableType | None = None, + soft_wrap: bool = True, + prompt: str | None = None, + ) -> None: """Queue an asynchronous alert to be displayed when the prompt is active. Examples: @@ -5625,6 +5647,9 @@ def add_alert(self, *, msg: str | None = None, prompt: str | None = None) -> Non add_alert(msg="Done", prompt="> ") # Update both :param msg: an optional message to be printed above the prompt. + :param soft_wrap: Enable soft wrap mode. This only applies with msg is not None. + Defaults to True. See print_to() docstring for more details on + this parameter. :param prompt: an optional string to dynamically replace the current prompt. """ @@ -5632,7 +5657,7 @@ def add_alert(self, *, msg: str | None = None, prompt: str | None = None) -> Non return with self._alert_condition: - alert = AsyncAlert(msg=msg, prompt=prompt) + alert = AsyncAlert(msg=msg, soft_wrap=soft_wrap, prompt=prompt) self._alert_queue.append(alert) self._alert_condition.notify_all() From f4a1962e8a135b6095674024e7e61f780d509d6a Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Thu, 9 Jul 2026 16:35:50 -0400 Subject: [PATCH 2/9] Added test for using a table as a alert message. --- tests/test_cmd2.py | 73 +++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 66 insertions(+), 7 deletions(-) diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index 7ba496d80..762f716cf 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -1258,7 +1258,7 @@ def test_ctrl_d_at_prompt(say_app, monkeypatch) -> None: (None, "", False), ], ) -def test_async_alert(base_app, msg, prompt, is_stale) -> None: +def test_async_alert(base_app: cmd2.Cmd, msg: str, prompt: str, is_stale: bool) -> None: import time with ( @@ -1311,18 +1311,77 @@ def test_async_alert(base_app, msg, prompt, is_stale) -> None: assert base_app.prompt == prompt -def test_add_alert(base_app) -> None: - orig_num_alerts = len(base_app._alert_queue) +def test_async_alert_rich(base_app: cmd2.Cmd) -> None: + from prompt_toolkit.formatted_text import ANSI + from rich.table import Table + + table = Table("Header") + table.add_row("Value") + + with ( + mock.patch("cmd2.cmd2.print_formatted_text") as mock_print, + mock.patch("cmd2.cmd2.get_app") as mock_get_app, + ): + mock_app = mock.MagicMock() + mock_get_app.return_value = mock_app + + # Add alert with a Rich table + base_app.add_alert(msg=table, soft_wrap=False) + + with create_pipe_input() as pipe_input: + base_app.main_session = PromptSession( + input=pipe_input, + output=DummyOutput(), + history=base_app.main_session.history, + completer=base_app.main_session.completer, + ) + pipe_input.send_text("quit\n") + base_app._cmdloop() + + # Verify that print_formatted_text was called with a formatted ANSI object + assert mock_print.called + printed_arg = mock_print.call_args[0][0] + assert isinstance(printed_arg, ANSI) + + # The printed text should contain our table values + assert "Header" in printed_arg.value + assert "Value" in printed_arg.value + + +def test_add_alert(base_app: cmd2.Cmd) -> None: + base_app._alert_queue.clear() # Nothing is added when both are None base_app.add_alert(msg=None, prompt=None) - assert len(base_app._alert_queue) == orig_num_alerts + assert not base_app._alert_queue - # Now test valid alert arguments + # Test that soft_wrap defaults to True base_app.add_alert(msg="Hello", prompt=None) - base_app.add_alert(msg="Hello", prompt="prompt> ") + last_alert = base_app._alert_queue[-1] + assert last_alert.msg == "Hello" + assert last_alert.soft_wrap is True + assert last_alert.prompt is None + + # Test that soft_wrap can be set to True + base_app.add_alert(msg="Hello", soft_wrap=True, prompt=None) + last_alert = base_app._alert_queue[-1] + assert last_alert.msg == "Hello" + assert last_alert.soft_wrap is True + assert last_alert.prompt is None + + # Test that soft_wrap can be set to False + base_app.add_alert(msg="Hello", soft_wrap=False, prompt=None) + last_alert = base_app._alert_queue[-1] + assert last_alert.msg == "Hello" + assert last_alert.soft_wrap is False + assert last_alert.prompt is None + + # Test only prompt base_app.add_alert(msg=None, prompt="prompt> ") - assert len(base_app._alert_queue) == orig_num_alerts + 3 + last_alert = base_app._alert_queue[-1] + assert last_alert.msg is None + assert last_alert.soft_wrap is True + assert last_alert.prompt == "prompt> " def test_visible_prompt() -> None: From f156b7585c8f71a882d7daba007d9f1c10d6bf64 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Thu, 9 Jul 2026 17:10:07 -0400 Subject: [PATCH 3/9] Updated async printing example to use Rich renderables. --- CHANGELOG.md | 2 + cmd2/cmd2.py | 10 +-- examples/async_printing.py | 129 ++++++++++++++++--------------------- 3 files changed, 65 insertions(+), 76 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dd3e00d99..2ae5b29aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ - Bug Fixes - Fixed a couple type issues that emerged with the upgrade to `mypy` 2.2.0 +- Enhancements + - Async alert messages can now be Rich renderables. ## 4.1.0 (July 7, 2026) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index acae17be2..48479b07c 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -312,7 +312,8 @@ def remove(self, command_method: BoundCommandFunc) -> None: class AsyncAlert: """Contents of an asynchronous alert which display while user is at prompt. - :param msg: an optional message to be printed above the prompt. + :param msg: an optional printable object (including Rich renderables) to be + printed above the prompt. :param soft_wrap: Enable soft wrap mode. This only applies with msg is not None. Defaults to True. See print_to() docstring for more details on this parameter. @@ -323,7 +324,7 @@ class AsyncAlert: to avoid a stale display, but its msg data will still be displayed. """ - msg: RenderableType | None = None + msg: Any | None = None soft_wrap: bool = True prompt: str | None = None timestamp: float = field(default_factory=time.monotonic, init=False) @@ -5635,7 +5636,7 @@ def do__relative_run_script(self, args: argparse.Namespace) -> bool | None: def add_alert( self, *, - msg: RenderableType | None = None, + msg: Any | None = None, soft_wrap: bool = True, prompt: str | None = None, ) -> None: @@ -5646,7 +5647,8 @@ def add_alert( add_alert(prompt="user@host> ") # Update prompt only add_alert(msg="Done", prompt="> ") # Update both - :param msg: an optional message to be printed above the prompt. + :param msg: an optional printable object (including Rich renderables) to be + printed above the prompt. :param soft_wrap: Enable soft wrap mode. This only applies with msg is not None. Defaults to True. See print_to() docstring for more details on this parameter. diff --git a/examples/async_printing.py b/examples/async_printing.py index bcb7da8a3..e51e4f070 100755 --- a/examples/async_printing.py +++ b/examples/async_printing.py @@ -6,6 +6,12 @@ import secrets import threading import time +from typing import Any + +from rich.console import Group +from rich.panel import Panel +from rich.table import Table +from rich.text import Text import cmd2 from cmd2 import ( @@ -13,18 +19,29 @@ stylize, ) -ALERTS = [ - "Watch as this application prints alerts and updates the prompt", - "This will only happen when the prompt is present", - "Notice how it doesn't interfere with your typing or cursor location", - "Go ahead and type some stuff and move the cursor throughout the line", - "Keep typing...", - "Move that cursor...", - "Pretty seamless, eh?", - "Feedback can also be given in the window title. Notice the alert count up there?", - "You can stop and start the alerts by typing stop_alerts and start_alerts", - "This demo will now continue to print alerts at random intervals", -] + +def get_alerts() -> list[tuple[Any, bool]]: + """Return a list of (alert_msg, soft_wrap) tuples.""" + table = Table("Command", "Description", title="Quick Help") + table.add_row("start_alerts", "Start the async alert generator") + table.add_row("stop_alerts", "Stop the async alert generator") + table.add_row("help", "Show help menu") + + return [ + (Text("Watch as this application prints alerts asynchronously!", style="bold bright_cyan"), True), + ("Notice how alerts don't interfere with your typing or cursor location.", True), + ( + Panel( + "This message is wrapped in a Rich Panel!", + title="System Alert", + border_style="bright_blue", + expand=False, + ), + False, + ), + (table, False), + ("You can stop and start the alerts by typing stop_alerts and start_alerts.", True), + ] class AlerterApp(cmd2.Cmd): @@ -40,7 +57,6 @@ def __init__(self) -> None: self._stop_event = threading.Event() self._add_alert_thread = threading.Thread() self._alert_count = 0 - self._next_alert_time = 0.0 # Create some hooks to handle the starting and stopping of our thread self.register_preloop_hook(self._preloop_hook) @@ -78,56 +94,6 @@ def do_stop_alerts(self, _: cmd2.Statement) -> None: else: print("The alert thread is already stopped") - def _get_alerts(self) -> list[str]: - """Reports alerts - :return: the list of alerts. - """ - cur_time = time.monotonic() - if cur_time < self._next_alert_time: - return [] - - alerts = [] - - if self._alert_count < len(ALERTS): - alerts.append(ALERTS[self._alert_count]) - self._alert_count += 1 - self._next_alert_time = cur_time + 4 - - else: - rand_num = self._secure_generator.randint(1, 20) - if rand_num > 2: - return [] - - for _ in range(rand_num): - self._alert_count += 1 - alerts.append(f"Alert {self._alert_count}") - - self._next_alert_time = 0 - - return alerts - - def _build_alert_str(self) -> str: - """Combines alerts into one string that can be printed to the terminal - :return: the alert string. - """ - alert_str = "" - alerts = self._get_alerts() - - longest_alert = max(ALERTS, key=len) - num_asterisks = len(longest_alert) + 8 - - for i, cur_alert in enumerate(alerts): - # Use padding to center the alert - padding = " " * int((num_asterisks - len(cur_alert)) / 2) - - if i > 0: - alert_str += "\n" - alert_str += "*" * num_asterisks + "\n" - alert_str += padding + cur_alert + padding + "\n" - alert_str += "*" * num_asterisks + "\n" - - return alert_str - def _build_colored_prompt(self) -> str: """Randomly builds a colored prompt :return: the new prompt. @@ -152,26 +118,45 @@ def _build_colored_prompt(self) -> str: def _add_alerts_func(self) -> None: """Prints alerts and updates the prompt any time the prompt is showing.""" self._alert_count = 0 - self._next_alert_time = 0 + + alerts = get_alerts() + alert_index = 0 + last_alert_time = 0.0 while not self._stop_event.is_set(): - # Get any alerts that need to be printed - alert_str = self._build_alert_str() + cur_time = time.monotonic() + alert_msg = None + soft_wrap = True + + # Trigger the next alert every 4 seconds + if cur_time - last_alert_time >= 4.0: + alert_msg, soft_wrap = alerts[alert_index] + alert_index = (alert_index + 1) % len(alerts) + self._alert_count += 1 + last_alert_time = cur_time - # Build a new prompt + # Build a new prompt (color changes randomly) new_prompt = self._build_colored_prompt() - # Check if we have alerts to print - if alert_str: - self.add_alert(msg=alert_str, prompt=new_prompt) + # Check if we have an alert to print + if alert_msg is not None: + # Wrap the alert message and an empty string in a Rich Group. + # This is a clean way to append a blank line after any RenderableType + # (strings, panels, tables, etc.) for visual separation. + self.add_alert( + msg=Group(alert_msg, ""), + soft_wrap=soft_wrap, + prompt=new_prompt, + ) + new_title = f"Alerts Printed: {self._alert_count}" self.set_window_title(new_title) - # Otherwise check if the prompt needs to be updated or refreshed + # Otherwise, check if the prompt needs to be updated or refreshed elif self.prompt != new_prompt: self.add_alert(prompt=new_prompt) - self._stop_event.wait(0.5) + self._stop_event.wait(1.0) if __name__ == "__main__": From 8305563dc24369aac8ecb3ebe686d6ce4208d404 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Thu, 9 Jul 2026 17:24:27 -0400 Subject: [PATCH 4/9] Updated comments. --- cmd2/cmd2.py | 2 +- examples/async_printing.py | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 48479b07c..d6adbfbd8 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -1577,7 +1577,7 @@ def print_to( If False, Rich wraps text to fit the terminal width. Set this to False when printing structured Renderables like Tables, Panels, or Columns to ensure they render as expected. - For example, when soft_wrap is True Panels truncate text + For example, when soft_wrap is True, Panels truncate text which is wider than the terminal. :param justify: justify method ("left", "center", "right", "full"). Defaults to None. :param emoji: If True, Rich will replace emoji codes (e.g., :smiley:) with their diff --git a/examples/async_printing.py b/examples/async_printing.py index e51e4f070..def663855 100755 --- a/examples/async_printing.py +++ b/examples/async_printing.py @@ -27,6 +27,9 @@ def get_alerts() -> list[tuple[Any, bool]]: table.add_row("stop_alerts", "Stop the async alert generator") table.add_row("help", "Show help menu") + # Set soft_wrap to False when printing structured Renderables like Tables, Panels, or Columns + # to ensure they render as expected. For example, when soft_wrap is True, Panels truncate + # text which is wider than the terminal. return [ (Text("Watch as this application prints alerts asynchronously!", style="bold bright_cyan"), True), ("Notice how alerts don't interfere with your typing or cursor location.", True), From 2c52d49893e767486dbcbe8ab0a13f85bac924f4 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Thu, 9 Jul 2026 17:29:07 -0400 Subject: [PATCH 5/9] Fixed tests on Windows. --- tests/test_cmd2.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index 762f716cf..4de0255e8 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -1311,6 +1311,10 @@ def test_async_alert(base_app: cmd2.Cmd, msg: str, prompt: str, is_stale: bool) assert base_app.prompt == prompt +@pytest.mark.skipif( + sys.platform.startswith("win"), + reason="Don't have a real Windows console with how we are currently running tests in GitHub Actions", +) def test_async_alert_rich(base_app: cmd2.Cmd) -> None: from prompt_toolkit.formatted_text import ANSI from rich.table import Table From 5cc5bcca49a0ebc6ffaa49e9b7306655ae391683 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Thu, 9 Jul 2026 17:38:49 -0400 Subject: [PATCH 6/9] Made a test more clear. --- tests/test_cmd2.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index 4de0255e8..e55197bc3 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -1344,7 +1344,8 @@ def test_async_alert_rich(base_app: cmd2.Cmd) -> None: # Verify that print_formatted_text was called with a formatted ANSI object assert mock_print.called - printed_arg = mock_print.call_args[0][0] + args, _kwargs = mock_print.call_args + printed_arg = args[0] assert isinstance(printed_arg, ANSI) # The printed text should contain our table values From 3083e2d70ebc1ebc23a2a69194dd81501b88bf8a Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Thu, 9 Jul 2026 17:47:37 -0400 Subject: [PATCH 7/9] Updated docstrings. --- examples/async_printing.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/examples/async_printing.py b/examples/async_printing.py index def663855..0ad5d5b0c 100755 --- a/examples/async_printing.py +++ b/examples/async_printing.py @@ -75,13 +75,13 @@ def _preloop_hook(self) -> None: self._add_alert_thread.start() def _postloop_hook(self) -> None: - """Stops the alerter thread.""" + """Stop the alerter thread.""" self._stop_event.set() if self._add_alert_thread.is_alive(): self._add_alert_thread.join() def do_start_alerts(self, _: cmd2.Statement) -> None: - """Starts the alerter thread.""" + """Start the alerter thread.""" if self._add_alert_thread.is_alive(): print("The alert thread is already started") else: @@ -90,7 +90,7 @@ def do_start_alerts(self, _: cmd2.Statement) -> None: self._add_alert_thread.start() def do_stop_alerts(self, _: cmd2.Statement) -> None: - """Stops the alerter thread.""" + """Stop the alerter thread.""" self._stop_event.set() if self._add_alert_thread.is_alive(): self._add_alert_thread.join() @@ -98,7 +98,8 @@ def do_stop_alerts(self, _: cmd2.Statement) -> None: print("The alert thread is already stopped") def _build_colored_prompt(self) -> str: - """Randomly builds a colored prompt + """Randomly build a colored prompt. + :return: the new prompt. """ rand_num = self._secure_generator.randint(1, 6) @@ -119,7 +120,7 @@ def _build_colored_prompt(self) -> str: return stylize(self.visible_prompt, style=status_color) def _add_alerts_func(self) -> None: - """Prints alerts and updates the prompt any time the prompt is showing.""" + """Print alerts and update the prompt any time the prompt is showing.""" self._alert_count = 0 alerts = get_alerts() From 4d9a4da64ba8e07756359eed6ccf3d25a655b1dd Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Thu, 9 Jul 2026 18:10:48 -0400 Subject: [PATCH 8/9] Using Rich Style objects and cmd2 color constants. --- examples/async_printing.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/examples/async_printing.py b/examples/async_printing.py index 0ad5d5b0c..720569d87 100755 --- a/examples/async_printing.py +++ b/examples/async_printing.py @@ -10,6 +10,7 @@ from rich.console import Group from rich.panel import Panel +from rich.style import Style from rich.table import Table from rich.text import Text @@ -31,13 +32,22 @@ def get_alerts() -> list[tuple[Any, bool]]: # to ensure they render as expected. For example, when soft_wrap is True, Panels truncate # text which is wider than the terminal. return [ - (Text("Watch as this application prints alerts asynchronously!", style="bold bright_cyan"), True), + ( + Text( + "Watch as this application prints alerts asynchronously!", + style=Style( + color=Color.BRIGHT_CYAN, + bold=True, + ), + ), + True, + ), ("Notice how alerts don't interfere with your typing or cursor location.", True), ( Panel( "This message is wrapped in a Rich Panel!", title="System Alert", - border_style="bright_blue", + border_style=Color.BRIGHT_BLUE, expand=False, ), False, From 0f82ccb22b41ec34a58fc17be009f1ffa63f9192 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Thu, 9 Jul 2026 18:15:24 -0400 Subject: [PATCH 9/9] Updated comments. --- examples/async_printing.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/examples/async_printing.py b/examples/async_printing.py index 720569d87..339226f23 100755 --- a/examples/async_printing.py +++ b/examples/async_printing.py @@ -1,6 +1,6 @@ #!/usr/bin/env python -"""A simple example demonstrating an application that asynchronously prints alerts, updates the prompt -and changes the window title. +"""A simple example demonstrating an application that asynchronously prints alerts, +updates the prompt, and changes the window title. """ import secrets @@ -58,10 +58,10 @@ def get_alerts() -> list[tuple[Any, bool]]: class AlerterApp(cmd2.Cmd): - """An app that shows off async_alert() and async_update_prompt().""" + """An app that shows off add_alert() and set_window_title().""" def __init__(self) -> None: - """Initializer.""" + """Initialize AlerterApp.""" super().__init__() self.prompt = "(APR)> "