diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 7d84590f4..78ec31ef9 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -1205,8 +1205,8 @@ def print_to( :param file: file stream being written to :param objects: objects to print - :param sep: string to write between print data. Defaults to " ". - :param end: string to write at end of print data. Defaults to a newline. + :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. If True, lines of text will not be word-wrapped or cropped to fit the terminal width. Defaults to True. diff --git a/cmd2/rich_utils.py b/cmd2/rich_utils.py index dfef892f7..d4a716341 100644 --- a/cmd2/rich_utils.py +++ b/cmd2/rich_utils.py @@ -1,7 +1,10 @@ """Provides common utilities to support Rich in cmd2-based applications.""" import re -from collections.abc import Mapping +from collections.abc import ( + Iterable, + Mapping, +) from enum import Enum from typing import ( IO, @@ -18,6 +21,7 @@ ) from rich.padding import Padding from rich.protocol import rich_cast +from rich.segment import Segment from rich.style import StyleType from rich.table import ( Column, @@ -258,47 +262,6 @@ def rich_text_to_string(text: Text) -> str: return capture.get() -# If True, Rich still has the bug addressed in string_to_rich_text(). -_from_ansi_has_newline_bug = Text.from_ansi("\n").plain == "" - - -def string_to_rich_text(text: str) -> Text: - r"""Create a Rich Text object from a string which can contain ANSI style sequences. - - This wraps rich.Text.from_ansi() to handle an issue where it removes the - trailing line break from a string (e.g. "Hello\n" becomes "Hello"). - - There is currently a pull request to fix this. - https://github.com/Textualize/rich/pull/3793 - - :param text: a string to convert to a Text object. - :return: the converted string - """ - result = Text.from_ansi(text) - - if _from_ansi_has_newline_bug: - # If the original string ends with a recognized line break character, - # then restore the missing newline. We use "\n" because Text.from_ansi() - # converts all line breaks into newlines. - # Source: https://docs.python.org/3/library/stdtypes.html#str.splitlines - line_break_chars = { - "\n", # Line Feed - "\r", # Carriage Return - "\v", # Vertical Tab - "\f", # Form Feed - "\x1c", # File Separator - "\x1d", # Group Separator - "\x1e", # Record Separator - "\x85", # Next Line (NEL) - "\u2028", # Line Separator - "\u2029", # Paragraph Separator - } - if text and text[-1] in line_break_chars: - result.append("\n") - - return result - - def indent(renderable: RenderableType, level: int) -> Padding: """Indent a Rich renderable. @@ -350,6 +313,136 @@ def prepare_objects_for_rendering(*objects: Any) -> tuple[Any, ...]: # Check for any ANSI style sequences in the string. if ANSI_STYLE_SEQUENCE_RE.search(renderable_as_str): - object_list[i] = string_to_rich_text(renderable_as_str) + object_list[i] = Text.from_ansi(renderable_as_str) return tuple(object_list) + + +################################################################################### +# Rich Library Monkey Patches +# +# These patches fix specific bugs in the Rich library. They are conditional and +# will only be applied if the bug is detected. When the bugs are fixed in a +# future Rich release, these patches and their corresponding tests should be +# removed. +################################################################################### + +################################################################################### +# Text.from_ansi() monkey patch +################################################################################### + +# Save original Text.from_ansi() so we can call it in our wrapper +_orig_text_from_ansi = Text.from_ansi + + +@classmethod # type: ignore[misc] +def _from_ansi_wrapper(cls: type[Text], text: str, *args: Any, **kwargs: Any) -> Text: # noqa: ARG001 + r"""Wrap Text.from_ansi() to fix its trailing newline bug. + + This wrapper handles an issue where Text.from_ansi() removes the + trailing line break from a string (e.g. "Hello\n" becomes "Hello"). + + There is currently a pull request on Rich to fix this. + https://github.com/Textualize/rich/pull/3793 + """ + result = _orig_text_from_ansi(text, *args, **kwargs) + + # If the original string ends with a recognized line break character, + # then restore the missing newline. We use "\n" because Text.from_ansi() + # converts all line breaks into newlines. + # Source: https://docs.python.org/3/library/stdtypes.html#str.splitlines + line_break_chars = { + "\n", # Line Feed + "\r", # Carriage Return + "\v", # Vertical Tab + "\f", # Form Feed + "\x1c", # File Separator + "\x1d", # Group Separator + "\x1e", # Record Separator + "\x85", # Next Line (NEL) + "\u2028", # Line Separator + "\u2029", # Paragraph Separator + } + if text and text[-1] in line_break_chars: + result.append("\n") + + return result + + +def _from_ansi_has_newline_bug() -> bool: + """Check if Test.from_ansi() strips the trailing line break from a string.""" + return Text.from_ansi("\n") == Text.from_ansi("") + + +# Only apply the monkey patch if the bug is present +if _from_ansi_has_newline_bug(): + Text.from_ansi = _from_ansi_wrapper # type: ignore[assignment] + + +################################################################################### +# Segment.apply_style() monkey patch +################################################################################### + +# Save original Segment.apply_style() so we can call it in our wrapper +_orig_segment_apply_style = Segment.apply_style + + +@classmethod # type: ignore[misc] +def _apply_style_wrapper(cls: type[Segment], *args: Any, **kwargs: Any) -> Iterable["Segment"]: + r"""Wrap Segment.apply_style() to fix bug with styling newlines. + + This wrapper handles an issue where Segment.apply_style() includes newlines + within styled Segments. As a result, when printing text using a background color + and soft wrapping, the background color incorrectly carries over onto the following line. + + You can reproduce this behavior by calling console.print() using a background color + and soft wrapping. + + For example: + console.print("line_1", style="blue on white", soft_wrap=True) + + When soft wrapping is disabled, console.print() splits Segments into their individual + lines, which separates the newlines from the styled text. Therefore, the background color + issue does not occur in that mode. + + This function copies that behavior to fix this the issue even when soft wrapping is enabled. + + There is currently a pull request on Rich to fix this. + https://github.com/Textualize/rich/pull/3839 + """ + styled_segments = list(_orig_segment_apply_style(*args, **kwargs)) + newline_segment = cls.line() + + # If the final segment is a newline, it will be stripped by Segment.split_lines(). + # Save an unstyled newline to restore later. + end_segment = newline_segment if styled_segments and styled_segments[-1].text == "\n" else None + + # Use Segment.split_lines() to separate the styled text from the newlines. + # This way the ANSI reset code will appear before any newline. + sanitized_segments: list[Segment] = [] + + lines = list(Segment.split_lines(styled_segments)) + for index, line in enumerate(lines): + sanitized_segments.extend(line) + if index < len(lines) - 1: + sanitized_segments.append(newline_segment) + + if end_segment is not None: + sanitized_segments.append(end_segment) + + return sanitized_segments + + +def _rich_has_styled_newline_bug() -> bool: + """Check if newlines are styled when soft wrapping.""" + console = Console(force_terminal=True) + with console.capture() as capture: + console.print("line_1", style="blue on white", soft_wrap=True) + + # Check if we see a styled newline in the output + return "\x1b[34;47m\n\x1b[0m" in capture.get() + + +# Only apply the monkey patch if the bug is present +if _rich_has_styled_newline_bug(): + Segment.apply_style = _apply_style_wrapper # type: ignore[assignment] diff --git a/cmd2/string_utils.py b/cmd2/string_utils.py index 9b9d590c7..384dcc2a0 100644 --- a/cmd2/string_utils.py +++ b/cmd2/string_utils.py @@ -7,6 +7,7 @@ from rich.align import AlignMethod from rich.style import StyleType +from rich.text import Text from . import rich_utils as ru @@ -30,7 +31,7 @@ def align( if width is None: width = ru.console_width() - text = ru.string_to_rich_text(val) + text = Text.from_ansi(val) text.align(align, width=width, character=character) return ru.rich_text_to_string(text) @@ -88,7 +89,7 @@ def stylize(val: str, style: StyleType) -> str: :return: the stylized string """ # Convert to a Rich Text object to parse and preserve existing ANSI styles. - text = ru.string_to_rich_text(val) + text = Text.from_ansi(val) text.stylize(style) return ru.rich_text_to_string(text) @@ -111,7 +112,7 @@ def str_width(val: str) -> int: :param val: the string being measured :return: width of the string when printed to the terminal """ - text = ru.string_to_rich_text(val) + text = Text.from_ansi(val) return text.cell_len diff --git a/examples/rich_theme.py b/examples/rich_theme.py index 03d8ff2a0..67914e33f 100755 --- a/examples/rich_theme.py +++ b/examples/rich_theme.py @@ -23,7 +23,7 @@ def __init__(self, *args, **kwargs): # Colors can come from the cmd2.color.Color StrEnum class, be RGB hex values, or # be any of the rich standard colors: https://rich.readthedocs.io/en/stable/appendix/colors.html custom_theme = { - Cmd2Style.SUCCESS: Style(color=Color.GREEN), # Use color from cmd2 Color class + Cmd2Style.SUCCESS: Style(color=Color.GREEN1, bgcolor=Color.GRAY30), # Use color from cmd2 Color class Cmd2Style.WARNING: Style(color=Color.ORANGE1), Cmd2Style.ERROR: Style(color=Color.PINK1), Cmd2Style.HELP_HEADER: Style(color=Color.CYAN, bgcolor="#44475a"), @@ -37,11 +37,10 @@ def __init__(self, *args, **kwargs): @cmd2.with_category("Theme Commands") def do_theme_show(self, _: cmd2.Statement): """Showcases the custom theme by printing messages with different styles.""" - # NOTE: Using soft_wrap=False will ensure display looks correct when background colors are part of the style self.poutput("This is a basic output message.") - self.psuccess("This is a success message.", soft_wrap=False) - self.pwarning("This is a warning message.", soft_wrap=False) - self.perror("This is an error message.", soft_wrap=False) + self.psuccess("This is a success message.") + self.pwarning("This is a warning message.") + self.perror("This is an error message.") self.pexcept(ValueError("This is a dummy ValueError exception.")) diff --git a/tests/conftest.py b/tests/conftest.py index 814d9a48c..3f14ff944 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,10 +2,15 @@ import argparse import sys +from collections.abc import Callable from contextlib import redirect_stderr -from unittest import ( - mock, +from typing import ( + ParamSpec, + TextIO, + TypeVar, + cast, ) +from unittest import mock import pytest @@ -14,6 +19,10 @@ from cmd2.rl_utils import readline from cmd2.utils import StdSim +# For type hinting decorators +P = ParamSpec('P') +T = TypeVar('T') + def verify_help_text(cmd2_app: cmd2.Cmd, help_output: str | list[str], verbose_strings: list[str] | None = None) -> None: """This function verifies that all expected commands are present in the help text. @@ -41,7 +50,7 @@ def verify_help_text(cmd2_app: cmd2.Cmd, help_output: str | list[str], verbose_s """ -def normalize(block): +def normalize(block: str) -> list[str]: """Normalize a block of text to perform comparison. Strip newlines from the very beginning and very end Then split into separate lines and strip trailing whitespace @@ -52,26 +61,26 @@ def normalize(block): return [line.rstrip() for line in block.splitlines()] -def run_cmd(app, cmd): +def run_cmd(app: cmd2.Cmd, cmd: str) -> tuple[list[str], list[str]]: """Clear out and err StdSim buffers, run the command, and return out and err""" # Only capture sys.stdout if it's the same stream as self.stdout stdouts_match = app.stdout == sys.stdout # This will be used to capture app.stdout and sys.stdout - copy_cmd_stdout = StdSim(app.stdout) + copy_cmd_stdout = StdSim(cast(TextIO, app.stdout)) # This will be used to capture sys.stderr copy_stderr = StdSim(sys.stderr) try: - app.stdout = copy_cmd_stdout + app.stdout = cast(TextIO, copy_cmd_stdout) if stdouts_match: sys.stdout = app.stdout - with redirect_stderr(copy_stderr): + with redirect_stderr(cast(TextIO, copy_stderr)): app.onecmd_plus_hooks(cmd) finally: - app.stdout = copy_cmd_stdout.inner_stream + app.stdout = cast(TextIO, copy_cmd_stdout.inner_stream) if stdouts_match: sys.stdout = app.stdout @@ -81,16 +90,16 @@ def run_cmd(app, cmd): @pytest.fixture -def base_app(): +def base_app() -> cmd2.Cmd: return cmd2.Cmd(include_py=True, include_ipy=True) -def with_ansi_style(style): - def arg_decorator(func): +def with_ansi_style(style: ru.AllowStyle) -> Callable[[Callable[P, T]], Callable[P, T]]: + def arg_decorator(func: Callable[P, T]) -> Callable[P, T]: import functools @functools.wraps(func) - def cmd_wrapper(*args, **kwargs): + def cmd_wrapper(*args: P.args, **kwargs: P.kwargs) -> T: old = ru.ALLOW_STYLE ru.ALLOW_STYLE = style try: @@ -108,7 +117,7 @@ def cmd_wrapper(*args, **kwargs): odd_file_names = ['nothingweird', 'has spaces', '"is_double_quoted"', "'is_single_quoted'"] -def complete_tester(text: str, line: str, begidx: int, endidx: int, app) -> str | None: +def complete_tester(text: str, line: str, begidx: int, endidx: int, app: cmd2.Cmd) -> str | None: """This is a convenience function to test cmd2.complete() since in a unit test environment there is no actual console readline is monitoring. Therefore we use mock to provide readline data @@ -124,13 +133,13 @@ def complete_tester(text: str, line: str, begidx: int, endidx: int, app) -> str These matches also have been sorted by complete() """ - def get_line(): + def get_line() -> str: return line - def get_begidx(): + def get_begidx() -> int: return begidx - def get_endidx(): + def get_endidx() -> int: return endidx # Run the readline tab completion function with readline mocks in place diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index abe578d8b..efbf7a4b9 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -224,11 +224,9 @@ def test_set_no_settables(base_app) -> None: ('invalid', False, ru.AllowStyle.TERMINAL), ], ) +@with_ansi_style(ru.AllowStyle.TERMINAL) def test_set_allow_style(base_app, new_val, is_valid, expected) -> None: - # Initialize allow_style for this test - ru.ALLOW_STYLE = ru.AllowStyle.TERMINAL - - # Use the set command to alter it + # Use the set command to alter allow_style out, err = run_cmd(base_app, f'set allow_style {new_val}') assert base_app.last_result is is_valid @@ -238,9 +236,6 @@ def test_set_allow_style(base_app, new_val, is_valid, expected) -> None: assert not err assert out - # Reset allow_style to its default since it's an application-wide setting that can affect other unit tests - ru.ALLOW_STYLE = ru.AllowStyle.TERMINAL - def test_set_with_choices(base_app) -> None: """Test choices validation of Settables""" @@ -2673,7 +2668,7 @@ def test_perror_style(base_app, capsys) -> None: msg = 'testing...' base_app.perror(msg) out, err = capsys.readouterr() - assert err == "\x1b[91mtesting...\x1b[0m\x1b[91m\n\x1b[0m" + assert err == "\x1b[91mtesting...\x1b[0m\n" @with_ansi_style(ru.AllowStyle.ALWAYS) diff --git a/tests/test_rich_utils.py b/tests/test_rich_utils.py index f471d7d58..ee87ef83f 100644 --- a/tests/test_rich_utils.py +++ b/tests/test_rich_utils.py @@ -2,6 +2,8 @@ import pytest import rich.box +from rich.console import Console +from rich.segment import Segment from rich.style import Style from rich.table import Table from rich.text import Text @@ -11,7 +13,6 @@ Color, ) from cmd2 import rich_utils as ru -from cmd2 import string_utils as su def test_cmd2_base_console() -> None: @@ -29,38 +30,6 @@ def test_cmd2_base_console() -> None: assert 'theme' in str(excinfo.value) -def test_string_to_rich_text() -> None: - # Line breaks recognized by str.splitlines(). - # Source: https://docs.python.org/3/library/stdtypes.html#str.splitlines - line_breaks = { - "\n", # Line Feed - "\r", # Carriage Return - "\r\n", # Carriage Return + Line Feed - "\v", # Vertical Tab - "\f", # Form Feed - "\x1c", # File Separator - "\x1d", # Group Separator - "\x1e", # Record Separator - "\x85", # Next Line (NEL) - "\u2028", # Line Separator - "\u2029", # Paragraph Separator - } - - # Test all line breaks - for lb in line_breaks: - input_string = f"Text{lb}" - expected_output = input_string.replace(lb, "\n") - assert ru.string_to_rich_text(input_string).plain == expected_output - - # Test string without trailing line break - input_string = "No trailing\nline break" - assert ru.string_to_rich_text(input_string).plain == input_string - - # Test empty string - input_string = "" - assert ru.string_to_rich_text(input_string).plain == input_string - - def test_indented_text() -> None: console = ru.Cmd2GeneralConsole() @@ -105,7 +74,7 @@ def test_indented_table() -> None: [ (Text("Hello"), "Hello"), (Text("Hello\n"), "Hello\n"), - (Text("Hello", style="blue"), su.stylize("Hello", style="blue")), + (Text("Hello", style="blue"), "\x1b[34mHello\x1b[0m"), ], ) def test_rich_text_to_string(rich_text: Text, string: str) -> None: @@ -139,3 +108,171 @@ def test_set_theme() -> None: assert ru.APP_THEME.styles[rich_style_key] != orig_rich_style assert ru.APP_THEME.styles[rich_style_key] == theme[rich_style_key] + + +def test_from_ansi_wrapper() -> None: + # Check if we are still patching Text.from_ansi(). If this check fails, then Rich + # has fixed the bug. Therefore, we can remove this test function and ru._from_ansi_wrapper. + assert Text.from_ansi.__func__ is ru._from_ansi_wrapper.__func__ # type: ignore[attr-defined] + + # Line breaks recognized by str.splitlines(). + # Source: https://docs.python.org/3/library/stdtypes.html#str.splitlines + line_breaks = { + "\n", # Line Feed + "\r", # Carriage Return + "\r\n", # Carriage Return + Line Feed + "\v", # Vertical Tab + "\f", # Form Feed + "\x1c", # File Separator + "\x1d", # Group Separator + "\x1e", # Record Separator + "\x85", # Next Line (NEL) + "\u2028", # Line Separator + "\u2029", # Paragraph Separator + } + + # Test all line breaks + for lb in line_breaks: + input_string = f"Text{lb}" + expected_output = input_string.replace(lb, "\n") + assert Text.from_ansi(input_string).plain == expected_output + + # Test string without trailing line break + input_string = "No trailing\nline break" + assert Text.from_ansi(input_string).plain == input_string + + # Test empty string + input_string = "" + assert Text.from_ansi(input_string).plain == input_string + + +@pytest.mark.parametrize( + # Print with style and verify that everything but newline characters have style. + ('objects', 'expected', 'sep', 'end'), + [ + # Print nothing + ((), "\n", " ", "\n"), + # Empty string + (("",), "\n", " ", "\n"), + # Multple empty strings + (("", ""), '\x1b[34;47m \x1b[0m\n', " ", "\n"), + # Basic string + ( + ("str_1",), + "\x1b[34;47mstr_1\x1b[0m\n", + " ", + "\n", + ), + # String which ends with newline + ( + ("str_1\n",), + "\x1b[34;47mstr_1\x1b[0m\n\n", + " ", + "\n", + ), + # String which ends with multiple newlines + ( + ("str_1\n\n",), + "\x1b[34;47mstr_1\x1b[0m\n\n\n", + " ", + "\n", + ), + # Mutiple lines + ( + ("str_1\nstr_2",), + "\x1b[34;47mstr_1\x1b[0m\n\x1b[34;47mstr_2\x1b[0m\n", + " ", + "\n", + ), + # Multiple strings + ( + ("str_1", "str_2"), + "\x1b[34;47mstr_1 str_2\x1b[0m\n", + " ", + "\n", + ), + # Multiple strings with newline between them. + ( + ("str_1\n", "str_2"), + "\x1b[34;47mstr_1\x1b[0m\n\x1b[34;47m str_2\x1b[0m\n", + " ", + "\n", + ), + # Multiple strings and non-space value for sep + ( + ("str_1", "str_2"), + "\x1b[34;47mstr_1(sep)str_2\x1b[0m\n", + "(sep)", + "\n", + ), + # Multiple strings and sep is a newline + ( + ("str_1", "str_2"), + "\x1b[34;47mstr_1\x1b[0m\n\x1b[34;47mstr_2\x1b[0m\n", + "\n", + "\n", + ), + # Multiple strings and sep has newlines + ( + ("str_1", "str_2"), + "\x1b[34;47mstr_1(sep1)\x1b[0m\n\x1b[34;47m(sep2)str_2\x1b[0m\n", + "(sep1)\n(sep2)", + "\n", + ), + # Non-newline value for end. + ( + ("str_1", "str_2"), + "\x1b[34;47mstr_1(sep1)\x1b[0m\n\x1b[34;47m(sep2)str_2\x1b[0m\x1b[34;47m(end)\x1b[0m", + "(sep1)\n(sep2)", + "(end)", + ), + # end has newlines. + ( + ("str_1", "str_2"), + "\x1b[34;47mstr_1(sep1)\x1b[0m\n\x1b[34;47m(sep2)str_2\x1b[0m\x1b[34;47m(end1)\x1b[0m\n\x1b[34;47m(end2)\x1b[0m", + "(sep1)\n(sep2)", + "(end1)\n(end2)", + ), + # Empty sep and end values + ( + ("str_1", "str_2"), + "\x1b[34;47mstr_1str_2\x1b[0m", + "", + "", + ), + ], +) +def test_apply_style_wrapper(objects: tuple[str], expected: str, sep: str, end: str) -> None: + # Check if we are still patching Segment.apply_style(). If this check fails, then Rich + # has fixed the bug. Therefore, we can remove this test function and ru._apply_style_wrapper. + assert Segment.apply_style.__func__ is ru._apply_style_wrapper.__func__ # type: ignore[attr-defined] + + console = Console(force_terminal=True) + + try: + # Since our patch was meant to fix behavior seen when soft wrapping, + # we will first test in that condition. + with console.capture() as capture: + console.print(*objects, sep=sep, end=end, style="blue on white", soft_wrap=True) + result = capture.get() + assert result == expected + + # Now print with soft wrapping disabled. Since none of our input strings are long enough + # to auto wrap, the results should be the same as our soft-wrapping output. + with console.capture() as capture: + console.print(*objects, sep=sep, end=end, style="blue on white", soft_wrap=False) + result = capture.get() + assert result == expected + + # Now remove our patch and disable soft wrapping. This will prove that our patch produces + # the same result as unpatched Rich + Segment.apply_style = ru._orig_segment_apply_style # type: ignore[assignment] + + with console.capture() as capture: + console.print(*objects, sep=sep, end=end, style="blue on white", soft_wrap=False) + result = capture.get() + assert result == expected + + finally: + # Restore the patch + Segment.apply_style = ru._apply_style_wrapper # type: ignore[assignment]