Skip to content

Commit f38e100

Browse files
authored
Merge pull request #571 from python-cmd2/argparse_remainder
Fixes related to handling of argparse.REMAINDER
2 parents 467be57 + 84f290b commit f38e100

16 files changed

+311
-85
lines changed

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,16 @@
44
* Fixed bug where **alias** command was dropping quotes around arguments
55
* Fixed bug where running help on argparse commands didn't work if they didn't support -h
66
* Fixed transcript testing bug where last command in transcript has no expected output
7+
* Fixed bugs with how AutoCompleter and ArgparseFunctor handle argparse
8+
arguments with nargs=argparse.REMAINDER. Tab completion now correctly
9+
matches how argparse will parse the values. Command strings generated by
10+
ArgparseFunctor should now be compliant with how argparse expects
11+
REMAINDER arguments to be ordered.
12+
* Fixed bugs with how AutoCompleter handles flag prefixes. It is no
13+
longer hard-coded to use '-' and will check against the prefix_chars in
14+
the argparse object. Also, single-character tokens that happen to be a
15+
prefix char are not treated as flags by argparse and AutoCompleter now
16+
matches that behavior.
717
* Enhancements
818
* Added ``exit_code`` attribute of ``cmd2.Cmd`` class
919
* Enables applications to return a non-zero exit code when exiting from ``cmdloop``
@@ -24,6 +34,7 @@
2434
* Never - output methods strip all ANSI escape sequences
2535
* Added ``macro`` command to create macros, which are similar to aliases, but can take arguments when called
2636
* All cmd2 command functions have been converted to use argparse.
37+
* Renamed argparse_example.py to decorator_example.py to help clarify its intent
2738
* Deprecations
2839
* Deprecated the built-in ``cmd2`` support for colors including ``Cmd.colorize()`` and ``Cmd._colorcodes``
2940
* Deletions (potentially breaking changes)

cmd2/argparse_completer.py

Lines changed: 104 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,34 @@ def register_custom_actions(parser: argparse.ArgumentParser) -> None:
209209
parser.register('action', 'append', _AppendRangeAction)
210210

211211

212+
def token_resembles_flag(token: str, parser: argparse.ArgumentParser) -> bool:
213+
"""Determine if a token looks like a flag. Based on argparse._parse_optional()."""
214+
# if it's an empty string, it was meant to be a positional
215+
if not token:
216+
return False
217+
218+
# if it doesn't start with a prefix, it was meant to be positional
219+
if not token[0] in parser.prefix_chars:
220+
return False
221+
222+
# if it's just a single character, it was meant to be positional
223+
if len(token) == 1:
224+
return False
225+
226+
# if it looks like a negative number, it was meant to be positional
227+
# unless there are negative-number-like options
228+
if parser._negative_number_matcher.match(token):
229+
if not parser._has_negative_number_optionals:
230+
return False
231+
232+
# if it contains a space, it was meant to be a positional
233+
if ' ' in token:
234+
return False
235+
236+
# Looks like a flag
237+
return True
238+
239+
212240
class AutoCompleter(object):
213241
"""Automatically command line tab completion based on argparse parameters"""
214242

