diff --git a/CHANGELOG.md b/CHANGELOG.md index 98d0a29e..c580f966 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -79,6 +79,18 @@ prompt is displayed. - **max_column_completion_results**: (int) the maximum number of completion results to display in a single column +## 3.4.0 (TBD) + +- Enhancements + - Moved cmd2-specific printing logic from `Cmd.print_to()` into `Cmd2BaseConsole.print()` and + `Cmd2BaseConsole.log()`. This removes need to pass a console object to `Cmd.print_to()`. + - Addressed a bug in `rich.console.Console` where complex renderables (like `Table` and `Rule`) + may not receive formatting settings passed to `console.print()` and `console.log()`. + +- Breaking Changes + - Renamed the `destination` parameter of `Cmd.print_to()` back to `file` since you can no longer + pass in a console. + ## 3.3.0 (March 1, 2026) - Enhancements diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index dddd10ed..9bfafd34 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -84,7 +84,6 @@ from prompt_toolkit.patch_stdout import patch_stdout from prompt_toolkit.shortcuts import CompleteStyle, PromptSession, set_title from rich.console import ( - Console, Group, RenderableType, ) @@ -1320,66 +1319,30 @@ def visible_prompt(self) -> str: def print_to( self, - destination: IO[str] | Cmd2BaseConsole, + file: IO[str], *objects: Any, sep: str = " ", end: str = "\n", style: StyleType | None = None, - soft_wrap: bool | None = None, - emoji: bool | None = None, - markup: bool | None = None, - highlight: bool | None = None, + soft_wrap: bool = True, + emoji: bool = False, + markup: bool = False, + highlight: bool = False, rich_print_kwargs: RichPrintKwargs | None = None, **kwargs: Any, # noqa: ARG002 ) -> None: - """Print objects to a given destination (file stream or cmd2 console). - - If ``destination`` is a file-like object, it is wrapped in a ``Cmd2GeneralConsole`` - which is configured for general-purpose printing. By default, it enables soft wrap and - disables Rich's automatic detection for markup, emoji, and highlighting. These defaults - can be overridden by passing explicit keyword arguments. - - If ``destination`` is a ``Cmd2BaseConsole``, the console's default settings for - soft wrap, markup, emoji, and highlighting are used unless overridden by passing - explicit keyword arguments. - - See the Rich documentation for more details on emoji codes, markup tags, and highlighting. - - **Why use this method instead of console.print()?** - - 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 when - printing. - - Example: - ```py - with console.capture() as capture: - self.print_to(console, some_ansi_styled_string) - ``` - - !!! note + """Print objects to a given file stream. - To ensure consistent behavior, this method requires a file-like object or - an instance of ``Cmd2BaseConsole``. - Consoles not derived from ``Cmd2BaseConsole`` are disallowed because: + This method is configured for general-purpose printing. By default, it enables + soft wrap and disables Rich's automatic detection for markup, emoji, and highlighting. + These defaults can be overridden by passing explicit keyword arguments. - 1. **Style Control**: They ignore the global ``ALLOW_STYLE`` setting. - 2. **Theming**: They do not respect the application-wide ``APP_THEME``. - 3. **Error Handling**: They trigger a ``SystemExit`` on broken pipes. - ``Cmd2BaseConsole`` instead raises a catchable ``BrokenPipeError``, - ensuring the CLI application remains alive if a pipe is closed. - - :param destination: The output target. File-like objects are automatically - wrapped in a ``Cmd2GeneralConsole`` to ensure they respect - cmd2 global settings; otherwise, this must be an - instance of ``Cmd2BaseConsole``. + :param file: file stream being written to :param objects: objects to print :param sep: string to write between printed text. Defaults to " ". :param end: string to write at end of printed text. Defaults to a newline. :param style: optional style to apply to output - :param soft_wrap: Enable soft wrap mode. Defaults to None. - If None, the destination console's default behavior is used. + :param soft_wrap: Enable soft wrap mode. Defaults to True. If True, text that doesn't fit will run on to the following line, just like with print(). This is useful for raw text and logs. If False, Rich wraps text to fit the terminal width. @@ -1388,44 +1351,23 @@ def print_to( For example, when soft_wrap is True Panels truncate text which is wider than the terminal. :param emoji: If True, Rich will replace emoji codes (e.g., :smiley:) with their - corresponding Unicode characters. Defaults to None. - If None, the destination console's default behavior is used. + corresponding Unicode characters. Defaults to False. :param markup: If True, Rich will interpret strings with tags (e.g., [bold]hello[/bold]) - as styled output. Defaults to None. - If None, the destination console's default behavior is used. + as styled output. Defaults to False. :param highlight: If True, Rich will automatically apply highlighting to elements within strings, such as common Python data types like numbers, booleans, or None. This is particularly useful when pretty printing objects like lists and - dictionaries to display them in color. Defaults to None. - If None, the destination console's default behavior is used. + dictionaries to display them in color. Defaults to False. :param rich_print_kwargs: optional additional keyword arguments to pass to Rich's Console.print(). :param kwargs: Arbitrary keyword arguments. This allows subclasses to extend the signature of this method and still call `super()` without encountering unexpected keyword argument errors. These arguments are not passed to Rich's Console.print(). - :raises TypeError: If ``destination`` is a non-cmd2 ``Console`` instance that - does not derive from ``Cmd2BaseConsole``. + See the Rich documentation for more details on emoji codes, markup tags, and highlighting. """ - if isinstance(destination, Console): - if not isinstance(destination, Cmd2BaseConsole): - # Explicitly reject non-cmd2 consoles to ensure safe behavior - raise TypeError( - f"destination must be a 'Cmd2BaseConsole' or a file-like object, " - f"not a non-cmd2 '{type(destination).__name__}'. " - "Consoles not derived from 'Cmd2BaseConsole' bypass cmd2's " - "'ALLOW_STYLE' logic, 'APP_THEME' settings, and trigger 'SystemExit' " - "on broken pipes." - ) - console = destination - else: - # It's a file-like object (e.g., sys.stdout, StringIO) - console = Cmd2GeneralConsole(file=destination) - - prepared_objects = ru.prepare_objects_for_rendering(*objects) - try: - console.print( - *prepared_objects, + Cmd2BaseConsole(file=file).print( + *objects, sep=sep, end=end, style=style, @@ -1441,7 +1383,7 @@ def print_to( # writing. If you would like your application to print a # warning message, then set the broken_pipe_warning attribute # to the message you want printed. - if self.broken_pipe_warning and console.file != sys.stderr: + if self.broken_pipe_warning and file != sys.stderr: Cmd2GeneralConsole(file=sys.stderr).print(self.broken_pipe_warning) def poutput( @@ -1450,10 +1392,10 @@ def poutput( sep: str = " ", end: str = "\n", style: StyleType | None = None, - soft_wrap: bool | None = None, - emoji: bool | None = None, - markup: bool | None = None, - highlight: bool | None = None, + soft_wrap: bool = True, + emoji: bool = False, + markup: bool = False, + highlight: bool = False, rich_print_kwargs: RichPrintKwargs | None = None, **kwargs: Any, # noqa: ARG002 ) -> None: @@ -1480,10 +1422,10 @@ def perror( sep: str = " ", end: str = "\n", style: StyleType | None = Cmd2Style.ERROR, - soft_wrap: bool | None = None, - emoji: bool | None = None, - markup: bool | None = None, - highlight: bool | None = None, + soft_wrap: bool = True, + emoji: bool = False, + markup: bool = False, + highlight: bool = False, rich_print_kwargs: RichPrintKwargs | None = None, **kwargs: Any, # noqa: ARG002 ) -> None: @@ -1511,10 +1453,10 @@ def psuccess( *objects: Any, sep: str = " ", end: str = "\n", - soft_wrap: bool | None = None, - emoji: bool | None = None, - markup: bool | None = None, - highlight: bool | None = None, + soft_wrap: bool = True, + emoji: bool = False, + markup: bool = False, + highlight: bool = False, rich_print_kwargs: RichPrintKwargs | None = None, **kwargs: Any, # noqa: ARG002 ) -> None: @@ -1539,10 +1481,10 @@ def pwarning( *objects: Any, sep: str = " ", end: str = "\n", - soft_wrap: bool | None = None, - emoji: bool | None = None, - markup: bool | None = None, - highlight: bool | None = None, + soft_wrap: bool = True, + emoji: bool = False, + markup: bool = False, + highlight: bool = False, rich_print_kwargs: RichPrintKwargs | None = None, **kwargs: Any, # noqa: ARG002 ) -> None: @@ -1633,10 +1575,10 @@ def pfeedback( sep: str = " ", end: str = "\n", style: StyleType | None = None, - soft_wrap: bool | None = None, - emoji: bool | None = None, - markup: bool | None = None, - highlight: bool | None = None, + soft_wrap: bool = True, + emoji: bool = False, + markup: bool = False, + highlight: bool = False, rich_print_kwargs: RichPrintKwargs | None = None, **kwargs: Any, # noqa: ARG002 ) -> None: @@ -1681,9 +1623,9 @@ def ppaged( style: StyleType | None = None, chop: bool = False, soft_wrap: bool = True, - emoji: bool | None = None, - markup: bool | None = None, - highlight: bool | None = None, + emoji: bool = False, + markup: bool = False, + highlight: bool = False, rich_print_kwargs: RichPrintKwargs | None = None, **kwargs: Any, # noqa: ARG002 ) -> None: @@ -1725,10 +1667,9 @@ def ppaged( soft_wrap = True # Generate the bytes to send to the pager - console = Cmd2GeneralConsole(file=self.stdout) + console = Cmd2BaseConsole(file=self.stdout) with console.capture() as capture: - self.print_to( - console, + console.print( *objects, sep=sep, end=end, @@ -2535,8 +2476,7 @@ def complete( console = Cmd2GeneralConsole(file=self.stdout) with console.capture() as capture: - self.print_to( - console, + console.print( err_str, style=Cmd2Style.ERROR if ex.apply_style else "", end=end, diff --git a/cmd2/rich_utils.py b/cmd2/rich_utils.py index 4178158d..46108f2c 100644 --- a/cmd2/rich_utils.py +++ b/cmd2/rich_utils.py @@ -1,6 +1,7 @@ """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 ( @@ -173,12 +174,152 @@ 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, + sep: str = " ", + end: str = "\n", + style: StyleType | None = None, + justify: JustifyMethod | None = None, + overflow: OverflowMethod | None = None, + no_wrap: bool | None = None, + emoji: bool | None = None, + markup: bool | None = None, + highlight: bool | None = None, + width: int | None = None, + height: int | None = None, + crop: bool = True, + soft_wrap: bool | None = None, + new_line_start: bool = False, + ) -> None: + """Override to support ANSI sequences and address a bug in Rich. + + 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 + + def log( + self, + *objects: Any, + sep: str = " ", + end: str = "\n", + style: StyleType | None = None, + justify: JustifyMethod | None = None, + emoji: bool | None = None, + markup: bool | None = None, + highlight: bool | None = None, + log_locals: bool = False, + _stack_offset: int = 1, + ) -> None: + """Override to support ANSI sequences and address a bug in Rich. + + 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 + class Cmd2GeneralConsole(Cmd2BaseConsole): """Rich console for general-purpose printing. diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index 2a5fa832..2352201e 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -2263,22 +2263,6 @@ def check_and_raise(*args, **kwargs): assert base_app.active_session == base_app.main_session -def test_print_to_custom_console(base_app) -> None: - console = ru.Cmd2GeneralConsole() - with console.capture() as capture: - base_app.print_to(console, "hello") - assert capture.get() == "hello\n" - - -def test_print_to_invalid_console_type(base_app) -> None: - from rich.console import Console - - console = Console() - with pytest.raises(TypeError) as excinfo: - base_app.print_to(console, "hello") - assert "destination must be a 'Cmd2BaseConsole'" in str(excinfo.value) - - def test_poutput_string(outsim_app) -> None: msg = 'This is a test' outsim_app.poutput(msg) diff --git a/tests/test_rich_utils.py b/tests/test_rich_utils.py index 9e0435b8..ea7eb9e8 100644 --- a/tests/test_rich_utils.py +++ b/tests/test_rich_utils.py @@ -13,6 +13,8 @@ ) 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. @@ -142,3 +144,51 @@ def test_from_ansi_wrapper() -> None: # Test empty string input_string = "" 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]") + + with console.capture() as capture: + # Override settings in the print() call + console.print(rule, emoji=True, markup=True) + + result = capture.get() + + # Verify that the overrides were respected by checking for the emoji and the color code + assert "🔢" in result + assert "\x1b[32mSuccess" in result + + +@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 + + # 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]") + + with console.capture() as capture: + # Override settings in the log() call + console.log(rule, emoji=True, markup=True) + + result = capture.get() + + # Verify that the formatting overrides were respected + assert "🔢" in result + assert "\x1b[32mSuccess" in result + + # 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