Skip to content

Commit b26974e

Browse files
authored
Merge pull request #975 from python-cmd2/help_format_fix
Fix tuple metavar crash in Cmd2HelpFormatter and ArgparseCompleter
2 parents 133e71a + ac26cae commit b26974e

File tree

5 files changed

+133
-38
lines changed

5 files changed

+133
-38
lines changed

CHANGELOG.md

+2
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
* Renamed `install_command_set()` and `uninstall_command_set()` to `register_command_set()` and
66
`unregister_command_set()` for better name consistency.
77
* Bug Fixes
8+
* Fixed help formatting bug in `Cmd2ArgumentParser` when `metavar` is a tuple
9+
* Fixed tab completion bug when using `CompletionItem` on an argument whose `metavar` is a tuple
810
* Added explicit testing against python 3.5.2 for Ubuntu 16.04, and 3.5.3 for Debian 9
911
* Added fallback definition of typing.Deque (taken from 3.5.4)
1012
* Removed explicit type hints that fail due to a bug in 3.5.2 favoring comment-based hints instead

cmd2/argparse_completer.py

+24-15
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
)
2626
from .command_definition import CommandSet
2727
from .table_creator import Column, SimpleTable
28-
from .utils import CompletionError, basic_complete, get_defining_class
28+
from .utils import CompletionError, basic_complete
2929

3030
# If no descriptive header is supplied, then this will be used instead
3131
DEFAULT_DESCRIPTIVE_HEADER = 'Description'
@@ -405,7 +405,7 @@ def update_mutex_groups(arg_action: argparse.Action) -> None:
405405

