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
19 changes: 11 additions & 8 deletions cmd2/cmd2.py
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,6 @@ class Cmd(cmd.Cmd):
Line-oriented command interpreters are often useful for test harnesses, internal tools, and rapid prototypes.
"""

ruler = "─"
DEFAULT_EDITOR = utils.find_editor()

# Sorting keys for strings
Expand Down Expand Up @@ -475,7 +474,10 @@ def __init__(
# The multiline command currently being typed which is used to tab complete multiline commands.
self._multiline_in_progress = ''

# Set text which prints right before all of the help topics are listed.
# Characters used to draw a horizontal rule. Should not be blank.
self.ruler = "─"

# Set text which prints right before all of the help tables are listed.
self.doc_leader = ""

# Set header for table listing documented commands.
Expand Down Expand Up @@ -4175,8 +4177,7 @@ def print_topics(self, header: str, cmds: list[str] | None, cmdlen: int, maxcol:

header_grid = Table.grid()
header_grid.add_row(header, style=Cmd2Style.HELP_HEADER)
if self.ruler:
header_grid.add_row(Rule(characters=self.ruler))
header_grid.add_row(Rule(characters=self.ruler))
self.poutput(header_grid)
self.columnize(cmds, maxcol - 1)
self.poutput()
Expand Down Expand Up @@ -5543,7 +5544,10 @@ class TestMyAppCase(Cmd2TestCase):
verinfo = ".".join(map(str, sys.version_info[:3]))
num_transcripts = len(transcripts_expanded)
plural = '' if len(transcripts_expanded) == 1 else 's'
self.poutput(su.align_center(' cmd2 transcript test ', character=self.ruler), style=Style(bold=True))
self.poutput(
Rule("cmd2 transcript test", style=Style.null()),
style=Style(bold=True),
)
self.poutput(f'platform {sys.platform} -- Python {verinfo}, cmd2-{cmd2.__version__}, readline-{rl_type}')
self.poutput(f'cwd: {os.getcwd()}')
self.poutput(f'cmd2 app: {sys.argv[0]}')
Expand All @@ -5559,9 +5563,8 @@ class TestMyAppCase(Cmd2TestCase):
execution_time = time.time() - start_time
if test_results.wasSuccessful():
self.perror(stream.read(), end="", style=None)
finish_msg = f' {num_transcripts} transcript{plural} passed in {execution_time:.3f} seconds '
finish_msg = su.align_center(finish_msg, character=self.ruler)
self.psuccess(finish_msg)
finish_msg = f'{num_transcripts} transcript{plural} passed in {execution_time:.3f} seconds'
self.psuccess(Rule(finish_msg, style=Style.null()))
else:
# Strip off the initial traceback which isn't particularly useful for end users
error_str = stream.read()
Expand Down
107 changes: 54 additions & 53 deletions cmd2/rich_utils.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Provides common utilities to support Rich in cmd2 applications."""
"""Provides common utilities to support Rich in cmd2-based applications."""

from collections.abc import Mapping
from enum import Enum
Expand All @@ -17,7 +17,6 @@
RichCast,
)
from rich.style import (
Style,
StyleType,
)
from rich.text import Text
Expand Down Expand Up @@ -47,47 +46,44 @@ def __repr__(self) -> str:
ALLOW_STYLE = AllowStyle.TERMINAL


class Cmd2Theme(Theme):
"""Rich theme class used by cmd2."""
def _create_default_theme() -> Theme:
"""Create a default theme for cmd2-based applications.

def __init__(self, styles: Mapping[str, StyleType] | None = None) -> None:
"""Cmd2Theme initializer.
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)

:param styles: optional mapping of style names on to styles.
Defaults to None for a theme with no styles.
"""
cmd2_styles = DEFAULT_CMD2_STYLES.copy()

# Include default styles from rich-argparse
cmd2_styles.update(RichHelpFormatter.styles.copy())
def set_theme(styles: Mapping[str, StyleType] | None = None) -> None:
"""Set the Rich theme used by cmd2.

if styles is not None:
cmd2_styles.update(styles)
Call set_theme() with no arguments to reset to the default theme.
This will clear any custom styles that were previously applied.

# Set inherit to True to include Rich's default styles
super().__init__(cmd2_styles, inherit=True)
: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)

# Current Rich theme used by Cmd2Console
THEME: Cmd2Theme = Cmd2Theme()
# Incorporate custom styles.
if styles is not None:
app_styles.update(styles)

APP_THEME = Theme(app_styles)

def set_theme(new_theme: Cmd2Theme) -> None:
"""Set the Rich theme used by cmd2.
# 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]

:param new_theme: new theme to use.
"""
global THEME # noqa: PLW0603
THEME = new_theme

