diff --git a/CHANGELOG.md b/CHANGELOG.md index c165ee055..cdc517584 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,14 @@ - 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`. It is + also now strictly an `__init__` parameter and not an instance attribute. + - `complete_in_thread` is now strictly an `__init__` parameter and not an instance attribute of + `Cmd`. + - `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..ea93b2d78 100644 --- a/docs/features/initialization.md +++ b/docs/features/initialization.md @@ -33,9 +33,7 @@ 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 - **debug**: if `True`, show full stack trace on error (Default: `False`) - **default_error**: the error that prints when a non-existent command is run 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..e872beaad 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,33 +4286,50 @@ def test_path_complete_users_windows(monkeypatch, base_app): assert expected in matches -def test_get_bottom_toolbar(base_app, monkeypatch): - # Test default (disabled) - assert base_app.get_bottom_toolbar() is None +def test_refresh_interval() -> None: + # Test default value + default_app = cmd2.Cmd() + assert default_app.main_session.refresh_interval == 0.0 - # 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 custom value + custom_app = cmd2.Cmd(refresh_interval=5.0) + assert custom_app.main_session.refresh_interval == 5.0 -def test_get_rprompt(base_app): +def test_enable_bottom_toolbar() -> None: # Test default - assert base_app.get_rprompt() is None + 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 overridden - from prompt_toolkit.formatted_text import FormattedText + # Test False + custom_app = cmd2.Cmd(enable_rprompt=False) + assert custom_app.main_session.rprompt is None - 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_get_bottom_toolbar(base_app: cmd2.Cmd) -> None: + assert base_app.get_bottom_toolbar() is None + + +def test_get_rprompt(base_app: cmd2.Cmd) -> None: + assert base_app.get_rprompt() is None def test_multiline_complete_statement_keyboard_interrupt(multiline_app, monkeypatch): @@ -4376,7 +4391,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 +4521,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)