Skip to content

Commit da3d81d

Browse files
[3.12] gh-125355: Rewrite parse_intermixed_args() in argparse (GH-125356) (GH-125839)
* The parser no longer changes temporarily during parsing. * Default values are not processed twice. * Required mutually exclusive groups containing positional arguments are now supported. * The missing arguments report now includes the names of all required optional and positional arguments. * Unknown options can be intermixed with positional arguments in parse_known_intermixed_args(). (cherry picked from commit 759a54d)
1 parent 98a0b36 commit da3d81d

File tree

3 files changed

+81
-77
lines changed

3 files changed

+81
-77
lines changed

Lib/argparse.py

Lines changed: 34 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -1911,6 +1911,9 @@ def parse_args(self, args=None, namespace=None):
19111911
return args
19121912

19131913
def parse_known_args(self, args=None, namespace=None):
1914+
return self._parse_known_args2(args, namespace, intermixed=False)
1915+
1916+
def _parse_known_args2(self, args, namespace, intermixed):
19141917
if args is None:
19151918
# args default to the system args
19161919
args = _sys.argv[1:]
@@ -1937,18 +1940,18 @@ def parse_known_args(self, args=None, namespace=None):
19371940
# parse the arguments and exit if there are any errors
19381941
if self.exit_on_error:
19391942
try:
1940-
namespace, args = self._parse_known_args(args, namespace)
1943+
namespace, args = self._parse_known_args(args, namespace, intermixed)
19411944
except ArgumentError as err:
19421945
self.error(str(err))
19431946
else:
1944-
namespace, args = self._parse_known_args(args, namespace)
1947+
namespace, args = self._parse_known_args(args, namespace, intermixed)
19451948

19461949
if hasattr(namespace, _UNRECOGNIZED_ARGS_ATTR):
19471950
args.extend(getattr(namespace, _UNRECOGNIZED_ARGS_ATTR))
19481951
delattr(namespace, _UNRECOGNIZED_ARGS_ATTR)
19491952
return namespace, args
19501953

1951-
def _parse_known_args(self, arg_strings, namespace):
1954+
def _parse_known_args(self, arg_strings, namespace, intermixed):
19521955
# replace arg strings that are file references
19531956
if self.fromfile_prefix_chars is not None:
19541957
arg_strings = self._read_args_from_files(arg_strings)
@@ -2038,6 +2041,7 @@ def consume_optional(start_index):
20382041
# if we found no optional action, skip it
20392042
if action is None:
20402043
extras.append(arg_strings[start_index])
2044+
extras_pattern.append('O')
20412045
return start_index + 1
20422046

20432047
# if there is an explicit argument, try to match the
@@ -2073,6 +2077,7 @@ def consume_optional(start_index):
20732077
sep = ''
20742078
else:
20752079
extras.append(char + explicit_arg)
2080+
extras_pattern.append('O')
20762081
stop = start_index + 1
20772082
break
20782083
# if the action expect exactly one argument, we've
@@ -2143,6 +2148,7 @@ def consume_positionals(start_index):
21432148
# consume Positionals and Optionals alternately, until we have
21442149
# passed the last option string
21452150
extras = []
2151+
extras_pattern = []
21462152
start_index = 0
21472153
if option_string_indices:
21482154
max_option_string_index = max(option_string_indices)
@@ -2155,7 +2161,7 @@ def consume_positionals(start_index):
21552161
index
21562162
for index in option_string_indices
21572163
if index >= start_index])
2158-
if start_index != next_option_string_index:
2164+
if not intermixed and start_index != next_option_string_index:
21592165
positionals_end_index = consume_positionals(start_index)
21602166

21612167
# only try to parse the next optional if we didn't consume
@@ -2171,16 +2177,35 @@ def consume_positionals(start_index):
21712177
if start_index not in option_string_indices:
21722178
strings = arg_strings[start_index:next_option_string_index]
21732179
extras.extend(strings)
2180+
extras_pattern.extend(arg_strings_pattern[start_index:next_option_string_index])
21742181
start_index = next_option_string_index
21752182

21762183
# consume the next optional and any arguments for it
21772184
start_index = consume_optional(start_index)
21782185

2179-
# consume any positionals following the last Optional
2180-
stop_index = consume_positionals(start_index)
2186+
if not intermixed:
2187+
# consume any positionals following the last Optional
2188+
stop_index = consume_positionals(start_index)
21812189

2182-
# if we didn't consume all the argument strings, there were extras
2183-
extras.extend(arg_strings[stop_index:])
2190+
# if we didn't consume all the argument strings, there were extras
2191+
extras.extend(arg_strings[stop_index:])
2192+
else:
2193+
extras.extend(arg_strings[start_index:])
2194+
extras_pattern.extend(arg_strings_pattern[start_index:])
2195+
extras_pattern = ''.join(extras_pattern)
2196+
assert len(extras_pattern) == len(extras)
2197+
# consume all positionals
2198+
arg_strings = [s for s, c in zip(extras, extras_pattern) if c != 'O']
2199+
arg_strings_pattern = extras_pattern.replace('O', '')
2200+
stop_index = consume_positionals(0)
2201+
# leave unknown optionals and non-consumed positionals in extras
2202+
for i, c in enumerate(extras_pattern):
2203+
if not stop_index:
2204+
break
2205+
if c != 'O':
2206+
stop_index -= 1
2207+
extras[i] = None
2208+
extras = [s for s in extras if s is not None]
21842209

