From f9a0acc8ae3a624a7ae511082fdbfdb4e9035bff Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Mon, 23 Mar 2026 16:38:47 -0400 Subject: [PATCH 1/5] Cache stdout and stderr consoles to avoid creating new instances each time we print. --- cmd2/cmd2.py | 63 +++++++++++++++++++++++++++++++++++++--------- cmd2/rich_utils.py | 33 ++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 12 deletions(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 4c929f703..6b61aaac3 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -308,6 +308,14 @@ class AsyncAlert: timestamp: float = field(default_factory=time.monotonic, init=False) +class _ConsoleCache(threading.local): + """Thread-local storage for cached Rich consoles used by print_to().""" + + def __init__(self) -> None: + self.stdout: Cmd2BaseConsole | None = None + self.stderr: Cmd2BaseConsole | None = None + + class Cmd: """An easy but powerful framework for writing line-oriented command interpreters. @@ -441,6 +449,9 @@ def __init__( self.scripts_add_to_history = True # Scripts and pyscripts add commands to history self.timing = False # Prints elapsed time for each command + # Thread-local storage for cached Rich consoles used by print_to() + self._console_cache = _ConsoleCache() + # The maximum number of items to display in a completion table. If the number of completion # suggestions exceeds this number, then no table will appear. self.max_completion_table_items: int = 50 @@ -1332,20 +1343,48 @@ def _create_base_printing_console( markup: bool, highlight: bool, ) -> Cmd2BaseConsole: - """Create a Cmd2BaseConsole with formatting overrides. + """Get a Cmd2BaseConsole configured for the specified stream and formatting settings. + + This method manages a thread-local cache for consoles printing to self.stdout or + sys.stderr to avoid the overhead of repeated initialization. It returns a cached + instance if its configuration matches the request. Otherwise, a new console is + created and cached. + + Note: This implementation works around a bug in Rich where passing formatting settings + (emoji, markup, and highlight) directly to console.print() or console.log() does not + always work when printing certain Renderables. Passing them to the constructor instead + ensures they are correctly propagated. Once this bug is fixed, these parameters can + be removed from this method. For more details, see: + https://github.com/Textualize/rich/issues/4028 + """ + # Dictionary of settings to check against cached consoles + kwargs = { + "emoji": emoji, + "markup": markup, + "highlight": highlight, + } - This works around a bug in Rich where passing these formatting settings directly to - console.print() or console.log() does not always work when printing certain Renderables. - Passing them to the constructor instead ensures they are correctly propagated. + # Check if we should use or update a cached console + if file is self.stdout: + cached = self._console_cache.stdout + if cached is not None and cached.matches_config(file, **kwargs): + return cached - See: https://github.com/Textualize/rich/issues/4028 - """ - return Cmd2BaseConsole( - file=file, - emoji=emoji, - markup=markup, - highlight=highlight, - ) + # Create new console and update cache + self._console_cache.stdout = Cmd2BaseConsole(file=file, **kwargs) + return self._console_cache.stdout + + if file is sys.stderr: + cached = self._console_cache.stderr + if cached is not None and cached.matches_config(file, **kwargs): + return cached + + # Create new console and update cache + self._console_cache.stderr = Cmd2BaseConsole(file=file, **kwargs) + return self._console_cache.stderr + + # For any other file, just create a new console + return Cmd2BaseConsole(file=file, **kwargs) def print_to( self, diff --git a/cmd2/rich_utils.py b/cmd2/rich_utils.py index 4aafa5b95..83b3dac5d 100644 --- a/cmd2/rich_utils.py +++ b/cmd2/rich_utils.py @@ -160,6 +160,9 @@ def __init__( "Passing 'theme' is not allowed. Its behavior is controlled by the global APP_THEME and set_theme()." ) + # Store the configuration used to create this console for caching purposes. + self._config_key = self._generate_config_key(file, kwargs) + force_terminal: bool | None = None force_interactive: bool | None = None @@ -180,6 +183,36 @@ def __init__( **kwargs, ) + @staticmethod + def _generate_config_key( + file: IO[str] | None, + kwargs: dict[str, Any], + ) -> tuple[Any, ...]: + """Generate a key representing the settings used to initialize a console. + + This key includes the file identity, global settings (ALLOW_STYLE, APP_THEME), + and any other settings passed in via kwargs. + """ + return ( + id(file), + ALLOW_STYLE, + id(APP_THEME), + tuple(sorted(kwargs.items())), + ) + + def matches_config( + self, + file: IO[str] | None, + **kwargs: Any, + ) -> bool: + """Check if this console instance is compatible with the given settings. + + :param file: file stream being checked + :param kwargs: formatting settings being checked + :return: True if the settings match this console's configuration + """ + return self._config_key == self._generate_config_key(file, kwargs) + def on_broken_pipe(self) -> None: """Override which raises BrokenPipeError instead of SystemExit.""" self.quiet = True From a4c853683f2ba5bd3b9fec9d56fda343b0e4befa Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Mon, 23 Mar 2026 18:02:58 -0400 Subject: [PATCH 2/5] Added tests for Cmd._create_base_printing_console(). --- tests/test_cmd2.py | 144 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 144 insertions(+) diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index c07d70d04..ed0fd7df2 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -2524,6 +2524,150 @@ def test_poutput_all_keyword_args(outsim_app): assert "My string" in out +@pytest.mark.parametrize( + 'stream', + ['stdout', 'stderr'], +) +@pytest.mark.parametrize( + ('emoji', 'markup', 'highlight'), + [ + (True, True, True), + (False, False, False), + (True, False, True), + ], +) +def test_create_base_printing_console_caching( + base_app: cmd2.Cmd, stream: str, emoji: bool, markup: bool, highlight: bool +) -> None: + """Test that base printing consoles are cached and reused when settings match.""" + file = sys.stderr if stream == 'stderr' else base_app.stdout + + # Initial creation + console1 = base_app._create_base_printing_console( + file=file, + emoji=emoji, + markup=markup, + highlight=highlight, + ) + + # Verify it's in the cache + cached = getattr(base_app._console_cache, stream) + assert cached is console1 + + # Identical request should return the same object + console2 = base_app._create_base_printing_console( + file=file, + emoji=emoji, + markup=markup, + highlight=highlight, + ) + assert console2 is console1 + + +@pytest.mark.parametrize( + 'stream', + ['stdout', 'stderr'], +) +def test_create_base_printing_console_invalidation(base_app: cmd2.Cmd, stream: str) -> None: + """Test that changing settings, theme, or ALLOW_STYLE invalidates the cache.""" + file = sys.stderr if stream == 'stderr' else base_app.stdout + + # Initial creation + console1 = base_app._create_base_printing_console( + file=file, + emoji=True, + markup=True, + highlight=True, + ) + + # Changing emoji should create a new console + console2 = base_app._create_base_printing_console( + file=file, + emoji=False, + markup=True, + highlight=True, + ) + assert console2 is not console1 + assert getattr(base_app._console_cache, stream) is console2 + + # Changing markup should create a new console + console3 = base_app._create_base_printing_console( + file=file, + emoji=False, + markup=False, + highlight=True, + ) + assert console3 is not console2 + assert getattr(base_app._console_cache, stream) is console3 + + # Changing highlight should create a new console + console4 = base_app._create_base_printing_console( + file=file, + emoji=False, + markup=False, + highlight=False, + ) + assert console4 is not console3 + assert getattr(base_app._console_cache, stream) is console4 + + # Changing ALLOW_STYLE should create a new console + orig_allow_style = ru.ALLOW_STYLE + try: + ru.ALLOW_STYLE = ru.AllowStyle.ALWAYS if orig_allow_style != ru.AllowStyle.ALWAYS else ru.AllowStyle.NEVER + console5 = base_app._create_base_printing_console( + file=file, + emoji=False, + markup=False, + highlight=False, + ) + assert console5 is not console4 + assert getattr(base_app._console_cache, stream) is console5 + finally: + ru.ALLOW_STYLE = orig_allow_style + + # Changing the theme should create a new console + from rich.theme import Theme + + old_theme = ru.APP_THEME + try: + ru.APP_THEME = Theme() + console6 = base_app._create_base_printing_console( + file=file, + emoji=False, + markup=False, + highlight=False, + ) + assert console6 is not console5 + assert getattr(base_app._console_cache, stream) is console6 + finally: + ru.APP_THEME = old_theme + + +def test_create_base_printing_console_non_cached(base_app: cmd2.Cmd) -> None: + """Test that arbitrary file objects are not cached.""" + file = io.StringIO() + + console1 = base_app._create_base_printing_console( + file=file, + emoji=True, + markup=True, + highlight=True, + ) + + # Cache for stdout/stderr should still be None (assuming they haven't been touched yet) + assert base_app._console_cache.stdout is None + assert base_app._console_cache.stderr is None + + # A second request for the same file should still create a new object + console2 = base_app._create_base_printing_console( + file=file, + emoji=True, + markup=True, + highlight=True, + ) + assert console2 is not console1 + + def test_broken_pipe_error(outsim_app, monkeypatch, capsys): write_mock = mock.MagicMock() write_mock.side_effect = BrokenPipeError From d6788568a1fcabe4665ecac885e1411bd7c3d431 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Mon, 23 Mar 2026 18:20:32 -0400 Subject: [PATCH 3/5] Fixed type hint. --- cmd2/decorators.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd2/decorators.py b/cmd2/decorators.py index eb159d157..3c8bc9ed6 100644 --- a/cmd2/decorators.py +++ b/cmd2/decorators.py @@ -278,7 +278,7 @@ def arg_decorator(func: ArgparseCommandFunc[CmdOrSet]) -> RawCommandFuncOptional """ @functools.wraps(func) - def cmd_wrapper(*args: Any, **kwargs: dict[str, Any]) -> bool | None: + def cmd_wrapper(*args: Any, **kwargs: Any) -> bool | None: """Command function wrapper which translates command line into argparse Namespace and call actual command function. :param args: All positional arguments to this function. We're expecting there to be: From 1c8d8f316e5c7014e4ffa6e7db579b92a2a2d0a9 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Mon, 23 Mar 2026 18:49:06 -0400 Subject: [PATCH 4/5] Changed some function signatures. --- cmd2/cmd2.py | 4 ++-- cmd2/rich_utils.py | 15 ++++++++++----- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 6b61aaac3..fe0d285eb 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -1367,7 +1367,7 @@ def _create_base_printing_console( # Check if we should use or update a cached console if file is self.stdout: cached = self._console_cache.stdout - if cached is not None and cached.matches_config(file, **kwargs): + if cached is not None and cached.matches_config(file=file, **kwargs): return cached # Create new console and update cache @@ -1376,7 +1376,7 @@ def _create_base_printing_console( if file is sys.stderr: cached = self._console_cache.stderr - if cached is not None and cached.matches_config(file, **kwargs): + if cached is not None and cached.matches_config(file=file, **kwargs): return cached # Create new console and update cache diff --git a/cmd2/rich_utils.py b/cmd2/rich_utils.py index 83b3dac5d..7b07185d2 100644 --- a/cmd2/rich_utils.py +++ b/cmd2/rich_utils.py @@ -161,7 +161,7 @@ def __init__( ) # Store the configuration used to create this console for caching purposes. - self._config_key = self._generate_config_key(file, kwargs) + self._config_key = self._generate_config_key(file=file, **kwargs) force_terminal: bool | None = None force_interactive: bool | None = None @@ -185,13 +185,17 @@ def __init__( @staticmethod def _generate_config_key( + *, file: IO[str] | None, - kwargs: dict[str, Any], + **kwargs: Any, ) -> tuple[Any, ...]: """Generate a key representing the settings used to initialize a console. This key includes the file identity, global settings (ALLOW_STYLE, APP_THEME), and any other settings passed in via kwargs. + + :param file: file stream being checked + :param kwargs: other console settings """ return ( id(file), @@ -202,16 +206,17 @@ def _generate_config_key( def matches_config( self, + *, file: IO[str] | None, **kwargs: Any, ) -> bool: - """Check if this console instance is compatible with the given settings. + """Check if this console instance was initialized with the specified settings. :param file: file stream being checked - :param kwargs: formatting settings being checked + :param kwargs: other console settings being checked :return: True if the settings match this console's configuration """ - return self._config_key == self._generate_config_key(file, kwargs) + return self._config_key == self._generate_config_key(file=file, **kwargs) def on_broken_pipe(self) -> None: """Override which raises BrokenPipeError instead of SystemExit.""" From 4b9faca52d3dd3fe7aa8d20c1eeb137bb578e468 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Mon, 23 Mar 2026 19:49:08 -0400 Subject: [PATCH 5/5] Updated comments and renamed a function. --- cmd2/cmd2.py | 21 +++++++++++---------- tests/test_cmd2.py | 30 ++++++++++++++---------------- 2 files changed, 25 insertions(+), 26 deletions(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index fe0d285eb..1282d3cb1 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -309,7 +309,7 @@ class AsyncAlert: class _ConsoleCache(threading.local): - """Thread-local storage for cached Rich consoles used by print_to().""" + """Thread-local storage for cached Rich consoles used by core print methods.""" def __init__(self) -> None: self.stdout: Cmd2BaseConsole | None = None @@ -449,7 +449,7 @@ def __init__( self.scripts_add_to_history = True # Scripts and pyscripts add commands to history self.timing = False # Prints elapsed time for each command - # Thread-local storage for cached Rich consoles used by print_to() + # Cached Rich consoles used by core print methods. self._console_cache = _ConsoleCache() # The maximum number of items to display in a completion table. If the number of completion @@ -1335,7 +1335,7 @@ def visible_prompt(self) -> str: """ return su.strip_style(self.prompt) - def _create_base_printing_console( + def _get_core_print_console( self, *, file: IO[str], @@ -1343,12 +1343,13 @@ def _create_base_printing_console( markup: bool, highlight: bool, ) -> Cmd2BaseConsole: - """Get a Cmd2BaseConsole configured for the specified stream and formatting settings. + """Get a console configured for the specified stream and formatting settings. - This method manages a thread-local cache for consoles printing to self.stdout or - sys.stderr to avoid the overhead of repeated initialization. It returns a cached - instance if its configuration matches the request. Otherwise, a new console is - created and cached. + This method is intended for internal use by cmd2's core print methods. + To avoid the overhead of repeated initialization, it manages a thread-local + cache for consoles targeting ``self.stdout`` or ``sys.stderr``. It returns a cached + instance if its configuration matches the request. For all other streams, or if + the configuration has changed, a new console is created. Note: This implementation works around a bug in Rich where passing formatting settings (emoji, markup, and highlight) directly to console.print() or console.log() does not @@ -1437,7 +1438,7 @@ def print_to( See the Rich documentation for more details on emoji codes, markup tags, and highlighting. """ try: - self._create_base_printing_console( + self._get_core_print_console( file=file, emoji=emoji, markup=markup, @@ -1753,7 +1754,7 @@ def ppaged( soft_wrap = True # Generate the bytes to send to the pager - console = self._create_base_printing_console( + console = self._get_core_print_console( file=self.stdout, emoji=emoji, markup=markup, diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index ed0fd7df2..e971ae736 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -2536,14 +2536,12 @@ def test_poutput_all_keyword_args(outsim_app): (True, False, True), ], ) -def test_create_base_printing_console_caching( - base_app: cmd2.Cmd, stream: str, emoji: bool, markup: bool, highlight: bool -) -> None: - """Test that base printing consoles are cached and reused when settings match.""" +def test_get_core_print_console_caching(base_app: cmd2.Cmd, stream: str, emoji: bool, markup: bool, highlight: bool) -> None: + """Test that printing consoles are cached and reused when settings match.""" file = sys.stderr if stream == 'stderr' else base_app.stdout # Initial creation - console1 = base_app._create_base_printing_console( + console1 = base_app._get_core_print_console( file=file, emoji=emoji, markup=markup, @@ -2555,7 +2553,7 @@ def test_create_base_printing_console_caching( assert cached is console1 # Identical request should return the same object - console2 = base_app._create_base_printing_console( + console2 = base_app._get_core_print_console( file=file, emoji=emoji, markup=markup, @@ -2568,12 +2566,12 @@ def test_create_base_printing_console_caching( 'stream', ['stdout', 'stderr'], ) -def test_create_base_printing_console_invalidation(base_app: cmd2.Cmd, stream: str) -> None: +def test_get_core_print_console_invalidation(base_app: cmd2.Cmd, stream: str) -> None: """Test that changing settings, theme, or ALLOW_STYLE invalidates the cache.""" file = sys.stderr if stream == 'stderr' else base_app.stdout # Initial creation - console1 = base_app._create_base_printing_console( + console1 = base_app._get_core_print_console( file=file, emoji=True, markup=True, @@ -2581,7 +2579,7 @@ def test_create_base_printing_console_invalidation(base_app: cmd2.Cmd, stream: s ) # Changing emoji should create a new console - console2 = base_app._create_base_printing_console( + console2 = base_app._get_core_print_console( file=file, emoji=False, markup=True, @@ -2591,7 +2589,7 @@ def test_create_base_printing_console_invalidation(base_app: cmd2.Cmd, stream: s assert getattr(base_app._console_cache, stream) is console2 # Changing markup should create a new console - console3 = base_app._create_base_printing_console( + console3 = base_app._get_core_print_console( file=file, emoji=False, markup=False, @@ -2601,7 +2599,7 @@ def test_create_base_printing_console_invalidation(base_app: cmd2.Cmd, stream: s assert getattr(base_app._console_cache, stream) is console3 # Changing highlight should create a new console - console4 = base_app._create_base_printing_console( + console4 = base_app._get_core_print_console( file=file, emoji=False, markup=False, @@ -2614,7 +2612,7 @@ def test_create_base_printing_console_invalidation(base_app: cmd2.Cmd, stream: s orig_allow_style = ru.ALLOW_STYLE try: ru.ALLOW_STYLE = ru.AllowStyle.ALWAYS if orig_allow_style != ru.AllowStyle.ALWAYS else ru.AllowStyle.NEVER - console5 = base_app._create_base_printing_console( + console5 = base_app._get_core_print_console( file=file, emoji=False, markup=False, @@ -2631,7 +2629,7 @@ def test_create_base_printing_console_invalidation(base_app: cmd2.Cmd, stream: s old_theme = ru.APP_THEME try: ru.APP_THEME = Theme() - console6 = base_app._create_base_printing_console( + console6 = base_app._get_core_print_console( file=file, emoji=False, markup=False, @@ -2643,11 +2641,11 @@ def test_create_base_printing_console_invalidation(base_app: cmd2.Cmd, stream: s ru.APP_THEME = old_theme -def test_create_base_printing_console_non_cached(base_app: cmd2.Cmd) -> None: +def test_get_core_print_console_non_cached(base_app: cmd2.Cmd) -> None: """Test that arbitrary file objects are not cached.""" file = io.StringIO() - console1 = base_app._create_base_printing_console( + console1 = base_app._get_core_print_console( file=file, emoji=True, markup=True, @@ -2659,7 +2657,7 @@ def test_create_base_printing_console_non_cached(base_app: cmd2.Cmd) -> None: assert base_app._console_cache.stderr is None # A second request for the same file should still create a new object - console2 = base_app._create_base_printing_console( + console2 = base_app._get_core_print_console( file=file, emoji=True, markup=True,