From aa2dbb5637409c8f15387be39d1168eda47dbd54 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Sat, 24 Jan 2026 09:52:24 -0500 Subject: [PATCH 1/2] Removed monkeypatch for Rich's Segment.apply_style() since they fixed the bug in 14.3.0. Updated minimum Rich dependency to this version. --- .github/CONTRIBUTING.md | 2 +- cmd2/rich_utils.py | 75 +--------------- pyproject.toml | 2 +- tests/test_rich_utils.py | 187 --------------------------------------- 4 files changed, 3 insertions(+), 263 deletions(-) diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index e2524d74..55cbfd6c 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -65,7 +65,7 @@ See the `dependencies` list under the `[project]` heading in [pyproject.toml](.. | ---------------------------------------------------------- | --------------- | ------------------------------------------------------ | | [python](https://www.python.org/downloads/) | `3.10` | Python programming language | | [pyperclip](https://github.com/asweigart/pyperclip) | `1.8` | Cross-platform clipboard functions | -| [rich](https://github.com/Textualize/rich) | `14.1.0` | Add rich text and beautiful formatting in the terminal | +| [rich](https://github.com/Textualize/rich) | `14.3.0` | Add rich text and beautiful formatting in the terminal | | [rich-argparse](https://github.com/hamdanal/rich-argparse) | `1.7.1` | A rich-enabled help formatter for argparse | > `macOS` and `Windows` each have an extra dependency to ensure they have a viable alternative to diff --git a/cmd2/rich_utils.py b/cmd2/rich_utils.py index e5a52db5..cf921029 100644 --- a/cmd2/rich_utils.py +++ b/cmd2/rich_utils.py @@ -1,10 +1,7 @@ """Provides common utilities to support Rich in cmd2-based applications.""" import re -from collections.abc import ( - Iterable, - Mapping, -) +from collections.abc import Mapping from enum import Enum from typing import ( IO, @@ -22,7 +19,6 @@ from rich.padding import Padding from rich.pretty import is_expandable from rich.protocol import rich_cast -from rich.segment import Segment from rich.style import StyleType from rich.table import ( Column, @@ -380,72 +376,3 @@ def _from_ansi_has_newline_bug() -> bool: # 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 ends in a newline, that newline 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.endswith("\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/pyproject.toml b/pyproject.toml index 06c01696..d65c789d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,7 @@ dependencies = [ "gnureadline>=8; platform_system == 'Darwin'", "pyperclip>=1.8.2", "pyreadline3>=3.4; platform_system == 'Windows'", - "rich>=14.1.0", + "rich>=14.3.0", "rich-argparse>=1.7.1", ] diff --git a/tests/test_rich_utils.py b/tests/test_rich_utils.py index dad300bf..9e0435b8 100644 --- a/tests/test_rich_utils.py +++ b/tests/test_rich_utils.py @@ -3,7 +3,6 @@ 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 @@ -143,189 +142,3 @@ def test_from_ansi_wrapper() -> None: # 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', 'sep', 'end', 'expected'), - [ - # Print nothing - ((), " ", "\n", "\n"), - # Empty string - (("",), " ", "\n", "\n"), - # Multple empty strings - (("", ""), " ", "\n", "\x1b[34;47m \x1b[0m\n"), - # Basic string - ( - ("str_1",), - " ", - "\n", - "\x1b[34;47mstr_1\x1b[0m\n", - ), - # String which ends with newline - ( - ("str_1\n",), - " ", - "\n", - "\x1b[34;47mstr_1\x1b[0m\n\n", - ), - # String which ends with multiple newlines - ( - ("str_1\n\n",), - " ", - "\n", - "\x1b[34;47mstr_1\x1b[0m\n\n\n", - ), - # Mutiple lines - ( - ("str_1\nstr_2",), - " ", - "\n", - "\x1b[34;47mstr_1\x1b[0m\n\x1b[34;47mstr_2\x1b[0m\n", - ), - # Multiple strings - ( - ("str_1", "str_2"), - " ", - "\n", - "\x1b[34;47mstr_1 str_2\x1b[0m\n", - ), - # Multiple strings with newline between them. - ( - ("str_1\n", "str_2"), - " ", - "\n", - "\x1b[34;47mstr_1\x1b[0m\n\x1b[34;47m str_2\x1b[0m\n", - ), - # Multiple strings and non-space value for sep - ( - ("str_1", "str_2"), - "(sep)", - "\n", - "\x1b[34;47mstr_1(sep)str_2\x1b[0m\n", - ), - # Multiple strings and sep is a newline - ( - ("str_1", "str_2"), - "\n", - "\n", - "\x1b[34;47mstr_1\x1b[0m\n\x1b[34;47mstr_2\x1b[0m\n", - ), - # Multiple strings and sep has newlines - ( - ("str_1", "str_2"), - "(sep1)\n(sep2)\n", - "\n", - ("\x1b[34;47mstr_1(sep1)\x1b[0m\n\x1b[34;47m(sep2)\x1b[0m\n\x1b[34;47mstr_2\x1b[0m\n"), - ), - # Non-newline value for end. - ( - ("str_1", "str_2"), - "(sep1)\n(sep2)", - "(end)", - "\x1b[34;47mstr_1(sep1)\x1b[0m\n\x1b[34;47m(sep2)str_2\x1b[0m\x1b[34;47m(end)\x1b[0m", - ), - # end has newlines. - ( - ("str_1", "str_2"), - "(sep1)\n(sep2)\n", - "(end1)\n(end2)\n", - ( - "\x1b[34;47mstr_1(sep1)\x1b[0m\n" - "\x1b[34;47m(sep2)\x1b[0m\n" - "\x1b[34;47mstr_2\x1b[0m\x1b[34;47m(end1)\x1b[0m\n" - "\x1b[34;47m(end2)\x1b[0m\n" - ), - ), - # Empty sep and end values - ( - ("str_1", "str_2"), - "", - "", - "\x1b[34;47mstr_1str_2\x1b[0m", - ), - ], -) -def test_apply_style_wrapper_soft_wrap(objects: tuple[str], sep: str, end: str, expected: 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] - - -def test_apply_style_wrapper_word_wrap() -> None: - """ - Test that our patch didn't mess up word wrapping. - Make sure it does not insert styled newlines or apply style to existing newlines. - """ - # 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] - - str1 = "this\nwill word wrap\n" - str2 = "and\nso will this\n" - sep = "(sep1)\n(sep2)\n" - end = "(end1)\n(end2)\n" - style = "blue on white" - - # All newlines should appear outside of ANSI style sequences. - expected = ( - "\x1b[34;47mthis\x1b[0m\n" - "\x1b[34;47mwill word \x1b[0m\n" - "\x1b[34;47mwrap\x1b[0m\n" - "\x1b[34;47m(sep1)\x1b[0m\n" - "\x1b[34;47m(sep2)\x1b[0m\n" - "\x1b[34;47mand\x1b[0m\n" - "\x1b[34;47mso will \x1b[0m\n" - "\x1b[34;47mthis\x1b[0m\n" - "\x1b[34;47m(end1)\x1b[0m\n" - "\x1b[34;47m(end2)\x1b[0m\n" - ) - - # Set a width which will cause word wrapping. - console = Console(force_terminal=True, width=10) - - try: - with console.capture() as capture: - console.print(str1, str2, sep=sep, end=end, style=style, soft_wrap=False) - assert capture.get() == expected - - # Now remove our patch and make sure it produced the same result as unpatched Rich. - Segment.apply_style = ru._orig_segment_apply_style # type: ignore[assignment] - - with console.capture() as capture: - console.print(str1, str2, sep=sep, end=end, style=style, soft_wrap=False) - assert capture.get() == expected - - finally: - # Restore the patch - Segment.apply_style = ru._apply_style_wrapper # type: ignore[assignment] From 005b93c9cace68006519b2fd8b930c4da85b4056 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Sat, 24 Jan 2026 09:56:17 -0500 Subject: [PATCH 2/2] Removed unused Cmd2Style.EXCEPTION_TYPE style. --- cmd2/styles.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/cmd2/styles.py b/cmd2/styles.py index 56ebb0d7..8370c87a 100644 --- a/cmd2/styles.py +++ b/cmd2/styles.py @@ -51,7 +51,6 @@ class Cmd2Style(StrEnum): COMMAND_LINE = "cmd2.example" # Command line examples in help text ERROR = "cmd2.error" # Error text (used by perror()) - EXCEPTION_TYPE = "cmd2.exception.type" # Used by pexcept to mark an exception type HELP_HEADER = "cmd2.help.header" # Help table header text HELP_LEADER = "cmd2.help.leader" # Text right before the help tables are listed SUCCESS = "cmd2.success" # Success text (used by psuccess()) @@ -63,7 +62,6 @@ class Cmd2Style(StrEnum): DEFAULT_CMD2_STYLES: dict[str, StyleType] = { Cmd2Style.COMMAND_LINE: Style(color=Color.CYAN, bold=True), Cmd2Style.ERROR: Style(color=Color.BRIGHT_RED), - Cmd2Style.EXCEPTION_TYPE: Style(color=Color.DARK_ORANGE, bold=True), Cmd2Style.HELP_HEADER: Style(color=Color.BRIGHT_GREEN, bold=True), Cmd2Style.HELP_LEADER: Style(color=Color.CYAN, bold=True), Cmd2Style.SUCCESS: Style(color=Color.GREEN),