# Make sure the new theme has all style names included in a Cmd2Theme.
missing_names = Cmd2Theme().styles.keys() - THEME.styles.keys()
for name in missing_names:
THEME.styles[name] = Style()

# Update rich-argparse styles
for name in RichHelpFormatter.styles.keys() & THEME.styles.keys():
RichHelpFormatter.styles[name] = THEME.styles[name]
# The main theme for cmd2-based applications.
# You can change it with set_theme().
APP_THEME = _create_default_theme()


class RichPrintKwargs(TypedDict, total=False):
Expand All @@ -113,7 +109,7 @@ class RichPrintKwargs(TypedDict, total=False):


class Cmd2Console(Console):
"""Rich console with characteristics appropriate for cmd2 applications."""
"""Rich console with characteristics appropriate for cmd2-based applications."""

def __init__(self, file: IO[str] | None = None) -> None:
"""Cmd2Console initializer.
Expand Down Expand Up @@ -147,7 +143,7 @@ def __init__(self, file: IO[str] | None = None) -> None:
markup=False,
emoji=False,
highlight=False,
theme=THEME,
theme=APP_THEME,
)

def on_broken_pipe(self) -> None:
Expand Down Expand Up @@ -178,13 +174,17 @@ def rich_text_to_string(text: Text) -> str:
markup=False,
emoji=False,
highlight=False,
theme=THEME,
theme=APP_THEME,
)
with console.capture() as capture:
console.print(text, end="")
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 Text object from a string which can contain ANSI escape codes.

Expand All @@ -199,24 +199,25 @@ def string_to_rich_text(text: str) -> Text:
"""
result = Text.from_ansi(text)

# 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")
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

Expand Down
14 changes: 7 additions & 7 deletions cmd2/styles.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,13 @@ class Cmd2Style(StrEnum):
added here must have a corresponding style definition there.
"""

ERROR = "cmd2.error"
EXAMPLE = "cmd2.example"
HELP_HEADER = "cmd2.help.header"
HELP_LEADER = "cmd2.help.leader"
RULE_LINE = "cmd2.rule.line"
SUCCESS = "cmd2.success"
WARNING = "cmd2.warning"
ERROR = "cmd2.error" # Error text (used by perror())
EXAMPLE = "cmd2.example" # Command line examples in help text
HELP_HEADER = "cmd2.help.header" # Help table header text
HELP_LEADER = "cmd2.help.leader" # Text right before the help tables are listed
RULE_LINE = "rule.line" # Rich style for horizontal rules
SUCCESS = "cmd2.success" # Success text (used by psuccess())
WARNING = "cmd2.warning" # Warning text (used by pwarning())


# Default styles used by cmd2. Tightly coupled with the Cmd2Style enum.
Expand Down
85 changes: 85 additions & 0 deletions tests/test_rich_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
"""Unit testing for cmd2/rich_utils.py module"""

import pytest
from rich.style import Style
from rich.text import Text

from cmd2 import (
Cmd2Style,
Color,
)
from cmd2 import rich_utils as ru
from cmd2 import string_utils as su


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


@pytest.mark.parametrize(
('rich_text', 'string'),
[
(Text("Hello"), "Hello"),
(Text("Hello\n"), "Hello\n"),
(Text("Hello", style="blue"), su.stylize("Hello", style="blue")),
],
)
def test_rich_text_to_string(rich_text: Text, string: str) -> None:
assert ru.rich_text_to_string(rich_text) == string


def test_set_style() -> None:
# Save a cmd2, rich-argparse, and rich-specific style.
cmd2_style_key = Cmd2Style.ERROR
argparse_style_key = "argparse.args"
rich_style_key = "inspect.attr"

orig_cmd2_style = ru.APP_THEME.styles[cmd2_style_key]
orig_argparse_style = ru.APP_THEME.styles[argparse_style_key]
orig_rich_style = ru.APP_THEME.styles[rich_style_key]

# Overwrite these styles by setting a new theme.
theme = {
cmd2_style_key: Style(color=Color.CYAN),
argparse_style_key: Style(color=Color.AQUAMARINE3, underline=True),
rich_style_key: Style(color=Color.DARK_GOLDENROD, bold=True),
}
ru.set_theme(theme)

# Verify theme styles have changed to our custom values.
assert ru.APP_THEME.styles[cmd2_style_key] != orig_cmd2_style
assert ru.APP_THEME.styles[cmd2_style_key] == theme[cmd2_style_key]

assert ru.APP_THEME.styles[argparse_style_key] != orig_argparse_style
assert ru.APP_THEME.styles[argparse_style_key] == theme[argparse_style_key]

assert ru.APP_THEME.styles[rich_style_key] != orig_rich_style
assert ru.APP_THEME.styles[rich_style_key] == theme[rich_style_key]
Loading