Skip to content

Commit 1fe63b1

Browse files
[3.13] gh-125355: Rewrite parse_intermixed_args() in argparse (GH-125356) (GH-125834)
* 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) Co-authored-by: Serhiy Storchaka <[email protected]>
1 parent e3bfe1e commit 1fe63b1

File tree

3 files changed

+80
-82
lines changed

3 files changed

+80
-82
lines changed

Lib/argparse.py

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

18981898
def parse_known_args(self, args=None, namespace=None):
1899+
return self._parse_known_args2(args, namespace, intermixed=False)
1900+
1901+
def _parse_known_args2(self, args, namespace, intermixed):
18991902
if args is None:
19001903
# args default to the system args
19011904
args = _sys.argv[1:]
@@ -1922,18 +1925,18 @@ def parse_known_args(self, args=None, namespace=None):
19221925
# parse the arguments and exit if there are any errors
19231926
if self.exit_on_error:
19241927
try:
1925-
namespace, args = self._parse_known_args(args, namespace)
1928+
namespace, args = self._parse_known_args(args, namespace, intermixed)
19261929
except ArgumentError as err:
19271930
self.error(str(err))
19281931
else:
1929-
namespace, args = self._parse_known_args(args, namespace)
1932+
namespace, args = self._parse_known_args(args, namespace, intermixed)
19301933

19311934
if hasattr(namespace, _UNRECOGNIZED_ARGS_ATTR):
19321935
args.extend(getattr(namespace, _UNRECOGNIZED_ARGS_ATTR))
19331936
delattr(namespace, _UNRECOGNIZED_ARGS_ATTR)
19341937
return namespace, args
19351938

1936-
def _parse_known_args(self, arg_strings, namespace):
1939+
def _parse_known_args(self, arg_strings, namespace, intermixed):
19371940
# replace arg strings that are file references
19381941
if self.fromfile_prefix_chars is not None:
19391942
arg_strings = self._read_args_from_files(arg_strings)
@@ -2024,6 +2027,7 @@ def consume_optional(start_index):
20242027
# if we found no optional action, skip it
20252028
if action is None:
20262029
extras.append(arg_strings[start_index])
2030+
extras_pattern.append('O')
20272031
return start_index + 1
20282032

20292033
# if there is an explicit argument, try to match the
@@ -2059,6 +2063,7 @@ def consume_optional(start_index):
20592063
sep = ''
20602064
else:
20612065
extras.append(char + explicit_arg)
2066+
extras_pattern.append('O')
20622067
stop = start_index + 1
20632068
break
20642069
# if the action expect exactly one argument, we've
@@ -2137,6 +2142,7 @@ def consume_positionals(start_index):
21372142
# consume Positionals and Optionals alternately, until we have
21382143
# passed the last option string
21392144
extras = []
2145+
extras_pattern = []
21402146
start_index = 0
21412147
if option_string_indices:
21422148
max_option_string_index = max(option_string_indices)
@@ -2150,7 +2156,7 @@ def consume_positionals(start_index):
21502156
if next_option_string_index in option_string_indices:
21512157
break
21522158
next_option_string_index += 1
2153-
if start_index != next_option_string_index:
2159+
if not intermixed and start_index != next_option_string_index:
21542160
positionals_end_index = consume_positionals(start_index)
21552161

21562162
# only try to parse the next optional if we didn't consume
@@ -2166,16 +2172,35 @@ def consume_positionals(start_index):
21662172
if start_index not in option_string_indices:
21672173
strings = arg_strings[start_index:next_option_string_index]
21682174
extras.extend(strings)
2175+
extras_pattern.extend(arg_strings_pattern[start_index:next_option_string_index])
21692176
start_index = next_option_string_index
21702177

21712178
# consume the next optional and any arguments for it
21722179
start_index = consume_optional(start_index)
21732180

2174-
# consume any positionals following the last Optional
2175-
stop_index = consume_positionals(start_index)
2181+
if not intermixed:
2182+
# consume any positionals following the last Optional
2183+
stop_index = consume_positionals(start_index)
21762184

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

21802205
# make sure all required actions were present and also convert
21812206
# action defaults which were not given as arguments
@@ -2441,10 +2466,6 @@ def parse_known_intermixed_args(self, args=None, namespace=None):
24412466
# are then parsed. If the parser definition is incompatible with the
24422467
# intermixed assumptions (e.g. use of REMAINDER, subparsers) a
24432468
# TypeError is raised.
2444-
#
2445-
# positionals are 'deactivated' by setting nargs and default to
2446-
# SUPPRESS. This blocks the addition of that positional to the
2447-
# namespace
24482469

