diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index b3b1c86b..844ae83e 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -1315,6 +1315,27 @@ def visible_prompt(self) -> str: """ return su.strip_style(self.prompt) + def _create_base_printing_console( + self, + file: IO[str], + emoji: bool, + markup: bool, + highlight: bool, + ) -> Cmd2BaseConsole: + """Create a Cmd2BaseConsole with formatting overrides. + + This works around a bug in Rich where complex renderables (like Table and Rule) + may not receive formatting settings passed directly to print() or log(). Passing + them to the constructor instead ensures they are correctly propagated. + See: https://github.com/Textualize/rich/issues/4028 + """ + return Cmd2BaseConsole( + file=file, + emoji=emoji, + markup=markup, + highlight=highlight, + ) + def print_to( self, file: IO[str], @@ -1364,15 +1385,17 @@ def print_to( See the Rich documentation for more details on emoji codes, markup tags, and highlighting. """ try: - Cmd2BaseConsole(file=file).print( + self._create_base_printing_console( + file=file, + emoji=emoji, + markup=markup, + highlight=highlight, + ).print( *objects, sep=sep, end=end, style=style, soft_wrap=soft_wrap, - emoji=emoji, - markup=markup, - highlight=highlight, **(rich_print_kwargs if rich_print_kwargs is not None else {}), ) except BrokenPipeError: @@ -1665,7 +1688,12 @@ def ppaged( soft_wrap = True # Generate the bytes to send to the pager - console = Cmd2BaseConsole(file=self.stdout) + console = self._create_base_printing_console( + file=self.stdout, + emoji=emoji, + markup=markup, + highlight=highlight, + ) with console.capture() as capture: console.print( *objects, @@ -1673,9 +1701,6 @@ def ppaged( end=end, style=style, soft_wrap=soft_wrap, - emoji=emoji, - markup=markup, - highlight=highlight, **(rich_print_kwargs if rich_print_kwargs is not None else {}), ) output_bytes = capture.get().encode('utf-8', 'replace') diff --git a/cmd2/rich_utils.py b/cmd2/rich_utils.py index 32095988..dda625a2 100644 --- a/cmd2/rich_utils.py +++ b/cmd2/rich_utils.py @@ -1,7 +1,6 @@ """Provides common utilities to support Rich in cmd2-based applications.""" import re -import threading from collections.abc import Mapping from enum import Enum from typing import ( @@ -178,31 +177,12 @@ def __init__( theme=APP_THEME, **kwargs, ) - self._thread_local = threading.local() def on_broken_pipe(self) -> None: """Override which raises BrokenPipeError instead of SystemExit.""" self.quiet = True raise BrokenPipeError - def render_str( - self, - text: str, - highlight: bool | None = None, - markup: bool | None = None, - emoji: bool | None = None, - **kwargs: Any, - ) -> Text: - """Override to ensure formatting overrides passed to print() and log() are respected.""" - if emoji is None: - emoji = getattr(self._thread_local, "emoji", None) - if markup is None: - markup = getattr(self._thread_local, "markup", None) - if highlight is None: - highlight = getattr(self._thread_local, "highlight", None) - - return super().render_str(text, highlight=highlight, markup=markup, emoji=emoji, **kwargs) - def print( self, *objects: Any, @@ -221,52 +201,32 @@ def print( soft_wrap: bool | None = None, new_line_start: bool = False, ) -> None: - """Override to support ANSI sequences and address a bug in Rich. + """Override to support ANSI sequences. This method calls [cmd2.rich_utils.prepare_objects_for_rendering][] on the objects being printed. This ensures that strings containing ANSI style sequences are converted to Rich Text objects, so that Rich can correctly calculate their display width. - - Additionally, it works around a bug in Rich where complex renderables - (like Table and Rule) may not receive formatting settings passed to print(). - By temporarily injecting these settings into thread-local storage, we ensure - that all internal rendering calls within the print() operation respect the - requested overrides. - - There is an issue on Rich to fix the latter: - https://github.com/Textualize/rich/issues/4028 """ prepared_objects = prepare_objects_for_rendering(*objects) - # Inject overrides into thread-local storage - self._thread_local.emoji = emoji - self._thread_local.markup = markup - self._thread_local.highlight = highlight - - try: - super().print( - *prepared_objects, - sep=sep, - end=end, - style=style, - justify=justify, - overflow=overflow, - no_wrap=no_wrap, - emoji=emoji, - markup=markup, - highlight=highlight, - width=width, - height=height, - crop=crop, - soft_wrap=soft_wrap, - new_line_start=new_line_start, - ) - finally: - # Clear overrides from thread-local storage - self._thread_local.emoji = None - self._thread_local.markup = None - self._thread_local.highlight = None + super().print( + *prepared_objects, + sep=sep, + end=end, + style=style, + justify=justify, + overflow=overflow, + no_wrap=no_wrap, + emoji=emoji, + markup=markup, + highlight=highlight, + width=width, + height=height, + crop=crop, + soft_wrap=soft_wrap, + new_line_start=new_line_start, + ) def log( self, @@ -281,56 +241,35 @@ def log( log_locals: bool = False, _stack_offset: int = 1, ) -> None: - """Override to support ANSI sequences and address a bug in Rich. + """Override to support ANSI sequences. This method calls [cmd2.rich_utils.prepare_objects_for_rendering][] on the objects being logged. This ensures that strings containing ANSI style sequences are converted to Rich Text objects, so that Rich can correctly calculate their display width. - - Additionally, it works around a bug in Rich where complex renderables - (like Table and Rule) may not receive formatting settings passed to log(). - By temporarily injecting these settings into thread-local storage, we ensure - that all internal rendering calls within the log() operation respect the - requested overrides. - - There is an issue on Rich to fix the latter: - https://github.com/Textualize/rich/issues/4028 """ prepared_objects = prepare_objects_for_rendering(*objects) - # Inject overrides into thread-local storage - self._thread_local.emoji = emoji - self._thread_local.markup = markup - self._thread_local.highlight = highlight - - try: - # Increment _stack_offset because we added this wrapper frame - super().log( - *prepared_objects, - sep=sep, - end=end, - style=style, - justify=justify, - emoji=emoji, - markup=markup, - highlight=highlight, - log_locals=log_locals, - _stack_offset=_stack_offset + 1, - ) - finally: - # Clear overrides from thread-local storage - self._thread_local.emoji = None - self._thread_local.markup = None - self._thread_local.highlight = None + # Increment _stack_offset because we added this wrapper frame + super().log( + *prepared_objects, + sep=sep, + end=end, + style=style, + justify=justify, + emoji=emoji, + markup=markup, + highlight=highlight, + log_locals=log_locals, + _stack_offset=_stack_offset + 1, + ) class Cmd2GeneralConsole(Cmd2BaseConsole): """Rich console for general-purpose printing. - It enables soft wrap and disables Rich's automatic detection for markup, - emoji, and highlighting. These defaults can be overridden in calls to the - console's or cmd2's print methods. + It enables soft wrap and disables Rich's automatic detection + for markup, emoji, and highlighting. """ def __init__(self, *, file: IO[str] | None = None) -> None: diff --git a/tests/test_rich_utils.py b/tests/test_rich_utils.py index c853c5e5..a3e8f9d3 100644 --- a/tests/test_rich_utils.py +++ b/tests/test_rich_utils.py @@ -2,6 +2,7 @@ import pytest import rich.box +from pytest_mock import MockerFixture from rich.console import Console from rich.style import Style from rich.table import Table @@ -13,8 +14,6 @@ ) from cmd2 import rich_utils as ru -from .conftest import with_ansi_style - def test_cmd2_base_console() -> None: # Test the keyword arguments which are not allowed. @@ -152,49 +151,43 @@ def test_from_ansi_wrapper() -> None: assert Text.from_ansi(input_string).plain == input_string -@with_ansi_style(ru.AllowStyle.ALWAYS) -def test_cmd2_base_console_print() -> None: - """Test that Cmd2BaseConsole.print() correctly propagates formatting overrides to structured renderables.""" - from rich.rule import Rule - - # Create a console that defaults to no formatting - console = ru.Cmd2BaseConsole(emoji=False, markup=False) - - # Use a Rule with emoji and markup in the title - rule = Rule(title="[green]Success :1234:[/green]") +def test_cmd2_base_console_print(mocker: MockerFixture) -> None: + """Test that Cmd2BaseConsole.print() calls prepare_objects_for_rendering().""" + # Mock prepare_objects_for_rendering to return a specific value + prepared_val = ("prepared",) + mock_prepare = mocker.patch("cmd2.rich_utils.prepare_objects_for_rendering", return_value=prepared_val) - with console.capture() as capture: - # Override settings in the print() call - console.print(rule, emoji=True, markup=True) - - result = capture.get() + # Mock the superclass print() method + mock_super_print = mocker.patch("rich.console.Console.print") - # Verify that the overrides were respected by checking for the emoji and the color code - assert "🔢" in result - assert "\x1b[32mSuccess" in result + console = ru.Cmd2BaseConsole() + console.print("hello") + # Verify that prepare_objects_for_rendering() was called with the input objects + mock_prepare.assert_called_once_with("hello") -@with_ansi_style(ru.AllowStyle.ALWAYS) -def test_cmd2_base_console_log() -> None: - """Test that Cmd2BaseConsole.log() correctly propagates formatting overrides to structured renderables.""" - from rich.rule import Rule + # Verify that the superclass print() method was called with the prepared objects + args, _ = mock_super_print.call_args + assert args == prepared_val - # Create a console that defaults to no formatting - console = ru.Cmd2BaseConsole(emoji=False, markup=False) - # Use a Rule with emoji and markup in the title - rule = Rule(title="[green]Success :1234:[/green]") +def test_cmd2_base_console_log(mocker: MockerFixture) -> None: + """Test that Cmd2BaseConsole.log() calls prepare_objects_for_rendering() and increments _stack_offset.""" + # Mock prepare_objects_for_rendering to return a specific value + prepared_val = ("prepared",) + mock_prepare = mocker.patch("cmd2.rich_utils.prepare_objects_for_rendering", return_value=prepared_val) - with console.capture() as capture: - # Override settings in the log() call - console.log(rule, emoji=True, markup=True) + # Mock the superclass log() method + mock_super_log = mocker.patch("rich.console.Console.log") - result = capture.get() + console = ru.Cmd2BaseConsole() + console.log("test", _stack_offset=2) - # Verify that the formatting overrides were respected - assert "🔢" in result - assert "\x1b[32mSuccess" in result + # Verify that prepare_objects_for_rendering() was called with the input objects + mock_prepare.assert_called_once_with("test") - # Verify stack offset: the log line should point to this file, not rich_utils.py - # Rich logs include the filename and line number on the right. - assert "test_rich_utils.py" in result + # Verify that the superclass log() method was called with the prepared objects + # and that the stack offset was correctly incremented. + args, kwargs = mock_super_log.call_args + assert args == prepared_val + assert kwargs["_stack_offset"] == 3