Skip to content

Commit e9938c2

Browse files
authored
Fix: Improper string conversion (#1489)
* Updated rich_utils.prepare_objects_for_rendering() to only convert styled strings to Rich Text objects. * Corrected display width issue with styled strings in CompletionItem.descriptive_data.
1 parent c1af802 commit e9938c2

File tree

9 files changed

+241
-90
lines changed

9 files changed

+241
-90
lines changed

cmd2/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
)
4545
from .parsing import Statement
4646
from .py_bridge import CommandResult
47+
from .rich_utils import RichPrintKwargs
4748
from .string_utils import stylize
4849
from .styles import Cmd2Style
4950
from .utils import (
@@ -86,6 +87,8 @@
8687
'plugin',
8788
'rich_utils',
8889
'string_utils',
90+
# Rich Utils
91+
'RichPrintKwargs',
8992
# String Utils
9093
'stylize',
9194
# Styles,

cmd2/argparse_custom.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,7 @@ def get_items(self) -> list[CompletionItems]:
194194
truncated with an ellipsis at the end. You can override this and other settings
195195
when you create the ``Column``.
196196
197-
``descriptive_data`` items can include Rich objects, including styled text.
197+
``descriptive_data`` items can include Rich objects, including styled Text and Tables.
198198
199199
To avoid printing a excessive information to the screen at once when a user
200200
presses tab, there is a maximum threshold for the number of CompletionItems
@@ -388,13 +388,18 @@ def __init__(self, value: object, descriptive_data: Sequence[Any], *args: Any) -
388388
"""CompletionItem Initializer.
389389
390390
:param value: the value being tab completed
391-
:param descriptive_data: descriptive data to display
391+
:param descriptive_data: a list of descriptive data to display in the columns that follow
392+
the completion value. The number of items in this list must equal
393+
the number of descriptive headers defined for the argument.
392394
:param args: args for str __init__
393395
"""
394396
super().__init__(*args)
395397

396398
# Make sure all objects are renderable by a Rich table.
397-
self.descriptive_data = [obj if is_renderable(obj) else str(obj) for obj in descriptive_data]
399+
renderable_data = [obj if is_renderable(obj) else str(obj) for obj in descriptive_data]
400+
401+
# Convert objects with ANSI styles to Rich Text for correct display width.
402+
self.descriptive_data = ru.prepare_objects_for_rendering(*renderable_data)
398403

399404
# Save the original value to support CompletionItems as argparse choices.
400405
# cmd2 has patched argparse so input is compared to this value instead of the CompletionItem instance.

cmd2/cmd2.py

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1225,7 +1225,7 @@ def print_to(
12251225
method and still call `super()` without encountering unexpected keyword argument errors.
12261226
These arguments are not passed to Rich's Console.print().
12271227
"""
1228-
prepared_objects = ru.prepare_objects_for_rich_print(*objects)
1228+
prepared_objects = ru.prepare_objects_for_rendering(*objects)
12291229

12301230
try:
12311231
Cmd2GeneralConsole(file).print(
@@ -1469,7 +1469,7 @@ def ppaged(
14691469

14701470
# Check if we are outputting to a pager.
14711471
if functional_terminal and can_block:
1472-
prepared_objects = ru.prepare_objects_for_rich_print(*objects)
1472+
prepared_objects = ru.prepare_objects_for_rendering(*objects)
14731473

14741474
# Chopping overrides soft_wrap
14751475
if chop:
@@ -2487,29 +2487,32 @@ def _get_alias_completion_items(self) -> list[CompletionItem]:
24872487
"""Return list of alias names and values as CompletionItems."""
24882488
results: list[CompletionItem] = []
24892489

2490-
for cur_key in self.aliases:
2491-
descriptive_data = [self.aliases[cur_key]]
2492-
results.append(CompletionItem(cur_key, descriptive_data))
2490+
for name, value in self.aliases.items():
2491+
descriptive_data = [value]
2492+
results.append(CompletionItem(name, descriptive_data))
24932493

24942494
return results
24952495

24962496
def _get_macro_completion_items(self) -> list[CompletionItem]:
24972497
"""Return list of macro names and values as CompletionItems."""
24982498
results: list[CompletionItem] = []
24992499

2500-
for cur_key in self.macros:
2501-
descriptive_data = [self.macros[cur_key].value]
2502-
results.append(CompletionItem(cur_key, descriptive_data))
2500+
for name, macro in self.macros.items():
2501+
descriptive_data = [macro.value]
2502+
results.append(CompletionItem(name, descriptive_data))
25032503

25042504
return results
25052505

25062506
def _get_settable_completion_items(self) -> list[CompletionItem]:
25072507
"""Return list of Settable names, values, and descriptions as CompletionItems."""
25082508
results: list[CompletionItem] = []
25092509

2510-
for cur_key in self.settables:
2511-
descriptive_data = [self.settables[cur_key].get_value(), self.settables[cur_key].description]
2512-
results.append(CompletionItem(cur_key, descriptive_data))
2510+
for name, settable in self.settables.items():
2511+
descriptive_data = [
2512+
str(settable.get_value()),
2513+
settable.description,
2514+
]
2515+
results.append(CompletionItem(name, descriptive_data))
25132516

25142517
return results
25152518

@@ -4157,8 +4160,8 @@ def _print_documented_command_topics(self, header: str, cmds: list[str], verbose
41574160
Column("Name", no_wrap=True),
41584161
Column("Description", overflow="fold"),
41594162
box=rich.box.SIMPLE_HEAD,
4160-
border_style=Cmd2Style.TABLE_BORDER,
41614163
show_edge=False,
4164+
border_style=Cmd2Style.TABLE_BORDER,
41624165
)
41634166

41644167
# Try to get the documentation string for each command
@@ -4478,16 +4481,20 @@ def do_set(self, args: argparse.Namespace) -> None:
44784481
Column("Value", overflow="fold"),
44794482
Column("Description", overflow="fold"),
44804483
box=rich.box.SIMPLE_HEAD,
4481-
border_style=Cmd2Style.TABLE_BORDER,
44824484
show_edge=False,
4485+
border_style=Cmd2Style.TABLE_BORDER,
44834486
)
44844487

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

44884491
for param in sorted(to_show, key=self.default_sort_key):
44894492
settable = self.settables[param]
4490-
settable_table.add_row(param, str(settable.get_value()), settable.description)
4493+
settable_table.add_row(
4494+
param,
4495+
str(settable.get_value()),
4496+
settable.description,
4497+
)
44914498
self.last_result[param] = settable.get_value()
44924499

44934500
self.poutput()

cmd2/rich_utils.py

Lines changed: 25 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,9 @@
1414
JustifyMethod,
1515
OverflowMethod,
1616
RenderableType,
17-
RichCast,
1817
)
1918
from rich.padding import Padding
19+
from rich.protocol import rich_cast
2020
from rich.style import StyleType
2121
from rich.table import (
2222
Column,
@@ -40,10 +40,6 @@ def __str__(self) -> str:
4040
"""Return value instead of enum name for printing in cmd2's set command."""
4141
return str(self.value)
4242

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

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

305301

306-
def prepare_objects_for_rich_print(*objects: Any) -> tuple[RenderableType, ...]:
302+
def prepare_objects_for_rendering(*objects: Any) -> tuple[Any, ...]:
307303
"""Prepare a tuple of objects for printing by Rich's Console.print().
308304
309-
Converts any non-Rich objects (i.e., not ConsoleRenderable or RichCast)
310-
into rich.Text objects by stringifying them and processing them with
311-
from_ansi(). This ensures Rich correctly interprets any embedded ANSI
312-
escape sequences.
305+
This function converts any non-Rich object whose string representation contains
306+
ANSI style codes into a rich.Text object. This ensures correct display width
307+
calculation, as Rich can then properly parse and account for the non-printing
308+
ANSI codes. All other objects are left untouched, allowing Rich's native
309+
renderers to handle them.
313310
314311
:param objects: objects to prepare
315-
:return: a tuple containing the processed objects, where non-Rich objects are
316-
converted to rich.Text.
312+
:return: a tuple containing the processed objects.
317313
"""
318314
object_list = list(objects)
315+
319316
for i, obj in enumerate(object_list):
320-
if not isinstance(obj, (ConsoleRenderable, RichCast)):
321-
object_list[i] = string_to_rich_text(str(obj))
317+
# Resolve the object's final renderable form, including those
318+
# with a __rich__ method that might return a string.
319+
renderable = rich_cast(obj)
320+
321+
# This object implements the Rich console protocol, so no preprocessing is needed.
322+
if isinstance(renderable, ConsoleRenderable):
323+
continue
324+
325+
# Check if the object's string representation contains ANSI styles, and if so,
326+
# replace it with a Rich Text object for correct width calculation.
327+
renderable_as_str = str(renderable)
328+
renderable_as_text = string_to_rich_text(renderable_as_str)
329+
330+
if renderable_as_text.plain != renderable_as_str:
331+
object_list[i] = renderable_as_text
332+
322333
return tuple(object_list)

examples/argparse_completion.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
import argparse
55

6-
from rich.box import SIMPLE_HEAD
6+
import rich.box
77
from rich.style import Style
88
from rich.table import Table
99
from rich.text import Text
@@ -49,7 +49,12 @@ def choices_completion_item(self) -> list[CompletionItem]:
4949
Text("styled text!!", style=Style(color=Color.BRIGHT_YELLOW, underline=True)),
5050
)
5151

52-
table_item = Table("Left Column", "Right Column", box=SIMPLE_HEAD, border_style=Cmd2Style.TABLE_BORDER)
52+
table_item = Table(
53+
"Left Column",
54+
"Right Column",
55+
box=rich.box.ROUNDED,
56+
border_style=Cmd2Style.TABLE_BORDER,
57+
)
5358
table_item.add_row("Yes, it's true.", "CompletionItems can")
5459
table_item.add_row("even display description", "data in tables!")
5560

tests/conftest.py

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,9 @@
1010
import pytest
1111

1212
import cmd2
13-
from cmd2.rl_utils import (
14-
readline,
15-
)
16-
from cmd2.utils import (
17-
StdSim,
18-
)
13+
from cmd2 import rich_utils as ru
14+
from cmd2.rl_utils import readline
15+
from cmd2.utils import StdSim
1916

2017

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

9087

88+
def with_ansi_style(style):
89+
def arg_decorator(func):
90+
import functools
91+
92+
@functools.wraps(func)
93+
def cmd_wrapper(*args, **kwargs):
94+
old = ru.ALLOW_STYLE
95+
ru.ALLOW_STYLE = style
96+
try:
97+
retval = func(*args, **kwargs)
98+
finally:
99+
ru.ALLOW_STYLE = old
100+
return retval
101+
102+
return cmd_wrapper
103+
104+
return arg_decorator
105+
106+
91107
# These are odd file names for testing quoting of them
92108
odd_file_names = ['nothingweird', 'has spaces', '"is_double_quoted"', "'is_single_quoted'"]
93109

tests/test_argparse.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -425,7 +425,7 @@ def test_subcmd_decorator(subcommand_app) -> None:
425425

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

430430
out, err = run_cmd(subcommand_app, 'help test_subcmd_decorator helpless_subcmd')
431431
assert out[0] == 'Usage: test_subcmd_decorator helpless_subcmd'

tests/test_argparse_completer.py

Lines changed: 31 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import pytest
88

99
import cmd2
10+
import cmd2.string_utils as su
1011
from cmd2 import (
1112
Cmd2ArgumentParser,
1213
CompletionError,
@@ -15,11 +16,13 @@
1516
argparse_custom,
1617
with_argparser,
1718
)
19+
from cmd2 import rich_utils as ru
1820

1921
from .conftest import (
2022
complete_tester,
2123
normalize,
2224
run_cmd,
25+
with_ansi_style,
2326
)
2427

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

116121
# This tests that CompletionItems created with numerical values are sorted as numbers.
117-
num_completion_items = (CompletionItem(5, ["Five"]), CompletionItem(1.5, ["One.Five"]), CompletionItem(2, ["Five"]))
122+
num_completion_items = (
123+
CompletionItem(5, ["Five"]),
124+
CompletionItem(1.5, ["One.Five"]),
125+
CompletionItem(2, ["Five"]),
126+
)
118127

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

706715

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

719-
# Look for both the value and description in the hint table
720-
line_found = False
721-
for line in ac_app.formatted_completions.splitlines():
722-
# Since the CompletionItems were created from strings, the left-most column is left-aligned.
723-
# Therefore choice_1 will begin the line (with 1 space for padding).
724-
if line.startswith(' choice_1') and 'A description' in line:
725-
line_found = True
726-
break
729+
lines = ac_app.formatted_completions.splitlines()
730+
731+
# Since the CompletionItems were created from strings, the left-most column is left-aligned.
732+
# Therefore choice_1 will begin the line (with 1 space for padding).
733+
assert lines[2].startswith(' choice_1')
734+
assert lines[2].strip().endswith('Description 1')
735+
736+
# Verify that the styled string was converted to a Rich Text object so that
737+
# Rich could correctly calculate its display width. Since it was the longest
738+
# description in the table, we should only see one space of padding after it.
739+
assert lines[3].endswith("\x1b[34mString with style\x1b[0m ")
727740

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

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

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

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

752762

753763
@pytest.mark.parametrize(

0 commit comments

Comments
 (0)