From 6abfbd625e71e81dadf0f83b2fcec4f95afa6ca4 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Sat, 14 Mar 2026 13:26:39 -0400 Subject: [PATCH 1/5] Renamed completion_hint to hint. Renamed completion_error to error. Renamed completion_table to table and converted it from a string to a Rich Table. --- CHANGELOG.md | 4 +- cmd2/argparse_completer.py | 28 +++----- cmd2/cmd2.py | 14 ++-- cmd2/completion.py | 12 ++-- cmd2/pt_utils.py | 15 +++-- examples/argparse_completion.py | 2 +- tests/test_argparse_completer.py | 111 ++++++++++++++++++++----------- tests/test_commandset.py | 4 +- tests/test_completion.py | 8 +-- tests/test_pt_utils.py | 25 ++++--- 10 files changed, 132 insertions(+), 91 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 74e5acc66..d2ee05edb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,8 +42,8 @@ prompt is displayed. - `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 97d61fee7..2de583160 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 @@ -500,11 +499,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 +527,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,8 +591,8 @@ 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.""" + def _build_completion_table(self, arg_state: _ArgumentState, completions: Completions) -> Completions: + """Build a rich.Table for completion results if applicable.""" # 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: @@ -627,19 +626,14 @@ def _format_completions(self, arg_state: _ArgumentState, completions: Completion column if isinstance(column, Column) else Column(column, overflow="fold") for column in table_header ) - # 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_row) 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 +774,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/cmd2.py b/cmd2/cmd2.py index 7f0c7d158..e2f10637d 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: + 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.""" diff --git a/cmd2/completion.py b/cmd2/completion.py index 2c023dfe5..7814af5ee 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): @@ -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 f13855bb1..54c1fd62d 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 fa470b06e..722308349 100755 --- a/examples/argparse_completion.py +++ b/examples/argparse_completion.py @@ -114,7 +114,7 @@ def choices_arg_tokens(self, arg_tokens: dict[str, list[str]]) -> Choices: choices_provider=choices_completion_tables, metavar="ITEM_ID", table_header=["Description"], - help="demonstrate use of CompletionItems", + 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 150f70cdb..ec5279fd3 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, ) @@ -115,7 +114,6 @@ def do_pos_and_flag(self, args: argparse.Namespace) -> None: 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)]), ) @@ -124,7 +122,7 @@ def do_pos_and_flag(self, args: argparse.Namespace) -> None: num_completion_items = ( CompletionItem(5, table_row=["Five"]), CompletionItem(1.5, table_row=["One.Five"]), - CompletionItem(2, table_row=["Five"]), + CompletionItem(2, table_row=["Two"]), ) def choices_provider(self) -> Choices: @@ -271,13 +269,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 +653,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 +662,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 - # 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') + # Verify the column for the item being completed + col_0_cells = list(completions.table.columns[0].cells) + assert len(col_0_cells) == 3 - # 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 ") + # 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" - # Verify that the styled Rich Text also rendered. - assert lines[4].endswith("\x1b[31mText with style \x1b[0m ") + # 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" - # Now test completion table created from numbers + # 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 + + # Strings with no ANSI style remain strings + assert col_1_cells[0] == "Description 1" + + # CompletionItem converts strings with ANSI styles to Rich Text objects + assert col_1_cells[1].plain == "String with style" + + # This item was already a Rich Text object + assert col_1_cells[2].plain == "Text with style" + + +@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 +701,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" + + # 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" - # 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') + # 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 +748,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,7 +851,7 @@ 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: @@ -834,7 +862,8 @@ def test_completion_table_arg_header(ac_app) -> None: 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 == "TABLE_HEADER" # Test when metavar is a string text = '' @@ -843,7 +872,8 @@ def test_completion_table_arg_header(ac_app) -> None: 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 +883,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 +893,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,7 +904,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] + assert completions.table is not None + assert completions.table.columns[0].header == ac_app.TUPLE_METAVAR[1].upper() def test_completion_table_header(ac_app) -> None: @@ -887,7 +920,8 @@ def test_completion_table_header(ac_app) -> None: 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] + assert completions.table is not None + assert ac_app.CUSTOM_TABLE_HEADER[0] == completions.table.columns[1].header # This argument did not provide a table header, so it should be DEFAULT_TABLE_HEADER text = '' @@ -896,7 +930,8 @@ def test_completion_table_header(ac_app) -> None: 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 DEFAULT_TABLE_HEADER[0] == completions.table.columns[1].header @pytest.mark.parametrize( @@ -933,9 +968,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 +981,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 +999,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 +1057,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: diff --git a/tests/test_commandset.py b/tests/test_commandset.py index c27493786..686c79285 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 2d2578831..1492844a3 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 69ef4c105..2664848e3 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. From 082be108137dc71c6dda8e4ad0a5de164b71cbe5 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Sat, 14 Mar 2026 13:31:10 -0400 Subject: [PATCH 2/5] Renamed a variable. --- cmd2/argparse_custom.py | 12 ++++++------ tests/test_argparse_custom.py | 24 ++++++++++++------------ 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/cmd2/argparse_custom.py b/cmd2/argparse_custom.py index 77233080f..348750757 100644 --- a/cmd2/argparse_custom.py +++ b/cmd2/argparse_custom.py @@ -309,21 +309,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: diff --git a/tests/test_argparse_custom.py b/tests/test_argparse_custom.py index 953b6d914..1b063643b 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: From 77d432beab6edae5fe0000df6c3eb697ee4285c6 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Sat, 14 Mar 2026 15:37:54 -0400 Subject: [PATCH 3/5] Removed DEFAULT_TABLE_HEADER. --- CHANGELOG.md | 2 + cmd2/argparse_completer.py | 51 +++++++-- tests/test_argparse_completer.py | 191 +++++++++++++++++++++++-------- 3 files changed, 186 insertions(+), 58 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d2ee05edb..458820e52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,8 @@ prompt is displayed. - `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`. + - Removed `DEFAULT_DESCRIPTIVE_HEADERS`. This means you must define `table_header` when using + `CompletionItem.table_row` 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` diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index 2de583160..3a83ed8c1 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -48,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' @@ -591,15 +588,48 @@ def _complete_flags(self, text: str, line: str, begidx: int, endidx: int, used_f return Completions(items) + @staticmethod + def _validate_table_data(arg_state: _ArgumentState, completions: Completions) -> None: + """Verify the integrity of completion table data. + + :raises ValueError: if there is an error with the data. + """ + table_header = arg_state.action.get_table_header() # type: ignore[attr-defined] + has_table_data = any(item.table_row for item in completions) + + if table_header is None: + if has_table_data: + raise ValueError( + f"Argument '{arg_state.action.dest}' has CompletionItems with table_row, " + f"but no table_header was defined in add_argument()." + ) + return + + # If header is defined, then every item must have data, and lengths must match + for item in completions: + if not item.table_row: + raise ValueError( + f"Argument '{arg_state.action.dest}' has table_header defined, " + f"but the CompletionItem for '{item.text}' is missing table_row." + ) + if len(item.table_row) != len(table_header): + raise ValueError( + f"Argument '{arg_state.action.dest}': table_row length ({len(item.table_row)}) " + f"does not match table_header length ({len(table_header)}) for item '{item.text}'." + ) + def _build_completion_table(self, arg_state: _ArgumentState, completions: Completions) -> Completions: """Build a rich.Table for completion results if applicable.""" - # 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 + # Verify integrity of completion data + self._validate_table_data(arg_state, completions) - # Ensure every item provides table metadata to avoid an incomplete table. - if not all(item.table_row for item in completions): + table_header = cast( + Sequence[str | Column] | None, + arg_state.action.get_table_header(), # 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_header is None: return completions # If a metavar was defined, use that instead of the dest field @@ -619,9 +649,6 @@ def _build_completion_table(self, arg_state: _ArgumentState, completions: Comple # 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 ) diff --git a/tests/test_argparse_completer.py b/tests/test_argparse_completer.py index ec5279fd3..1c0628183 100644 --- a/tests/test_argparse_completer.py +++ b/tests/test_argparse_completer.py @@ -105,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_HEADER = ("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) @@ -140,45 +140,82 @@ def completion_item_method(self) -> list[CompletionItem]: 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_header=DESCRIPTION_TABLE_HEADER, ) 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_header=DESCRIPTION_TABLE_HEADER, ) 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_header=DESCRIPTION_TABLE_HEADER, + ) + 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_header=DESCRIPTION_TABLE_HEADER, ) - 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_header=DESCRIPTION_TABLE_HEADER, ) # 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( + "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 + '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: @@ -854,20 +891,20 @@ def test_unfinished_flag_error(ac_app, command_and_args, text, is_error) -> None 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 completions.table is not None - assert completions.table.columns[0].header == "TABLE_HEADER" + 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) @@ -908,32 +945,6 @@ def test_completion_table_arg_header(ac_app) -> None: assert completions.table.columns[0].header == ac_app.TUPLE_METAVAR[1].upper() -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 completions.table is not None - assert ac_app.CUSTOM_TABLE_HEADER[0] == completions.table.columns[1].header - - # 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 completions.table is not None - assert DEFAULT_TABLE_HEADER[0] == completions.table.columns[1].header - - @pytest.mark.parametrize( ('command_and_args', 'text', 'has_hint'), [ @@ -1165,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_header(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_header() -> None: + action = argparse.Action(option_strings=['-f'], dest='foo') + action.set_table_header(None) + arg_state = argparse_completer._ArgumentState(action) + + completions = Completions( + [ + CompletionItem('item1', table_row=['data1']), + CompletionItem('item2', table_row=['data2']), + ] + ) + + with pytest.raises( + ValueError, + match="Argument 'foo' has CompletionItems with table_row, but no table_header was defined", + ): + argparse_completer.ArgparseCompleter._validate_table_data(arg_state, completions) + + +def test_validate_table_data_missing_row_data() -> None: + action = argparse.Action(option_strings=['-f'], dest='foo') + action.set_table_header(['Col1']) + arg_state = argparse_completer._ArgumentState(action) + + completions = Completions( + [ + CompletionItem('item1', table_row=['data1']), + CompletionItem('item2'), # Missing table_row + ] + ) + + with pytest.raises( + ValueError, + match="Argument 'foo' has table_header defined, but the CompletionItem for 'item2' is missing table_row", + ): + argparse_completer.ArgparseCompleter._validate_table_data(arg_state, completions) + + +def test_validate_table_row_data_length_mismatch() -> None: + action = argparse.Action(option_strings=['-f'], dest='foo') + action.set_table_header(['Col1', 'Col2']) + arg_state = argparse_completer._ArgumentState(action) + + completions = Completions( + [ + CompletionItem('item1', table_row=['data1a', 'data1b']), + CompletionItem('item2', table_row=['only_one']), + ] + ) + + with pytest.raises( + ValueError, + match=r"Argument 'foo': table_row length \(1\) does not match table_header 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_header = lambda: ['Col1', 'Col2'] + arg_state = argparse_completer._ArgumentState(action) + + completions = Completions( + [ + CompletionItem('item1', table_row=['data1a', 'data1b']), + CompletionItem('item2', table_row=['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]: From 76941a6231b18a616714e35a5e061ee739b1d40d Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Sat, 14 Mar 2026 16:28:27 -0400 Subject: [PATCH 4/5] Renamed parser's table_header to table_columns. Renamed CompletionItem's table_row to table_data. --- CHANGELOG.md | 8 ++-- cmd2/argparse_completer.py | 38 ++++++++------- cmd2/argparse_custom.py | 81 ++++++++++++++++---------------- cmd2/cmd2.py | 18 +++---- cmd2/completion.py | 14 +++--- examples/argparse_completion.py | 6 +-- tests/test_argparse_completer.py | 64 ++++++++++++------------- tests/test_cmd2.py | 8 ++-- 8 files changed, 120 insertions(+), 117 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 458820e52..185b9fb4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,10 +37,10 @@ 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`. - - Removed `DEFAULT_DESCRIPTIVE_HEADERS`. This means you must define `table_header` when using - `CompletionItem.table_row` data. + - 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` diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index 3a83ed8c1..8047f9c79 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -594,28 +594,28 @@ def _validate_table_data(arg_state: _ArgumentState, completions: Completions) -> :raises ValueError: if there is an error with the data. """ - table_header = arg_state.action.get_table_header() # type: ignore[attr-defined] - has_table_data = any(item.table_row for item in completions) + 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_header is None: + if table_columns is None: if has_table_data: raise ValueError( - f"Argument '{arg_state.action.dest}' has CompletionItems with table_row, " - f"but no table_header was defined in add_argument()." + f"Argument '{arg_state.action.dest}' has CompletionItems with table_data, " + f"but no table_columns were defined in add_argument()." ) return - # If header is defined, then every item must have data, and lengths must match + # If columns are defined, then every item must have data, and lengths must match for item in completions: - if not item.table_row: + if not item.table_data: raise ValueError( - f"Argument '{arg_state.action.dest}' has table_header defined, " - f"but the CompletionItem for '{item.text}' is missing table_row." + 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_row) != len(table_header): + if len(item.table_data) != len(table_columns): raise ValueError( - f"Argument '{arg_state.action.dest}': table_row length ({len(item.table_row)}) " - f"does not match table_header length ({len(table_header)}) for item '{item.text}'." + 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: @@ -623,13 +623,17 @@ def _build_completion_table(self, arg_state: _ArgumentState, completions: Comple # Verify integrity of completion data self._validate_table_data(arg_state, completions) - table_header = cast( + table_columns = cast( Sequence[str | Column] | None, - arg_state.action.get_table_header(), # type: ignore[attr-defined] + 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_header is None: + 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 @@ -650,13 +654,13 @@ def _build_completion_table(self, arg_state: _ArgumentState, completions: Comple rich_columns: list[Column] = [] rich_columns.append(Column(destination.upper(), justify="right" if all_nums else "left", no_wrap=True)) 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 ) # Build the table table = Table(*rich_columns, box=SIMPLE_HEAD, show_edge=False, border_style=Cmd2Style.TABLE_BORDER) for item in completions: - table.add_row(Text.from_ansi(item.display), *item.table_row) + table.add_row(Text.from_ansi(item.display), *item.table_data) return dataclasses.replace( completions, diff --git a/cmd2/argparse_custom.py b/cmd2/argparse_custom.py index 348750757..e96097953 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. @@ -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 e2f10637d..c7b213edc 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -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 7814af5ee..6364be4b4 100644 --- a/cmd2/completion.py +++ b/cmd2/completion.py @@ -67,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__(). @@ -91,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), ) @@ -109,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 diff --git a/examples/argparse_completion.py b/examples/argparse_completion.py index 722308349..b6d3e40b7 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,7 +113,7 @@ 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"], + table_columns=["Description"], help="demonstrate use of completion table", ) diff --git a/tests/test_argparse_completer.py b/tests/test_argparse_completer.py index 1c0628183..a7e1b3a1b 100644 --- a/tests/test_argparse_completer.py +++ b/tests/test_argparse_completer.py @@ -105,7 +105,7 @@ def do_pos_and_flag(self, args: argparse.Namespace) -> None: ############################################################################################################ STR_METAVAR = "HEADLESS" TUPLE_METAVAR = ('arg1', 'others') - DESCRIPTION_TABLE_HEADER = ("Description",) + 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) @@ -113,16 +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']), - 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=["Two"]), + CompletionItem(5, table_data=["Five"]), + CompletionItem(1.5, table_data=["One.Five"]), + CompletionItem(2, table_data=["Two"]), ) def choices_provider(self) -> Choices: @@ -134,7 +134,7 @@ 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() @@ -156,14 +156,14 @@ def completion_item_method(self) -> list[CompletionItem]: "--no_metavar", help='this arg has no metavar', choices_provider=completion_item_method, - table_header=DESCRIPTION_TABLE_HEADER, + table_columns=DESCRIPTION_TABLE_COLUMNS, ) choices_parser.add_argument( "--str_metavar", help='this arg has str for a metavar', choices_provider=completion_item_method, metavar=STR_METAVAR, - table_header=DESCRIPTION_TABLE_HEADER, + table_columns=DESCRIPTION_TABLE_COLUMNS, ) choices_parser.add_argument( '-t', @@ -172,7 +172,7 @@ def completion_item_method(self) -> list[CompletionItem]: metavar=TUPLE_METAVAR, nargs=argparse.ONE_OR_MORE, choices_provider=completion_item_method, - table_header=DESCRIPTION_TABLE_HEADER, + table_columns=DESCRIPTION_TABLE_COLUMNS, ) choices_parser.add_argument( '-n', @@ -185,13 +185,13 @@ def completion_item_method(self) -> list[CompletionItem]: '--completion_items', help='choices are CompletionItems', choices=completion_item_choices, - table_header=DESCRIPTION_TABLE_HEADER, + table_columns=DESCRIPTION_TABLE_COLUMNS, ) choices_parser.add_argument( '--num_completion_items', help='choices are numerical CompletionItems', choices=num_completion_items, - table_header=DESCRIPTION_TABLE_HEADER, + table_columns=DESCRIPTION_TABLE_COLUMNS, ) # Positional args for choices command @@ -1178,7 +1178,7 @@ def test_display_meta(ac_app, subcommand, flag, display_meta) -> None: def test_validate_table_data_no_table() -> None: action = argparse.Action(option_strings=['-f'], dest='foo') - action.set_table_header(None) + action.set_table_columns(None) arg_state = argparse_completer._ArgumentState(action) completions = Completions( [ @@ -1191,72 +1191,72 @@ def test_validate_table_data_no_table() -> None: argparse_completer.ArgparseCompleter._validate_table_data(arg_state, completions) -def test_validate_table_data_missing_header() -> None: +def test_validate_table_data_missing_columns() -> None: action = argparse.Action(option_strings=['-f'], dest='foo') - action.set_table_header(None) + action.set_table_columns(None) arg_state = argparse_completer._ArgumentState(action) completions = Completions( [ - CompletionItem('item1', table_row=['data1']), - CompletionItem('item2', table_row=['data2']), + CompletionItem('item1', table_data=['data1']), + CompletionItem('item2', table_data=['data2']), ] ) with pytest.raises( ValueError, - match="Argument 'foo' has CompletionItems with table_row, but no table_header was defined", + 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_row_data() -> None: +def test_validate_table_data_missing_item_data() -> None: action = argparse.Action(option_strings=['-f'], dest='foo') - action.set_table_header(['Col1']) + action.set_table_columns(['Col1']) arg_state = argparse_completer._ArgumentState(action) completions = Completions( [ - CompletionItem('item1', table_row=['data1']), - CompletionItem('item2'), # Missing table_row + CompletionItem('item1', table_data=['data1']), + CompletionItem('item2'), # Missing table_data ] ) with pytest.raises( ValueError, - match="Argument 'foo' has table_header defined, but the CompletionItem for 'item2' is missing table_row", + 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_row_data_length_mismatch() -> None: +def test_validate_table_data_length_mismatch() -> None: action = argparse.Action(option_strings=['-f'], dest='foo') - action.set_table_header(['Col1', 'Col2']) + action.set_table_columns(['Col1', 'Col2']) arg_state = argparse_completer._ArgumentState(action) completions = Completions( [ - CompletionItem('item1', table_row=['data1a', 'data1b']), - CompletionItem('item2', table_row=['only_one']), + CompletionItem('item1', table_data=['data1a', 'data1b']), + CompletionItem('item2', table_data=['only_one']), ] ) with pytest.raises( ValueError, - match=r"Argument 'foo': table_row length \(1\) does not match table_header length \(2\) for item 'item2'.", + 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_header = lambda: ['Col1', 'Col2'] + action.get_table_columns = lambda: ['Col1', 'Col2'] arg_state = argparse_completer._ArgumentState(action) completions = Completions( [ - CompletionItem('item1', table_row=['data1a', 'data1b']), - CompletionItem('item2', table_row=['data2a', 'data2b']), + CompletionItem('item1', table_data=['data1a', 'data1b']), + CompletionItem('item2', table_data=['data2a', 'data2b']), ] ) diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index 3f27fa12d..5cfd0d5e4 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) From 688bba775ebd92db618d8775d6ebc37d7926606a Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Sat, 14 Mar 2026 17:15:31 -0400 Subject: [PATCH 5/5] Fixed spelling. --- cmd2/cmd2.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index c7b213edc..a8f5ee52d 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -2470,7 +2470,7 @@ def complete( error_msg = str(ex) formatted_error = "" - # Don't display anything if the error is blank (e.g. _NoResultsError for an argument which supresses hints) + # 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"