Skip to content

Commit f966e51

Browse files
committed
Removed rich_utils.string_to_rich_text() in favor of monkey patching
Rich's Text.from_ansi(). Now a dev isn't required to call a cmd2-specific function to get the bug fix. Monkey patched Rich's Segment.apply_style() to fix an issue where background colors incorrectly carry over onto the following line when printing with soft_wrap enabled.
1 parent d60e1a8 commit f966e51

File tree

7 files changed

+342
-111
lines changed

7 files changed

+342
-111
lines changed

cmd2/cmd2.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1205,8 +1205,8 @@ def print_to(
12051205
12061206
:param file: file stream being written to
12071207
:param objects: objects to print
1208-
:param sep: string to write between print data. Defaults to " ".
1209-
:param end: string to write at end of print data. Defaults to a newline.
1208+
:param sep: string to write between printed text. Defaults to " ".
1209+
:param end: string to write at end of printed text. Defaults to a newline.
12101210
:param style: optional style to apply to output
12111211
:param soft_wrap: Enable soft wrap mode. If True, lines of text will not be word-wrapped or cropped to
12121212
fit the terminal width. Defaults to True.

cmd2/rich_utils.py

Lines changed: 133 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
"""Provides common utilities to support Rich in cmd2-based applications."""
22

33
import re
4-
from collections.abc import Mapping
4+
from collections.abc import (
5+
Iterable,
6+
Mapping,
7+
)
58
from enum import Enum
69
from typing import (
710
IO,
@@ -18,6 +21,7 @@
1821
)
1922
from rich.padding import Padding
2023
from rich.protocol import rich_cast
24+
from rich.segment import Segment
2125
from rich.style import StyleType
2226
from rich.table import (
2327
Column,
@@ -258,47 +262,6 @@ def rich_text_to_string(text: Text) -> str:
258262
return capture.get()
259263

260264

261-
# If True, Rich still has the bug addressed in string_to_rich_text().
262-
_from_ansi_has_newline_bug = Text.from_ansi("\n").plain == ""
263-
264-
265-
def string_to_rich_text(text: str) -> Text:
266-
r"""Create a Rich Text object from a string which can contain ANSI style sequences.
267-
268-
This wraps rich.Text.from_ansi() to handle an issue where it removes the
269-
trailing line break from a string (e.g. "Hello\n" becomes "Hello").
270-
271-
There is currently a pull request to fix this.
272-
https://github.com/Textualize/rich/pull/3793
273-
274-
:param text: a string to convert to a Text object.
275-
:return: the converted string
276-
"""
277-
result = Text.from_ansi(text)
278-
279-
if _from_ansi_has_newline_bug:
280-
# If the original string ends with a recognized line break character,
281-
# then restore the missing newline. We use "\n" because Text.from_ansi()
282-
# converts all line breaks into newlines.
283-
# Source: https://docs.python.org/3/library/stdtypes.html#str.splitlines
284-
line_break_chars = {
285-
"\n", # Line Feed
286-
"\r", # Carriage Return
287-
"\v", # Vertical Tab
288-
"\f", # Form Feed
289-
"\x1c", # File Separator
290-
"\x1d", # Group Separator
291-
"\x1e", # Record Separator
292-
"\x85", # Next Line (NEL)
293-
"\u2028", # Line Separator
294-
"\u2029", # Paragraph Separator
295-
}
296-
if text and text[-1] in line_break_chars:
297-
result.append("\n")
298-
299-
return result
300-
301-
302265
def indent(renderable: RenderableType, level: int) -> Padding:
303266
"""Indent a Rich renderable.
304267
@@ -350,6 +313,133 @@ def prepare_objects_for_rendering(*objects: Any) -> tuple[Any, ...]:
350313

351314
# Check for any ANSI style sequences in the string.
352315
if ANSI_STYLE_SEQUENCE_RE.search(renderable_as_str):
353-
object_list[i] = string_to_rich_text(renderable_as_str)
316+
object_list[i] = Text.from_ansi(renderable_as_str)
354317

355318
return tuple(object_list)
319+
320+
321+
###################################################################################
322+
# Rich Library Monkey Patches
323+
#
324+
# These patches fix specific bugs in the Rich library. They are conditional and
325+
# will only be applied if the bug is detected. When the bugs are fixed in a
326+
# future Rich release, these patches and their corresponding tests should be
327+
# removed.
328+
###################################################################################
329+
330+
###################################################################################
331+
# Text.from_ansi() monkey patch
332+
###################################################################################
333+
334+
# Save original Text.from_ansi() so we can call it in our wrapper
335+
_orig_text_from_ansi = Text.from_ansi
336+
337+
338+
@classmethod # type: ignore[misc]
339+
def _from_ansi_wrapper(cls: type[Text], text: str, *args: Any, **kwargs: Any) -> Text: # noqa: ARG001
340+
r"""Wrap Text.from_ansi() to fix its trailing newline bug.
341+
342+
This wrapper handles an issue where Text.from_ansi() removes the
343+
trailing line break from a string (e.g. "Hello\n" becomes "Hello").
344+
345+
There is currently a pull request to fix this.
346+
https://github.com/Textualize/rich/pull/3793
347+
"""
348+
result = _orig_text_from_ansi(text, *args, **kwargs)
349+
350+
# If the original string ends with a recognized line break character,
351+
# then restore the missing newline. We use "\n" because Text.from_ansi()
352+
# converts all line breaks into newlines.
353+
# Source: https://docs.python.org/3/library/stdtypes.html#str.splitlines
354+
line_break_chars = {
355+
"\n", # Line Feed
356+
"\r", # Carriage Return
357+
"\v", # Vertical Tab
358+
"\f", # Form Feed
359+
"\x1c", # File Separator
360+
"\x1d", # Group Separator
361+
"\x1e", # Record Separator
362+
"\x85", # Next Line (NEL)
363+
"\u2028", # Line Separator
364+
"\u2029", # Paragraph Separator
365+
}
366+
if text and text[-1] in line_break_chars:
367+
result.append("\n")
368+
369+
return result
370+
371+
372+
def _from_ansi_has_newline_bug() -> bool:
373+
"""Check if Test.from_ansi() strips the trailing line break from a string."""
374+
return Text.from_ansi("\n") == Text.from_ansi("")
375+
376+
377+
# Only apply the monkey patch if the bug is present
378+
if _from_ansi_has_newline_bug():
379+
Text.from_ansi = _from_ansi_wrapper # type: ignore[assignment]
380+
381+
382+
###################################################################################
383+
# Segment.apply_style() monkey patch
384+
###################################################################################
385+
386+
# Save original Segment.apply_style() so we can call it in our wrapper
387+
_orig_segment_apply_style = Segment.apply_style
388+
389+
390+
@classmethod # type: ignore[misc]
391+
def _apply_style_wrapper(cls: type[Segment], *args: Any, **kwargs: Any) -> Iterable["Segment"]:
392+
r"""Wrap Segment.apply_style() to fix bug with styling newlines.
393+
394+
This wrapper handles an issue where Segment.apply_style() includes newlines
395+
within styled Segments. As a result, when printing text using a background color
396+
and soft wrapping, the background color incorrectly carries over onto the following line.
397+
398+
You can reproduce this behavior by calling console.print() using a background color
399+
and soft wrapping.
400+
401+
For example:
402+
console.print("line_1", style="blue on white", soft_wrap=True)
403+
404+
When soft wrapping is disabled, console.print() splits Segments into their individual
405+
lines, which separates the newlines from the styled text. Therefore, the background color
406+
issue does not occur in that mode.
407+
408+
This function copies that behavior to fix this the issue even when soft wrapping is enabled.
409+
"""
410+
styled_segments = list(_orig_segment_apply_style(*args, **kwargs))
411+
newline_segment = cls.line()
412+
413+
# If the final segment is a newline, it will be stripped by Segment.split_lines().
414+
# Save an unstyled newline to restore later.
415+
end_segment = newline_segment if styled_segments and styled_segments[-1].text == "\n" else None
416+
417+
# Use Segment.split_lines() to separate the styled text from the newlines.
418+
# This way the ANSI reset code will appear before any newline.
419+
sanitized_segments: list[Segment] = []
420+
421+
lines = list(Segment.split_lines(styled_segments))
422+
for index, line in enumerate(lines):
423+
sanitized_segments.extend(line)
424+
if index < len(lines) - 1:
425+
sanitized_segments.append(newline_segment)
426+
427+
if end_segment is not None:
428+
sanitized_segments.append(end_segment)
429+
430+
return sanitized_segments
431+
432+
433+
def _rich_has_styled_newline_bug() -> bool:
434+
"""Check if newlines are styled when soft wrapping."""
435+
console = Console(force_terminal=True)
436+
with console.capture() as capture:
437+
console.print("line_1", style="blue on white", soft_wrap=True)
438+
439+
# Check if we see a styled newline in the output
440+
return "\x1b[34;47m\n\x1b[0m" in capture.get()
441+
442+
443+
# Only apply the monkey patch if the bug is present
444+
if _rich_has_styled_newline_bug():
445+
Segment.apply_style = _apply_style_wrapper # type: ignore[assignment]

cmd2/string_utils.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
from rich.align import AlignMethod
99
from rich.style import StyleType
10+
from rich.text import Text
1011

1112
from . import rich_utils as ru
1213

@@ -30,7 +31,7 @@ def align(
3031
if width is None:
3132
width = ru.console_width()
3233

33-
text = ru.string_to_rich_text(val)
34+
text = Text.from_ansi(val)
3435
text.align(align, width=width, character=character)
3536
return ru.rich_text_to_string(text)
3637

@@ -88,7 +89,7 @@ def stylize(val: str, style: StyleType) -> str:
8889
:return: the stylized string
8990
"""
9091
# Convert to a Rich Text object to parse and preserve existing ANSI styles.
91-
text = ru.string_to_rich_text(val)
92+
text = Text.from_ansi(val)
9293
text.stylize(style)
9394
return ru.rich_text_to_string(text)
9495

@@ -111,7 +112,7 @@ def str_width(val: str) -> int:
111112
:param val: the string being measured
112113
:return: width of the string when printed to the terminal
113114
"""
114-
text = ru.string_to_rich_text(val)
115+
text = Text.from_ansi(val)
115116
return text.cell_len
116117

117118

examples/rich_theme.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ def __init__(self, *args, **kwargs):
2323
# Colors can come from the cmd2.color.Color StrEnum class, be RGB hex values, or
2424
# be any of the rich standard colors: https://rich.readthedocs.io/en/stable/appendix/colors.html
2525
custom_theme = {
26-
Cmd2Style.SUCCESS: Style(color=Color.GREEN), # Use color from cmd2 Color class
26+
Cmd2Style.SUCCESS: Style(color=Color.GREEN1, bgcolor=Color.GRAY30), # Use color from cmd2 Color class
2727
Cmd2Style.WARNING: Style(color=Color.ORANGE1),
2828
Cmd2Style.ERROR: Style(color=Color.PINK1),
2929
Cmd2Style.HELP_HEADER: Style(color=Color.CYAN, bgcolor="#44475a"),
@@ -37,11 +37,10 @@ def __init__(self, *args, **kwargs):
3737
@cmd2.with_category("Theme Commands")
3838
def do_theme_show(self, _: cmd2.Statement):
3939
"""Showcases the custom theme by printing messages with different styles."""
40-
# NOTE: Using soft_wrap=False will ensure display looks correct when background colors are part of the style
4140
self.poutput("This is a basic output message.")
42-
self.psuccess("This is a success message.", soft_wrap=False)
43-
self.pwarning("This is a warning message.", soft_wrap=False)
44-
self.perror("This is an error message.", soft_wrap=False)
41+
self.psuccess("This is a success message.")
42+
self.pwarning("This is a warning message.")
43+
self.perror("This is an error message.")
4544
self.pexcept(ValueError("This is a dummy ValueError exception."))
4645

4746

tests/conftest.py

Lines changed: 25 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,15 @@
22

33
import argparse
44
import sys
5+
from collections.abc import Callable
56
from contextlib import redirect_stderr
6-
from unittest import (
7-
mock,
7+
from typing import (
8+
ParamSpec,
9+
TextIO,
10+
TypeVar,
11+
cast,
812
)
13+
from unittest import mock
914

1015
import pytest
1116

@@ -14,6 +19,10 @@
1419
from cmd2.rl_utils import readline
1520
from cmd2.utils import StdSim
1621

22+
# For type hinting decorators
23+
P = ParamSpec('P')
24+
T = TypeVar('T')
25+
1726

1827
def verify_help_text(cmd2_app: cmd2.Cmd, help_output: str | list[str], verbose_strings: list[str] | None = None) -> None:
1928
"""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
4150
"""
4251

4352

44-
def normalize(block):
53+
def normalize(block: str) -> list[str]:
4554
"""Normalize a block of text to perform comparison.
4655
4756
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):
5261
return [line.rstrip() for line in block.splitlines()]
5362

5463

55-
def run_cmd(app, cmd):
64+
def run_cmd(app: cmd2.Cmd, cmd: str) -> tuple[list[str], list[str]]:
5665
"""Clear out and err StdSim buffers, run the command, and return out and err"""
5766

5867
# Only capture sys.stdout if it's the same stream as self.stdout
5968
stdouts_match = app.stdout == sys.stdout
6069

6170
# This will be used to capture app.stdout and sys.stdout
62-
copy_cmd_stdout = StdSim(app.stdout)
71+
copy_cmd_stdout = StdSim(cast(TextIO, app.stdout))
6372

6473
# This will be used to capture sys.stderr
6574
copy_stderr = StdSim(sys.stderr)
6675

6776
try:
68-
app.stdout = copy_cmd_stdout
77+
app.stdout = cast(TextIO, copy_cmd_stdout)
6978
if stdouts_match:
7079
sys.stdout = app.stdout
71-
with redirect_stderr(copy_stderr):
80+
with redirect_stderr(cast(TextIO, copy_stderr)):
7281
app.onecmd_plus_hooks(cmd)
7382
finally:
74-
app.stdout = copy_cmd_stdout.inner_stream
83+
app.stdout = cast(TextIO, copy_cmd_stdout.inner_stream)
7584
if stdouts_match:
7685
sys.stdout = app.stdout
7786

@@ -81,16 +90,16 @@ def run_cmd(app, cmd):
8190

8291

8392
@pytest.fixture
84-
def base_app():
93+
def base_app() -> cmd2.Cmd:
8594
return cmd2.Cmd(include_py=True, include_ipy=True)
8695

8796

88-
def with_ansi_style(style):
89-
def arg_decorator(func):
97+
def with_ansi_style(style: ru.AllowStyle) -> Callable[[Callable[P, T]], Callable[P, T]]:
98+
def arg_decorator(func: Callable[P, T]) -> Callable[P, T]:
9099
import functools
91100

92101
@functools.wraps(func)
93-
def cmd_wrapper(*args, **kwargs):
102+
def cmd_wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
94103
old = ru.ALLOW_STYLE
95104
ru.ALLOW_STYLE = style
96105
try:
@@ -108,7 +117,7 @@ def cmd_wrapper(*args, **kwargs):
108117
odd_file_names = ['nothingweird', 'has spaces', '"is_double_quoted"', "'is_single_quoted'"]
109118

110119

111-
def complete_tester(text: str, line: str, begidx: int, endidx: int, app) -> str | None:
120+
def complete_tester(text: str, line: str, begidx: int, endidx: int, app: cmd2.Cmd) -> str | None:
112121
"""This is a convenience function to test cmd2.complete() since
113122
in a unit test environment there is no actual console readline
114123
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
124133
These matches also have been sorted by complete()
125134
"""
126135

127-
def get_line():
136+
def get_line() -> str:
128137
return line
129138

130-
def get_begidx():
139+
def get_begidx() -> int:
131140
return begidx
132141

133-
def get_endidx():
142+
def get_endidx() -> int:
134143
return endidx
135144

136145
# Run the readline tab completion function with readline mocks in place

0 commit comments

Comments
 (0)