Skip to content

Commit 07ddcb4

Browse files
authored
Merge pull request #784 from python-cmd2/mutually_exclusive_groups
Mutually exclusive groups
2 parents b3408bc + 2ac65e0 commit 07ddcb4

File tree

4 files changed

+170
-96
lines changed

4 files changed

+170
-96
lines changed

CHANGELOG.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
## 0.9.18 (TBD, 2019)
22
* Bug Fixes
33
* Fixed bug introduced in 0.9.17 where help functions for hidden and disabled commands were not being filtered
4-
out as help topics
4+
out as help topics
5+
* Enhancements
6+
* `AutoCompleter` now handles argparse's mutually exclusive groups. It will not tab complete flag names or positionals
7+
for already completed groups. It also will print an error if you try tab completing a flag's value if the flag
8+
belongs to a completed group.
9+
* `AutoCompleter` now uses the passed-in parser's help formatter to generate hint text. This gives help and
10+
hint text for an argument consistent formatting.
511

612
## 0.9.17 (September 23, 2019)
713
* Bug Fixes

cmd2/argparse_completer.py

Lines changed: 89 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,12 @@
1010
import inspect
1111
import numbers
1212
import shutil
13+
from collections import deque
1314
from typing import Dict, List, Optional, Union
1415

1516
from . import cmd2
1617
from . import utils
17-
from .ansi import ansi_safe_wcswidth, style_error
18+
from .ansi import ansi_aware_write, ansi_safe_wcswidth, style_error
1819
from .argparse_custom import ATTR_CHOICES_CALLABLE, INFINITY, generate_range_error
1920
from .argparse_custom import ATTR_SUPPRESS_TAB_HINT, ATTR_DESCRIPTIVE_COMPLETION_HEADER, ATTR_NARGS_RANGE
2021
from .argparse_custom import ChoicesCallable, CompletionError, CompletionItem
@@ -116,13 +117,13 @@ def __init__(self, parser: argparse.ArgumentParser, cmd2_app: cmd2.Cmd, *,
116117
parent_tokens = dict()
117118
self._parent_tokens = parent_tokens
118119

119-
self._flags = [] # all flags in this command
120-
self._flag_to_action = {} # maps flags to the argparse action object
121-
self._positional_actions = [] # actions for positional arguments (by position index)
122-
self._subcommand_action = None # this will be set if self._parser has subcommands
120+
self._flags = [] # all flags in this command
121+
self._flag_to_action = {} # maps flags to the argparse action object
122+
self._positional_actions = [] # actions for positional arguments (by position index)
123+
self._subcommand_action = None # this will be set if self._parser has subcommands
123124

124125
# Start digging through the argparse structures.
125-
# _actions is the top level container of parameter definitions
126+
# _actions is the top level container of parameter definitions
126127
for action in self._parser._actions:
127128
# if the parameter is flag based, it will have option_strings
128129
if action.option_strings:
@@ -143,9 +144,8 @@ def complete_command(self, tokens: List[str], text: str, line: str, begidx: int,
143144
if not tokens:
144145
return []
145146

146-
# Count which positional argument index we're at now. Loop through all tokens on the command line so far
147-
# Skip any flags or flag parameter tokens
148-
next_pos_arg_index = 0
147+
# Positionals args that are left to parse
148+
remaining_positionals = deque(self._positional_actions)
149149

150150
# This gets set to True when flags will no longer be processed as argparse flags
151151
# That can happen when -- is used or an argument with nargs=argparse.REMAINDER is used
@@ -163,12 +163,58 @@ def complete_command(self, tokens: List[str], text: str, line: str, begidx: int,
163163
# Keeps track of arguments we've seen and any tokens they consumed
164164
consumed_arg_values = dict() # dict(arg_name -> List[tokens])
165165

166+
# Completed mutually exclusive groups
167+
completed_mutex_groups = dict() # dict(argparse._MutuallyExclusiveGroup -> Action which completed group)
168+
166169
def consume_argument(arg_state: AutoCompleter._ArgumentState) -> None:
167170
"""Consuming token as an argument"""
168171
arg_state.count += 1
169172
consumed_arg_values.setdefault(arg_state.action.dest, [])
170173
consumed_arg_values[arg_state.action.dest].append(token)
171174

175+
def update_mutex_groups(arg_action: argparse.Action) -> bool:
176+
"""
177+
Check if an argument belongs to a mutually exclusive group and either mark that group
178+
as complete or print an error if the group has already been completed
179+
:param arg_action: the action of the argument
180+
:return: False if the group has already been completed and there is a conflict, otherwise True
181+
"""
182+
# Check if this action is in a mutually exclusive group
183+
for group in self._parser._mutually_exclusive_groups:
184+
if arg_action in group._group_actions:
185+
186+
# Check if the group this action belongs to has already been completed
187+
if group in completed_mutex_groups:
188+
189+
# If this is the action that completed the group, then there is no error
190+
# since it's allowed to appear on the command line more than once.
191+
completer_action = completed_mutex_groups[group]
192+
if arg_action == completer_action:
193+
return True
194+
195+
error = style_error("\nError: argument {}: not allowed with argument {}\n".
196+
format(argparse._get_action_name(arg_action),
197+
argparse._get_action_name(completer_action)))
198+
self._print_message(error)
199+
return False
200+
201+
# Mark that this action completed the group
202+
completed_mutex_groups[group] = arg_action
203+
204+
# Don't tab complete any of the other args in the group
205+
for group_action in group._group_actions:
206+
if group_action == arg_action:
207+
continue
208+
elif group_action in self._flag_to_action.values():
209+
matched_flags.extend(group_action.option_strings)
210+
elif group_action in remaining_positionals:
211+
remaining_positionals.remove(group_action)
212+
213+
# Arg can only be in one group, so we are done
214+
break
215+
216+
return True
217+
172218
#############################################################################################
173219
# Parse all but the last token
174220
#############################################################################################
@@ -222,14 +268,17 @@ def consume_argument(arg_state: AutoCompleter._ArgumentState) -> None:
222268
action = self._flag_to_action[candidates_flags[0]]
223269

224270
if action is not None:
271+
if not update_mutex_groups(action):
272+
return []
273+
225274
if isinstance(action, (argparse._AppendAction,
226275
argparse._AppendConstAction,
227276
argparse._CountAction)):
228277
# Flags with action set to append, append_const, and count can be reused
229278
# Therefore don't erase any tokens already consumed for this flag
230279
consumed_arg_values.setdefault(action.dest, [])
231280
else:
232-
# This flag is not resusable, so mark that we've seen it
281+
# This flag is not reusable, so mark that we've seen it
233282
matched_flags.extend(action.option_strings)
234283

235284
# It's possible we already have consumed values for this flag if it was used
@@ -255,12 +304,9 @@ def consume_argument(arg_state: AutoCompleter._ArgumentState) -> None:
255304
else:
256305
# If we aren't current tracking a positional, then get the next positional arg to handle this token
257306
if pos_arg_state is None:
258-
pos_index = next_pos_arg_index
259-
next_pos_arg_index += 1
260-
261-
# Make sure we are still have positional arguments to fill
262-
if pos_index < len(self._positional_actions):
263-
action = self._positional_actions[pos_index]
307+
# Make sure we are still have positional arguments to parse
308+
if remaining_positionals:
309+
action = remaining_positionals.popleft()
264310

265311
# Are we at a subcommand? If so, forward to the matching completer
266312
if action == self._subcommand_action:
@@ -285,6 +331,10 @@ def consume_argument(arg_state: AutoCompleter._ArgumentState) -> None:
285331

286332
# Check if we have a positional to consume this token
287333
if pos_arg_state is not None:
334+
# No need to check for an error since we remove a completed group's positional from
335+
# remaining_positionals which means this action can't belong to a completed mutex group
336+
update_mutex_groups(pos_arg_state.action)
337+
288338
consume_argument(pos_arg_state)
289339

290340
# No more flags are allowed if this is a REMAINDER argument
@@ -295,10 +345,9 @@ def consume_argument(arg_state: AutoCompleter._ArgumentState) -> None:
295345
elif pos_arg_state.count >= pos_arg_state.max:
296346
pos_arg_state = None
297347

298-
# Check if this a case in which we've finished all positionals before one that has nargs
299-
# set to argparse.REMAINDER. At this point argparse allows no more flags to be processed.
300-
if next_pos_arg_index < len(self._positional_actions) and \
301-
self._positional_actions[next_pos_arg_index].nargs == argparse.REMAINDER:
348+
# Check if the next positional has nargs set to argparse.REMAINDER.
349+
# At this point argparse allows no more flags to be processed.
350+
if remaining_positionals and remaining_positionals[0].nargs == argparse.REMAINDER:
302351
skip_remaining_flags = True
303352

304353
#############################################################################################
@@ -338,12 +387,11 @@ def consume_argument(arg_state: AutoCompleter._ArgumentState) -> None:
338387
return []
339388

340389
# Otherwise check if we have a positional to complete
341-
elif pos_arg_state is not None or next_pos_arg_index < len(self._positional_actions):
390+
elif pos_arg_state is not None or remaining_positionals:
342391

343392
# If we aren't current tracking a positional, then get the next positional arg to handle this token
344393
if pos_arg_state is None:
345-
pos_index = next_pos_arg_index
346-
action = self._positional_actions[pos_index]
394+
action = remaining_positionals.popleft()
347395
pos_arg_state = AutoCompleter._ArgumentState(action)
348396

349397
try:
@@ -532,23 +580,11 @@ def _complete_for_arg(self, arg_action: argparse.Action,
532580

533581
return self._format_completions(arg_action, results)
534582

535-
@staticmethod
536-
def _format_message_prefix(arg_action: argparse.Action) -> str:
537-
"""Format the arg prefix text that appears before messages printed to the user"""
538-
# Check if this is a flag
539-
if arg_action.option_strings:
540-
flags = ', '.join(arg_action.option_strings)
541-
param = ' ' + str(arg_action.dest).upper()
542-
return '{}{}'.format(flags, param)
543-
544-
# Otherwise this is a positional
545-
else:
546-
return '{}'.format(str(arg_action.dest).upper())
547-
548583
@staticmethod
549584
def _print_message(msg: str) -> None:
550585
"""Print a message instead of tab completions and redraw the prompt and input line"""
551-
print(msg)
586+
import sys
587+
ansi_aware_write(sys.stdout, msg + '\n')
552588
rl_force_redisplay()
553589

554590
def _print_arg_hint(self, arg_action: argparse.Action) -> None:
@@ -558,47 +594,34 @@ def _print_arg_hint(self, arg_action: argparse.Action) -> None:
558594
"""
559595
# Check if hinting is disabled
560596
suppress_hint = getattr(arg_action, ATTR_SUPPRESS_TAB_HINT, False)
561-
if suppress_hint or arg_action.help == argparse.SUPPRESS or arg_action.dest == argparse.SUPPRESS:
597+
if suppress_hint or arg_action.help == argparse.SUPPRESS:
562598
return
563599

564-
prefix = self._format_message_prefix(arg_action)
565-
prefix = ' {0: <{width}} '.format(prefix, width=20)
566-
pref_len = len(prefix)
567-
568-
help_text = '' if arg_action.help is None else arg_action.help
569-
help_lines = help_text.splitlines()
570-
571-
if len(help_lines) == 1:
572-
self._print_message('\nHint:\n{}{}\n'.format(prefix, help_lines[0]))
573-
else:
574-
out_str = '\n{}'.format(prefix)
575-
out_str += '\n{0: <{width}}'.format('', width=pref_len).join(help_lines)
576-
self._print_message('\nHint:' + out_str + '\n')
600+
# Use the parser's help formatter to print just this action's help text
601+
formatter = self._parser._get_formatter()
602+
formatter.start_section("Hint")
603+
formatter.add_argument(arg_action)
604+
formatter.end_section()
605+
out_str = formatter.format_help()
606+
self._print_message('\n' + out_str)
577607

578608
def _print_unfinished_flag_error(self, flag_arg_state: _ArgumentState) -> None:
579609
"""
580610
Print an error during tab completion when the user has not finished the current flag
581611
:param flag_arg_state: information about the unfinished flag action
582612
"""
583-
prefix = self._format_message_prefix(flag_arg_state.action)
584-
585-
out_str = "\nError:\n"
586-
out_str += ' {0: <{width}} '.format(prefix, width=20)
587-
out_str += generate_range_error(flag_arg_state.min, flag_arg_state.max)
588-
589-
out_str += ' ({} entered)'.format(flag_arg_state.count)
590-
self._print_message(style_error('{}\n'.format(out_str)))
613+
error = "\nError: argument {}: {} ({} entered)\n".\
614+
format(argparse._get_action_name(flag_arg_state.action),
615+
generate_range_error(flag_arg_state.min, flag_arg_state.max),
616+
flag_arg_state.count)
617+
self._print_message(style_error('{}'.format(error)))
591618

592619
def _print_completion_error(self, arg_action: argparse.Action, completion_error: CompletionError) -> None:
593620
"""
594621
Print a CompletionError to the user
595622
:param arg_action: action being tab completed
596623
:param completion_error: error that occurred
597624
"""
598-
prefix = self._format_message_prefix(arg_action)
599-
600-
out_str = "\nError:\n"
601-
out_str += ' {0: <{width}} '.format(prefix, width=20)
602-
out_str += str(completion_error)
603-
604-
self._print_message(style_error('{}\n'.format(out_str)))
625+
error = ("\nError tab completing {}:\n"
626+
" {}\n".format(argparse._get_action_name(arg_action), str(completion_error)))
627+
self._print_message(style_error('{}'.format(error)))

cmd2/cmd2.py

100755100644
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1848,7 +1848,7 @@ def _complete_statement(self, line: str) -> Statement:
18481848
"""Keep accepting lines of input until the command is complete.
18491849
18501850
There is some pretty hacky code here to handle some quirks of
1851-
self.pseudo_raw_input(). It returns a literal 'eof' if the input
1851+
self._pseudo_raw_input(). It returns a literal 'eof' if the input
18521852
pipe runs out. We can't refactor it because we need to retain
18531853
backwards compatibility with the standard library version of cmd.
18541854

0 commit comments

Comments
 (0)