24492470
positionals = self._get_positional_actions()
24502471
a = [action for action in positionals
@@ -2453,59 +2474,7 @@ def parse_known_intermixed_args(self, args=None, namespace=None):
24532474
raise TypeError('parse_intermixed_args: positional arg'
24542475
' with nargs=%s'%a[0].nargs)
24552476

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

25102479
# ========================
25112480
# Value conversion methods

Lib/test/test_argparse.py

Lines changed: 39 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6257,12 +6257,23 @@ def test_basic(self):
62576257
# cannot parse the '1,2,3'
62586258
self.assertEqual(NS(bar='y', cmd='cmd', foo='x', rest=[1]), args)
62596259
self.assertEqual(["2", "3"], extras)
6260+
args, extras = parser.parse_known_intermixed_args(argv)
6261+
self.assertEqual(NS(bar='y', cmd='cmd', foo='x', rest=[1, 2, 3]), args)
6262+
self.assertEqual([], extras)
62606263

6264+
# unknown optionals go into extras
6265+
argv = 'cmd --foo x --error 1 2 --bar y 3'.split()
6266+
args, extras = parser.parse_known_intermixed_args(argv)
6267+
self.assertEqual(NS(bar='y', cmd='cmd', foo='x', rest=[1, 2, 3]), args)
6268+
self.assertEqual(['--error'], extras)
62616269
argv = 'cmd --foo x 1 --error 2 --bar y 3'.split()
62626270
args, extras = parser.parse_known_intermixed_args(argv)
6263-
# unknown optionals go into extras
6264-
self.assertEqual(NS(bar='y', cmd='cmd', foo='x', rest=[1]), args)
6265-
self.assertEqual(['--error', '2', '3'], extras)
6271+
self.assertEqual(NS(bar='y', cmd='cmd', foo='x', rest=[1, 2, 3]), args)
6272+
self.assertEqual(['--error'], extras)
6273+
argv = 'cmd --foo x 1 2 --error --bar y 3'.split()
6274+
args, extras = parser.parse_known_intermixed_args(argv)
6275+
self.assertEqual(NS(bar='y', cmd='cmd', foo='x', rest=[1, 2, 3]), args)
6276+
self.assertEqual(['--error'], extras)
62666277

62676278
# restores attributes that were temporarily changed
62686279
self.assertIsNone(parser.usage)
@@ -6281,37 +6292,48 @@ def test_remainder(self):
62816292
parser.parse_intermixed_args(argv)
62826293
self.assertRegex(str(cm.exception), r'\.\.\.')
62836294

6284-
def test_exclusive(self):
6285-
# mutually exclusive group; intermixed works fine
6286-
parser = ErrorRaisingArgumentParser(prog='PROG')
6295+
def test_required_exclusive(self):
6296+
# required mutually exclusive group; intermixed works fine
6297+
parser = argparse.ArgumentParser(prog='PROG', exit_on_error=False)
62876298
group = parser.add_mutually_exclusive_group(required=True)
62886299
group.add_argument('--foo', action='store_true', help='FOO')
62896300
group.add_argument('--spam', help='SPAM')
62906301
parser.add_argument('badger', nargs='*', default='X', help='BADGER')
6302+
args = parser.parse_intermixed_args('--foo 1 2'.split())
6303+
self.assertEqual(NS(badger=['1', '2'], foo=True, spam=None), args)
62916304
args = parser.parse_intermixed_args('1 --foo 2'.split())
62926305
self.assertEqual(NS(badger=['1', '2'], foo=True, spam=None), args)
6293-
self.assertRaises(ArgumentParserError, parser.parse_intermixed_args, '1 2'.split())
6306+
self.assertRaisesRegex(argparse.ArgumentError,
6307+
'one of the arguments --foo --spam is required',
6308+
parser.parse_intermixed_args, '1 2'.split())
62946309
self.assertEqual(group.required, True)
62956310

6296-
def test_exclusive_incompatible(self):
6297-
# mutually exclusive group including positional - fail
6298-
parser = ErrorRaisingArgumentParser(prog='PROG')
6311+
def test_required_exclusive_with_positional(self):
6312+
# required mutually exclusive group with positional argument
6313+
parser = argparse.ArgumentParser(prog='PROG', exit_on_error=False)
62996314
group = parser.add_mutually_exclusive_group(required=True)
63006315
group.add_argument('--foo', action='store_true', help='FOO')
63016316
group.add_argument('--spam', help='SPAM')
63026317
group.add_argument('badger', nargs='*', default='X', help='BADGER')
6303-
self.assertRaises(TypeError, parser.parse_intermixed_args, [])
6318+
args = parser.parse_intermixed_args(['--foo'])
6319+
self.assertEqual(NS(foo=True, spam=None, badger='X'), args)
6320+
args = parser.parse_intermixed_args(['a', 'b'])
6321+
self.assertEqual(NS(foo=False, spam=None, badger=['a', 'b']), args)
6322+
self.assertRaisesRegex(argparse.ArgumentError,
6323+
'one of the arguments --foo --spam badger is required',
6324+
parser.parse_intermixed_args, [])
6325+
self.assertRaisesRegex(argparse.ArgumentError,
6326+
'argument badger: not allowed with argument --foo',
6327+
parser.parse_intermixed_args, ['--foo', 'a', 'b'])
6328+
self.assertRaisesRegex(argparse.ArgumentError,
6329+
'argument badger: not allowed with argument --foo',
6330+
parser.parse_intermixed_args, ['a', '--foo', 'b'])
63046331
self.assertEqual(group.required, True)
63056332

63066333
def test_invalid_args(self):
63076334
parser = ErrorRaisingArgumentParser(prog='PROG')
63086335
self.assertRaises(ArgumentParserError, parser.parse_intermixed_args, ['a'])
63096336

6310-
parser = ErrorRaisingArgumentParser(prog='PROG')
6311-
parser.add_argument('--foo', nargs="*")
6312-
parser.add_argument('foo')
6313-
with self.assertWarns(UserWarning):
6314-
parser.parse_intermixed_args(['hello', '--foo'])
63156337

63166338
class TestIntermixedMessageContentError(TestCase):
63176339
# case where Intermixed gives different error message
@@ -6330,7 +6352,7 @@ def test_missing_argument_name_in_message(self):
63306352
with self.assertRaises(ArgumentParserError) as cm:
63316353
parser.parse_intermixed_args([])
63326354
msg = str(cm.exception)
6333-
self.assertNotRegex(msg, 'req_pos')
6355+
self.assertRegex(msg, 'req_pos')
63346356
self.assertRegex(msg, 'req_opt')
63356357

63366358
# ==========================
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)