diff --git a/CHANGELOG.md b/CHANGELOG.md index c165ee055..a58cd6a44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ - **complete_in_thread**: (boolean) if `True`, then completion will run in a separate thread. If `False` then completion runs in the main thread and causes it to block if slow. Defaults to `True`. + - **refresh_interval**: (float) How often, in seconds, to automatically refresh the UI. + Defaults to 0.0. This is used for bottom toolbars and right prompts which have dynamic + content needing to be refreshed at regular intervals and not just when a key is pressed. - Bug Fixes - Fixed type hinting so that methods decorated with `with_annotated` no longer trigger spurious @@ -53,6 +56,11 @@ - A command can share an argument block with its subcommands via `cmd2_base_args` / `cmd2_parent_args` parameters, passing parent-level options down without redeclaring them. +- Breaking Changes + - Renamed the `bottom_toolbar` argument in `Cmd.__init__()` to `enable_bottom_toolbar`. + - `get_rprompt()` is now only called if the `enable_rprompt` argument in `Cmd.__init__()` is set + to `True`. + ## 4.0.0 (June 5, 2026) ### Summary diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 66d01c1b8..8ac7dcfea 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -32,6 +32,7 @@ import contextlib import copy import dataclasses +import datetime import functools import glob import inspect @@ -73,7 +74,7 @@ from prompt_toolkit.application import create_app_session, get_app from prompt_toolkit.auto_suggest import AutoSuggestFromHistory from prompt_toolkit.completion import Completer, DummyCompleter -from prompt_toolkit.formatted_text import ANSI, FormattedText +from prompt_toolkit.formatted_text import ANSI, AnyFormattedText from prompt_toolkit.history import InMemoryHistory from prompt_toolkit.input import DummyInput, create_input from prompt_toolkit.key_binding import KeyBindings @@ -367,16 +368,17 @@ def __init__( allow_redirection: bool = True, auto_load_commands: bool = False, auto_suggest: bool = True, - bottom_toolbar: bool = False, complete_in_thread: bool = True, command_sets: Iterable[CommandSet[Any]] | None = None, + enable_bottom_toolbar: bool = False, + enable_rprompt: bool = False, include_ipy: bool = False, include_py: bool = False, intro: RenderableType = "", multiline_commands: Iterable[str] | None = None, persistent_history_file: str = "", persistent_history_length: int = 1000, - refresh_interval: float = 0, + refresh_interval: float = 0.0, shortcuts: Mapping[str, str] | None = None, silence_startup_script: bool = False, startup_script: str = "", @@ -405,12 +407,15 @@ def __init__( :param auto_suggest: If True, cmd2 will provide fish shell style auto-suggestions based on history. User can press right-arrow key to accept the provided suggestion. - :param bottom_toolbar: if ``True``, then a bottom toolbar will be displayed. :param complete_in_thread: if ``True``, then completion will run in a separate thread. :param command_sets: Provide CommandSet instances to load during cmd2 initialization. This allows CommandSets with custom constructor parameters to be loaded. This also allows the a set of CommandSets to be provided when `auto_load_commands` is set to False + :param enable_bottom_toolbar: if ``True``, enables a bottom toolbar while at the main prompt. + Override ``get_bottom_toolbar()`` to define its content. + :param enable_rprompt: if ``True``, enables a right prompt while at the main prompt. + Override ``get_rprompt()`` to define its content. :param include_ipy: should the "ipy" command be included for an embedded IPython shell :param include_py: should the "py" command be included for an embedded Python shell :param intro: introduction to display at startup @@ -418,7 +423,7 @@ def __init__( :param persistent_history_file: file path to load a persistent cmd2 command history from :param persistent_history_length: max number of history items to write to the persistent history file - :param refresh_interval: How often, in seconds, to refresh the UI. Defaults to 0. + :param refresh_interval: How often, in seconds, to refresh the UI. Defaults to 0.0. prompt-toolkit already refreshes the UI every time a key is pressed. Set this value if you need the UI to update automatically without user input (e.g., for displaying a clock or background status @@ -535,10 +540,14 @@ def __init__( self._initialize_history(persistent_history_file) # Create the main PromptSession - self.bottom_toolbar = bottom_toolbar - self.complete_in_thread = complete_in_thread - self.refresh_interval = refresh_interval - self.main_session = self._create_main_session(auto_suggest, completekey) + self.main_session = self._create_main_session( + auto_suggest=auto_suggest, + complete_in_thread=complete_in_thread, + completekey=completekey, + enable_bottom_toolbar=enable_bottom_toolbar, + enable_rprompt=enable_rprompt, + refresh_interval=refresh_interval, + ) # The session currently holding focus (either the main REPL or a command's # custom prompt). Completion and UI logic should reference this variable @@ -729,7 +738,16 @@ def _should_continue_multiline(self) -> bool: # No macro found or already processed. The statement is complete. return False - def _create_main_session(self, auto_suggest: bool, completekey: str) -> PromptSession[str]: + def _create_main_session( + self, + *, + auto_suggest: bool, + complete_in_thread: bool, + completekey: str, + enable_bottom_toolbar: bool, + enable_rprompt: bool, + refresh_interval: float, + ) -> PromptSession[str]: """Create and return the main PromptSession for the application. Builds an interactive session if self.stdin and self.stdout are TTYs. @@ -759,10 +777,10 @@ def _(event: Any) -> None: # pragma: no cover # Base configuration kwargs: dict[str, Any] = { "auto_suggest": AutoSuggestFromHistory() if auto_suggest else None, - "bottom_toolbar": self.get_bottom_toolbar if self.bottom_toolbar else None, + "bottom_toolbar": self.get_bottom_toolbar if enable_bottom_toolbar else None, "color_depth": ColorDepth.TRUE_COLOR, "complete_style": CompleteStyle.MULTI_COLUMN, - "complete_in_thread": self.complete_in_thread, + "complete_in_thread": complete_in_thread, "complete_while_typing": False, "completer": Cmd2Completer(self), "history": Cmd2History(item.raw for item in self.history), @@ -770,8 +788,8 @@ def _(event: Any) -> None: # pragma: no cover "lexer": Cmd2Lexer(self), "multiline": filters.Condition(self._should_continue_multiline), "prompt_continuation": self.continuation_prompt, - "refresh_interval": self.refresh_interval, - "rprompt": self.get_rprompt, + "refresh_interval": refresh_interval, + "rprompt": self.get_rprompt if enable_rprompt else None, "style": DynamicStyle(get_pt_theme), } @@ -1983,49 +2001,35 @@ def ppretty( end=end, ) - def get_bottom_toolbar(self) -> list[str | tuple[str, str]] | None: + def get_bottom_toolbar(self) -> AnyFormattedText: """Get the bottom toolbar content. - Returns None if `self.bottom_toolbar` is False. Otherwise, returns a - list of tokens to populate the toolbar (which can span multiple lines). - - NOTE: prompt-toolkit calls this method on every UI refresh (e.g., on every keypress - and at scheduled refresh intervals). To ensure the CLI remains responsive, keep - this function highly optimized. - """ - if not self.bottom_toolbar: - return None - - import datetime - import shutil + This method is called by prompt-toolkit while at the main prompt if ``enable_bottom_toolbar`` + was set to ``True`` during initialization. Because prompt-toolkit executes this callback + on every UI refresh (such as on every keypress or at scheduled refresh intervals), keeping + this function highly optimized is critical to ensuring the CLI remains responsive. - # Get the current time in ISO format with 0.01s precision - dt = datetime.datetime.now(datetime.timezone.utc).astimezone() - now = dt.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-4] + dt.strftime("%z") - left_text = sys.argv[0] + Override this if you want a bottom toolbar displaying contextual information useful for + your application. This could be information like the application name, current state, + or even a real-time clock. - # Get terminal width to calculate padding for right-alignment - cols, _ = shutil.get_terminal_size() - padding_size = cols - len(left_text) - len(now) - 1 - if padding_size < 1: - padding_size = 1 - padding = " " * padding_size + :return: Content to populate the bottom toolbar. + """ + return None - # Return formatted text for prompt-toolkit - return [ - ("ansigreen", left_text), - ("", padding), - ("ansicyan", now), - ] + def get_rprompt(self) -> AnyFormattedText: + """Provide text to populate the prompt-toolkit right prompt. - def get_rprompt(self) -> str | FormattedText | None: - """Provide text to populate prompt-toolkit right prompt with. + This method is called by prompt-toolkit while at the main prompt if ``enable_rprompt`` + was set to ``True`` during initialization. Because prompt-toolkit executes this callback + on every UI refresh (such as on every keypress or at scheduled refresh intervals), keeping + this function highly optimized is critical to ensuring the CLI remains responsive. - Override this if you want a right-prompt displaying contetual information useful for your application. - This could be information like current Git branch, time, current working directory, etc that is displayed - without cluttering the main input area. + Override this if you want a right prompt displaying contextual information useful for + your application. This could be information like the current Git branch, time, or current + working directory that is displayed without cluttering the main input area. - :return: any type of formatted text to display as the right prompt + :return: Content to populate the right prompt. """ return None @@ -2932,8 +2936,6 @@ def onecmd_plus_hooks( command's stdout. :return: True if running of commands should stop """ - import datetime - stop = False statement = None diff --git a/docs/features/initialization.md b/docs/features/initialization.md index 491e71025..e354d5271 100644 --- a/docs/features/initialization.md +++ b/docs/features/initialization.md @@ -33,7 +33,6 @@ The `cmd2.Cmd` class provides a large number of public instance attributes which Here are instance attributes of `cmd2.Cmd` which developers might wish to override: -- **bottom_toolbar**: if `True`, then a bottom toolbar will be displayed (Default: `False`) - **broken_pipe_warning**: if non-empty, this string will be displayed if a broken pipe error occurs - **complete_in_thread**: if `True`, then completion will run in a separate thread (Default: `True`) - **continuation_prompt**: used for multiline commands on 2nd+ line of input @@ -42,6 +41,8 @@ Here are instance attributes of `cmd2.Cmd` which developers might wish to overri - **disabled_commands**: commands that have been disabled from use. This is to support commands that are only available during specific states of the application. This dictionary's keys are the command names and its values are DisabledCommand objects. - **echo**: if `True`, each command the user issues will be repeated to the screen before it is executed. This is particularly useful when running scripts. This behavior does not occur when running a command at the prompt. (Default: `False`) - **editor**: text editor program to use with _edit_ command (e.g. `vim`) +- **enable_bottom_toolbar**: if `True`, enables a bottom toolbar while at the main prompt. (Default: `False`) +- **enable_rprompt**: if `True`, enables a right prompt while at the main prompt. (Default: `False`) - **exclude_from_history**: commands to exclude from the _history_ command - **exit_code**: this determines the value returned by `cmdloop()` when exiting the application - **help_error**: the error that prints when no help information can be found @@ -55,6 +56,7 @@ Here are instance attributes of `cmd2.Cmd` which developers might wish to overri - **py_bridge_name**: name by which embedded Python environments and scripts refer to the `cmd2` application by in order to call commands (Default: `app`) - **py_locals**: dictionary that defines specific variables/functions available in Python shells and scripts (provides more fine-grained control than making everything available with **self_in_py**) - **quiet**: if `True`, then completely suppress nonessential output (Default: `False`) +- **refresh_interval**: how often, in seconds, to automatically refresh the UI. (Default: 0.0) - **scripts_add_to_history**: if `True`, scripts and pyscripts add commands to history (Default: `True`) - **self_in_py**: if `True`, allow access to your application in _py_ command via `self` (Default: `False`) - **settable**: dictionary that controls which of these instance attributes are settable at runtime using the _set_ command diff --git a/docs/features/prompt.md b/docs/features/prompt.md index fdb4e2391..93fb6b495 100644 --- a/docs/features/prompt.md +++ b/docs/features/prompt.md @@ -65,22 +65,23 @@ terminal window while the application is idle and waiting for input. ### Enabling the Toolbar -To enable the toolbar, set `bottom_toolbar=True` in the [cmd2.Cmd.__init__][] constructor: +To enable the toolbar, set `enable_bottom_toolbar=True` in the [cmd2.Cmd.__init__][] constructor: ```py class App(cmd2.Cmd): def __init__(self): - super().__init__(bottom_toolbar=True) + super().__init__(enable_bottom_toolbar=True) ``` ### Customizing Toolbar Content You can customize the content of the toolbar by overriding the [cmd2.Cmd.get_bottom_toolbar][] -method. This method should return either a string or a list of `(style, text)` tuples for formatted -text. +method. ```py - def get_bottom_toolbar(self) -> list[str | tuple[str, str]] | None: + from prompt_toolkit.formatted_text import AnyFormattedText + + def get_bottom_toolbar(self) -> AnyFormattedText: return [ ('ansigreen', 'My Application Name'), ('', ' - '), @@ -92,7 +93,14 @@ text. Since the toolbar is rendered by `prompt-toolkit` as part of the prompt, it is naturally redrawn whenever the prompt is refreshed. If you want the toolbar to update automatically (for example, to -display a clock), you can use a background thread to call `app.invalidate()` periodically. +display a clock), you can set `refresh_interval` in the [cmd2.Cmd.__init__][] constructor to a value +greater than 0.0. + +```py +class App(cmd2.Cmd): + def __init__(self): + super().__init__(refresh_interval=0.5) +``` See the [getting_started.py](https://github.com/python-cmd2/cmd2/blob/main/examples/getting_started.py) diff --git a/docs/upgrades.md b/docs/upgrades.md index f6a247760..a316819e9 100644 --- a/docs/upgrades.md +++ b/docs/upgrades.md @@ -36,10 +36,9 @@ While we have strived to maintain compatibility, there are some differences: `cmd2` now supports an optional, persistent bottom toolbar. This can be used to display information such as the application name, current state, or even a real-time clock. -- **Enablement**: Set `bottom_toolbar=True` in the [cmd2.Cmd.__init__][] constructor. +- **Enablement**: Set `enable_bottom_toolbar=True` in the [cmd2.Cmd.__init__][] constructor. - **Customization**: Override the [cmd2.Cmd.get_bottom_toolbar][] method to return the content you - wish to display. The content can be a simple string or a list of `(style, text)` tuples for - formatted text with colors. + wish to display. See the [getting_started.py](https://github.com/python-cmd2/cmd2/blob/main/examples/getting_started.py) diff --git a/examples/getting_started.py b/examples/getting_started.py index 98713e173..ad1a08d9f 100755 --- a/examples/getting_started.py +++ b/examples/getting_started.py @@ -14,11 +14,15 @@ 10) How to make custom attributes settable at runtime. 11) Shortcuts for commands 12) Persistent bottom toolbar with realtime status updates +13) Right prompt which displays contextual information """ +import datetime import pathlib +import sys -from prompt_toolkit.formatted_text import FormattedText +from prompt_toolkit.application import get_app +from prompt_toolkit.formatted_text import AnyFormattedText from rich.style import Style import cmd2 @@ -44,7 +48,8 @@ def __init__(self) -> None: super().__init__( auto_suggest=True, - bottom_toolbar=True, + enable_bottom_toolbar=True, + enable_rprompt=True, include_ipy=True, multiline_commands=["echo"], persistent_history_file="cmd2_history.dat", @@ -87,11 +92,33 @@ def __init__(self) -> None: ) ) - def get_rprompt(self) -> str | FormattedText | None: + def get_bottom_toolbar(self) -> AnyFormattedText: + # Get the current time in ISO format with 0.01s precision + dt = datetime.datetime.now(datetime.timezone.utc).astimezone() + now = dt.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-4] + dt.strftime("%z") + left_text = sys.argv[0] + + # Fetch the terminal width to calculate padding for right-alignment. + # If called outside a running app loop (e.g., in unit tests), get_app() + # safely returns a dummy app with an 80-column fallback. + cols = get_app().output.get_size().columns + padding_size = cols - len(left_text) - len(now) - 1 + if padding_size < 1: + padding_size = 1 + padding = " " * padding_size + + # Return formatted text for prompt-toolkit + return [ + ("ansigreen", left_text), + ("", padding), + ("ansicyan", now), + ] + + def get_rprompt(self) -> AnyFormattedText: current_working_directory = pathlib.Path.cwd() style = "bg:ansired fg:ansiwhite" text = f"cwd={current_working_directory}" - return FormattedText([(style, text)]) + return [(style, text)] def do_intro(self, _: cmd2.Statement) -> None: """Display the intro banner.""" @@ -108,7 +135,5 @@ def do_echo(self, arg: cmd2.Statement) -> None: if __name__ == "__main__": - import sys - app = BasicApp() sys.exit(app.cmdloop()) diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index df6200898..bec336830 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -67,17 +67,14 @@ def test_version(base_app) -> None: def test_complete_in_thread() -> None: # Test default app_default = cmd2.Cmd() - assert app_default.complete_in_thread is True assert app_default.main_session.complete_in_thread is True # Test True app_true = cmd2.Cmd(complete_in_thread=True) - assert app_true.complete_in_thread is True assert app_true.main_session.complete_in_thread is True # Test False app_false = cmd2.Cmd(complete_in_thread=False) - assert app_false.complete_in_thread is False assert app_false.main_session.complete_in_thread is False @@ -4229,13 +4226,14 @@ def test_custom_completekey_ctrl_k(): def test_completekey_empty_string() -> None: # Test that an empty string for completekey defaults to DEFAULT_COMPLETEKEY + with mock.patch("cmd2.Cmd._create_main_session", autospec=True) as create_session_mock: create_session_mock.return_value = mock.MagicMock(spec=PromptSession) - app = cmd2.Cmd(completekey="") - # Verify it was called with DEFAULT_COMPLETEKEY - # auto_suggest is the second arg and it defaults to True - create_session_mock.assert_called_once_with(app, True, app.DEFAULT_COMPLETEKEY) + app = cmd2.Cmd(completekey="") + create_session_mock.assert_called_once() + _, kwargs = create_session_mock.call_args + assert kwargs["completekey"] == app.DEFAULT_COMPLETEKEY def test_create_main_session_exception(monkeypatch): @@ -4288,17 +4286,52 @@ def test_path_complete_users_windows(monkeypatch, base_app): assert expected in matches +def test_refresh_interval() -> None: + # Test default value + default_app = cmd2.Cmd() + assert default_app.main_session.refresh_interval == 0.0 + + # Test custom value + custom_app = cmd2.Cmd(refresh_interval=5.0) + assert custom_app.main_session.refresh_interval == 5.0 + + +def test_enable_bottom_toolbar() -> None: + # Test default + default_app = cmd2.Cmd() + assert default_app.main_session.bottom_toolbar is None + + # Test True + custom_app = cmd2.Cmd(enable_bottom_toolbar=True) + assert custom_app.main_session.bottom_toolbar == custom_app.get_bottom_toolbar + + # Test False + custom_app = cmd2.Cmd(enable_bottom_toolbar=False) + assert custom_app.main_session.bottom_toolbar is None + + +def test_enable_rprompt() -> None: + # Test default + default_app = cmd2.Cmd() + assert default_app.main_session.rprompt is None + + # Test True + custom_app = cmd2.Cmd(enable_rprompt=True) + assert custom_app.main_session.rprompt == custom_app.get_rprompt + + # Test False + custom_app = cmd2.Cmd(enable_rprompt=False) + assert custom_app.main_session.rprompt is None + + def test_get_bottom_toolbar(base_app, monkeypatch): - # Test default (disabled) + # Test default assert base_app.get_bottom_toolbar() is None - # Test enabled - base_app.bottom_toolbar = True - monkeypatch.setattr(sys, "argv", ["myapp.py"]) - toolbar = base_app.get_bottom_toolbar() - assert isinstance(toolbar, list) - assert toolbar[0] == ("ansigreen", "myapp.py") - assert toolbar[2][0] == "ansicyan" + # Test overridden + expected_text = "bottom toolbar text" + base_app.get_bottom_toolbar = lambda: expected_text + assert base_app.get_bottom_toolbar() == expected_text def test_get_rprompt(base_app): @@ -4306,16 +4339,10 @@ def test_get_rprompt(base_app): assert base_app.get_rprompt() is None # Test overridden - from prompt_toolkit.formatted_text import FormattedText - expected_text = "rprompt text" base_app.get_rprompt = lambda: expected_text assert base_app.get_rprompt() == expected_text - expected_formatted = FormattedText([("class:status", "OK")]) - base_app.get_rprompt = lambda: expected_formatted - assert base_app.get_rprompt() == expected_formatted - def test_multiline_complete_statement_keyboard_interrupt(multiline_app, monkeypatch): # Mock _read_command_line to raise KeyboardInterrupt @@ -4376,7 +4403,14 @@ def test_create_main_session_with_custom_tty() -> None: app = cmd2.Cmd() app.stdin = custom_stdin app.stdout = custom_stdout - app._create_main_session(auto_suggest=True, completekey=app.DEFAULT_COMPLETEKEY) + app._create_main_session( + auto_suggest=True, + completekey=app.DEFAULT_COMPLETEKEY, + enable_bottom_toolbar=False, + enable_rprompt=False, + complete_in_thread=False, + refresh_interval=0.0, + ) mock_create_input.assert_called_once_with(stdin=custom_stdin) mock_create_output.assert_called_once_with(stdout=custom_stdout) @@ -4499,25 +4533,6 @@ def my_pre_prompt(): assert loop_check["running"] -def test_get_bottom_toolbar_narrow_terminal(base_app, monkeypatch): - """Test get_bottom_toolbar when terminal is too narrow for calculated padding""" - import shutil - - base_app.bottom_toolbar = True - monkeypatch.setattr(sys, "argv", ["myapp.py"]) - - # Mock shutil.get_terminal_size to return a very small width (e.g. 5) - # Calculated padding_size = 5 - len('myapp.py') - len(now) - 1 - # Since len(now) is ~29, this will definitely be < 1 - monkeypatch.setattr(shutil, "get_terminal_size", lambda: os.terminal_size((5, 20))) - - toolbar = base_app.get_bottom_toolbar() - assert isinstance(toolbar, list) - - # The padding (index 1) should be exactly 1 space - assert toolbar[1] == ("", " ") - - def test_auto_suggest_true(): """Test that auto_suggest=True initializes AutoSuggestFromHistory.""" app = cmd2.Cmd(auto_suggest=True)