"""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 ( IO, Any, TypedDict, ) from rich.console import ( Console, ConsoleRenderable, JustifyMethod, OverflowMethod, RenderableType, ) from rich.padding import Padding from rich.pretty import is_expandable from rich.protocol import rich_cast from rich.style import StyleType from rich.table import ( Column, Table, ) from rich.text import Text from rich.theme import Theme from rich_argparse import RichHelpFormatter from .styles import DEFAULT_CMD2_STYLES # Matches ANSI SGR (Select Graphic Rendition) sequences for text styling. # \x1b[ - the CSI (Control Sequence Introducer) # [0-9;]* - zero or more digits or semicolons (parameters for the style) # m - the SGR final character ANSI_STYLE_SEQUENCE_RE = re.compile(r"\x1b\[[0-9;]*m") class AllowStyle(Enum): """Values for ``cmd2.rich_utils.ALLOW_STYLE``.""" ALWAYS = "Always" # Always output ANSI style sequences NEVER = "Never" # Remove ANSI style sequences from all output TERMINAL = "Terminal" # Remove ANSI style sequences if the output is not going to the terminal def __str__(self) -> str: """Return value instead of enum name for printing in cmd2's set command.""" return str(self.value) def __repr__(self) -> str: """Return quoted value instead of enum description for printing in cmd2's set command.""" return repr(self.value) # Controls when ANSI style sequences are allowed in output ALLOW_STYLE = AllowStyle.TERMINAL def _create_default_theme() -> Theme: """Create a default theme for the application. This theme combines the default styles from cmd2, rich-argparse, and Rich. """ app_styles = DEFAULT_CMD2_STYLES.copy() app_styles.update(RichHelpFormatter.styles.copy()) return Theme(app_styles, inherit=True) def set_theme(styles: Mapping[str, StyleType] | None = None) -> None: """Set the Rich theme used by cmd2. Call set_theme() with no arguments to reset to the default theme. This will clear any custom styles that were previously applied. :param styles: optional mapping of style names to styles """ global APP_THEME # noqa: PLW0603 # Start with a fresh copy of the default styles. app_styles: dict[str, StyleType] = {} app_styles.update(_create_default_theme().styles) # Incorporate custom styles. if styles is not None: app_styles.update(styles) APP_THEME = Theme(app_styles) # Synchronize rich-argparse styles with the main application theme. for name in RichHelpFormatter.styles.keys() & APP_THEME.styles.keys(): RichHelpFormatter.styles[name] = APP_THEME.styles[name] # The application-wide theme. You can change it with set_theme(). APP_THEME = _create_default_theme() class RichPrintKwargs(TypedDict, total=False): """Keyword arguments that can be passed to rich.console.Console.print() via cmd2's print methods. See Rich's Console.print() documentation for full details on these parameters. https://rich.readthedocs.io/en/stable/reference/console.html#rich.console.Console.print Note: All fields are optional (total=False). If a key is not present in the dictionary, Rich's default behavior for that argument will apply. """ justify: JustifyMethod | None overflow: OverflowMethod | None no_wrap: bool | None width: int | None height: int | None crop: bool new_line_start: bool class Cmd2BaseConsole(Console): """Base class for all cmd2 Rich consoles. This class handles the core logic for managing Rich behavior based on cmd2's global settings, such as `ALLOW_STYLE` and `APP_THEME`. """ def __init__( self, *, file: IO[str] | None = None, **kwargs: Any, ) -> None: """Cmd2BaseConsole initializer. :param file: optional file object where the console should write to. Defaults to sys.stdout. :param kwargs: keyword arguments passed to the parent Console class. :raises TypeError: if disallowed keyword argument is passed in. """ # These settings are controlled by the ALLOW_STYLE setting and cannot be overridden. if "color_system" in kwargs: raise TypeError("Passing 'color_system' is not allowed. Its behavior is controlled by the 'ALLOW_STYLE' setting.") if "force_terminal" in kwargs: raise TypeError( "Passing 'force_terminal' is not allowed. Its behavior is controlled by the 'ALLOW_STYLE' setting." ) if "force_interactive" in kwargs: raise TypeError( "Passing 'force_interactive' is not allowed. Its behavior is controlled by the 'ALLOW_STYLE' setting." ) # Don't allow a theme to be passed in, as it is controlled by the global APP_THEME. # Use cmd2.rich_utils.set_theme() to set the global theme or use a temporary # theme with console.use_theme(). if "theme" in kwargs: raise TypeError( "Passing 'theme' is not allowed. Its behavior is controlled by the global APP_THEME and set_theme()." ) force_terminal: bool | None = None force_interactive: bool | None = None allow_style = False if ALLOW_STYLE == AllowStyle.ALWAYS: force_terminal = True allow_style = True # Turn off interactive mode if dest is not actually a terminal which supports it tmp_console = Console(file=file) force_interactive = tmp_console.is_interactive elif ALLOW_STYLE == AllowStyle.TERMINAL: tmp_console = Console(file=file) allow_style = tmp_console.is_terminal elif ALLOW_STYLE == AllowStyle.NEVER: force_terminal = False super().__init__( file=file, color_system="truecolor" if allow_style else None, force_terminal=force_terminal, force_interactive=force_interactive, 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. 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. """ def __init__(self, *, file: IO[str] | None = None) -> None: """Cmd2GeneralConsole initializer. :param file: optional file object where the console should write to. Defaults to sys.stdout. """ super().__init__( file=file, soft_wrap=True, markup=False, emoji=False, highlight=False, ) class Cmd2RichArgparseConsole(Cmd2BaseConsole): """Rich console for rich-argparse output. Ensures long lines in help text are not truncated by disabling soft_wrap, which conflicts with rich-argparse's explicit no_wrap and overflow settings. Since this console is used to print error messages which may not be intended for Rich formatting, it disables Rich's automatic detection for markup, emoji, and highlighting. Because rich-argparse does markup and highlighting without involving the console, disabling these settings does not affect the library's internal functionality. """ def __init__(self, *, file: IO[str] | None = None) -> None: """Cmd2RichArgparseConsole initializer. :param file: optional file object where the console should write to. Defaults to sys.stdout. """ super().__init__( file=file, soft_wrap=False, markup=False, emoji=False, highlight=False, ) class Cmd2ExceptionConsole(Cmd2BaseConsole): """Rich console for printing exceptions and Rich Tracebacks. Ensures that output is always word-wrapped for readability and disables Rich's automatic detection for markup, emoji, and highlighting to prevent interference with raw error data. """ def __init__(self, *, file: IO[str] | None = None) -> None: """Cmd2ExceptionConsole initializer. :param file: optional file object where the console should write to. Defaults to sys.stdout. """ super().__init__( file=file, soft_wrap=False, markup=False, emoji=False, highlight=False, ) def console_width() -> int: """Return the width of the console.""" return Console().width def rich_text_to_string(text: Text) -> str: """Convert a Rich Text object to a string. This function's purpose is to render a Rich Text object, including any styles (e.g., color, bold), to a plain Python string with ANSI style sequences. It differs from `text.plain`, which strips all formatting. :param text: the text object to convert :return: the resulting string with ANSI styles preserved. """ console = Console( force_terminal=True, color_system="truecolor", soft_wrap=True, no_color=False, theme=APP_THEME, ) with console.capture() as capture: console.print(text, end="") return capture.get() def indent(renderable: RenderableType, level: int) -> Padding: """Indent a Rich renderable. When soft-wrapping is enabled, a Rich console is unable to properly print a Padding object of indented text, as it truncates long strings instead of wrapping them. This function provides a workaround for this issue, ensuring that indented text is printed correctly regardless of the soft-wrap setting. For non-text objects, this function merely serves as a convenience wrapper around Padding.indent(). :param renderable: a Rich renderable to indent. :param level: number of characters to indent. :return: a Padding object containing the indented content. """ if isinstance(renderable, (str, Text)): # Wrap text in a grid to handle the wrapping. text_grid = Table.grid(Column(overflow="fold")) text_grid.add_row(renderable) renderable = text_grid return Padding.indent(renderable, level) def prepare_objects_for_rendering(*objects: Any) -> tuple[Any, ...]: """Prepare a tuple of objects for printing by Rich's Console.print(). This function processes objects to ensure they are rendered correctly by Rich. It inspects each object and, if its string representation contains ANSI style sequences, it converts the object to a Rich Text object. This ensures Rich can properly parse the non-printing codes for accurate display width calculation. Objects that already implement the Rich console protocol or are expandable by its pretty printer are left untouched, as they can be handled directly by Rich's native renderers. :param objects: objects to prepare :return: a tuple containing the processed objects. """ object_list = list(objects) for i, obj in enumerate(object_list): # Resolve the object's final renderable form, including those # with a __rich__ method that might return a string. renderable = rich_cast(obj) # No preprocessing is needed for Rich-compatible or expandable objects. if isinstance(renderable, ConsoleRenderable) or is_expandable(renderable): continue # Check for ANSI style sequences in its string representation. renderable_as_str = str(renderable) if ANSI_STYLE_SEQUENCE_RE.search(renderable_as_str): object_list[i] = Text.from_ansi(renderable_as_str) return tuple(object_list)