21852210
# make sure all required actions were present and also convert
21862211
# action defaults which were not given as arguments
@@ -2446,10 +2471,6 @@ def parse_known_intermixed_args(self, args=None, namespace=None):
24462471
# are then parsed. If the parser definition is incompatible with the
24472472
# intermixed assumptions (e.g. use of REMAINDER, subparsers) a
24482473
# TypeError is raised.
2449-
#
2450-
# positionals are 'deactivated' by setting nargs and default to
2451-
# SUPPRESS. This blocks the addition of that positional to the
2452-
# namespace
24532474

24542475
positionals = self._get_positional_actions()
24552476
a = [action for action in positionals
@@ -2458,59 +2479,7 @@ def parse_known_intermixed_args(self, args=None, namespace=None):
24582479
raise TypeError('parse_intermixed_args: positional arg'
24592480
' with nargs=%s'%a[0].nargs)
24602481

2461-
if [action.dest for group in self._mutually_exclusive_groups
2462-
for action in group._group_actions if action in positionals]:
2463-
raise TypeError('parse_intermixed_args: positional in'
2464-
' mutuallyExclusiveGroup')
2465-
2466-
try:
2467-
save_usage = self.usage
2468-
try:
2469-
if self.usage is None:
2470-
# capture the full usage for use in error messages
2471-
self.usage = self.format_usage()[7:]
2472-
for action in positionals:
2473-
# deactivate positionals
2474-
action.save_nargs = action.nargs
2475-
# action.nargs = 0
2476-
action.nargs = SUPPRESS
2477-
action.save_default = action.default
2478-
action.default = SUPPRESS
2479-
namespace, remaining_args = self.parse_known_args(args,
2480-
namespace)
2481-
for action in positionals:
2482-
# remove the empty positional values from namespace
2483-
if (hasattr(namespace, action.dest)
2484-
and getattr(namespace, action.dest)==[]):
2485-
from warnings import warn
2486-
warn('Do not expect %s in %s' % (action.dest, namespace))
2487-
delattr(namespace, action.dest)
2488-
finally:
2489-
# restore nargs and usage before exiting
2490-
for action in positionals:
2491-
action.nargs = action.save_nargs
2492-
action.default = action.save_default
2493-
optionals = self._get_optional_actions()
2494-
try:
2495-
# parse positionals. optionals aren't normally required, but
2496-
# they could be, so make sure they aren't.
2497-
for action in optionals:
2498-
action.save_required = action.required
2499-
action.required = False
2500-
for group in self._mutually_exclusive_groups:
2501-
group.save_required = group.required
2502-
group.required = False
2503-
namespace, extras = self.parse_known_args(remaining_args,
2504-
namespace)
2505-
finally:
2506-
# restore parser values before exiting
2507-
for action in optionals:
2508-
action.required = action.save_required
2509-
for group in self._mutually_exclusive_groups:
2510-
group.required = group.save_required
2511-
finally:
2512-
self.usage = save_usage
2513-
return namespace, extras
2482+
return self._parse_known_args2(args, namespace, intermixed=True)
25142483

25152484
# ========================
25162485
# Value conversion methods

Lib/test/test_argparse.py

Lines changed: 40 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5897,12 +5897,23 @@ def test_basic(self):
58975897
# cannot parse the '1,2,3'
58985898
self.assertEqual(NS(bar='y', cmd='cmd', foo='x', rest=[1]), args)
58995899
self.assertEqual(["2", "3"], extras)
5900+
args, extras = parser.parse_known_intermixed_args(argv)
5901+
self.assertEqual(NS(bar='y', cmd='cmd', foo='x', rest=[1, 2, 3]), args)
5902+
self.assertEqual([], extras)
59005903

5904+
# unknown optionals go into extras
5905+
argv = 'cmd --foo x --error 1 2 --bar y 3'.split()
5906+
args, extras = parser.parse_known_intermixed_args(argv)
5907+
self.assertEqual(NS(bar='y', cmd='cmd', foo='x', rest=[1, 2, 3]), args)
5908+
self.assertEqual(['--error'], extras)
59015909
argv = 'cmd --foo x 1 --error 2 --bar y 3'.split()
59025910
args, extras = parser.parse_known_intermixed_args(argv)
5903-
# unknown optionals go into extras
5904-
self.assertEqual(NS(bar='y', cmd='cmd', foo='x', rest=[1]), args)
5905-
self.assertEqual(['--error', '2', '3'], extras)
5911+
self.assertEqual(NS(bar='y', cmd='cmd', foo='x', rest=[1, 2, 3]), args)
5912+
self.assertEqual(['--error'], extras)
5913+
argv = 'cmd --foo x 1 2 --error --bar y 3'.split()
5914+
args, extras = parser.parse_known_intermixed_args(argv)
5915+
self.assertEqual(NS(bar='y', cmd='cmd', foo='x', rest=[1, 2, 3]), args)
5916+
self.assertEqual(['--error'], extras)
59065917

59075918
# restores attributes that were temporarily changed
59085919
self.assertIsNone(parser.usage)
@@ -5921,28 +5932,45 @@ def test_remainder(self):
59215932
parser.parse_intermixed_args(argv)
59225933
self.assertRegex(str(cm.exception), r'\.\.\.')
59235934

5924-
def test_exclusive(self):
5925-
# mutually exclusive group; intermixed works fine
5926-
parser = ErrorRaisingArgumentParser(prog='PROG')
5935+
def test_required_exclusive(self):
5936+
# required mutually exclusive group; intermixed works fine
5937+
parser = argparse.ArgumentParser(prog='PROG', exit_on_error=False)
59275938
group = parser.add_mutually_exclusive_group(required=True)
59285939
group.add_argument('--foo', action='store_true', help='FOO')
59295940
group.add_argument('--spam', help='SPAM')
59305941
parser.add_argument('badger', nargs='*', default='X', help='BADGER')
5942+
args = parser.parse_intermixed_args('--foo 1 2'.split())
5943+
self.assertEqual(NS(badger=['1', '2'], foo=True, spam=None), args)
59315944
args = parser.parse_intermixed_args('1 --foo 2'.split())
59325945
self.assertEqual(NS(badger=['1', '2'], foo=True, spam=None), args)
5933-
self.assertRaises(ArgumentParserError, parser.parse_intermixed_args, '1 2'.split())
5946+
self.assertRaisesRegex(argparse.ArgumentError,
5947+
'one of the arguments --foo --spam is required',
5948+
parser.parse_intermixed_args, '1 2'.split())
59345949
self.assertEqual(group.required, True)
59355950

5936-
def test_exclusive_incompatible(self):
5937-
# mutually exclusive group including positional - fail
5938-
parser = ErrorRaisingArgumentParser(prog='PROG')
5951+
def test_required_exclusive_with_positional(self):
5952+
# required mutually exclusive group with positional argument
5953+
parser = argparse.ArgumentParser(prog='PROG', exit_on_error=False)
59395954
group = parser.add_mutually_exclusive_group(required=True)
59405955
group.add_argument('--foo', action='store_true', help='FOO')
59415956
group.add_argument('--spam', help='SPAM')
59425957
group.add_argument('badger', nargs='*', default='X', help='BADGER')
5943-
self.assertRaises(TypeError, parser.parse_intermixed_args, [])
5958+
args = parser.parse_intermixed_args(['--foo'])
5959+
self.assertEqual(NS(foo=True, spam=None, badger='X'), args)
5960+
args = parser.parse_intermixed_args(['a', 'b'])
5961+
self.assertEqual(NS(foo=False, spam=None, badger=['a', 'b']), args)
5962+
self.assertRaisesRegex(argparse.ArgumentError,
5963+
'one of the arguments --foo --spam badger is required',
5964+
parser.parse_intermixed_args, [])
5965+
self.assertRaisesRegex(argparse.ArgumentError,
5966+
'argument badger: not allowed with argument --foo',
5967+
parser.parse_intermixed_args, ['--foo', 'a', 'b'])
5968+
self.assertRaisesRegex(argparse.ArgumentError,
5969+
'argument badger: not allowed with argument --foo',
5970+
parser.parse_intermixed_args, ['a', '--foo', 'b'])
59445971
self.assertEqual(group.required, True)
59455972

5973+
59465974
class TestIntermixedMessageContentError(TestCase):
59475975
# case where Intermixed gives different error message
59485976
# error is raised by 1st parsing step
@@ -5960,7 +5988,7 @@ def test_missing_argument_name_in_message(self):
59605988
with self.assertRaises(ArgumentParserError) as cm:
59615989
parser.parse_intermixed_args([])
59625990
msg = str(cm.exception)
5963-
self.assertNotRegex(msg, 'req_pos')
5991+
self.assertRegex(msg, 'req_pos')
59645992
self.assertRegex(msg, 'req_opt')
59655993

59665994
# ==========================
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
Fix several bugs in :meth:`argparse.ArgumentParser.parse_intermixed_args`.
2+
3+
* The parser no longer changes temporarily during parsing.
4+
* Default values are not processed twice.
5+
* Required mutually exclusive groups containing positional arguments are now supported.
6+
* The missing arguments report now includes the names of all required optional and positional arguments.
7+
* Unknown options can be intermixed with positional arguments in parse_known_intermixed_args().

0 commit comments

Comments
 (0)