From a2a14625f2b3670999aaa1da84eacbf54721ea5d Mon Sep 17 00:00:00 2001 From: Jacob Walls Date: Sun, 23 Jan 2022 12:11:24 -0500 Subject: [PATCH 1/6] bpo-46440: Prevent all exits if `ArgumentParser.exit_on_error` is False --- Lib/argparse.py | 6 ++++-- Lib/test/test_argparse.py | 6 +++++- .../next/Library/2022-01-23-11-58-40.bpo-46440.tMts6U.rst | 2 ++ 3 files changed, 11 insertions(+), 3 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2022-01-23-11-58-40.bpo-46440.tMts6U.rst diff --git a/Lib/argparse.py b/Lib/argparse.py index 9344dab3e60d5a..cf560ad722405b 100644 --- a/Lib/argparse.py +++ b/Lib/argparse.py @@ -2591,11 +2591,13 @@ def error(self, message): """error(message: string) Prints a usage message incorporating the message to stderr and - exits. + exits (or raises ArgumentError if exit_on_error is False). If you override this in a subclass, it should not return -- it should either exit or raise an exception. """ self.print_usage(_sys.stderr) args = {'prog': self.prog, 'message': message} - self.exit(2, _('%(prog)s: error: %(message)s\n') % args) + if self.exit_on_error: + self.exit(2, _('%(prog)s: error: %(message)s\n') % args) + raise ArgumentError(None, message) diff --git a/Lib/test/test_argparse.py b/Lib/test/test_argparse.py index df6da928c9bebf..10043c2c2f00dd 100644 --- a/Lib/test/test_argparse.py +++ b/Lib/test/test_argparse.py @@ -5452,7 +5452,8 @@ class TestExitOnError(TestCase): def setUp(self): self.parser = argparse.ArgumentParser(exit_on_error=False) - self.parser.add_argument('--integers', metavar='N', type=int) + self.parser.add_argument( + '--integers', metavar='N', type=int, required=True) def test_exit_on_error_with_good_args(self): ns = self.parser.parse_args('--integers 4'.split()) @@ -5461,6 +5462,9 @@ def test_exit_on_error_with_good_args(self): def test_exit_on_error_with_bad_args(self): with self.assertRaises(argparse.ArgumentError): self.parser.parse_args('--integers a'.split()) + msg = 'the following arguments are required: --integers' + with self.assertRaisesRegex(argparse.ArgumentError, msg): + self.parser.parse_args([]) def tearDownModule(): diff --git a/Misc/NEWS.d/next/Library/2022-01-23-11-58-40.bpo-46440.tMts6U.rst b/Misc/NEWS.d/next/Library/2022-01-23-11-58-40.bpo-46440.tMts6U.rst new file mode 100644 index 00000000000000..92ea080a15d15a --- /dev/null +++ b/Misc/NEWS.d/next/Library/2022-01-23-11-58-40.bpo-46440.tMts6U.rst @@ -0,0 +1,2 @@ +The :attr:`exit_on_error` attribute of :class:`argparse.ArgumentParser` did +not always avoid exiting when set to False. From 0fc30d319940da54ec2aa13f913d66bb4ab8b1fa Mon Sep 17 00:00:00 2001 From: Jacob Walls Date: Sun, 23 Jan 2022 21:51:20 -0500 Subject: [PATCH 2/6] Make all error paths raise ArgumentError --- Lib/argparse.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/Lib/argparse.py b/Lib/argparse.py index cf560ad722405b..e1e45b85849d74 100644 --- a/Lib/argparse.py +++ b/Lib/argparse.py @@ -2111,8 +2111,11 @@ def consume_positionals(start_index): self._get_value(action, action.default)) if required_actions: - self.error(_('the following arguments are required: %s') % - ', '.join(required_actions)) + raise ArgumentError( + None, + _('the following arguments are required: %s') % + ', '.join(required_actions) + ) # make sure all required groups had one option present for group in self._mutually_exclusive_groups: @@ -2127,7 +2130,10 @@ def consume_positionals(start_index): for action in group._group_actions if action.help is not SUPPRESS] msg = _('one of the arguments %s is required') - self.error(msg % ' '.join(names)) + raise ArgumentError( + None, + msg % ' '.join(names) + ) # return the updated namespace and the extra arguments return namespace, extras @@ -2591,13 +2597,11 @@ def error(self, message): """error(message: string) Prints a usage message incorporating the message to stderr and - exits (or raises ArgumentError if exit_on_error is False). + exits. If you override this in a subclass, it should not return -- it should either exit or raise an exception. """ self.print_usage(_sys.stderr) args = {'prog': self.prog, 'message': message} - if self.exit_on_error: - self.exit(2, _('%(prog)s: error: %(message)s\n') % args) - raise ArgumentError(None, message) + self.exit(2, _('%(prog)s: error: %(message)s\n') % args) From f3e173cf2ffcce7607286be9f26ed923c49a811d Mon Sep 17 00:00:00 2001 From: Jacob Walls Date: Fri, 28 Jan 2022 22:09:45 -0500 Subject: [PATCH 3/6] Extend solution --- Lib/argparse.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Lib/argparse.py b/Lib/argparse.py index e1e45b85849d74..38edcedf9ee6d5 100644 --- a/Lib/argparse.py +++ b/Lib/argparse.py @@ -1844,7 +1844,10 @@ def parse_args(self, args=None, namespace=None): args, argv = self.parse_known_args(args, namespace) if argv: msg = _('unrecognized arguments: %s') - self.error(msg % ' '.join(argv)) + raise ArgumentError( + None, + msg % ' '.join(argv) + ) return args def parse_known_args(self, args=None, namespace=None): From 10c748af3e1c56b5d5cb2d1d1aa886238cd18360 Mon Sep 17 00:00:00 2001 From: Jacob Walls Date: Fri, 28 Jan 2022 23:37:05 -0500 Subject: [PATCH 4/6] Guard according to `exit_on_error` --- Lib/argparse.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/Lib/argparse.py b/Lib/argparse.py index 38edcedf9ee6d5..b49a10e5edd9c3 100644 --- a/Lib/argparse.py +++ b/Lib/argparse.py @@ -1844,10 +1844,12 @@ def parse_args(self, args=None, namespace=None): args, argv = self.parse_known_args(args, namespace) if argv: msg = _('unrecognized arguments: %s') - raise ArgumentError( - None, - msg % ' '.join(argv) - ) + if not self.exit_on_error: + raise ArgumentError( + None, + msg % ' '.join(argv) + ) + self.error(msg % ' '.join(argv)) return args def parse_known_args(self, args=None, namespace=None): From 7b83cf6f746cc1baefedaff74c577b9e3f3b823a Mon Sep 17 00:00:00 2001 From: Jacob Walls Date: Sat, 29 Jan 2022 08:53:58 -0500 Subject: [PATCH 5/6] Additional regression tests --- Lib/test/test_argparse.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/Lib/test/test_argparse.py b/Lib/test/test_argparse.py index 10043c2c2f00dd..5128caf52e612e 100644 --- a/Lib/test/test_argparse.py +++ b/Lib/test/test_argparse.py @@ -5462,10 +5462,27 @@ def test_exit_on_error_with_good_args(self): def test_exit_on_error_with_bad_args(self): with self.assertRaises(argparse.ArgumentError): self.parser.parse_args('--integers a'.split()) + + def test_exit_on_error_missing_required_arg(self): msg = 'the following arguments are required: --integers' with self.assertRaisesRegex(argparse.ArgumentError, msg): self.parser.parse_args([]) + def test_exit_on_error_unknown_arg(self): + msg = 'unrecognized arguments: --unknown' + with self.assertRaisesRegex(argparse.ArgumentError, msg): + self.parser.parse_args('--integers 4 --unknown'.split()) + + def test_exit_on_error_mutually_exclusive_group(self): + other_parser = argparse.ArgumentParser(exit_on_error=False) + group = other_parser.add_mutually_exclusive_group(required=True) + group.add_argument('--up', action='store_true') + group.add_argument('--down', action='store_true') + + msg = 'one of the arguments --up --down is required' + with self.assertRaisesRegex(argparse.ArgumentError, msg): + other_parser.parse_args([]) + def tearDownModule(): # Remove global references to avoid looking like we have refleaks. From 7e16f627b8d43929ca11cf6d4f4cc5cff0d16efb Mon Sep 17 00:00:00 2001 From: Jacob Walls Date: Fri, 14 Apr 2023 22:52:33 -0400 Subject: [PATCH 6/6] Extend solution to parse_intermixed_args() --- Lib/argparse.py | 5 ++++- Lib/test/test_argparse.py | 2 ++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/Lib/argparse.py b/Lib/argparse.py index a802aaaa393301..8f892d4eb4cd8b 100644 --- a/Lib/argparse.py +++ b/Lib/argparse.py @@ -2395,7 +2395,10 @@ def parse_intermixed_args(self, args=None, namespace=None): args, argv = self.parse_known_intermixed_args(args, namespace) if argv: msg = _('unrecognized arguments: %s') - self.error(msg % ' '.join(argv)) + formatted_msg = msg % ' '.join(argv) + if self.exit_on_error: + self.error(formatted_msg) + raise ArgumentError(None, formatted_msg) return args def parse_known_intermixed_args(self, args=None, namespace=None): diff --git a/Lib/test/test_argparse.py b/Lib/test/test_argparse.py index 0f52a95d314dc1..3c417e28d0ba0b 100644 --- a/Lib/test/test_argparse.py +++ b/Lib/test/test_argparse.py @@ -5614,6 +5614,8 @@ def test_exit_on_error_unknown_arg(self): msg = 'unrecognized arguments: --unknown' with self.assertRaisesRegex(argparse.ArgumentError, msg): self.parser.parse_args('--integers 4 --unknown'.split()) + with self.assertRaisesRegex(argparse.ArgumentError, msg.replace('--', '')): + self.parser.parse_intermixed_args('--integers 4 unknown'.split()) def test_exit_on_error_mutually_exclusive_group(self): other_parser = argparse.ArgumentParser(exit_on_error=False)