diff --git a/CHANGELOG.md b/CHANGELOG.md index 74e5acc6..185b9fb4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index 97d61fee..8047f9c7 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -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 @@ -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' @@ -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 @@ -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 @@ -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 @@ -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: @@ -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 diff --git a/cmd2/argparse_custom.py b/cmd2/argparse_custom.py index 77233080..e9609795 100644 --- a/cmd2/argparse_custom.py +++ b/cmd2/argparse_custom.py @@ -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 @@ -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: @@ -155,22 +155,21 @@ 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. @@ -178,12 +177,12 @@ def my_completer(self, text, line, begidx, endidx, arg_tokens) -> Completions 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) @@ -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 @@ -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 @@ -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. @@ -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: @@ -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' @@ -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) ############################################################################################################ @@ -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. @@ -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 @@ -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) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 7f0c7d15..a8f5ee52 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -2467,26 +2467,26 @@ def complete( return completions # noqa: TRY300 except CompletionError as ex: - err_str = str(ex) - completion_error = "" + error_msg = str(ex) + formatted_error = "" - # Don't display anything if the error is blank (e.g. _NoResultsError for an argument which supresses hints) - if err_str: + # Don't display anything if the error is blank (e.g. _NoResultsError for an argument which suppresses hints) + if error_msg: # _NoResultsError completion hints already include a trailing "\n". end = "" if isinstance(ex, argparse_completer._NoResultsError) else "\n" console = Cmd2GeneralConsole(file=self.stdout) with console.capture() as capture: console.print( - err_str, + error_msg, style=Cmd2Style.ERROR if ex.apply_style else "", end=end, ) - completion_error = capture.get() - return Completions(completion_error=completion_error) + formatted_error = capture.get() + return Completions(error=formatted_error) except Exception as ex: # noqa: BLE001 formatted_exception = self.format_exception(ex) - return Completions(completion_error=formatted_exception) + return Completions(error=formatted_exception) def in_script(self) -> bool: """Return whether a text script is running.""" @@ -2526,7 +2526,7 @@ def _get_alias_choices(self) -> Choices: items: list[CompletionItem] = [] for name, value in self.aliases.items(): - items.append(CompletionItem(name, display_meta=value, table_row=[value])) + items.append(CompletionItem(name, display_meta=value, table_data=[value])) return Choices(items=items) @@ -2535,7 +2535,7 @@ def _get_macro_choices(self) -> Choices: items: list[CompletionItem] = [] for name, macro in self.macros.items(): - items.append(CompletionItem(name, display_meta=macro.value, table_row=[macro.value])) + items.append(CompletionItem(name, display_meta=macro.value, table_data=[macro.value])) return Choices(items=items) @@ -2545,12 +2545,12 @@ def _get_settable_choices(self) -> Choices: for name, settable in self.settables.items(): value_str = str(settable.value) - table_row = [ + table_data = [ value_str, settable.description, ] display_meta = f"[Current: {su.stylize(value_str, Style(bold=True))}] {settable.description}" - items.append(CompletionItem(name, display_meta=display_meta, table_row=table_row)) + items.append(CompletionItem(name, display_meta=display_meta, table_data=table_data)) return Choices(items=items) @@ -3658,7 +3658,7 @@ def _build_alias_delete_parser(cls) -> Cmd2ArgumentParser: nargs=argparse.ZERO_OR_MORE, help='alias(es) to delete', choices_provider=cls._get_alias_choices, - table_header=["Value"], + table_columns=["Value"], ) return alias_delete_parser @@ -3700,7 +3700,7 @@ def _build_alias_list_parser(cls) -> Cmd2ArgumentParser: nargs=argparse.ZERO_OR_MORE, help='alias(es) to list', choices_provider=cls._get_alias_choices, - table_header=["Value"], + table_columns=["Value"], ) return alias_list_parser @@ -3949,7 +3949,7 @@ def _build_macro_delete_parser(cls) -> Cmd2ArgumentParser: nargs=argparse.ZERO_OR_MORE, help='macro(s) to delete', choices_provider=cls._get_macro_choices, - table_header=["Value"], + table_columns=["Value"], ) return macro_delete_parser @@ -3991,7 +3991,7 @@ def _build_macro_list_parser(cls) -> Cmd2ArgumentParser: nargs=argparse.ZERO_OR_MORE, help='macro(s) to list', choices_provider=cls._get_macro_choices, - table_header=["Value"], + table_columns=["Value"], ) return macro_list_parser @@ -4475,7 +4475,7 @@ def _build_base_set_parser(cls) -> Cmd2ArgumentParser: nargs=argparse.OPTIONAL, help='parameter to set or view', choices_provider=cls._get_settable_choices, - table_header=["Value", "Description"], + table_columns=["Value", "Description"], ) return base_set_parser diff --git a/cmd2/completion.py b/cmd2/completion.py index 2c023dfe..6364be4b 100644 --- a/cmd2/completion.py +++ b/cmd2/completion.py @@ -18,6 +18,8 @@ overload, ) +from rich.table import Table + from . import string_utils as su if sys.version_info >= (3, 11): @@ -65,9 +67,9 @@ class CompletionItem: # This can contain ANSI style sequences. A plain version is stored in display_meta_plain. display_meta: str = "" - # Optional row data for completion tables. Length must match the associated argparse - # argument's table_header. This is stored internally as a tuple. - table_row: Sequence[Any] = field(default_factory=tuple) + # Optional data for completion tables. Length must match the associated argparse + # argument's table_columns. This is stored internally as a tuple. + table_data: Sequence[Any] = field(default_factory=tuple) # Plain text versions of display fields (stripped of ANSI) for sorting/filtering. # These are set in __post_init__(). @@ -89,13 +91,13 @@ def __post_init__(self) -> None: object.__setattr__(self, "display_plain", su.strip_style(self.display)) object.__setattr__(self, "display_meta_plain", su.strip_style(self.display_meta)) - # Make sure all table row objects are renderable by a Rich table. - renderable_data = [obj if is_renderable(obj) else str(obj) for obj in self.table_row] + # Make sure all table data objects are renderable by a Rich table. + renderable_data = [obj if is_renderable(obj) else str(obj) for obj in self.table_data] # Convert strings containing ANSI style sequences to Rich Text objects for correct display width. object.__setattr__( self, - 'table_row', + 'table_data', ru.prepare_objects_for_rendering(*renderable_data), ) @@ -107,7 +109,7 @@ def __eq__(self, other: object) -> bool: """Compare this CompletionItem for equality. Identity is determined by value, text, display, and display_meta. - table_row is excluded from equality checks to ensure that items + table_data is excluded from equality checks to ensure that items with the same functional value are treated as duplicates. Also supports comparison against non-CompletionItems to facilitate argparse @@ -214,14 +216,14 @@ class Choices(CompletionResultsBase): class Completions(CompletionResultsBase): """The results of a completion operation.""" - # An optional hint which prints above completion suggestions - completion_hint: str = "" + # Optional hint which prints above completion suggestions + hint: str = "" # Optional message to display if an error occurs during completion - completion_error: str = "" + error: str = "" - # An optional table string populated by the argparse completer - completion_table: str = "" + # Optional Rich table which provides more context for the data being completed + table: Table | None = None # If True, the completion engine is allowed to finalize a completion # when a single match is found by appending a trailing space and diff --git a/cmd2/pt_utils.py b/cmd2/pt_utils.py index f13855bb..54c1fd62 100644 --- a/cmd2/pt_utils.py +++ b/cmd2/pt_utils.py @@ -86,17 +86,20 @@ def get_completions(self, document: Document, _complete_event: object) -> Iterab text, line=line, begidx=begidx, endidx=endidx, custom_settings=self.custom_settings ) - if completions.completion_error: - print_formatted_text(pt_filter_style(completions.completion_error)) + if completions.error: + print_formatted_text(pt_filter_style(completions.error)) return # Print completion table if present - if completions.completion_table: - print_formatted_text(pt_filter_style("\n" + completions.completion_table)) + if completions.table is not None: + console = ru.Cmd2GeneralConsole(file=self.cmd_app.stdout) + with console.capture() as capture: + console.print(completions.table, end="", soft_wrap=False) + print_formatted_text(pt_filter_style("\n" + capture.get())) # Print hint if present and settings say we should - if completions.completion_hint and (self.cmd_app.always_show_hint or not completions): - print_formatted_text(pt_filter_style(completions.completion_hint)) + if completions.hint and (self.cmd_app.always_show_hint or not completions): + print_formatted_text(pt_filter_style(completions.hint)) if not completions: return diff --git a/examples/argparse_completion.py b/examples/argparse_completion.py index fa470b06..b6d3e40b 100755 --- a/examples/argparse_completion.py +++ b/examples/argparse_completion.py @@ -67,13 +67,13 @@ def choices_completion_tables(self) -> Choices: 5: table_item, } - completion_items = [CompletionItem(item_id, table_row=[description]) for item_id, description in item_dict.items()] + completion_items = [CompletionItem(item_id, table_data=[description]) for item_id, description in item_dict.items()] return Choices(items=completion_items) def choices_arg_tokens(self, arg_tokens: dict[str, list[str]]) -> Choices: """If a choices or completer function/method takes a value called arg_tokens, then it will be passed a dictionary that maps the command line tokens up through the one being completed - to their argparse argument name. All values of the arg_tokens dictionary are lists, even if + to their argparse destination name. All values of the arg_tokens dictionary are lists, even if a particular argument expects only 1 token. """ # Check if choices_provider flag has appeared @@ -113,8 +113,8 @@ def choices_arg_tokens(self, arg_tokens: dict[str, list[str]]) -> Choices: '--completion_table', choices_provider=choices_completion_tables, metavar="ITEM_ID", - table_header=["Description"], - help="demonstrate use of CompletionItems", + table_columns=["Description"], + help="demonstrate use of completion table", ) # Demonstrate use of arg_tokens dictionary diff --git a/tests/test_argparse_completer.py b/tests/test_argparse_completer.py index 150f70cd..a7e1b3a1 100644 --- a/tests/test_argparse_completer.py +++ b/tests/test_argparse_completer.py @@ -21,7 +21,6 @@ from cmd2 import rich_utils as ru from .conftest import ( - normalize, run_cmd, with_ansi_style, ) @@ -106,7 +105,7 @@ def do_pos_and_flag(self, args: argparse.Namespace) -> None: ############################################################################################################ STR_METAVAR = "HEADLESS" TUPLE_METAVAR = ('arg1', 'others') - CUSTOM_TABLE_HEADER = ("Custom Header",) + DESCRIPTION_TABLE_COLUMNS = ("Description",) # tuples (for sake of immutability) used in our tests (there is a mix of sorted and unsorted on purpose) non_negative_num_choices = (1, 2, 3, 0.5, 22) @@ -114,17 +113,16 @@ 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', table_row=['Description 1']), - # Make this the longest description so we can test display width. - CompletionItem('choice_2', table_row=[su.stylize("String with style", style=cmd2.Color.BLUE)]), - CompletionItem('choice_3', table_row=[Text("Text with style", style=cmd2.Color.RED)]), + CompletionItem('choice_1', table_data=['Description 1']), + CompletionItem('choice_2', table_data=[su.stylize("String with style", style=cmd2.Color.BLUE)]), + CompletionItem('choice_3', table_data=[Text("Text with style", style=cmd2.Color.RED)]), ) # This tests that CompletionItems created with numerical values are sorted as numbers. num_completion_items = ( - CompletionItem(5, table_row=["Five"]), - CompletionItem(1.5, table_row=["One.Five"]), - CompletionItem(2, table_row=["Five"]), + CompletionItem(5, table_data=["Five"]), + CompletionItem(1.5, table_data=["One.Five"]), + CompletionItem(2, table_data=["Two"]), ) def choices_provider(self) -> Choices: @@ -136,51 +134,88 @@ def completion_item_method(self) -> list[CompletionItem]: items = [] for i in range(10): main_str = f'main_str{i}' - items.append(CompletionItem(main_str, table_row=['blah blah'])) + items.append(CompletionItem(main_str, table_data=['blah blah'])) return items choices_parser = Cmd2ArgumentParser() # Flag args for choices command. Include string and non-string arg types. - choices_parser.add_argument("-l", "--list", help="a flag populated with a choices list", choices=static_choices_list) choices_parser.add_argument( - "-p", "--provider", help="a flag populated with a choices provider", choices_provider=choices_provider + "-l", + "--list", + help="a flag populated with a choices list", + choices=static_choices_list, ) choices_parser.add_argument( - "--table_header", - help='this arg has a table header', + "-p", + "--provider", + help="a flag populated with a choices provider", + choices_provider=choices_provider, + ) + choices_parser.add_argument( + "--no_metavar", + help='this arg has no metavar', choices_provider=completion_item_method, - table_header=CUSTOM_TABLE_HEADER, + table_columns=DESCRIPTION_TABLE_COLUMNS, ) choices_parser.add_argument( - "--no_header", - help='this arg has no table header', + "--str_metavar", + help='this arg has str for a metavar', choices_provider=completion_item_method, metavar=STR_METAVAR, + table_columns=DESCRIPTION_TABLE_COLUMNS, ) choices_parser.add_argument( '-t', "--tuple_metavar", help='this arg has tuple for a metavar', - choices_provider=completion_item_method, metavar=TUPLE_METAVAR, nargs=argparse.ONE_OR_MORE, + choices_provider=completion_item_method, + table_columns=DESCRIPTION_TABLE_COLUMNS, + ) + choices_parser.add_argument( + '-n', + '--num', + type=int, + help='a flag with an int type', + choices=num_choices, + ) + choices_parser.add_argument( + '--completion_items', + help='choices are CompletionItems', + choices=completion_item_choices, + table_columns=DESCRIPTION_TABLE_COLUMNS, ) - choices_parser.add_argument('-n', '--num', type=int, help='a flag with an int type', choices=num_choices) - choices_parser.add_argument('--completion_items', help='choices are CompletionItems', choices=completion_item_choices) choices_parser.add_argument( - '--num_completion_items', help='choices are numerical CompletionItems', choices=num_completion_items + '--num_completion_items', + help='choices are numerical CompletionItems', + choices=num_completion_items, + table_columns=DESCRIPTION_TABLE_COLUMNS, ) # Positional args for choices command - choices_parser.add_argument("list_pos", help="a positional populated with a choices list", choices=static_choices_list) choices_parser.add_argument( - "method_pos", help="a positional populated with a choices provider", choices_provider=choices_provider + "list_pos", + help="a positional populated with a choices list", + choices=static_choices_list, ) choices_parser.add_argument( - 'non_negative_num', type=int, help='a positional with non-negative numerical choices', choices=non_negative_num_choices + "method_pos", + help="a positional populated with a choices provider", + choices_provider=choices_provider, + ) + choices_parser.add_argument( + 'non_negative_num', + type=int, + help='a positional with non-negative numerical choices', + choices=non_negative_num_choices, + ) + choices_parser.add_argument( + 'empty_choices', + help='a positional with empty choices', + choices=[], ) - choices_parser.add_argument('empty_choices', help='a positional with empty choices', choices=[]) @with_argparser(choices_parser) def do_choices(self, args: argparse.Namespace) -> None: @@ -271,13 +306,13 @@ def completer_raise_error(self, text: str, line: str, begidx: int, endidx: int) """Raises CompletionError""" raise CompletionError('completer broke something') - def choice_raise_error(self) -> list[str]: + def choice_raise_completion_error(self) -> list[str]: """Raises CompletionError""" raise CompletionError('choice broke something') comp_error_parser = Cmd2ArgumentParser() comp_error_parser.add_argument('completer_pos', help='positional arg', completer=completer_raise_error) - comp_error_parser.add_argument('--choice', help='flag arg', choices_provider=choice_raise_error) + comp_error_parser.add_argument('--choice', help='flag arg', choices_provider=choice_raise_completion_error) @with_argparser(comp_error_parser) def do_raise_completion_error(self, args: argparse.Namespace) -> None: @@ -655,8 +690,8 @@ def test_autocomp_blank_token(ac_app) -> None: @with_ansi_style(ru.AllowStyle.ALWAYS) -def test_completion_tables(ac_app) -> None: - # First test completion table created from strings +def test_completion_tables_strings(ac_app) -> None: + # Test completion table created from strings text = '' line = f'choices --completion_items {text}' endidx = len(line) @@ -664,22 +699,38 @@ def test_completion_tables(ac_app) -> None: completions = ac_app.complete(text, line, begidx, endidx) assert len(completions) == len(ac_app.completion_item_choices) - lines = completions.completion_table.splitlines() + assert completions.table is not None + + # Verify the column for the item being completed + col_0_cells = list(completions.table.columns[0].cells) + assert len(col_0_cells) == 3 + + # Since the completed item column is all strings, it is left-aligned + assert completions.table.columns[0].justify == "left" + assert completions.table.columns[0].header == "COMPLETION_ITEMS" + + # ArgparseCompleter converts all items in this column to Rich Text objects + assert col_0_cells[0].plain == "choice_1" + assert col_0_cells[1].plain == "choice_2" + assert col_0_cells[2].plain == "choice_3" + + # Verify the column containing contextual data about the item being completed + col_1_cells = list(completions.table.columns[1].cells) + assert len(col_1_cells) == 3 - # Since the completion table was 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') + # Strings with no ANSI style remain strings + assert col_1_cells[0] == "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 ") + # CompletionItem converts strings with ANSI styles to Rich Text objects + assert col_1_cells[1].plain == "String with style" - # Verify that the styled Rich Text also rendered. - assert lines[4].endswith("\x1b[31mText with style \x1b[0m ") + # This item was already a Rich Text object + assert col_1_cells[2].plain == "Text with style" - # Now test completion table created from numbers + +@with_ansi_style(ru.AllowStyle.ALWAYS) +def test_completion_tables_numbers(ac_app) -> None: + # Test completion table created from numbers text = '' line = f'choices --num_completion_items {text}' endidx = len(line) @@ -687,12 +738,26 @@ def test_completion_tables(ac_app) -> None: completions = ac_app.complete(text, line, begidx, endidx) assert len(completions) == len(ac_app.num_completion_items) - lines = completions.completion_table.splitlines() + assert completions.table is not None + + # Verify the column for the item being completed + col_0_cells = list(completions.table.columns[0].cells) + assert len(col_0_cells) == 3 + + # Since the completed item column is all numbers, it is right-aligned + assert completions.table.columns[0].justify == "right" - # Since the completion table was 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') + # ArgparseCompleter converts all items in this column to Rich Text objects + assert col_0_cells[0].plain == "1.5" + assert col_0_cells[1].plain == "2" + assert col_0_cells[2].plain == "5" + + # Verify the column containing contextual data about the item being completed + col_1_cells = list(completions.table.columns[1].cells) + assert len(col_1_cells) == 3 + assert col_1_cells[0] == "One.Five" + assert col_1_cells[1] == "Two" + assert col_1_cells[2] == "Five" @pytest.mark.parametrize( @@ -720,7 +785,7 @@ def test_max_completion_table_items(ac_app, num_aliases, show_table) -> None: completions = ac_app.complete(text, line, begidx, endidx) assert len(completions) == num_aliases - assert bool(completions.completion_table) == show_table + assert show_table == (completions.table is not None) @pytest.mark.parametrize( @@ -823,27 +888,29 @@ def test_unfinished_flag_error(ac_app, command_and_args, text, is_error) -> None begidx = endidx - len(text) completions = ac_app.complete(text, line, begidx, endidx) - assert is_error == all(x in completions.completion_error for x in ["Error: argument", "expected"]) + assert is_error == all(x in completions.error for x in ["Error: argument", "expected"]) -def test_completion_table_arg_header(ac_app) -> None: +def test_completion_table_metavar(ac_app) -> None: # Test when metavar is None text = '' - line = f'choices --table_header {text}' + line = f'choices --no_metavar {text}' endidx = len(line) begidx = endidx - len(text) completions = ac_app.complete(text, line, begidx, endidx) - assert "TABLE_HEADER" in normalize(completions.completion_table)[0] + assert completions.table is not None + assert completions.table.columns[0].header == "NO_METAVAR" # Test when metavar is a string text = '' - line = f'choices --no_header {text}' + line = f'choices --str_metavar {text}' endidx = len(line) begidx = endidx - len(text) completions = ac_app.complete(text, line, begidx, endidx) - assert ac_app.STR_METAVAR in normalize(completions.completion_table)[0] + assert completions.table is not None + assert completions.table.columns[0].header == ac_app.STR_METAVAR # Test when metavar is a tuple text = '' @@ -853,7 +920,8 @@ def test_completion_table_arg_header(ac_app) -> None: # We are completing the first argument of this flag. The first element in the tuple should be the column header. completions = ac_app.complete(text, line, begidx, endidx) - assert ac_app.TUPLE_METAVAR[0].upper() in normalize(completions.completion_table)[0] + assert completions.table is not None + assert completions.table.columns[0].header == ac_app.TUPLE_METAVAR[0].upper() text = '' line = f'choices --tuple_metavar token_1 {text}' @@ -862,7 +930,8 @@ def test_completion_table_arg_header(ac_app) -> None: # We are completing the second argument of this flag. The second element in the tuple should be the column header. completions = ac_app.complete(text, line, begidx, endidx) - assert ac_app.TUPLE_METAVAR[1].upper() in normalize(completions.completion_table)[0] + assert completions.table is not None + assert completions.table.columns[0].header == ac_app.TUPLE_METAVAR[1].upper() text = '' line = f'choices --tuple_metavar token_1 token_2 {text}' @@ -872,31 +941,8 @@ def test_completion_table_arg_header(ac_app) -> None: # We are completing the third argument of this flag. It should still be the second tuple element # in the column header since the tuple only has two strings in it. completions = ac_app.complete(text, line, begidx, endidx) - assert ac_app.TUPLE_METAVAR[1].upper() in normalize(completions.completion_table)[0] - - -def test_completion_table_header(ac_app) -> None: - from cmd2.argparse_completer import ( - DEFAULT_TABLE_HEADER, - ) - - # This argument provided a table header - text = '' - line = f'choices --table_header {text}' - endidx = len(line) - begidx = endidx - len(text) - - completions = ac_app.complete(text, line, begidx, endidx) - assert ac_app.CUSTOM_TABLE_HEADER[0] in normalize(completions.completion_table)[0] - - # This argument did not provide a table header, so it should be DEFAULT_TABLE_HEADER - text = '' - line = f'choices --no_header {text}' - endidx = len(line) - begidx = endidx - len(text) - - completions = ac_app.complete(text, line, begidx, endidx) - assert DEFAULT_TABLE_HEADER[0] in normalize(completions.completion_table)[0] + assert completions.table is not None + assert completions.table.columns[0].header == ac_app.TUPLE_METAVAR[1].upper() @pytest.mark.parametrize( @@ -933,9 +979,9 @@ def test_autocomp_no_results_hint(ac_app, command_and_args, text, has_hint) -> N completions = ac_app.complete(text, line, begidx, endidx) if has_hint: - assert "Hint:\n" in completions.completion_error + assert "Hint:\n" in completions.error else: - assert not completions.completion_error + assert not completions.error def test_autocomp_hint_no_help_text(ac_app) -> None: @@ -946,7 +992,7 @@ def test_autocomp_hint_no_help_text(ac_app) -> None: begidx = endidx - len(text) completions = ac_app.complete(text, line, begidx, endidx) - assert completions.completion_error.strip() == "Hint:\n no_help_pos" + assert completions.error.strip() == "Hint:\n no_help_pos" @pytest.mark.parametrize( @@ -964,7 +1010,7 @@ def test_completion_error(ac_app, args, text) -> None: begidx = endidx - len(text) completions = ac_app.complete(text, line, begidx, endidx) - assert f"{text} broke something" in completions.completion_error + assert f"{text} broke something" in completions.error @pytest.mark.parametrize( @@ -1022,7 +1068,7 @@ def test_complete_mutex_group(ac_app, command_and_args, text, output_contains, f else: assert first_match == completions[0].text - assert output_contains in completions.completion_error + assert output_contains in completions.error def test_single_prefix_char() -> None: @@ -1130,6 +1176,94 @@ def test_display_meta(ac_app, subcommand, flag, display_meta) -> None: assert completions[0].display_meta == display_meta +def test_validate_table_data_no_table() -> None: + action = argparse.Action(option_strings=['-f'], dest='foo') + action.set_table_columns(None) + arg_state = argparse_completer._ArgumentState(action) + completions = Completions( + [ + CompletionItem('item1'), + CompletionItem('item2'), + ] + ) + + # This should not raise an exception + argparse_completer.ArgparseCompleter._validate_table_data(arg_state, completions) + + +def test_validate_table_data_missing_columns() -> None: + action = argparse.Action(option_strings=['-f'], dest='foo') + action.set_table_columns(None) + arg_state = argparse_completer._ArgumentState(action) + + completions = Completions( + [ + CompletionItem('item1', table_data=['data1']), + CompletionItem('item2', table_data=['data2']), + ] + ) + + with pytest.raises( + ValueError, + match="Argument 'foo' has CompletionItems with table_data, but no table_columns were defined", + ): + argparse_completer.ArgparseCompleter._validate_table_data(arg_state, completions) + + +def test_validate_table_data_missing_item_data() -> None: + action = argparse.Action(option_strings=['-f'], dest='foo') + action.set_table_columns(['Col1']) + arg_state = argparse_completer._ArgumentState(action) + + completions = Completions( + [ + CompletionItem('item1', table_data=['data1']), + CompletionItem('item2'), # Missing table_data + ] + ) + + with pytest.raises( + ValueError, + match="Argument 'foo' has table_columns defined, but the CompletionItem for 'item2' is missing table_data", + ): + argparse_completer.ArgparseCompleter._validate_table_data(arg_state, completions) + + +def test_validate_table_data_length_mismatch() -> None: + action = argparse.Action(option_strings=['-f'], dest='foo') + action.set_table_columns(['Col1', 'Col2']) + arg_state = argparse_completer._ArgumentState(action) + + completions = Completions( + [ + CompletionItem('item1', table_data=['data1a', 'data1b']), + CompletionItem('item2', table_data=['only_one']), + ] + ) + + with pytest.raises( + ValueError, + match=r"Argument 'foo': table_data length \(1\) does not match table_columns length \(2\) for item 'item2'.", + ): + argparse_completer.ArgparseCompleter._validate_table_data(arg_state, completions) + + +def test_validate_table_data_valid() -> None: + action = argparse.Action(option_strings=['-f'], dest='foo') + action.get_table_columns = lambda: ['Col1', 'Col2'] + arg_state = argparse_completer._ArgumentState(action) + + completions = Completions( + [ + CompletionItem('item1', table_data=['data1a', 'data1b']), + CompletionItem('item2', table_data=['data2a', 'data2b']), + ] + ) + + # This should not raise an exception + argparse_completer.ArgparseCompleter._validate_table_data(arg_state, completions) + + # Custom ArgparseCompleter-based class class CustomCompleter(argparse_completer.ArgparseCompleter): def _complete_flags(self, text: str, line: str, begidx: int, endidx: int, matched_flags: list[str]) -> list[str]: diff --git a/tests/test_argparse_custom.py b/tests/test_argparse_custom.py index 953b6d91..1b063643 100644 --- a/tests/test_argparse_custom.py +++ b/tests/test_argparse_custom.py @@ -270,25 +270,25 @@ def test_apcustom_print_message(capsys) -> None: def test_generate_range_error() -> None: # max is INFINITY - err_str = generate_range_error(1, constants.INFINITY) - assert err_str == "expected at least 1 argument" + err_msg = generate_range_error(1, constants.INFINITY) + assert err_msg == "expected at least 1 argument" - err_str = generate_range_error(2, constants.INFINITY) - assert err_str == "expected at least 2 arguments" + err_msg = generate_range_error(2, constants.INFINITY) + assert err_msg == "expected at least 2 arguments" # min and max are equal - err_str = generate_range_error(1, 1) - assert err_str == "expected 1 argument" + err_msg = generate_range_error(1, 1) + assert err_msg == "expected 1 argument" - err_str = generate_range_error(2, 2) - assert err_str == "expected 2 arguments" + err_msg = generate_range_error(2, 2) + assert err_msg == "expected 2 arguments" # min and max are not equal - err_str = generate_range_error(0, 1) - assert err_str == "expected 0 to 1 argument" + err_msg = generate_range_error(0, 1) + assert err_msg == "expected 0 to 1 argument" - err_str = generate_range_error(0, 2) - assert err_str == "expected 0 to 2 arguments" + err_msg = generate_range_error(0, 2) + assert err_msg == "expected 0 to 2 arguments" def test_apcustom_metavar_tuple() -> None: diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index 3f27fa12..5cfd0d5e 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -2563,7 +2563,7 @@ def test_get_alias_choices(base_app: cmd2.Cmd) -> None: for cur_choice in choices: assert cur_choice.text in aliases assert cur_choice.display_meta == aliases[cur_choice.text] - assert cur_choice.table_row == (aliases[cur_choice.text],) + assert cur_choice.table_data == (aliases[cur_choice.text],) def test_get_macro_choices(base_app: cmd2.Cmd) -> None: @@ -2578,7 +2578,7 @@ def test_get_macro_choices(base_app: cmd2.Cmd) -> None: for cur_choice in choices: assert cur_choice.text in macros assert cur_choice.display_meta == macros[cur_choice.text].value - assert cur_choice.table_row == (macros[cur_choice.text].value,) + assert cur_choice.table_data == (macros[cur_choice.text].value,) def test_get_commands_aliases_and_macros_choices(base_app: cmd2.Cmd) -> None: @@ -2633,11 +2633,11 @@ def test_get_settable_choices(base_app: cmd2.Cmd) -> None: # Convert fields so we can compare them str_value = str(cur_settable.value) - choice_value = cur_choice.table_row[0] + choice_value = cur_choice.table_data[0] if isinstance(choice_value, Text): choice_value = ru.rich_text_to_string(choice_value) - choice_description = cur_choice.table_row[1] + choice_description = cur_choice.table_data[1] if isinstance(choice_description, Text): choice_description = ru.rich_text_to_string(choice_description) diff --git a/tests/test_commandset.py b/tests/test_commandset.py index c2749378..686c7928 100644 --- a/tests/test_commandset.py +++ b/tests/test_commandset.py @@ -923,7 +923,7 @@ def test_cross_commandset_completer(manual_command_sets_app) -> None: completions = manual_command_sets_app.complete(text, line, begidx, endidx) assert not completions - assert "Could not find CommandSet instance" in completions.completion_error + assert "Could not find CommandSet instance" in completions.error manual_command_sets_app.unregister_command_set(user_unrelated) @@ -944,7 +944,7 @@ def test_cross_commandset_completer(manual_command_sets_app) -> None: completions = manual_command_sets_app.complete(text, line, begidx, endidx) assert not completions - assert "Could not find CommandSet instance" in completions.completion_error + assert "Could not find CommandSet instance" in completions.error manual_command_sets_app.unregister_command_set(user_unrelated) manual_command_sets_app.unregister_command_set(user_sub2) diff --git a/tests/test_completion.py b/tests/test_completion.py index 2d257883..1492844a 100644 --- a/tests/test_completion.py +++ b/tests/test_completion.py @@ -228,7 +228,7 @@ def test_command_completion_nomatch(cmd2_app) -> None: assert not completions # ArgparseCompleter raises a _NoResultsError in this case - assert "Hint" in completions.completion_error + assert "Hint" in completions.error def test_complete_bogus_command(cmd2_app) -> None: @@ -251,7 +251,7 @@ def test_complete_exception(cmd2_app) -> None: completions = cmd2_app.complete(text, line, begidx, endidx) assert not completions - assert "IndexError" in completions.completion_error + assert "IndexError" in completions.error def test_complete_macro(base_app, request) -> None: @@ -1050,7 +1050,7 @@ def test_complete_set_value(cmd2_app) -> None: expected = ["SUCCESS"] completions = cmd2_app.complete(text, line, begidx, endidx) assert completions.to_strings() == Completions.from_values(expected).to_strings() - assert completions.completion_hint.strip() == "Hint:\n value a test settable param" + assert completions.hint.strip() == "Hint:\n value a test settable param" def test_complete_set_value_invalid_settable(cmd2_app) -> None: @@ -1061,7 +1061,7 @@ def test_complete_set_value_invalid_settable(cmd2_app) -> None: completions = cmd2_app.complete(text, line, begidx, endidx) assert not completions - assert "fake is not a settable parameter" in completions.completion_error + assert "fake is not a settable parameter" in completions.error @pytest.fixture diff --git a/tests/test_pt_utils.py b/tests/test_pt_utils.py index 69ef4c10..2664848e 100644 --- a/tests/test_pt_utils.py +++ b/tests/test_pt_utils.py @@ -1,5 +1,6 @@ """Unit tests for cmd2/pt_utils.py""" +import io import re from typing import Any, cast from unittest.mock import Mock @@ -10,6 +11,7 @@ ANSI, to_formatted_text, ) +from rich.table import Table import cmd2 from cmd2 import ( @@ -31,6 +33,7 @@ def __init__(self) -> None: # Return empty completions by default self.complete = Mock(return_value=cmd2.Completions()) + self.stdout = io.StringIO() self.always_show_hint = False self.statement_parser = Mock() self.statement_parser.terminators = [';'] @@ -286,7 +289,10 @@ def test_get_completions(self, mock_cmd_app: MockCmd, monkeypatch) -> None: cmd2.CompletionItem(foo_text, display=foo_display, display_meta=foo_meta), cmd2.CompletionItem(bar_text, display=bar_display, display_meta=bar_meta), ] - cmd2_completions = cmd2.Completions(completion_items, completion_table="Table Data") + + table = Table("Table Header") + table.add_row("Table Data") + cmd2_completions = cmd2.Completions(completion_items, table=table) mock_cmd_app.complete.return_value = cmd2_completions # Call get_completions @@ -305,7 +311,8 @@ def test_get_completions(self, mock_cmd_app: MockCmd, monkeypatch) -> None: # Verify that only the completion table printed assert mock_print.call_count == 1 args, _ = mock_print.call_args - assert cmd2_completions.completion_table in str(args[0]) + assert "Table Header" in str(args[0]) + assert "Table Data" in str(args[0]) def test_get_completions_no_matches(self, mock_cmd_app: MockCmd, monkeypatch) -> None: """Test get_completions with no matches.""" @@ -317,7 +324,7 @@ def test_get_completions_no_matches(self, mock_cmd_app: MockCmd, monkeypatch) -> document = Document("", cursor_position=0) # Set up matches - cmd2_completions = cmd2.Completions(completion_hint="Completion Hint") + cmd2_completions = cmd2.Completions(hint="Completion Hint") mock_cmd_app.complete.return_value = cmd2_completions completions = list(completer.get_completions(document, None)) @@ -326,7 +333,7 @@ def test_get_completions_no_matches(self, mock_cmd_app: MockCmd, monkeypatch) -> # Verify that only the completion hint printed assert mock_print.call_count == 1 args, _ = mock_print.call_args - assert cmd2_completions.completion_hint in str(args[0]) + assert cmd2_completions.hint in str(args[0]) def test_get_completions_always_show_hints(self, mock_cmd_app: MockCmd, monkeypatch) -> None: """Test that get_completions respects 'always_show_hint' and prints a hint even with no matches.""" @@ -340,7 +347,7 @@ def test_get_completions_always_show_hints(self, mock_cmd_app: MockCmd, monkeypa mock_cmd_app.always_show_hint = True # Set up matches - cmd2_completions = cmd2.Completions(completion_hint="Completion Hint") + cmd2_completions = cmd2.Completions(hint="Completion Hint") mock_cmd_app.complete.return_value = cmd2_completions completions = list(completer.get_completions(document, None)) @@ -349,10 +356,10 @@ def test_get_completions_always_show_hints(self, mock_cmd_app: MockCmd, monkeypa # Verify that only the completion hint printed assert mock_print.call_count == 1 args, _ = mock_print.call_args - assert cmd2_completions.completion_hint in str(args[0]) + assert cmd2_completions.hint in str(args[0]) def test_get_completions_with_error(self, mock_cmd_app: MockCmd, monkeypatch) -> None: - """Test get_completions with a completion_error.""" + """Test get_completions with a completion error.""" mock_print = Mock() monkeypatch.setattr(pt_utils, "print_formatted_text", mock_print) @@ -361,7 +368,7 @@ def test_get_completions_with_error(self, mock_cmd_app: MockCmd, monkeypatch) -> document = Document("", cursor_position=0) # Set up matches - cmd2_completions = cmd2.Completions(completion_error="Completion Error") + cmd2_completions = cmd2.Completions(error="Completion Error") mock_cmd_app.complete.return_value = cmd2_completions completions = list(completer.get_completions(document, None)) @@ -370,7 +377,7 @@ def test_get_completions_with_error(self, mock_cmd_app: MockCmd, monkeypatch) -> # Verify that only the completion error printed assert mock_print.call_count == 1 args, _ = mock_print.call_args - assert cmd2_completions.completion_error in str(args[0]) + assert cmd2_completions.error in str(args[0]) @pytest.mark.parametrize( # search_text_offset is the starting index of the user-provided search text within a full match.