406406
# Check if we are completing a flag's argument
407407
if flag_arg_state is not None:
408-
completion_results = self._complete_for_arg(flag_arg_state.action, text, line,
408+
completion_results = self._complete_for_arg(flag_arg_state, text, line,
409409
begidx, endidx, consumed_arg_values,
410410
cmd_set=cmd_set)
411411

@@ -426,7 +426,7 @@ def update_mutex_groups(arg_action: argparse.Action) -> None:
426426
action = remaining_positionals.popleft()
427427
pos_arg_state = _ArgumentState(action)
428428

429-
completion_results = self._complete_for_arg(pos_arg_state.action, text, line,
429+
completion_results = self._complete_for_arg(pos_arg_state, text, line,
430430
begidx, endidx, consumed_arg_values,
431431
cmd_set=cmd_set)
432432

@@ -461,7 +461,7 @@ def _complete_flags(self, text: str, line: str, begidx: int, endidx: int, matche
461461

462462
return basic_complete(text, line, begidx, endidx, match_against)
463463

464-
def _format_completions(self, action, completions: List[Union[str, CompletionItem]]) -> List[str]:
464+
def _format_completions(self, arg_state: _ArgumentState, completions: List[Union[str, CompletionItem]]) -> List[str]:
465465
# Check if the results are CompletionItems and that there aren't too many to display
466466
if 1 < len(completions) <= self._cmd2_app.max_completion_items and \
467467
isinstance(completions[0], CompletionItem):
@@ -472,9 +472,18 @@ def _format_completions(self, action, completions: List[Union[str, CompletionIte
472472
self._cmd2_app.matches_sorted = True
473473

474474
# If a metavar was defined, use that instead of the dest field
475-
destination = action.metavar if action.metavar else action.dest
476-
477-
desc_header = getattr(action, ATTR_DESCRIPTIVE_COMPLETION_HEADER, None)
475+
destination = arg_state.action.metavar if arg_state.action.metavar else arg_state.action.dest
476+
477+
# Handle case where metavar was a tuple
478+
if isinstance(destination, tuple):
479+
# Figure out what string in the tuple to use based on how many of the arguments have been completed.
480+
# Use min() to avoid going passed the end of the tuple to support nargs being ZERO_OR_MORE and
481+
# ONE_OR_MORE. In those cases, argparse limits metavar tuple to 2 elements but we may be completing
482+
# the 3rd or more argument here.
483+
tuple_index = min(len(destination) - 1, arg_state.count)
484+
destination = destination[tuple_index]
485+
486+
desc_header = getattr(arg_state.action, ATTR_DESCRIPTIVE_COMPLETION_HEADER, None)
478487
if desc_header is None:
479488
desc_header = DEFAULT_DESCRIPTIVE_HEADER
480489

@@ -546,7 +555,7 @@ def format_help(self, tokens: List[str]) -> str:
546555
break
547556
return self._parser.format_help()
548557

549-
def _complete_for_arg(self, arg_action: argparse.Action,
558+
def _complete_for_arg(self, arg_state: _ArgumentState,
550559
text: str, line: str, begidx: int, endidx: int,
551560
consumed_arg_values: Dict[str, List[str]], *,
552561
cmd_set: Optional[CommandSet] = None) -> List[str]:
@@ -556,10 +565,10 @@ def _complete_for_arg(self, arg_action: argparse.Action,
556565
:raises: CompletionError if the completer or choices function this calls raises one
557566
"""
558567
# Check if the arg provides choices to the user
559-
if arg_action.choices is not None:
560-
arg_choices = arg_action.choices
568+
if arg_state.action.choices is not None:
569+
arg_choices = arg_state.action.choices
561570
else:
562-
arg_choices = getattr(arg_action, ATTR_CHOICES_CALLABLE, None)
571+
arg_choices = getattr(arg_state.action, ATTR_CHOICES_CALLABLE, None)
563572

564573
if arg_choices is None:
565574
return []
@@ -586,8 +595,8 @@ def _complete_for_arg(self, arg_action: argparse.Action,
586595
arg_tokens = {**self._parent_tokens, **consumed_arg_values}
587596

588597
# Include the token being completed
589-
arg_tokens.setdefault(arg_action.dest, [])
590-
arg_tokens[arg_action.dest].append(text)
598+
arg_tokens.setdefault(arg_state.action.dest, [])
599+
arg_tokens[arg_state.action.dest].append(text)
591600

592601
# Add the namespace to the keyword arguments for the function we are calling
593602
kwargs[ARG_TOKENS] = arg_tokens
@@ -617,10 +626,10 @@ def _complete_for_arg(self, arg_action: argparse.Action,
617626
arg_choices[index] = str(choice)
618627

619628
# Filter out arguments we already used
620-
used_values = consumed_arg_values.get(arg_action.dest, [])
629+
used_values = consumed_arg_values.get(arg_state.action.dest, [])
621630
arg_choices = [choice for choice in arg_choices if choice not in used_values]
622631

623632
# Do tab completion on the choices
624633
results = basic_complete(text, line, begidx, endidx, arg_choices)
625634

626-
return self._format_completions(arg_action, results)
635+
return self._format_completions(arg_state, results)

cmd2/argparse_custom.py

+27-18
Original file line numberDiff line numberDiff line change
@@ -733,7 +733,8 @@ def _format_action_invocation(self, action) -> str:
733733
return ', '.join(action.option_strings) + ' ' + args_string
734734
# End cmd2 customization
735735

736-
def _metavar_formatter(self, action, default_metavar) -> Callable:
736+
def _determine_metavar(self, action, default_metavar) -> Union[str, Tuple]:
737+
"""Custom method to determine what to use as the metavar value of an action"""
737738
if action.metavar is not None:
738739
result = action.metavar
739740
elif action.choices is not None:
@@ -743,38 +744,46 @@ def _metavar_formatter(self, action, default_metavar) -> Callable:
743744
# End cmd2 customization
744745
else:
745746
result = default_metavar
747+
return result
748+
749+
def _metavar_formatter(self, action, default_metavar) -> Callable:
750+
metavar = self._determine_metavar(action, default_metavar)
746751

747752
# noinspection PyMissingOrEmptyDocstring
748753
def format(tuple_size):
749-
if isinstance(result, tuple):
750-
return result
754+
if isinstance(metavar, tuple):
755+
return metavar
751756
else:
752-
return (result, ) * tuple_size
757+
return (metavar, ) * tuple_size
753758
return format
754759

755760
# noinspection PyProtectedMember
756761
def _format_args(self, action, default_metavar) -> str:
757-
get_metavar = self._metavar_formatter(action, default_metavar)
758-
# Begin cmd2 customization (less verbose)
759-
nargs_range = getattr(action, ATTR_NARGS_RANGE, None)
762+
"""Customized to handle ranged nargs and make other output less verbose"""
763+
metavar = self._determine_metavar(action, default_metavar)
764+
metavar_formatter = self._metavar_formatter(action, default_metavar)
760765

766+
# Handle nargs specified as a range
767+
nargs_range = getattr(action, ATTR_NARGS_RANGE, None)
761768
if nargs_range is not None:
762769
if nargs_range[1] == constants.INFINITY:
763770
range_str = '{}+'.format(nargs_range[0])
764771
else:
765772
range_str = '{}..{}'.format(nargs_range[0], nargs_range[1])
766773

767-
result = '{}{{{}}}'.format('%s' % get_metavar(1), range_str)
768-
elif action.nargs == ZERO_OR_MORE:
769-
result = '[%s [...]]' % get_metavar(1)
770-
elif action.nargs == ONE_OR_MORE:
771-
result = '%s [...]' % get_metavar(1)
772-
elif isinstance(action.nargs, int) and action.nargs > 1:
773-
result = '{}{{{}}}'.format('%s' % get_metavar(1), action.nargs)
774-
# End cmd2 customization
775-
else:
776-
result = super()._format_args(action, default_metavar)
777-
return result
774+
return '{}{{{}}}'.format('%s' % metavar_formatter(1), range_str)
775+
776+
# Make this output less verbose. Do not customize the output when metavar is a
777+
# tuple of strings. Allow argparse's formatter to handle that instead.
778+
elif isinstance(metavar, str):
779+
if action.nargs == ZERO_OR_MORE:
780+
return '[%s [...]]' % metavar_formatter(1)
781+
elif action.nargs == ONE_OR_MORE:
782+
return '%s [...]' % metavar_formatter(1)
783+
elif isinstance(action.nargs, int) and action.nargs > 1:
784+
return '{}{{{}}}'.format('%s' % metavar_formatter(1), action.nargs)
785+
786+
return super()._format_args(action, default_metavar)
778787

779788

780789
# noinspection PyCompatibility

tests/test_argparse_completer.py

+73-5
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,10 @@ def do_plus_flag(self, args: argparse.Namespace) -> None:
107107
############################################################################################################
108108
# Begin code related to testing choices, choices_function, and choices_method parameters
109109
############################################################################################################
110+
STR_METAVAR = "HEADLESS"
111+
TUPLE_METAVAR = ('arg1', 'others')
112+
CUSTOM_DESC_HEADER = "Custom Header"
113+
110114
def choices_method(self) -> List[str]:
111115
"""Method that provides choices"""
112116
return choices_from_method
@@ -128,8 +132,14 @@ def completion_item_method(self) -> List[CompletionItem]:
128132
choices_function=choices_function)
129133
choices_parser.add_argument("-m", "--method", help="a flag populated with a choices method",
130134
choices_method=choices_method)
131-
choices_parser.add_argument('-n', "--no_header", help='this arg has a no descriptive header',
132-
choices_method=completion_item_method)
135+
choices_parser.add_argument('-d', "--desc_header", help='this arg has a descriptive header',
136+
choices_method=completion_item_method,
137+
descriptive_header=CUSTOM_DESC_HEADER)
138+
choices_parser.add_argument('-n', "--no_header", help='this arg has no descriptive header',
139+
choices_method=completion_item_method, metavar=STR_METAVAR)
140+
choices_parser.add_argument('-t', "--tuple_metavar", help='this arg has tuple for a metavar',
141+
choices_method=completion_item_method, metavar=TUPLE_METAVAR,
142+
nargs=argparse.ONE_OR_MORE)
133143
choices_parser.add_argument('-i', '--int', type=int, help='a flag with an int type',
134144
choices=static_int_choices_list)
135145

@@ -683,15 +693,73 @@ def test_unfinished_flag_error(ac_app, command_and_args, text, is_error, capsys)
683693
assert is_error == all(x in out for x in ["Error: argument", "expected"])
684694

685695

686-
def test_completion_items_default_header(ac_app):
696+
def test_completion_items_arg_header(ac_app):
697+
# Test when metavar is None
698+
text = ''
699+
line = 'choices --desc_header {}'.format(text)
700+
endidx = len(line)
701+
begidx = endidx - len(text)
702+
703+
complete_tester(text, line, begidx, endidx, ac_app)
704+
assert "DESC_HEADER" in ac_app.completion_header
705+
706+
# Test when metavar is a string
707+
text = ''
708+
line = 'choices --no_header {}'.format(text)
709+
endidx = len(line)
710+
begidx = endidx - len(text)
711+
712+
complete_tester(text, line, begidx, endidx, ac_app)
713+
assert ac_app.STR_METAVAR in ac_app.completion_header
714+
715+
# Test when metavar is a tuple
716+
text = ''
717+
line = 'choices --tuple_metavar {}'.format(text)
718+
endidx = len(line)
719+
begidx = endidx - len(text)
720+
721+
# We are completing the first argument of this flag. The first element in the tuple should be the column header.
722+
complete_tester(text, line, begidx, endidx, ac_app)
723+
assert ac_app.TUPLE_METAVAR[0].upper() in ac_app.completion_header
724+
725+
text = ''
726+
line = 'choices --tuple_metavar token_1 {}'.format(text)
727+
endidx = len(line)
728+
begidx = endidx - len(text)
729+
730+
# We are completing the second argument of this flag. The second element in the tuple should be the column header.
731+
complete_tester(text, line, begidx, endidx, ac_app)
732+
assert ac_app.TUPLE_METAVAR[1].upper() in ac_app.completion_header
733+
734+
text = ''
735+
line = 'choices --tuple_metavar token_1 token_2 {}'.format(text)
736+
endidx = len(line)
737+
begidx = endidx - len(text)
738+
739+
# We are completing the third argument of this flag. It should still be the second tuple element
740+
# in the column header since the tuple only has two strings in it.
741+
complete_tester(text, line, begidx, endidx, ac_app)
742+
assert ac_app.TUPLE_METAVAR[1].upper() in ac_app.completion_header
743+
744+
745+
def test_completion_items_descriptive_header(ac_app):
687746
from cmd2.argparse_completer import DEFAULT_DESCRIPTIVE_HEADER
688747

748+
# This argument provided a descriptive header
749+
text = ''
750+
line = 'choices --desc_header {}'.format(text)
751+
endidx = len(line)
752+
begidx = endidx - len(text)
753+
754+
complete_tester(text, line, begidx, endidx, ac_app)
755+
assert ac_app.CUSTOM_DESC_HEADER in ac_app.completion_header
756+
757+
# This argument did not provide a descriptive header, so it should be DEFAULT_DESCRIPTIVE_HEADER
689758
text = ''
690-
line = 'choices -n {}'.format(text)
759+
line = 'choices --no_header {}'.format(text)
691760
endidx = len(line)
692761
begidx = endidx - len(text)
693762

694-
# This positional argument did not provide a descriptive header, so it should be DEFAULT_DESCRIPTIVE_HEADER
695763
complete_tester(text, line, begidx, endidx, ac_app)
696764
assert DEFAULT_DESCRIPTIVE_HEADER in ac_app.completion_header
697765

tests/test_argparse_custom.py

+7
Original file line numberDiff line numberDiff line change
@@ -260,3 +260,10 @@ def test_override_parser():
260260
# Verify DEFAULT_ARGUMENT_PARSER is now our CustomParser
261261
from examples.custom_parser import CustomParser
262262
assert DEFAULT_ARGUMENT_PARSER == CustomParser
263+
264+
265+
def test_apcustom_metavar_tuple():
266+
# Test the case when a tuple metavar is used with nargs an integer > 1
267+
parser = Cmd2ArgumentParser()
268+
parser.add_argument('--aflag', nargs=2, metavar=('foo', 'bar'), help='This is a test')
269+
assert '[--aflag foo bar]' in parser.format_help()

0 commit comments

Comments
 (0)