Skip to content

Commit affe309

Browse files
committed
All tables in cmd2 are now rich-based, including those used in tab completion.
Added a StrEnum for all cmd2 text styles.
1 parent a7129c5 commit affe309

File tree

13 files changed

+314
-341
lines changed

13 files changed

+314
-341
lines changed

cmd2/argparse_completer.py

Lines changed: 34 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -6,28 +6,31 @@
66
import argparse
77
import inspect
88
import numbers
9+
import sys
910
from collections import (
1011
deque,
1112
)
13+
from collections.abc import Sequence
1214
from typing import (
1315
IO,
1416
TYPE_CHECKING,
1517
cast,
1618
)
1719

18-
from .ansi import (
19-
style_aware_wcswidth,
20-
widest_line,
21-
)
2220
from .constants import (
2321
INFINITY,
2422
)
23+
from .rich_utils import Cmd2Console
2524

2625
if TYPE_CHECKING: # pragma: no cover
2726
from .cmd2 import (
2827
Cmd,
2928
)
3029

30+
31+
from rich.box import SIMPLE_HEAD
32+
from rich.table import Column, Table
33+
3134
from .argparse_custom import (
3235
ChoicesCallable,
3336
ChoicesProviderFuncWithTokens,
@@ -40,14 +43,9 @@
4043
from .exceptions import (
4144
CompletionError,
4245
)
43-
from .table_creator import (
44-
Column,
45-
HorizontalAlignment,
46-
SimpleTable,
47-
)
4846

49-
# If no descriptive header is supplied, then this will be used instead
50-
DEFAULT_DESCRIPTIVE_HEADER = 'Description'
47+
# If no descriptive headers are supplied, then this will be used instead
48+
DEFAULT_DESCRIPTIVE_HEADERS: Sequence[str | Column] = ('Description',)
5149

5250
# Name of the choice/completer function argument that, if present, will be passed a dictionary of
5351
# command line tokens up through the token being completed mapped to their argparse destination name.
@@ -546,8 +544,6 @@ def _format_completions(self, arg_state: _ArgumentState, completions: list[str]
546544

547545
# Check if there are too many CompletionItems to display as a table
548546
if len(completions) <= self._cmd2_app.max_completion_items:
549-
four_spaces = 4 * ' '
550-
551547
# If a metavar was defined, use that instead of the dest field
552548
destination = arg_state.action.metavar if arg_state.action.metavar else arg_state.action.dest
553549

@@ -560,39 +556,34 @@ def _format_completions(self, arg_state: _ArgumentState, completions: list[str]
560556
tuple_index = min(len(destination) - 1, arg_state.count)
561557
destination = destination[tuple_index]
562558

563-
desc_header = arg_state.action.get_descriptive_header() # type: ignore[attr-defined]
564-
if desc_header is None:
565-
desc_header = DEFAULT_DESCRIPTIVE_HEADER
566-
567-
# Replace tabs with 4 spaces so we can calculate width
568-
desc_header = desc_header.replace('\t', four_spaces)
569-
570-
# Calculate needed widths for the token and description columns of the table
571-
token_width = style_aware_wcswidth(destination)
572-
desc_width = widest_line(desc_header)
559+
desc_headers = cast(Sequence[str | Column] | None, arg_state.action.get_descriptive_headers()) # type: ignore[attr-defined]
560+
if desc_headers is None:
561+
desc_headers = DEFAULT_DESCRIPTIVE_HEADERS
562+
563+
# Build all headers for the hint table
564+
headers: list[Column] = []
565+
headers.append(Column(destination.upper(), justify="right" if all_nums else "left", no_wrap=True))
566+
for desc_header in desc_headers:
567+
header = (
568+
desc_header
569+
if isinstance(desc_header, Column)
570+
else Column(
571+
desc_header,
572+
overflow="fold",
573+
)
574+
)
575+
headers.append(header)
573576

577+
# Build the hint table
578+
hint_table = Table(*headers, box=SIMPLE_HEAD, show_edge=False, border_style="rule.line")
574579
for item in completion_items:
575-
token_width = max(style_aware_wcswidth(item), token_width)
576-
577-
# Replace tabs with 4 spaces so we can calculate width
578-
item.description = item.description.replace('\t', four_spaces)
579-
desc_width = max(widest_line(item.description), desc_width)
580-
581-
cols = []
582-
dest_alignment = HorizontalAlignment.RIGHT if all_nums else HorizontalAlignment.LEFT
583-
cols.append(
584-
Column(
585-
destination.upper(),
586-
width=token_width,
587-
header_horiz_align=dest_alignment,
588-
data_horiz_align=dest_alignment,
589-
)
590-
)
591-
cols.append(Column(desc_header, width=desc_width))
580+
hint_table.add_row(item, *item.descriptive_data)
592581

593-
hint_table = SimpleTable(cols, divider_char=self._cmd2_app.ruler)
594-
table_data = [[item, item.description] for item in completion_items]
595-
self._cmd2_app.formatted_completions = hint_table.generate_table(table_data, row_spacing=0)
582+
# Generate the hint table string
583+
console = Cmd2Console(sys.stdout)
584+
with console.capture() as capture:
585+
console.print(hint_table)
586+
self._cmd2_app.formatted_completions = capture.get()
596587

597588
# Return sorted list of completions
598589
return cast(list[str], completions)

cmd2/argparse_custom.py

Lines changed: 91 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -122,38 +122,25 @@ def my_completer(self, text, line, begidx, endidx, arg_tokens)
122122
numbers isn't very helpful to a user without context. Returning a list of
123123
CompletionItems instead of a regular string for completion results will signal
124124
the ArgparseCompleter to output the completion results in a table of completion
125-
tokens with descriptions instead of just a table of tokens::
125+
tokens with descriptive data instead of just a table of tokens::
126126
127127
Instead of this:
128128
1 2 3
129129
130130
The user sees this:
131-
ITEM_ID Item Name
132-
============================
133-
1 My item
134-
2 Another item
135-
3 Yet another item
131+
ITEM_ID Description
132+
────────────────────────────
133+
1 My item
134+
2 Another item
135+
3 Yet another item
136136
137137
138138
The left-most column is the actual value being tab completed and its header is
139139
that value's name. The right column header is defined using the
140-
descriptive_header parameter of add_argument(). The right column values come
141-
from the CompletionItem.description value.
142-
143-
Example::
144-
145-
token = 1
146-
token_description = "My Item"
147-
completion_item = CompletionItem(token, token_description)
148-
149-
Since descriptive_header and CompletionItem.description are just strings, you
150-
can format them in such a way to have multiple columns::
151-
152-
ITEM_ID Item Name Checked Out Due Date
153-
==========================================================
154-
1 My item True 02/02/2022
155-
2 Another item False
156-
3 Yet another item False
140+
``descriptive_headers`` parameter of add_argument(), which is a list of header
141+
names that defaults to ["Description"]. The right column values come from the
142+
``CompletionItem.descriptive_data`` member, which is a list with the same number
143+
of items as columns defined in descriptive_headers.
157144
158145
To use CompletionItems, just return them from your choices_provider or
159146
completer functions. They can also be used as argparse choices. When a
@@ -162,12 +149,59 @@ def my_completer(self, text, line, begidx, endidx, arg_tokens)
162149
argparse so that when evaluating choices, input is compared to
163150
CompletionItem.orig_value instead of the CompletionItem instance.
164151
165-
To avoid printing a ton of information to the screen at once when a user
152+
Example::
153+
154+
Add an argument and define its descriptive_headers.
155+
156+
parser.add_argument(
157+
add_argument(
158+
"item_id",
159+
type=int,
160+
choices_provider=get_items,
161+
descriptive_headers=["Item Name", "Checked Out", "Due Date"],
162+
)
163+
164+
Implement the choices_provider to return CompletionItems.
165+
166+
def get_items(self) -> list[CompletionItems]:
167+
\"\"\"choices_provider which returns CompletionItems\"\"\"
168+
169+
# CompletionItem's second argument is descriptive_data.
170+
# Its item count should match that of descriptive_headers.
171+
return [
172+
CompletionItem(1, ["My item", True, "02/02/2022"]),
173+
CompletionItem(2, ["Another item", False, ""]),
174+
CompletionItem(3, ["Yet another item", False, ""]),
175+
]
176+
177+
This is what the user will see during tab completion.
178+
179+
ITEM_ID Item Name Checked Out Due Date
180+
───────────────────────────────────────────────────────
181+
1 My item True 02/02/2022
182+
2 Another item False
183+
3 Yet another item False
184+
185+
``descriptive_headers`` can be strings or ``Rich.table.Columns`` for more
186+
control over things like alignment.
187+
188+
- If a header is a string, it will render as a left-aligned column with its
189+
overflow behavior set to "fold". This means a long string will wrap within its
190+
cell, creating as many new lines as required to fit.
191+
192+
- If a header is a ``Column``, it defaults to "ellipsis" overflow behavior.
193+
This means a long string which exceeds the width of its column will be
194+
truncated with an ellipsis at the end. You can override this and other settings
195+
when you create the ``Column``.
196+
197+
``descriptive_data`` items can include Rich objects, including styled text.
198+
199+
To avoid printing a excessive information to the screen at once when a user
166200
presses tab, there is a maximum threshold for the number of CompletionItems
167-
that will be shown. Its value is defined in cmd2.Cmd.max_completion_items. It
168-
defaults to 50, but can be changed. If the number of completion suggestions
201+
that will be shown. Its value is defined in ``cmd2.Cmd.max_completion_items``.
202+
It defaults to 50, but can be changed. If the number of completion suggestions
169203
exceeds this number, they will be displayed in the typical columnized format
170-
and will not include the description value of the CompletionItems.
204+
and will not include the descriptive_data of the CompletionItems.
171205
172206
173207
**Patched argparse functions**
@@ -200,8 +234,8 @@ def my_completer(self, text, line, begidx, endidx, arg_tokens)
200234
- ``argparse.Action.get_choices_callable()`` - See `action_get_choices_callable` for more details.
201235
- ``argparse.Action.set_choices_provider()`` - See `_action_set_choices_provider` for more details.
202236
- ``argparse.Action.set_completer()`` - See `_action_set_completer` for more details.
203-
- ``argparse.Action.get_descriptive_header()`` - See `_action_get_descriptive_header` for more details.
204-
- ``argparse.Action.set_descriptive_header()`` - See `_action_set_descriptive_header` for more details.
237+
- ``argparse.Action.get_descriptive_headers()`` - See `_action_get_descriptive_headers` for more details.
238+
- ``argparse.Action.set_descriptive_headers()`` - See `_action_set_descriptive_headers` for more details.
205239
- ``argparse.Action.get_nargs_range()`` - See `_action_get_nargs_range` for more details.
206240
- ``argparse.Action.set_nargs_range()`` - See `_action_set_nargs_range` for more details.
207241
- ``argparse.Action.get_suppress_tab_hint()`` - See `_action_get_suppress_tab_hint` for more details.
@@ -249,6 +283,7 @@ def my_completer(self, text, line, begidx, endidx, arg_tokens)
249283
Group,
250284
RenderableType,
251285
)
286+
from rich.protocol import is_renderable
252287
from rich.table import Column, Table
253288
from rich.text import Text
254289
from rich_argparse import (
@@ -263,6 +298,7 @@ def my_completer(self, text, line, begidx, endidx, arg_tokens)
263298
constants,
264299
rich_utils,
265300
)
301+
from .rich_utils import Cmd2Style
266302

267303
if TYPE_CHECKING: # pragma: no cover
268304
from .argparse_completer import (
@@ -349,15 +385,17 @@ def __new__(cls, value: object, *_args: Any, **_kwargs: Any) -> 'CompletionItem'
349385
"""Responsible for creating and returning a new instance, called before __init__ when an object is instantiated."""
350386
return super().__new__(cls, value)
351387

352-
def __init__(self, value: object, description: str = '', *args: Any) -> None:
388+
def __init__(self, value: object, descriptive_data: Sequence[Any], *args: Any) -> None:
353389
"""CompletionItem Initializer.
354390
355391
:param value: the value being tab completed
356-
:param description: description text to display
392+
:param descriptive_data: descriptive data to display
357393
:param args: args for str __init__
358394
"""
359395
super().__init__(*args)
360-
self.description = description
396+
397+
# Make sure all objects are renderable by a Rich table.
398+
self.descriptive_data = [obj if is_renderable(obj) else str(obj) for obj in descriptive_data]
361399

362400
# Save the original value to support CompletionItems as argparse choices.
363401
# cmd2 has patched argparse so input is compared to this value instead of the CompletionItem instance.
@@ -483,7 +521,7 @@ def choices_provider(self) -> ChoicesProviderFunc:
483521
ATTR_CHOICES_CALLABLE = 'choices_callable'
484522

485523
# Descriptive header that prints when using CompletionItems
486-
ATTR_DESCRIPTIVE_HEADER = 'descriptive_header'
524+
ATTR_DESCRIPTIVE_HEADERS = 'descriptive_headers'
487525

488526
# A tuple specifying nargs as a range (min, max)
489527
ATTR_NARGS_RANGE = 'nargs_range'
@@ -580,38 +618,38 @@ def _action_set_completer(
580618

581619

582620
############################################################################################################
583-
# Patch argparse.Action with accessors for descriptive_header attribute
621+
# Patch argparse.Action with accessors for descriptive_headers attribute
584622
############################################################################################################
585-
def _action_get_descriptive_header(self: argparse.Action) -> str | None:
586-
"""Get the descriptive_header attribute of an argparse Action.
623+
def _action_get_descriptive_headers(self: argparse.Action) -> Sequence[str | Column] | None:
624+
"""Get the descriptive_headers attribute of an argparse Action.
587625
588-
This function is added by cmd2 as a method called ``get_descriptive_header()`` to ``argparse.Action`` class.
626+
This function is added by cmd2 as a method called ``get_descriptive_headers()`` to ``argparse.Action`` class.
589627
590-
To call: ``action.get_descriptive_header()``
628+
To call: ``action.get_descriptive_headers()``
591629
592630
:param self: argparse Action being queried
593-
:return: The value of descriptive_header or None if attribute does not exist
631+
:return: The value of descriptive_headers or None if attribute does not exist
594632
"""
595-
return cast(str | None, getattr(self, ATTR_DESCRIPTIVE_HEADER, None))
633+
return cast(Sequence[str | Column] | None, getattr(self, ATTR_DESCRIPTIVE_HEADERS, None))
596634

597635

598-
setattr(argparse.Action, 'get_descriptive_header', _action_get_descriptive_header)
636+
setattr(argparse.Action, 'get_descriptive_headers', _action_get_descriptive_headers)
599637

600638

601-
def _action_set_descriptive_header(self: argparse.Action, descriptive_header: str | None) -> None:
602-
"""Set the descriptive_header attribute of an argparse Action.
639+
def _action_set_descriptive_headers(self: argparse.Action, descriptive_headers: Sequence[str | Column] | None) -> None:
640+
"""Set the descriptive_headers attribute of an argparse Action.
603641
604-
This function is added by cmd2 as a method called ``set_descriptive_header()`` to ``argparse.Action`` class.
642+
This function is added by cmd2 as a method called ``set_descriptive_headers()`` to ``argparse.Action`` class.
605643
606-
To call: ``action.set_descriptive_header(descriptive_header)``
644+
To call: ``action.set_descriptive_headers(descriptive_headers)``
607645
608646
:param self: argparse Action being updated
609-
:param descriptive_header: value being assigned
647+
:param descriptive_headers: value being assigned
610648
"""
611-
setattr(self, ATTR_DESCRIPTIVE_HEADER, descriptive_header)
649+
setattr(self, ATTR_DESCRIPTIVE_HEADERS, descriptive_headers)
612650

613651

614-
setattr(argparse.Action, 'set_descriptive_header', _action_set_descriptive_header)
652+
setattr(argparse.Action, 'set_descriptive_headers', _action_set_descriptive_headers)
615653

616654

617655
############################################################################################################
@@ -762,7 +800,7 @@ def _add_argument_wrapper(
762800
choices_provider: ChoicesProviderFunc | None = None,
763801
completer: CompleterFunc | None = None,
764802
suppress_tab_hint: bool = False,
765-
descriptive_header: str | None = None,
803+
descriptive_headers: list[Column | str] | None = None,
766804
**kwargs: Any,
767805
) -> argparse.Action:
768806
"""Wrap ActionsContainer.add_argument() which supports more settings used by cmd2.
@@ -782,8 +820,8 @@ def _add_argument_wrapper(
782820
current argument's help text as a hint. Set this to True to suppress the hint. If this
783821
argument's help text is set to argparse.SUPPRESS, then tab hints will not display
784822
regardless of the value passed for suppress_tab_hint. Defaults to False.
785-
:param descriptive_header: if the provided choices are CompletionItems, then this header will display
786-
during tab completion. Defaults to None.
823+
:param descriptive_headers: if the provided choices are CompletionItems, then these are the headers
824+
of the descriptive data. Defaults to None.
787825
788826
# Args from original function
789827
:param kwargs: keyword-arguments recognized by argparse._ActionsContainer.add_argument
@@ -874,7 +912,7 @@ def _add_argument_wrapper(
874912
new_arg.set_completer(completer) # type: ignore[attr-defined]
875913

876914
new_arg.set_suppress_tab_hint(suppress_tab_hint) # type: ignore[attr-defined]
877-
new_arg.set_descriptive_header(descriptive_header) # type: ignore[attr-defined]
915+
new_arg.set_descriptive_headers(descriptive_headers) # type: ignore[attr-defined]
878916

879917
for keyword, value in custom_attribs.items():
880918
attr_setter = getattr(new_arg, f'set_{keyword}', None)
@@ -1445,7 +1483,7 @@ def error(self, message: str) -> NoReturn:
14451483
# Add error style to message
14461484
console = self._get_formatter().console
14471485
with console.capture() as capture:
1448-
console.print(formatted_message, style="cmd2.error", crop=False)
1486+
console.print(formatted_message, style=Cmd2Style.ERROR, crop=False)
14491487
formatted_message = f"{capture.get()}"
14501488

14511489
self.exit(2, f'{formatted_message}\n')

0 commit comments

Comments
 (0)