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
10 changes: 6 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,15 @@ prompt is displayed.
longer needed
- `completer` functions must now return a `cmd2.Completions` object instead of `list[str]`.
- `choices_provider` functions must now return a `cmd2.Choices` object instead of `list[str]`.
- An argparse argument's `descriptive_headers` field is now called `table_header`.
- `CompletionItem.descriptive_data` is now called `CompletionItem.table_row`.
- An argparse argument's `descriptive_headers` field is now called `table_columns`.
- `CompletionItem.descriptive_data` is now called `CompletionItem.table_data`.
- Removed `DEFAULT_DESCRIPTIVE_HEADERS`. This means you must define `table_columns` when using
`CompletionItem.table_data` data.
- `Cmd.default_sort_key` moved to `utils.DEFAULT_STR_SORT_KEY`.
- Moved completion state data, which previously resided in `Cmd`, into other classes.
- `Cmd.matches_sorted` -> `Completions.is_sorted` and `Choices.is_sorted`
- `Cmd.completion_hint` -> `Completions.completion_hint`
- `Cmd.formatted_completions` -> `Completions.completion_table`
- `Cmd.completion_hint` -> `Completions.hint`
- `Cmd.formatted_completions` -> `Completions.table` (Now a Rich Table)
- `Cmd.allow_appended_space/allow_closing_quote` -> `Completions.allow_finalization`
- Removed `Cmd.matches_delimited` since it's no longer used.
- Removed `flag_based_complete` and `index_based_complete` functions since their functionality
Expand Down
85 changes: 55 additions & 30 deletions cmd2/argparse_completer.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@
from rich.text import Text

from .constants import INFINITY
from .rich_utils import Cmd2GeneralConsole

if TYPE_CHECKING: # pragma: no cover
from .cmd2 import Cmd
Expand All @@ -49,9 +48,6 @@
from .exceptions import CompletionError
from .styles import Cmd2Style

# If no table header is supplied, then this will be used instead
DEFAULT_TABLE_HEADER: Sequence[str | Column] = ['Description']

