From 69e64d5f11d8372481603a815e43476eded27ec0 Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Sun, 22 Sep 2024 00:43:05 +0300 Subject: [PATCH 1/2] gh-63143: Fix parsing mutually exclusive arguments in argparse Arguments with the value identical to the default value (e.g. booleans, small integers, empty or 1-character strings) are no longer considered "not present". --- Lib/argparse.py | 5 +- Lib/test/test_argparse.py | 85 +++++++++++++++++-- ...4-09-21-23-56-41.gh-issue-63143.YKu-LQ.rst | 3 + 3 files changed, 84 insertions(+), 9 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2024-09-21-23-56-41.gh-issue-63143.YKu-LQ.rst diff --git a/Lib/argparse.py b/Lib/argparse.py index 7988c447d03584..58fecd0214844f 100644 --- a/Lib/argparse.py +++ b/Lib/argparse.py @@ -1950,9 +1950,8 @@ def take_action(action, argument_strings, option_string=None): argument_values = self._get_values(action, argument_strings) # error if this argument is not allowed with other previously - # seen arguments, assuming that actions that use the default - # value don't really count as "present" - if argument_values is not action.default: + # seen arguments + if action.option_strings or argument_strings: seen_non_default_actions.add(action) for conflict_action in action_conflicts.get(action, []): if conflict_action in seen_non_default_actions: diff --git a/Lib/test/test_argparse.py b/Lib/test/test_argparse.py index 138ff19e86acf4..64a58262b0e665 100644 --- a/Lib/test/test_argparse.py +++ b/Lib/test/test_argparse.py @@ -2877,26 +2877,30 @@ def test_failures_when_not_required(self): parse_args = self.get_parser(required=False).parse_args error = ArgumentParserError for args_string in self.failures: - self.assertRaises(error, parse_args, args_string.split()) + with self.subTest(args=args_string): + self.assertRaises(error, parse_args, args_string.split()) def test_failures_when_required(self): parse_args = self.get_parser(required=True).parse_args error = ArgumentParserError for args_string in self.failures + ['']: - self.assertRaises(error, parse_args, args_string.split()) + with self.subTest(args=args_string): + self.assertRaises(error, parse_args, args_string.split()) def test_successes_when_not_required(self): parse_args = self.get_parser(required=False).parse_args successes = self.successes + self.successes_when_not_required for args_string, expected_ns in successes: - actual_ns = parse_args(args_string.split()) - self.assertEqual(actual_ns, expected_ns) + with self.subTest(args=args_string): + actual_ns = parse_args(args_string.split()) + self.assertEqual(actual_ns, expected_ns) def test_successes_when_required(self): parse_args = self.get_parser(required=True).parse_args for args_string, expected_ns in self.successes: - actual_ns = parse_args(args_string.split()) - self.assertEqual(actual_ns, expected_ns) + with self.subTest(args=args_string): + actual_ns = parse_args(args_string.split()) + self.assertEqual(actual_ns, expected_ns) def test_usage_when_not_required(self): format_usage = self.get_parser(required=False).format_usage @@ -3283,6 +3287,75 @@ def get_parser(self, required): test_successes_when_not_required = None test_successes_when_required = None + +class TestMutuallyExclusiveOptionalWithDefault(MEMixin, TestCase): + def get_parser(self, required=None): + parser = ErrorRaisingArgumentParser(prog='PROG') + group = parser.add_mutually_exclusive_group(required=required) + group.add_argument('--foo') + group.add_argument('--bar', type=bool, default=True) + return parser + + failures = [ + '--foo X --bar Y', + '--foo X --bar=', + ] + successes = [ + ('--foo X', NS(foo='X', bar=True)), + ('--bar X', NS(foo=None, bar=True)), + ('--bar=', NS(foo=None, bar=False)), + ] + successes_when_not_required = [ + ('', NS(foo=None, bar=True)), + ] + usage_when_required = '''\ + usage: PROG [-h] (--foo FOO | --bar BAR) + ''' + usage_when_not_required = '''\ + usage: PROG [-h] [--foo FOO | --bar BAR] + ''' + help = '''\ + + options: + -h, --help show this help message and exit + --foo FOO + --bar BAR + ''' + +class TestMutuallyExclusivePositionalWithDefault(MEMixin, TestCase): + def get_parser(self, required=None): + parser = ErrorRaisingArgumentParser(prog='PROG') + group = parser.add_mutually_exclusive_group(required=required) + group.add_argument('--foo') + group.add_argument('bar', nargs='?', type=bool, default=True) + return parser + + failures = [ + '--foo X Y', + ] + successes = [ + ('--foo X', NS(foo='X', bar=True)), + ('X', NS(foo=None, bar=True)), + ] + successes_when_not_required = [ + ('', NS(foo=None, bar=True)), + ] + usage_when_required = '''\ + usage: PROG [-h] (--foo FOO | bar) + ''' + usage_when_not_required = '''\ + usage: PROG [-h] [--foo FOO | bar] + ''' + help = '''\ + + positional arguments: + bar + + options: + -h, --help show this help message and exit + --foo FOO + ''' + # ================================================= # Mutually exclusive group in parent parser tests # ================================================= diff --git a/Misc/NEWS.d/next/Library/2024-09-21-23-56-41.gh-issue-63143.YKu-LQ.rst b/Misc/NEWS.d/next/Library/2024-09-21-23-56-41.gh-issue-63143.YKu-LQ.rst new file mode 100644 index 00000000000000..cb031fd601a9bd --- /dev/null +++ b/Misc/NEWS.d/next/Library/2024-09-21-23-56-41.gh-issue-63143.YKu-LQ.rst @@ -0,0 +1,3 @@ +Fix parsing mutually exclusive arguments in :mod:`argparse`. Arguments with +the value identical to the default value (e.g. booleans, small integers, +empty or 1-character strings) are no longer considered "not present". From 82a56b69861b65c4f154ffb79619b0c87cf2d47b Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Sun, 22 Sep 2024 01:14:55 +0300 Subject: [PATCH 2/2] Add test for gh-85531. --- Lib/test/test_argparse.py | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/Lib/test/test_argparse.py b/Lib/test/test_argparse.py index 64a58262b0e665..a31f4324002bc4 100644 --- a/Lib/test/test_argparse.py +++ b/Lib/test/test_argparse.py @@ -3288,6 +3288,41 @@ def get_parser(self, required): test_successes_when_required = None +class TestMutuallyExclusiveOptionalOptional(MEMixin, TestCase): + def get_parser(self, required=None): + parser = ErrorRaisingArgumentParser(prog='PROG') + group = parser.add_mutually_exclusive_group(required=required) + group.add_argument('--foo') + group.add_argument('--bar', nargs='?') + return parser + + failures = [ + '--foo X --bar Y', + '--foo X --bar', + ] + successes = [ + ('--foo X', NS(foo='X', bar=None)), + ('--bar X', NS(foo=None, bar='X')), + ('--bar', NS(foo=None, bar=None)), + ] + successes_when_not_required = [ + ('', NS(foo=None, bar=None)), + ] + usage_when_required = '''\ + usage: PROG [-h] (--foo FOO | --bar [BAR]) + ''' + usage_when_not_required = '''\ + usage: PROG [-h] [--foo FOO | --bar [BAR]] + ''' + help = '''\ + + options: + -h, --help show this help message and exit + --foo FOO + --bar [BAR] + ''' + + class TestMutuallyExclusiveOptionalWithDefault(MEMixin, TestCase): def get_parser(self, required=None): parser = ErrorRaisingArgumentParser(prog='PROG') @@ -3322,6 +3357,7 @@ def get_parser(self, required=None): --bar BAR ''' + class TestMutuallyExclusivePositionalWithDefault(MEMixin, TestCase): def get_parser(self, required=None): parser = ErrorRaisingArgumentParser(prog='PROG')