Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
3 changes: 3 additions & 0 deletions cmd2/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
)
from .parsing import Statement
from .py_bridge import CommandResult
from .rich_utils import RichPrintKwargs
from .string_utils import stylize
from .styles import Cmd2Style
from .utils import (
Expand Down Expand Up @@ -86,6 +87,8 @@
'plugin',
'rich_utils',
'string_utils',
# Rich Utils
'RichPrintKwargs',
# String Utils
'stylize',
# Styles,
Expand Down
11 changes: 8 additions & 3 deletions cmd2/argparse_custom.py
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@ def get_items(self) -> list[CompletionItems]:
truncated with an ellipsis at the end. You can override this and other settings
when you create the ``Column``.

``descriptive_data`` items can include Rich objects, including styled text.
``descriptive_data`` items can include Rich objects, including styled Text and Tables.

To avoid printing a excessive information to the screen at once when a user
presses tab, there is a maximum threshold for the number of CompletionItems
Expand Down Expand Up @@ -388,13 +388,18 @@ def __init__(self, value: object, descriptive_data: Sequence[Any], *args: Any) -
"""CompletionItem Initializer.

:param value: the value being tab completed
:param descriptive_data: descriptive data to display
:param descriptive_data: a list of descriptive data to display in the columns that follow
the completion value. The number of items in this list must equal
the number of descriptive headers defined for the argument.
:param args: args for str __init__
"""
super().__init__(*args)

# Make sure all objects are renderable by a Rich table.
self.descriptive_data = [obj if is_renderable(obj) else str(obj) for obj in descriptive_data]
renderable_data = [obj if is_renderable(obj) else str(obj) for obj in descriptive_data]

# Convert objects with ANSI styles to Rich Text for correct display width.
self.descriptive_data = ru.prepare_objects_for_rendering(*renderable_data)

# Save the original value to support CompletionItems as argparse choices.
# cmd2 has patched argparse so input is compared to this value instead of the CompletionItem instance.
Expand Down
20 changes: 14 additions & 6 deletions cmd2/cmd2.py
Original file line number Diff line number Diff line change
Expand Up @@ -1225,7 +1225,7 @@ def print_to(
method and still call `super()` without encountering unexpected keyword argument errors.
These arguments are not passed to Rich's Console.print().
"""
prepared_objects = ru.prepare_objects_for_rich_print(*objects)
prepared_objects = ru.prepare_objects_for_rendering(*objects)

try:
Cmd2GeneralConsole(file).print(
Expand Down Expand Up @@ -1469,7 +1469,7 @@ def ppaged(

# Check if we are outputting to a pager.
if functional_terminal and can_block:
prepared_objects = ru.prepare_objects_for_rich_print(*objects)
prepared_objects = ru.prepare_objects_for_rendering(*objects)

# Chopping overrides soft_wrap
if chop:
Expand Down Expand Up @@ -2508,7 +2508,11 @@ def _get_settable_completion_items(self) -> list[CompletionItem]:
results: list[CompletionItem] = []

for cur_key in self.settables:
descriptive_data = [self.settables[cur_key].get_value(), self.settables[cur_key].description]
settable = self.settables[cur_key]
descriptive_data = [
str(settable.get_value()),
settable.description,
]
results.append(CompletionItem(cur_key, descriptive_data))

return results
Expand Down Expand Up @@ -4157,8 +4161,8 @@ def _print_documented_command_topics(self, header: str, cmds: list[str], verbose
Column("Name", no_wrap=True),
Column("Description", overflow="fold"),
box=rich.box.SIMPLE_HEAD,
border_style=Cmd2Style.TABLE_BORDER,
show_edge=False,
border_style=Cmd2Style.TABLE_BORDER,
)

# Try to get the documentation string for each command
Expand Down Expand Up @@ -4478,16 +4482,20 @@ def do_set(self, args: argparse.Namespace) -> None:
Column("Value", overflow="fold"),
Column("Description", overflow="fold"),
box=rich.box.SIMPLE_HEAD,
border_style=Cmd2Style.TABLE_BORDER,
show_edge=False,
border_style=Cmd2Style.TABLE_BORDER,
)

# Build the table and populate self.last_result
self.last_result = {} # dict[settable_name, settable_value]

for param in sorted(to_show, key=self.default_sort_key):
settable = self.settables[param]
settable_table.add_row(param, str(settable.get_value()), settable.description)
settable_table.add_row(
param,
str(settable.get_value()),
settable.description,
)
self.last_result[param] = settable.get_value()

self.poutput()
Expand Down
39 changes: 25 additions & 14 deletions cmd2/rich_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@
JustifyMethod,
OverflowMethod,
RenderableType,
RichCast,
)
from rich.padding import Padding
from rich.protocol import rich_cast
from rich.style import StyleType
from rich.table import (
Column,
Expand All @@ -40,10 +40,6 @@ def __str__(self) -> str:
"""Return value instead of enum name for printing in cmd2's set command."""
return str(self.value)

def __repr__(self) -> str:
"""Return quoted value instead of enum description for printing in cmd2's set command."""
return repr(self.value)


# Controls when ANSI style sequences are allowed in output
ALLOW_STYLE = AllowStyle.TERMINAL
Expand Down Expand Up @@ -303,20 +299,35 @@ def indent(renderable: RenderableType, level: int) -> Padding:
return Padding.indent(renderable, level)


def prepare_objects_for_rich_print(*objects: Any) -> tuple[RenderableType, ...]:
def prepare_objects_for_rendering(*objects: Any) -> tuple[Any, ...]:
"""Prepare a tuple of objects for printing by Rich's Console.print().

Converts any non-Rich objects (i.e., not ConsoleRenderable or RichCast)
into rich.Text objects by stringifying them and processing them with
from_ansi(). This ensures Rich correctly interprets any embedded ANSI
escape sequences.
This function converts any non-Rich object whose string representation contains
ANSI style codes into a rich.Text object. This ensures correct display width
calculation, as Rich can then properly parse and account for the non-printing
ANSI codes. All other objects are left untouched, allowing Rich's native
renderers to handle them.

:param objects: objects to prepare
:return: a tuple containing the processed objects, where non-Rich objects are
converted to rich.Text.
:return: a tuple containing the processed objects.
"""
object_list = list(objects)

for i, obj in enumerate(object_list):
if not isinstance(obj, (ConsoleRenderable, RichCast)):
object_list[i] = string_to_rich_text(str(obj))
# Resolve the object's final renderable form, including those
# with a __rich__ method that might return a string.
renderable = rich_cast(obj)

# This object implements the Rich console protocol, so no preprocessing is needed.
if isinstance(renderable, ConsoleRenderable):
continue

# Check if the object's string representation contains ANSI styles, and if so,
# replace it with a Rich Text object for correct width calculation.
renderable_as_str = str(renderable)
renderable_as_text = string_to_rich_text(renderable_as_str)

if renderable_as_text.plain != renderable_as_str:
object_list[i] = renderable_as_text

return tuple(object_list)
9 changes: 7 additions & 2 deletions examples/argparse_completion.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

import argparse

from rich.box import SIMPLE_HEAD
import rich.box
from rich.style import Style
from rich.table import Table
from rich.text import Text
Expand Down Expand Up @@ -49,7 +49,12 @@ def choices_completion_item(self) -> list[CompletionItem]:
Text("styled text!!", style=Style(color=Color.BRIGHT_YELLOW, underline=True)),
)

table_item = Table("Left Column", "Right Column", box=SIMPLE_HEAD, border_style=Cmd2Style.TABLE_BORDER)
table_item = Table(
"Left Column",
"Right Column",
box=rich.box.ROUNDED,
border_style=Cmd2Style.TABLE_BORDER,
)
table_item.add_row("Yes, it's true.", "CompletionItems can")
table_item.add_row("even display description", "data in tables!")

Expand Down
28 changes: 22 additions & 6 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,9 @@
import pytest

import cmd2
from cmd2.rl_utils import (
readline,
)
from cmd2.utils import (
StdSim,
)
from cmd2 import rich_utils as ru
from cmd2.rl_utils import readline
from cmd2.utils import StdSim


def verify_help_text(cmd2_app: cmd2.Cmd, help_output: str | list[str], verbose_strings: list[str] | None = None) -> None:
Expand Down Expand Up @@ -88,6 +85,25 @@ def base_app():
return cmd2.Cmd(include_py=True, include_ipy=True)


def with_ansi_style(style):
def arg_decorator(func):
import functools

@functools.wraps(func)
def cmd_wrapper(*args, **kwargs):
old = ru.ALLOW_STYLE
ru.ALLOW_STYLE = style
try:
retval = func(*args, **kwargs)
finally:
ru.ALLOW_STYLE = old
return retval

return cmd_wrapper

return arg_decorator


# These are odd file names for testing quoting of them
odd_file_names = ['nothingweird', 'has spaces', '"is_double_quoted"', "'is_single_quoted'"]

Expand Down
2 changes: 1 addition & 1 deletion tests/test_argparse.py
Original file line number Diff line number Diff line change
Expand Up @@ -425,7 +425,7 @@ def test_subcmd_decorator(subcommand_app) -> None:

# Test subcommand that has no help option
out, err = run_cmd(subcommand_app, 'test_subcmd_decorator helpless_subcmd')
assert "'subcommand': 'helpless_subcmd'" in out[0]
assert "'subcommand': 'helpless_subcmd'" in out[1]

out, err = run_cmd(subcommand_app, 'help test_subcmd_decorator helpless_subcmd')
assert out[0] == 'Usage: test_subcmd_decorator helpless_subcmd'
Expand Down
52 changes: 31 additions & 21 deletions tests/test_argparse_completer.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import pytest

import cmd2
import cmd2.string_utils as su
from cmd2 import (
Cmd2ArgumentParser,
CompletionError,
Expand All @@ -15,11 +16,13 @@
argparse_custom,
with_argparser,
)
from cmd2 import rich_utils as ru

from .conftest import (
complete_tester,
normalize,
run_cmd,
with_ansi_style,
)

# Data and functions for testing standalone choice_provider and completer
Expand Down Expand Up @@ -109,12 +112,18 @@ def do_pos_and_flag(self, args: argparse.Namespace) -> None:
static_choices_list = ('static', 'choices', 'stop', 'here')
choices_from_provider = ('choices', 'provider', 'probably', 'improved')
completion_item_choices = (
CompletionItem('choice_1', ['A description']),
CompletionItem('choice_2', ['Another description']),
CompletionItem('choice_1', ['Description 1']),
# Make this the longest description so we can test display width.
CompletionItem('choice_2', [su.stylize("String with style", style=cmd2.Color.BLUE)]),
CompletionItem('choice_3', [su.stylize("Text with style", style=cmd2.Color.RED)]),
)

# This tests that CompletionItems created with numerical values are sorted as numbers.
num_completion_items = (CompletionItem(5, ["Five"]), CompletionItem(1.5, ["One.Five"]), CompletionItem(2, ["Five"]))
num_completion_items = (
CompletionItem(5, ["Five"]),
CompletionItem(1.5, ["One.Five"]),
CompletionItem(2, ["Five"]),
)

def choices_provider(self) -> tuple[str]:
"""Method that provides choices"""
Expand Down Expand Up @@ -704,6 +713,7 @@ def test_autocomp_blank_token(ac_app) -> None:
assert sorted(completions) == sorted(ArgparseCompleterTester.completions_for_pos_2)


@with_ansi_style(ru.AllowStyle.ALWAYS)
def test_completion_items(ac_app) -> None:
# First test CompletionItems created from strings
text = ''
Expand All @@ -716,16 +726,20 @@ def test_completion_items(ac_app) -> None:
assert len(ac_app.completion_matches) == len(ac_app.completion_item_choices)
assert len(ac_app.display_matches) == len(ac_app.completion_item_choices)

# Look for both the value and description in the hint table
line_found = False
for line in ac_app.formatted_completions.splitlines():
# Since the CompletionItems were created from strings, the left-most column is left-aligned.
# Therefore choice_1 will begin the line (with 1 space for padding).
if line.startswith(' choice_1') and 'A description' in line:
line_found = True
break
lines = ac_app.formatted_completions.splitlines()

# Since the CompletionItems were created from strings, the left-most column is left-aligned.
# Therefore choice_1 will begin the line (with 1 space for padding).
assert lines[2].startswith(' choice_1')
assert lines[2].strip().endswith('Description 1')

# Verify that the styled string was converted to a Rich Text object so that
# Rich could correctly calculate its display width. Since it was the longest
# description in the table, we should only see one space of padding after it.
assert lines[3].endswith("\x1b[34mString with style\x1b[0m ")

assert line_found
# Verify that the styled Rich Text also rendered.
assert lines[4].endswith("\x1b[31mText with style\x1b[0m ")

# Now test CompletionItems created from numbers
text = ''
Expand All @@ -738,16 +752,12 @@ def test_completion_items(ac_app) -> None:
assert len(ac_app.completion_matches) == len(ac_app.num_completion_items)
assert len(ac_app.display_matches) == len(ac_app.num_completion_items)

# Look for both the value and description in the hint table
line_found = False
for line in ac_app.formatted_completions.splitlines():
# Since the CompletionItems were created from numbers, the left-most column is right-aligned.
# Therefore 1.5 will be right-aligned.
if line.startswith(" 1.5") and "One.Five" in line:
line_found = True
break
lines = ac_app.formatted_completions.splitlines()

assert line_found
# Since the CompletionItems were created from numbers, the left-most column is right-aligned.
# Therefore 1.5 will be right-aligned.
assert lines[2].startswith(" 1.5")
assert lines[2].strip().endswith('One.Five')


@pytest.mark.parametrize(
Expand Down
Loading
Loading