# Name of the choice/completer function argument that, if present, will be passed a dictionary of
# command line tokens up through the token being completed mapped to their argparse destination name.
ARG_TOKENS = 'arg_tokens'
Expand Down Expand Up @@ -500,11 +496,11 @@ def _handle_last_token(

# If we have results, then return them
if completions:
if not completions.completion_hint:
if not completions.hint:
# Add a hint even though there are results in case Cmd.always_show_hint is True.
completions = dataclasses.replace(
completions,
completion_hint=_build_hint(self._parser, flag_arg_state.action),
hint=_build_hint(self._parser, flag_arg_state.action),
)

return completions
Expand All @@ -528,11 +524,11 @@ def _handle_last_token(

# If we have results, then return them
if completions:
if not completions.completion_hint:
if not completions.hint:
# Add a hint even though there are results in case Cmd.always_show_hint is True.
completions = dataclasses.replace(
completions,
completion_hint=_build_hint(self._parser, pos_arg_state.action),
hint=_build_hint(self._parser, pos_arg_state.action),
)
return completions

Expand Down Expand Up @@ -592,15 +588,52 @@ def _complete_flags(self, text: str, line: str, begidx: int, endidx: int, used_f

return Completions(items)

def _format_completions(self, arg_state: _ArgumentState, completions: Completions) -> Completions:
"""Format CompletionItems into completion table."""
# Skip table generation for single results or if the list exceeds the
# user-defined threshold for table display.
if len(completions) < 2 or len(completions) > self._cmd2_app.max_completion_table_items:
return completions
@staticmethod
def _validate_table_data(arg_state: _ArgumentState, completions: Completions) -> None:
"""Verify the integrity of completion table data.

# Ensure every item provides table metadata to avoid an incomplete table.
if not all(item.table_row for item in completions):
:raises ValueError: if there is an error with the data.
"""
table_columns = arg_state.action.get_table_columns() # type: ignore[attr-defined]
has_table_data = any(item.table_data for item in completions)

if table_columns is None:
if has_table_data:
raise ValueError(
f"Argument '{arg_state.action.dest}' has CompletionItems with table_data, "
f"but no table_columns were defined in add_argument()."
)
return

# If columns are defined, then every item must have data, and lengths must match
for item in completions:
if not item.table_data:
raise ValueError(
f"Argument '{arg_state.action.dest}' has table_columns defined, "
f"but the CompletionItem for '{item.text}' is missing table_data."
)
if len(item.table_data) != len(table_columns):
raise ValueError(
f"Argument '{arg_state.action.dest}': table_data length ({len(item.table_data)}) "
f"does not match table_columns length ({len(table_columns)}) for item '{item.text}'."
)

def _build_completion_table(self, arg_state: _ArgumentState, completions: Completions) -> Completions:
"""Build a rich.Table for completion results if applicable."""
# Verify integrity of completion data
self._validate_table_data(arg_state, completions)

table_columns = cast(
Sequence[str | Column] | None,
arg_state.action.get_table_columns(), # type: ignore[attr-defined]
)

# Skip table generation if results are outside thresholds or no columns are defined
if (
len(completions) < 2
or len(completions) > self._cmd2_app.max_completion_table_items
or table_columns is None
): # fmt: skip
return completions

# If a metavar was defined, use that instead of the dest field
Expand All @@ -620,26 +653,18 @@ def _format_completions(self, arg_state: _ArgumentState, completions: Completion
# Build header row
rich_columns: list[Column] = []
rich_columns.append(Column(destination.upper(), justify="right" if all_nums else "left", no_wrap=True))
table_header = cast(Sequence[str | Column] | None, arg_state.action.get_table_header()) # type: ignore[attr-defined]
if table_header is None:
table_header = DEFAULT_TABLE_HEADER
rich_columns.extend(
column if isinstance(column, Column) else Column(column, overflow="fold") for column in table_header
column if isinstance(column, Column) else Column(column, overflow="fold") for column in table_columns
)

# Add the data rows
hint_table = Table(*rich_columns, box=SIMPLE_HEAD, show_edge=False, border_style=Cmd2Style.TABLE_BORDER)
# Build the table
table = Table(*rich_columns, box=SIMPLE_HEAD, show_edge=False, border_style=Cmd2Style.TABLE_BORDER)
for item in completions:
hint_table.add_row(Text.from_ansi(item.display), *item.table_row)

# Generate the table string
console = Cmd2GeneralConsole(file=self._cmd2_app.stdout)
with console.capture() as capture:
console.print(hint_table, end="", soft_wrap=False)
table.add_row(Text.from_ansi(item.display), *item.table_data)

return dataclasses.replace(
completions,
completion_table=capture.get(),
table=table,
)

def complete_subcommand_help(self, text: str, line: str, begidx: int, endidx: int, tokens: Sequence[str]) -> Completions:
Expand Down Expand Up @@ -780,7 +805,7 @@ def _complete_arg(
filtered = [choice for choice in all_choices if choice.text not in used_values]
completions = self._cmd2_app.basic_complete(text, line, begidx, endidx, filtered)

return self._format_completions(arg_state, completions)
return self._build_completion_table(arg_state, completions)


# The default ArgparseCompleter class for a cmd2 app
Expand Down
93 changes: 46 additions & 47 deletions cmd2/argparse_custom.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ def my_completer(self, text, line, begidx, endidx, arg_tokens) -> Completions

1. display - string for displaying the completion differently in the completion menu
2. display_meta - meta information about completion which displays in the completion menu
3. table_row - row data for completion tables
3. table_data - supplemental data for completion tables

They can also be used as argparse choices. When a ``CompletionItem`` is created, it
stores the original value (e.g. ID number) and makes it accessible through a property
Expand All @@ -139,8 +139,8 @@ def my_completer(self, text, line, begidx, endidx, arg_tokens) -> Completions
These were added to help in cases where uninformative data is being completed.
For instance, completing ID numbers isn't very helpful to a user without context.

Providing ``table_row`` data in your ``CompletionItem`` signals ArgparseCompleter
to output the completion results in a table with descriptive data instead of just a table
Providing ``table_data`` in your ``CompletionItem`` signals ArgparseCompleter
to output the completion results in a table with supplemental data instead of just a table
of tokens::

Instead of this:
Expand All @@ -155,35 +155,34 @@ def my_completer(self, text, line, begidx, endidx, arg_tokens) -> Completions


The left-most column is the actual value being completed and its header is
that value's name. The right column header is defined using the
``table_header`` parameter of add_argument(), which is a list of header
names that defaults to ["Description"]. The right column values come from the
``table_row`` argument to ``CompletionItem``. It's a ``Sequence`` with the
same number of items as ``table_header``.
that value's name. Any additional column headers are defined using the
``table_columns`` parameter of add_argument(), which is a list of header
names. The supplemental column values come from the
``table_data`` argument to ``CompletionItem``. It's a ``Sequence`` with the
same number of items as ``table_columns``.

Example::

Add an argument and define its table_header.
Add an argument and define its table_columns.

parser.add_argument(
add_argument(
"item_id",
type=int,
choices_provider=get_choices,
table_header=["Item Name", "Checked Out", "Due Date"],
table_columns=["Item Name", "Checked Out", "Due Date"],
)

Implement the choices_provider to return Choices.

def get_choices(self) -> Choices:
\"\"\"choices_provider which returns CompletionItems\"\"\"

# Populate CompletionItem's table_row argument.
# Its item count should match that of table_header.
# Populate CompletionItem's table_data argument.
# Its item count should match that of table_columns.
items = [
CompletionItem(1, table_row=["My item", True, "02/02/2022"]),
CompletionItem(2, table_row=["Another item", False, ""]),
CompletionItem(3, table_row=["Yet another item", False, ""]),
CompletionItem(1, table_data=["My item", True, "02/02/2022"]),
CompletionItem(2, table_data=["Another item", False, ""]),
CompletionItem(3, table_data=["Yet another item", False, ""]),
]
return Choices(items)

Expand All @@ -195,7 +194,7 @@ def get_choices(self) -> Choices:
2 Another item False
3 Yet another item False

``table_header`` can be strings or ``Rich.table.Columns`` for more
``table_columns`` can be strings or ``Rich.table.Columns`` for more
control over things like alignment.

- If a header is a string, it will render as a left-aligned column with its
Expand All @@ -207,9 +206,9 @@ def get_choices(self) -> Choices:
truncated with an ellipsis at the end. You can override this and other settings
when you create the ``Column``.

``table_row`` items can include Rich objects, including styled Text and Tables.
``table_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
To avoid printing excessive information to the screen at once when a user
presses tab, there is a maximum threshold for the number of ``CompletionItems``
that will be shown. Its value is defined in ``cmd2.Cmd.max_completion_table_items``.
It defaults to 50, but can be changed. If the number of completion suggestions
Expand Down Expand Up @@ -240,8 +239,8 @@ def get_choices(self) -> Choices:
- ``argparse.Action.get_choices_callable()`` - See `action_get_choices_callable` for more details.
- ``argparse.Action.set_choices_provider()`` - See `_action_set_choices_provider` for more details.
- ``argparse.Action.set_completer()`` - See `_action_set_completer` for more details.
- ``argparse.Action.get_table_header()`` - See `_action_get_table_header` for more details.
- ``argparse.Action.set_table_header()`` - See `_action_set_table_header` for more details.
- ``argparse.Action.get_table_columns()`` - See `_action_get_table_columns` for more details.
- ``argparse.Action.set_table_columns()`` - See `_action_set_table_columns` for more details.
- ``argparse.Action.get_nargs_range()`` - See `_action_get_nargs_range` for more details.
- ``argparse.Action.set_nargs_range()`` - See `_action_set_nargs_range` for more details.
- ``argparse.Action.get_suppress_tab_hint()`` - See `_action_get_suppress_tab_hint` for more details.
Expand Down Expand Up @@ -309,21 +308,21 @@ def get_choices(self) -> Choices:

def generate_range_error(range_min: int, range_max: float) -> str:
"""Generate an error message when the the number of arguments provided is not within the expected range."""
err_str = "expected "
err_msg = "expected "

if range_max == constants.INFINITY:
plural = '' if range_min == 1 else 's'
err_str += f"at least {range_min}"
err_msg += f"at least {range_min}"
else:
plural = '' if range_max == 1 else 's'
if range_min == range_max:
err_str += f"{range_min}"
err_msg += f"{range_min}"
else:
err_str += f"{range_min} to {range_max}"
err_msg += f"{range_min} to {range_max}"

err_str += f" argument{plural}"
err_msg += f" argument{plural}"

return err_str
return err_msg


def set_parser_prog(parser: argparse.ArgumentParser, prog: str) -> None:
Expand Down Expand Up @@ -418,8 +417,8 @@ def completer(self) -> CompleterUnbound[CmdOrSet]:
# ChoicesCallable object that specifies the function to be called which provides choices to the argument
ATTR_CHOICES_CALLABLE = 'choices_callable'

# A completion table header
ATTR_TABLE_HEADER = 'table_header'
# Completion table columns
ATTR_TABLE_COLUMNS = 'table_columns'

# A tuple specifying nargs as a range (min, max)
ATTR_NARGS_RANGE = 'nargs_range'
Expand Down Expand Up @@ -516,38 +515,38 @@ def _action_set_completer(


############################################################################################################
# Patch argparse.Action with accessors for table_header attribute
# Patch argparse.Action with accessors for table_columns attribute
############################################################################################################
def _action_get_table_header(self: argparse.Action) -> Sequence[str | Column] | None:
"""Get the table_header attribute of an argparse Action.
def _action_get_table_columns(self: argparse.Action) -> Sequence[str | Column] | None:
"""Get the table_columns attribute of an argparse Action.

This function is added by cmd2 as a method called ``get_table_header()`` to ``argparse.Action`` class.
This function is added by cmd2 as a method called ``get_table_columns()`` to ``argparse.Action`` class.

To call: ``action.get_table_header()``
To call: ``action.get_table_columns()``

:param self: argparse Action being queried
:return: The value of table_header or None if attribute does not exist
:return: The value of table_columns or None if attribute does not exist
"""
return cast(Sequence[str | Column] | None, getattr(self, ATTR_TABLE_HEADER, None))
return cast(Sequence[str | Column] | None, getattr(self, ATTR_TABLE_COLUMNS, None))


setattr(argparse.Action, 'get_table_header', _action_get_table_header)
setattr(argparse.Action, 'get_table_columns', _action_get_table_columns)


def _action_set_table_header(self: argparse.Action, table_header: Sequence[str | Column] | None) -> None:
"""Set the table_header attribute of an argparse Action.
def _action_set_table_columns(self: argparse.Action, table_columns: Sequence[str | Column] | None) -> None:
"""Set the table_columns attribute of an argparse Action.

This function is added by cmd2 as a method called ``set_table_header()`` to ``argparse.Action`` class.
This function is added by cmd2 as a method called ``set_table_columns()`` to ``argparse.Action`` class.

To call: ``action.set_table_header(table_header)``
To call: ``action.set_table_columns(table_columns)``

:param self: argparse Action being updated
:param table_header: value being assigned
:param table_columns: value being assigned
"""
setattr(self, ATTR_TABLE_HEADER, table_header)
setattr(self, ATTR_TABLE_COLUMNS, table_columns)


setattr(argparse.Action, 'set_table_header', _action_set_table_header)
setattr(argparse.Action, 'set_table_columns', _action_set_table_columns)


############################################################################################################
Expand Down Expand Up @@ -698,7 +697,7 @@ def _add_argument_wrapper(
choices_provider: ChoicesProviderUnbound[CmdOrSet] | None = None,
completer: CompleterUnbound[CmdOrSet] | None = None,
suppress_tab_hint: bool = False,
table_header: Sequence[str | Column] | None = None,
table_columns: Sequence[str | Column] | None = None,
**kwargs: Any,
) -> argparse.Action:
"""Wrap ActionsContainer.add_argument() which supports more settings used by cmd2.
Expand All @@ -718,7 +717,7 @@ def _add_argument_wrapper(
current argument's help text as a hint. Set this to True to suppress the hint. If this
argument's help text is set to argparse.SUPPRESS, then tab hints will not display
regardless of the value passed for suppress_tab_hint. Defaults to False.
:param table_header: optional header for when displaying a completion table. Defaults to None.
:param table_columns: optional headers for when displaying a completion table. Defaults to None.

# Args from original function
:param kwargs: keyword-arguments recognized by argparse._ActionsContainer.add_argument
Expand Down Expand Up @@ -809,7 +808,7 @@ def _add_argument_wrapper(
new_arg.set_completer(completer) # type: ignore[attr-defined]

new_arg.set_suppress_tab_hint(suppress_tab_hint) # type: ignore[attr-defined]
new_arg.set_table_header(table_header) # type: ignore[attr-defined]
new_arg.set_table_columns(table_columns) # type: ignore[attr-defined]

for keyword, value in custom_attribs.items():
attr_setter = getattr(new_arg, f'set_{keyword}', None)
Expand Down
Loading
Loading