@@ -318,6 +346,9 @@ def complete_command(self, tokens: List[str], text: str, line: str, begidx: int,
318346
flag_arg = AutoCompleter._ArgumentState()
319347
flag_action = None
320348

349+
# dict is used because object wrapper is necessary to allow inner functions to modify outer variables
350+
remainder = {'arg': None, 'action': None}
351+
321352
matched_flags = []
322353
current_is_positional = False
323354
consumed_arg_values = {} # dict(arg_name -> [values, ...])
@@ -331,8 +362,8 @@ def complete_command(self, tokens: List[str], text: str, line: str, begidx: int,
331362
def consume_flag_argument() -> None:
332363
"""Consuming token as a flag argument"""
333364
# we're consuming flag arguments
334-
# if this is not empty and is not another potential flag, count towards flag arguments
335-
if token and token[0] not in self._parser.prefix_chars and flag_action is not None:
365+
# if the token does not look like a new flag, then count towards flag arguments
366+
if not token_resembles_flag(token, self._parser) and flag_action is not None:
336367
flag_arg.count += 1
337368

338369
# does this complete a option item for the flag
@@ -355,17 +386,79 @@ def consume_positional_argument() -> None:
355386
consumed_arg_values.setdefault(pos_action.dest, [])
356387
consumed_arg_values[pos_action.dest].append(token)
357388

389+
def process_action_nargs(action: argparse.Action, arg_state: AutoCompleter._ArgumentState) -> None:
390+
"""Process the current argparse Action and initialize the ArgumentState object used
391+
to track what arguments we have processed for this action"""
392+
if isinstance(action, _RangeAction):
393+
arg_state.min = action.nargs_min
394+
arg_state.max = action.nargs_max
395+
arg_state.variable = True
396+
if arg_state.min is None or arg_state.max is None:
397+
if action.nargs is None:
398+
arg_state.min = 1
399+
arg_state.max = 1
400+
elif action.nargs == '+':
401+
arg_state.min = 1
402+
arg_state.max = float('inf')
403+
arg_state.variable = True
404+
elif action.nargs == '*' or action.nargs == argparse.REMAINDER:
405+
arg_state.min = 0
406+
arg_state.max = float('inf')
407+
arg_state.variable = True
408+
if action.nargs == argparse.REMAINDER:
409+
remainder['action'] = action
410+
remainder['arg'] = arg_state
411+
elif action.nargs == '?':
412+
arg_state.min = 0
413+
arg_state.max = 1
414+
arg_state.variable = True
415+
else:
416+
arg_state.min = action.nargs
417+
arg_state.max = action.nargs
418+
419+
# This next block of processing tries to parse all parameters before the last parameter.
420+
# We're trying to determine what specific argument the current cursor positition should be
421+
# matched with. When we finish parsing all of the arguments, we can determine whether the
422+
# last token is a positional or flag argument and which specific argument it is.
423+
#
424+
# We're also trying to save every flag that has been used as well as every value that
425+
# has been used for a positional or flag parameter. By saving this information we can exclude
426+
# it from the completion results we generate for the last token. For example, single-use flag
427+
# arguments will be hidden from the list of available flags. Also, arguments with a
428+
# defined list of possible values will exclude values that have already been used.
429+
430+
# notes when the last token has been reached
358431
is_last_token = False
432+
359433
for idx, token in enumerate(tokens):
360434
is_last_token = idx >= len(tokens) - 1
361435
# Only start at the start token index
362436
if idx >= self._token_start_index:
437+
# If a remainder action is found, force all future tokens to go to that
438+
if remainder['arg'] is not None:
439+
if remainder['action'] == pos_action:
440+
consume_positional_argument()
441+
continue
442+
elif remainder['action'] == flag_action:
443+
consume_flag_argument()
444+
continue
363445
current_is_positional = False
364446
# Are we consuming flag arguments?
365447
if not flag_arg.needed:
366-
# we're not consuming flag arguments, is the current argument a potential flag?
367-
if len(token) > 0 and token[0] in self._parser.prefix_chars and\
368-
(is_last_token or (not is_last_token and token != '-')):
448+
# Special case when each of the following is true:
449+
# - We're not in the middle of consuming flag arguments
450+
# - The current positional argument count has hit the max count
451+
# - The next positional argument is a REMAINDER argument
452+
# Argparse will now treat all future tokens as arguments to the positional including tokens that
453+
# look like flags so the completer should skip any flag related processing once this happens
454+
skip_flag = False
455+
if (pos_action is not None) and pos_arg.count >= pos_arg.max and \
456+
next_pos_arg_index < len(self._positional_actions) and \
457+
self._positional_actions[next_pos_arg_index].nargs == argparse.REMAINDER:
458+
skip_flag = True
459+
460+
# At this point we're no longer consuming flag arguments. Is the current argument a potential flag?
461+
if token_resembles_flag(token, self._parser) and not skip_flag:
369462
# reset some tracking values
370463
flag_arg.reset()
371464
# don't reset positional tracking because flags can be interspersed anywhere between positionals
@@ -381,7 +474,7 @@ def consume_positional_argument() -> None:
381474

382475
if flag_action is not None:
383476
# resolve argument counts
384-
self._process_action_nargs(flag_action, flag_arg)
477+
process_action_nargs(flag_action, flag_arg)
385478
if not is_last_token and not isinstance(flag_action, argparse._AppendAction):
386479
matched_flags.extend(flag_action.option_strings)
387480

@@ -418,7 +511,7 @@ def consume_positional_argument() -> None:
418511
return sub_completers[token].complete_command(tokens, text, line,
419512
begidx, endidx)
420513
pos_action = action
421-
self._process_action_nargs(pos_action, pos_arg)
514+
process_action_nargs(pos_action, pos_arg)
422515
consume_positional_argument()
423516

424517
elif not is_last_token and pos_arg.max is not None:
@@ -435,10 +528,13 @@ def consume_positional_argument() -> None:
435528
if not is_last_token and flag_arg.min is not None:
436529
flag_arg.needed = flag_arg.count < flag_arg.min
437530

531+
# Here we're done parsing all of the prior arguments. We know what the next argument is.
532+
438533
# if we don't have a flag to populate with arguments and the last token starts with
439534
# a flag prefix then we'll complete the list of flag options
440535
completion_results = []
441-
if not flag_arg.needed and len(tokens[-1]) > 0 and tokens[-1][0] in self._parser.prefix_chars:
536+
if not flag_arg.needed and len(tokens[-1]) > 0 and tokens[-1][0] in self._parser.prefix_chars and \
537+
remainder['arg'] is None:
442538
return AutoCompleter.basic_complete(text, line, begidx, endidx,
443539
[flag for flag in self._flags if flag not in matched_flags])
444540
# we're not at a positional argument, see if we're in a flag argument
@@ -522,32 +618,6 @@ def format_help(self, tokens: List[str]) -> str:
522618
return completers[token].format_help(tokens)
523619
return self._parser.format_help()
524620

525-
@staticmethod
526-
def _process_action_nargs(action: argparse.Action, arg_state: _ArgumentState) -> None:
527-
if isinstance(action, _RangeAction):
528-
arg_state.min = action.nargs_min
529-
arg_state.max = action.nargs_max
530-
arg_state.variable = True
531-
if arg_state.min is None or arg_state.max is None:
532-
if action.nargs is None:
533-
arg_state.min = 1
534-
arg_state.max = 1
535-
elif action.nargs == '+':
536-
arg_state.min = 1
537-
arg_state.max = float('inf')
538-
arg_state.variable = True
539-
elif action.nargs == '*':
540-
arg_state.min = 0
541-
arg_state.max = float('inf')
542-
arg_state.variable = True
543-
elif action.nargs == '?':
544-
arg_state.min = 0
545-
arg_state.max = 1
546-
arg_state.variable = True
547-
else:
548-
arg_state.min = action.nargs
549-
arg_state.max = action.nargs
550-
551621
def _complete_for_arg(self, action: argparse.Action,
552622
text: str,
553623
line: str,

cmd2/cmd2.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,7 @@ def with_argparser_and_unknown_args(argparser: argparse.ArgumentParser, preserve
193193
"""A decorator to alter a cmd2 method to populate its ``args`` argument by parsing arguments with the given
194194
instance of argparse.ArgumentParser, but also returning unknown args as a list.
195195
196-
:param argparser: given instance of ArgumentParser
196+
:param argparser: unique instance of ArgumentParser
197197
:param preserve_quotes: if True, then the arguments passed to arparse be maintain their quotes
198198
:return: function that gets passed parsed args and a list of unknown args
199199
"""
@@ -234,7 +234,7 @@ def with_argparser(argparser: argparse.ArgumentParser, preserve_quotes: bool=Fal
234234
"""A decorator to alter a cmd2 method to populate its ``args`` argument by parsing arguments
235235
with the given instance of argparse.ArgumentParser.
236236
237-
:param argparser: given instance of ArgumentParser
237+
:param argparser: unique instance of ArgumentParser
238238
:param preserve_quotes: if True, then the arguments passed to arparse be maintain their quotes
239239
:return: function that gets passed parsed args
240240
"""

0 commit comments

Comments
 (0)