Skip to content

Commit 4a36b8c

Browse files
committed
Further customization of argparse applying patch submitted to https://bugs.python.org/issue15112
Added additional tests - now at 100% coverage of pyscript_bridge.py Still need handling of special action types: StoreTrue/False, store_const, count
1 parent e5699bc commit 4a36b8c

File tree

4 files changed

+266
-2
lines changed

4 files changed

+266
-2
lines changed

cmd2/argparse_completer.py

Lines changed: 262 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,8 @@ def my_completer(text: str, line: str, begidx: int, endidx:int, extra_param: str
6464

6565

6666
# imports copied from argparse to support our customized argparse functions
67-
from argparse import ZERO_OR_MORE, ONE_OR_MORE, ArgumentError, _
67+
from argparse import ZERO_OR_MORE, ONE_OR_MORE, ArgumentError, _, _get_action_name, SUPPRESS
68+
6869
import re as _re
6970

7071

@@ -538,7 +539,10 @@ def _print_action_help(self, action: argparse.Action) -> None:
538539

539540
prefix = ' {0: <{width}} '.format(prefix, width=20)
540541
pref_len = len(prefix)
541-
help_lines = action.help.splitlines()
542+
if action.help is not None:
543+
help_lines = action.help.splitlines()
544+
else:
545+
help_lines = ['']
542546
if len(help_lines) == 1:
543547
print('\nHint:\n{}{}\n'.format(prefix, help_lines[0]))
544548
else:
@@ -872,3 +876,259 @@ def _match_argument(self, action, arg_strings_pattern):
872876
'Expected between {} and {} arguments'.format(action.nargs_min, action.nargs_max))
873877

