Skip to content

Commit 77d432b

Browse files
committed
Removed DEFAULT_TABLE_HEADER.
1 parent 082be10 commit 77d432b

3 files changed

Lines changed: 186 additions & 58 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ prompt is displayed.
3939
- `choices_provider` functions must now return a `cmd2.Choices` object instead of `list[str]`.
4040
- An argparse argument's `descriptive_headers` field is now called `table_header`.
4141
- `CompletionItem.descriptive_data` is now called `CompletionItem.table_row`.
42+
- Removed `DEFAULT_DESCRIPTIVE_HEADERS`. This means you must define `table_header` when using
43+
`CompletionItem.table_row` data.
4244
- `Cmd.default_sort_key` moved to `utils.DEFAULT_STR_SORT_KEY`.
4345
- Moved completion state data, which previously resided in `Cmd`, into other classes.
4446
- `Cmd.matches_sorted` -> `Completions.is_sorted` and `Choices.is_sorted`

cmd2/argparse_completer.py

Lines changed: 39 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,6 @@
4848
from .exceptions import CompletionError
4949
from .styles import Cmd2Style
5050

51-
# If no table header is supplied, then this will be used instead
52-
DEFAULT_TABLE_HEADER: Sequence[str | Column] = ['Description']
53-
5451
# Name of the choice/completer function argument that, if present, will be passed a dictionary of
5552
# command line tokens up through the token being completed mapped to their argparse destination name.
5653
ARG_TOKENS = 'arg_tokens'
@@ -591,15 +588,48 @@ def _complete_flags(self, text: str, line: str, begidx: int, endidx: int, used_f
591588

592589
return Completions(items)
593590

591+
@staticmethod
592+
def _validate_table_data(arg_state: _ArgumentState, completions: Completions) -> None:
593+
"""Verify the integrity of completion table data.
594+
595+
:raises ValueError: if there is an error with the data.
596+
"""
597+
table_header = arg_state.action.get_table_header() # type: ignore[attr-defined]
598+
has_table_data = any(item.table_row for item in completions)
599+
600+
if table_header is None:
601+
if has_table_data:
602+
raise ValueError(
603+
f"Argument '{arg_state.action.dest}' has CompletionItems with table_row, "
604+
f"but no table_header was defined in add_argument()."
605+
)
606+
return
607+
608+
# If header is defined, then every item must have data, and lengths must match
609+
for item in completions:
610+
if not item.table_row:
611+
raise ValueError(
612+
f"Argument '{arg_state.action.dest}' has table_header defined, "
613+
f"but the CompletionItem for '{item.text}' is missing table_row."
614+
)
615+
if len(item.table_row) != len(table_header):
616+
raise ValueError(
617+
f"Argument '{arg_state.action.dest}': table_row length ({len(item.table_row)}) "
618+
f"does not match table_header length ({len(table_header)}) for item '{item.text}'."
619+
)
620+
594621
def _build_completion_table(self, arg_state: _ArgumentState, completions: Completions) -> Completions:
595622
"""Build a rich.Table for completion results if applicable."""
596-
# Skip table generation for single results or if the list exceeds the
597-
# user-defined threshold for table display.
598-
if len(completions) < 2 or len(completions) > self._cmd2_app.max_completion_table_items:
599-
return completions
623+
# Verify integrity of completion data
624+
self._validate_table_data(arg_state, completions)
600625

601-
# Ensure every item provides table metadata to avoid an incomplete table.
602-
if not all(item.table_row for item in completions):
626+
table_header = cast(
627+
Sequence[str | Column] | None,
628+
arg_state.action.get_table_header(), # type: ignore[attr-defined]
629+
)
630+
631+
# Skip table generation if results are outside thresholds or no columns are defined
632+
if len(completions) < 2 or len(completions) > self._cmd2_app.max_completion_table_items or table_header is None:
603633
return completions
604634

605635
# 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
619649
# Build header row
620650
rich_columns: list[Column] = []
621651
rich_columns.append(Column(destination.upper(), justify="right" if all_nums else "left", no_wrap=True))
622-
table_header = cast(Sequence[str | Column] | None, arg_state.action.get_table_header()) # type: ignore[attr-defined]
623-
if table_header is None:
624-
table_header = DEFAULT_TABLE_HEADER
625652
rich_columns.extend(
626653
column if isinstance(column, Column) else Column(column, overflow="fold") for column in table_header
627654
)

tests/test_argparse_completer.py

Lines changed: 145 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ def do_pos_and_flag(self, args: argparse.Namespace) -> None:
105105
############################################################################################################
106106
STR_METAVAR = "HEADLESS"
107107
TUPLE_METAVAR = ('arg1', 'others')
108-
CUSTOM_TABLE_HEADER = ("Custom Header",)
108+
DESCRIPTION_TABLE_HEADER = ("Description",)
109109

110110
# tuples (for sake of immutability) used in our tests (there is a mix of sorted and unsorted on purpose)
111111
non_negative_num_choices = (1, 2, 3, 0.5, 22)
@@ -140,45 +140,82 @@ def completion_item_method(self) -> list[CompletionItem]:
140140
choices_parser = Cmd2ArgumentParser()
141141

142142
# Flag args for choices command. Include string and non-string arg types.
143-
choices_parser.add_argument("-l", "--list", help="a flag populated with a choices list", choices=static_choices_list)
144143
choices_parser.add_argument(
145-
"-p", "--provider", help="a flag populated with a choices provider", choices_provider=choices_provider
144+
"-l",
145+
"--list",
146+
help="a flag populated with a choices list",
147+
choices=static_choices_list,
146148
)
147149
choices_parser.add_argument(
148-
"--table_header",
149-
help='this arg has a table header',
150+
"-p",
151+
"--provider",
152+
help="a flag populated with a choices provider",
153+
choices_provider=choices_provider,
154+
)
155+
choices_parser.add_argument(
156+
"--no_metavar",
157+
help='this arg has no metavar',
150158
choices_provider=completion_item_method,
151-
table_header=CUSTOM_TABLE_HEADER,
159+
table_header=DESCRIPTION_TABLE_HEADER,
152160
)
153161
choices_parser.add_argument(
154-
"--no_header",
155-
help='this arg has no table header',
162+
"--str_metavar",
163+
help='this arg has str for a metavar',
156164
choices_provider=completion_item_method,
157165
metavar=STR_METAVAR,
166+
table_header=DESCRIPTION_TABLE_HEADER,
158167
)
159168
choices_parser.add_argument(
160169
'-t',
161170
"--tuple_metavar",
162171
help='this arg has tuple for a metavar',
163-
choices_provider=completion_item_method,
164172
metavar=TUPLE_METAVAR,
165173
nargs=argparse.ONE_OR_MORE,
174+
choices_provider=completion_item_method,
175+
table_header=DESCRIPTION_TABLE_HEADER,
176+
)
177+
choices_parser.add_argument(
178+
'-n',
179+
'--num',
180+
type=int,
181+
help='a flag with an int type',
182+
choices=num_choices,
183+
)
184+
choices_parser.add_argument(
185+
'--completion_items',
186+
help='choices are CompletionItems',
187+
choices=completion_item_choices,
188+
table_header=DESCRIPTION_TABLE_HEADER,
166189
)
167-
choices_parser.add_argument('-n', '--num', type=int, help='a flag with an int type', choices=num_choices)
168-
choices_parser.add_argument('--completion_items', help='choices are CompletionItems', choices=completion_item_choices)
169190
choices_parser.add_argument(
170-
'--num_completion_items', help='choices are numerical CompletionItems', choices=num_completion_items
191+
'--num_completion_items',
192+
help='choices are numerical CompletionItems',
193+
choices=num_completion_items,
194+
table_header=DESCRIPTION_TABLE_HEADER,
171195
)
172196

173197
# Positional args for choices command
174-
choices_parser.add_argument("list_pos", help="a positional populated with a choices list", choices=static_choices_list)
175198
choices_parser.add_argument(
176-
"method_pos", help="a positional populated with a choices provider", choices_provider=choices_provider
199+
"list_pos",
200+
help="a positional populated with a choices list",
201+
choices=static_choices_list,
202+
)
203+
choices_parser.add_argument(
204+
"method_pos",
205+
help="a positional populated with a choices provider",
206+
choices_provider=choices_provider,
177207
)
178208
choices_parser.add_argument(
179-
'non_negative_num', type=int, help='a positional with non-negative numerical choices', choices=non_negative_num_choices
209+
'non_negative_num',
210+
type=int,
211+
help='a positional with non-negative numerical choices',
212+
choices=non_negative_num_choices,
213+
)
214+
choices_parser.add_argument(
215+
'empty_choices',
216+
help='a positional with empty choices',
217+
choices=[],
180218
)
181-
choices_parser.add_argument('empty_choices', help='a positional with empty choices', choices=[])
182219

183220
@with_argparser(choices_parser)
184221
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
854891
assert is_error == all(x in completions.error for x in ["Error: argument", "expected"])
855892

856893

857-
def test_completion_table_arg_header(ac_app) -> None:
894+
def test_completion_table_metavar(ac_app) -> None:
858895
# Test when metavar is None
859896
text = ''
860-
line = f'choices --table_header {text}'
897+
line = f'choices --no_metavar {text}'
861898
endidx = len(line)
862899
begidx = endidx - len(text)
863900

864901
completions = ac_app.complete(text, line, begidx, endidx)
865902
assert completions.table is not None
866-
assert completions.table.columns[0].header == "TABLE_HEADER"
903+
assert completions.table.columns[0].header == "NO_METAVAR"
867904

868905
# Test when metavar is a string
869906
text = ''
870-
line = f'choices --no_header {text}'
907+
line = f'choices --str_metavar {text}'
871908
endidx = len(line)
872909
begidx = endidx - len(text)
873910

@@ -908,32 +945,6 @@ def test_completion_table_arg_header(ac_app) -> None:
908945
assert completions.table.columns[0].header == ac_app.TUPLE_METAVAR[1].upper()
909946

910947

911-
def test_completion_table_header(ac_app) -> None:
912-
from cmd2.argparse_completer import (
913-
DEFAULT_TABLE_HEADER,
914-
)
915-
916-
# This argument provided a table header
917-
text = ''
918-
line = f'choices --table_header {text}'
919-
endidx = len(line)
920-
begidx = endidx - len(text)
921-
922-
completions = ac_app.complete(text, line, begidx, endidx)
923-
assert completions.table is not None
924-
assert ac_app.CUSTOM_TABLE_HEADER[0] == completions.table.columns[1].header
925-
926-
# This argument did not provide a table header, so it should be DEFAULT_TABLE_HEADER
927-
text = ''
928-
line = f'choices --no_header {text}'
929-
endidx = len(line)
930-
begidx = endidx - len(text)
931-
932-
completions = ac_app.complete(text, line, begidx, endidx)
933-
assert completions.table is not None
934-
assert DEFAULT_TABLE_HEADER[0] == completions.table.columns[1].header
935-
936-
937948
@pytest.mark.parametrize(
938949
('command_and_args', 'text', 'has_hint'),
939950
[
@@ -1165,6 +1176,94 @@ def test_display_meta(ac_app, subcommand, flag, display_meta) -> None:
11651176
assert completions[0].display_meta == display_meta
11661177

11671178

1179+
def test_validate_table_data_no_table() -> None:
1180+
action = argparse.Action(option_strings=['-f'], dest='foo')
1181+
action.set_table_header(None)
1182+
arg_state = argparse_completer._ArgumentState(action)
1183+
completions = Completions(
1184+
[
1185+
CompletionItem('item1'),
1186+
CompletionItem('item2'),
1187+
]
1188+
)
1189+
1190+
# This should not raise an exception
1191+
argparse_completer.ArgparseCompleter._validate_table_data(arg_state, completions)
1192+
1193+
1194+
def test_validate_table_data_missing_header() -> None:
1195+
action = argparse.Action(option_strings=['-f'], dest='foo')
1196+
action.set_table_header(None)
1197+
arg_state = argparse_completer._ArgumentState(action)
1198+
1199+
completions = Completions(
1200+
[
1201+
CompletionItem('item1', table_row=['data1']),
1202+
CompletionItem('item2', table_row=['data2']),
1203+
]
1204+
)
1205+
1206+
with pytest.raises(
1207+
ValueError,
1208+
match="Argument 'foo' has CompletionItems with table_row, but no table_header was defined",
1209+
):
1210+
argparse_completer.ArgparseCompleter._validate_table_data(arg_state, completions)
1211+
1212+
1213+
def test_validate_table_data_missing_row_data() -> None:
1214+
action = argparse.Action(option_strings=['-f'], dest='foo')
1215+
action.set_table_header(['Col1'])
1216+
arg_state = argparse_completer._ArgumentState(action)
1217+
1218+
completions = Completions(
1219+
[
1220+
CompletionItem('item1', table_row=['data1']),
1221+
CompletionItem('item2'), # Missing table_row
1222+
]
1223+
)
1224+
1225+
with pytest.raises(
1226+
ValueError,
1227+
match="Argument 'foo' has table_header defined, but the CompletionItem for 'item2' is missing table_row",
1228+
):
1229+
argparse_completer.ArgparseCompleter._validate_table_data(arg_state, completions)
1230+
1231+
1232+
def test_validate_table_row_data_length_mismatch() -> None:
1233+
action = argparse.Action(option_strings=['-f'], dest='foo')
1234+
action.set_table_header(['Col1', 'Col2'])
1235+
arg_state = argparse_completer._ArgumentState(action)
1236+
1237+
completions = Completions(
1238+
[
1239+
CompletionItem('item1', table_row=['data1a', 'data1b']),
1240+
CompletionItem('item2', table_row=['only_one']),
1241+
]
1242+
)
1243+
1244+
with pytest.raises(
1245+
ValueError,
1246+
match=r"Argument 'foo': table_row length \(1\) does not match table_header length \(2\) for item 'item2'.",
1247+
):
1248+
argparse_completer.ArgparseCompleter._validate_table_data(arg_state, completions)
1249+
1250+
1251+
def test_validate_table_data_valid() -> None:
1252+
action = argparse.Action(option_strings=['-f'], dest='foo')
1253+
action.get_table_header = lambda: ['Col1', 'Col2']
1254+
arg_state = argparse_completer._ArgumentState(action)
1255+
1256+
completions = Completions(
1257+
[
1258+
CompletionItem('item1', table_row=['data1a', 'data1b']),
1259+
CompletionItem('item2', table_row=['data2a', 'data2b']),
1260+
]
1261+
)
1262+
1263+
# This should not raise an exception
1264+
argparse_completer.ArgparseCompleter._validate_table_data(arg_state, completions)
1265+
1266+
11681267
# Custom ArgparseCompleter-based class
11691268
class CustomCompleter(argparse_completer.ArgparseCompleter):
11701269
def _complete_flags(self, text: str, line: str, begidx: int, endidx: int, matched_flags: list[str]) -> list[str]:

0 commit comments

Comments
 (0)