Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 33 additions & 8 deletions cmd2/cmd2.py
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -1665,17 +1688,19 @@ 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,
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 {}),
)
output_bytes = capture.get().encode('utf-8', 'replace')
Expand Down
129 changes: 34 additions & 95 deletions cmd2/rich_utils.py
Original file line number Diff line number Diff line change
@@ -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 (
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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:
Expand Down
69 changes: 31 additions & 38 deletions tests/test_rich_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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
Loading