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
4 changes: 2 additions & 2 deletions cmd2/cmd2.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
179 changes: 136 additions & 43 deletions cmd2/rich_utils.py
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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]
7 changes: 4 additions & 3 deletions cmd2/string_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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)

Expand Down Expand Up @@ -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)

Expand All @@ -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


Expand Down
9 changes: 4 additions & 5 deletions examples/rich_theme.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand All @@ -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."))


Expand Down
41 changes: 25 additions & 16 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand All @@ -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:
Expand All @@ -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
Expand All @@ -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
Expand Down
Loading
Loading