874878
return super(ACArgumentParser, self)._match_argument(action, arg_strings_pattern)
879+
880+
def _parse_known_args(self, arg_strings, namespace):
881+
# replace arg strings that are file references
882+
if self.fromfile_prefix_chars is not None:
883+
arg_strings = self._read_args_from_files(arg_strings)
884+
885+
# map all mutually exclusive arguments to the other arguments
886+
# they can't occur with
887+
action_conflicts = {}
888+
for mutex_group in self._mutually_exclusive_groups:
889+
group_actions = mutex_group._group_actions
890+
for i, mutex_action in enumerate(mutex_group._group_actions):
891+
conflicts = action_conflicts.setdefault(mutex_action, [])
892+
conflicts.extend(group_actions[:i])
893+
conflicts.extend(group_actions[i + 1:])
894+
895+
# find all option indices, and determine the arg_string_pattern
896+
# which has an 'O' if there is an option at an index,
897+
# an 'A' if there is an argument, or a '-' if there is a '--'
898+
option_string_indices = {}
899+
arg_string_pattern_parts = []
900+
arg_strings_iter = iter(arg_strings)
901+
for i, arg_string in enumerate(arg_strings_iter):
902+
903+
# all args after -- are non-options
904+
if arg_string == '--':
905+
arg_string_pattern_parts.append('-')
906+
for arg_string in arg_strings_iter:
907+
arg_string_pattern_parts.append('A')
908+
909+
# otherwise, add the arg to the arg strings
910+
# and note the index if it was an option
911+
else:
912+
option_tuple = self._parse_optional(arg_string)
913+
if option_tuple is None:
914+
pattern = 'A'
915+
else:
916+
option_string_indices[i] = option_tuple
917+
pattern = 'O'
918+
arg_string_pattern_parts.append(pattern)
919+
920+
# join the pieces together to form the pattern
921+
arg_strings_pattern = ''.join(arg_string_pattern_parts)
922+
923+
# converts arg strings to the appropriate and then takes the action
924+
seen_actions = set()
925+
seen_non_default_actions = set()
926+
927+
def take_action(action, argument_strings, option_string=None):
928+
seen_actions.add(action)
929+
argument_values = self._get_values(action, argument_strings)
930+
931+
# error if this argument is not allowed with other previously
932+
# seen arguments, assuming that actions that use the default
933+
# value don't really count as "present"
934+
if argument_values is not action.default:
935+
seen_non_default_actions.add(action)
936+
for conflict_action in action_conflicts.get(action, []):
937+
if conflict_action in seen_non_default_actions:
938+
msg = _('not allowed with argument %s')
939+
action_name = _get_action_name(conflict_action)
940+
raise ArgumentError(action, msg % action_name)
941+
942+
# take the action if we didn't receive a SUPPRESS value
943+
# (e.g. from a default)
944+
if argument_values is not SUPPRESS:
945+
action(self, namespace, argument_values, option_string)
946+
947+
# function to convert arg_strings into an optional action
948+
def consume_optional(start_index):
949+
950+
# get the optional identified at this index
951+
option_tuple = option_string_indices[start_index]
952+
action, option_string, explicit_arg = option_tuple
953+
954+
# identify additional optionals in the same arg string
955+
# (e.g. -xyz is the same as -x -y -z if no args are required)
956+
match_argument = self._match_argument
957+
action_tuples = []
958+
while True:
959+
960+
# if we found no optional action, skip it
961+
if action is None:
962+
extras.append(arg_strings[start_index])
963+
return start_index + 1
964+
965+
# if there is an explicit argument, try to match the
966+
# optional's string arguments to only this
967+
if explicit_arg is not None:
968+
arg_count = match_argument(action, 'A')
969+
970+
# if the action is a single-dash option and takes no
971+
# arguments, try to parse more single-dash options out
972+
# of the tail of the option string
973+
chars = self.prefix_chars
974+
if arg_count == 0 and option_string[1] not in chars:
975+
action_tuples.append((action, [], option_string))
976+
char = option_string[0]
977+
option_string = char + explicit_arg[0]
978+
new_explicit_arg = explicit_arg[1:] or None
979+
optionals_map = self._option_string_actions
980+
if option_string in optionals_map:
981+
action = optionals_map[option_string]
982+
explicit_arg = new_explicit_arg
983+
else:
984+
msg = _('ignored explicit argument %r')
985+
raise ArgumentError(action, msg % explicit_arg)
986+
987+
# if the action expect exactly one argument, we've
988+
# successfully matched the option; exit the loop
989+
elif arg_count == 1:
990+
stop = start_index + 1
991+
args = [explicit_arg]
992+
action_tuples.append((action, args, option_string))
993+
break
994+
995+
# error if a double-dash option did not use the
996+
# explicit argument
997+
else:
998+
msg = _('ignored explicit argument %r')
999+
raise ArgumentError(action, msg % explicit_arg)
1000+
1001+
# if there is no explicit argument, try to match the
1002+
# optional's string arguments with the following strings
1003+
# if successful, exit the loop
1004+
else:
1005+
start = start_index + 1
1006+
selected_patterns = arg_strings_pattern[start:]
1007+
arg_count = match_argument(action, selected_patterns)
1008+
stop = start + arg_count
1009+
args = arg_strings[start:stop]
1010+
action_tuples.append((action, args, option_string))
1011+
break
1012+
1013+
# add the Optional to the list and return the index at which
1014+
# the Optional's string args stopped
1015+
assert action_tuples
1016+
for action, args, option_string in action_tuples:
1017+
take_action(action, args, option_string)
1018+
return stop
1019+
1020+
# the list of Positionals left to be parsed; this is modified
1021+
# by consume_positionals()
1022+
positionals = self._get_positional_actions()
1023+
1024+
# function to convert arg_strings into positional actions
1025+
def consume_positionals(start_index):
1026+
# match as many Positionals as possible
1027+
match_partial = self._match_arguments_partial
1028+
selected_pattern = arg_strings_pattern[start_index:]
1029+
arg_counts = match_partial(positionals, selected_pattern)
1030+
1031+
####################################################################
1032+
# Applied mixed.patch from https://bugs.python.org/issue15112
1033+
if 'O' in arg_strings_pattern[start_index:]:
1034+
# if there is an optional after this, remove
1035+
# 'empty' positionals from the current match
1036+
1037+
while len(arg_counts) > 1 and arg_counts[-1] == 0:
1038+
arg_counts = arg_counts[:-1]
1039+
####################################################################
1040+
1041+
# slice off the appropriate arg strings for each Positional
1042+
# and add the Positional and its args to the list
1043+
for action, arg_count in zip(positionals, arg_counts):
1044+
args = arg_strings[start_index: start_index + arg_count]
1045+
start_index += arg_count
1046+
take_action(action, args)
1047+
1048+
# slice off the Positionals that we just parsed and return the
1049+
# index at which the Positionals' string args stopped
1050+
positionals[:] = positionals[len(arg_counts):]
1051+
return start_index
1052+
1053+
# consume Positionals and Optionals alternately, until we have
1054+
# passed the last option string
1055+
extras = []
1056+
start_index = 0
1057+
if option_string_indices:
1058+
max_option_string_index = max(option_string_indices)
1059+
else:
1060+
max_option_string_index = -1
1061+
while start_index <= max_option_string_index:
1062+
1063+
# consume any Positionals preceding the next option
1064+
next_option_string_index = min([
1065+
index
1066+
for index in option_string_indices
1067+
if index >= start_index])
1068+
if start_index != next_option_string_index:
1069+
positionals_end_index = consume_positionals(start_index)
1070+
1071+
# only try to parse the next optional if we didn't consume
1072+
# the option string during the positionals parsing
1073+
if positionals_end_index > start_index:
1074+
start_index = positionals_end_index
1075+
continue
1076+
else:
1077+
start_index = positionals_end_index
1078+
1079+
# if we consumed all the positionals we could and we're not
1080+
# at the index of an option string, there were extra arguments
1081+
if start_index not in option_string_indices:
1082+
strings = arg_strings[start_index:next_option_string_index]
1083+
extras.extend(strings)
1084+
start_index = next_option_string_index
1085+
1086+
# consume the next optional and any arguments for it
1087+
start_index = consume_optional(start_index)
1088+
1089+
# consume any positionals following the last Optional
1090+
stop_index = consume_positionals(start_index)
1091+
1092+
# if we didn't consume all the argument strings, there were extras
1093+
extras.extend(arg_strings[stop_index:])
1094+
1095+
# make sure all required actions were present and also convert
1096+
# action defaults which were not given as arguments
1097+
required_actions = []
1098+
for action in self._actions:
1099+
if action not in seen_actions:
1100+
if action.required:
1101+
required_actions.append(_get_action_name(action))
1102+
else:
1103+
# Convert action default now instead of doing it before
1104+
# parsing arguments to avoid calling convert functions
1105+
# twice (which may fail) if the argument was given, but
1106+
# only if it was defined already in the namespace
1107+
if (action.default is not None and
1108+
isinstance(action.default, str) and
1109+
hasattr(namespace, action.dest) and
1110+
action.default is getattr(namespace, action.dest)):
1111+
setattr(namespace, action.dest,
1112+
self._get_value(action, action.default))
1113+
1114+
if required_actions:
1115+
self.error(_('the following arguments are required: %s') %
1116+
', '.join(required_actions))
1117+
1118+
# make sure all required groups had one option present
1119+
for group in self._mutually_exclusive_groups:
1120+
if group.required:
1121+
for action in group._group_actions:
1122+
if action in seen_non_default_actions:
1123+
break
1124+
1125+
# if no actions were used, report the error
1126+
else:
1127+
names = [_get_action_name(action)
1128+
for action in group._group_actions
1129+
if action.help is not SUPPRESS]
1130+
msg = _('one of the arguments %s is required')
1131+
self.error(msg % ' '.join(names))
1132+
1133+
# return the updated namespace and the extra arguments
1134+
return namespace, extras

cmd2/pyscript_bridge.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ def traverse_parser(parser):
114114
process_flag(action, values)
115115
else:
116116
process_flag(action, self._args[action.dest])
117+
# TODO: StoreTrue/StoreFalse
117118
else:
118119
process_flag(action, self._args[action.dest])
119120

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
app.media.movies.add('My Movie', 'PG-13', actor=('Mark Hamill'), director=('George Lucas', 'J. J. Abrams'))

tests/test_pyscript.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,8 @@ def test_pyscript_help(ps_app, capsys, request, command, pyscript_file):
106106
('media movies list -r PG PG-13', 'media_movies_list7.py'),
107107
('media movies add "My Movie" PG-13 --director "George Lucas" "J. J. Abrams"',
108108
'media_movies_add1.py'),
109+
('media movies add "My Movie" PG-13 --director "George Lucas" "J. J. Abrams" "Mark Hamill"',
110+
'media_movies_add2.py'),
109111
])
110112
def test_pyscript_out(ps_app, capsys, request, command, pyscript_file):
111113
test_dir = os.path.dirname(request.module.__file__)

0 commit comments

Comments
 (0)