diff --git a/Doc/library/argparse.rst b/Doc/library/argparse.rst index 29396c7a0366a1..6f2d7fd37bfaa9 100644 --- a/Doc/library/argparse.rst +++ b/Doc/library/argparse.rst @@ -671,6 +671,9 @@ The add_argument() method * choices_ - A sequence of the allowable values for the argument. + * convert_choices_ - Whether to convert the choices_ using the type_ callable + before checking. + * required_ - Whether or not the command-line option may be omitted (optionals only). @@ -1156,10 +1159,6 @@ if the argument was not one of the acceptable values:: game.py: error: argument move: invalid choice: 'fire' (choose from 'rock', 'paper', 'scissors') -Note that inclusion in the *choices* sequence is checked after any type_ -conversions have been performed, so the type of the objects in the *choices* -sequence should match the type_ specified. - Any sequence can be passed as the *choices* value, so :class:`list` objects, :class:`tuple` objects, and custom sequences are all supported. @@ -1172,6 +1171,36 @@ from *dest*. This is usually what you want because the user never sees the many choices), just specify an explicit metavar_. +.. _convert_choices: + +convert_choices +^^^^^^^^^^^^^^^ + +By default, when a user passes both a ``type`` and a ``choices`` argument, the +``choices`` need to be specified in the target type, after conversion. +This can cause confusing ``usage`` and ``help`` strings. +To specify ``choices`` before conversion, set the flag ``convert_choices``:: + + >>> def to_dow(s): + ... return ['mo', 'tu', 'we', 'th', 'fr', 'sa', 'su'].index(x) + ... + >>> parser = argparse.ArgumentParser() + >>> parser.add_argument('when', + ... choices=['mo', 'tu', 'we', 'th', 'fr', 'sa', 'su'], + ... convert_choices=True, + ... type=to_dow) + >>> parser.print_help() + usage: sphinx-build [-h] {mo,tu,we,th,fr,sa,su} + + positional arguments: + {mo,tu,we,th,fr,sa,su} + + options: + -h, --help show this help message and exit + +.. versionadded:: next + + .. _required: required diff --git a/Lib/argparse.py b/Lib/argparse.py index f13ac82dbc50b3..6980240eb0584f 100644 --- a/Lib/argparse.py +++ b/Lib/argparse.py @@ -875,6 +875,9 @@ class Action(_AttributeHolder): type, an exception will be raised if it is not a member of this collection. + - convert_choices - Runs the ``choices`` through the ``type`` callable + during checking. (default: ``False``) + - required -- True if the action must always be specified at the command line. This is only meaningful for optional command-line arguments. @@ -893,6 +896,7 @@ def __init__(self, default=None, type=None, choices=None, + convert_choices=False, required=False, help=None, metavar=None, @@ -904,6 +908,7 @@ def __init__(self, self.default = default self.type = type self.choices = choices + self.convert_choices = convert_choices self.required = required self.help = help self.metavar = metavar @@ -918,6 +923,7 @@ def _get_kwargs(self): 'default', 'type', 'choices', + 'convert_choices', 'required', 'help', 'metavar', @@ -980,6 +986,7 @@ def __init__(self, default=None, type=None, choices=None, + convert_choices=False, required=False, help=None, metavar=None, @@ -998,6 +1005,7 @@ def __init__(self, default=default, type=type, choices=choices, + convert_choices=convert_choices, required=required, help=help, metavar=metavar, @@ -2617,7 +2625,7 @@ def _get_values(self, action, arg_strings): elif len(arg_strings) == 1 and action.nargs in [None, OPTIONAL]: arg_string, = arg_strings value = self._get_value(action, arg_string) - self._check_value(action, value) + self._check_value(action, value, arg_string) # REMAINDER arguments convert all values, checking none elif action.nargs == REMAINDER: @@ -2626,7 +2634,7 @@ def _get_values(self, action, arg_strings): # PARSER arguments convert all values, but check only the first elif action.nargs == PARSER: value = [self._get_value(action, v) for v in arg_strings] - self._check_value(action, value[0]) + self._check_value(action, value[0], arg_strings[0]) # SUPPRESS argument does not put anything in the namespace elif action.nargs == SUPPRESS: @@ -2635,8 +2643,8 @@ def _get_values(self, action, arg_strings): # all other types of nargs produce a list else: value = [self._get_value(action, v) for v in arg_strings] - for v in value: - self._check_value(action, v) + for v, s in zip(value, arg_strings): + self._check_value(action, v, s) # return the converted value return value @@ -2665,24 +2673,33 @@ def _get_value(self, action, arg_string): # return the converted value return result - def _check_value(self, action, value): + def _check_value(self, action, value, arg_string=None): # converted value must be one of the choices (if specified) choices = action.choices if choices is None: return + if arg_string is None: + arg_string = value + if isinstance(choices, str): choices = iter(choices) - if value not in choices: - args = {'value': str(value), + typed_choices = [] + if (action.convert_choices and + action.type + ): + typed_choices = [action.type(v) for v in choices] + + if value not in choices and value not in typed_choices: + args = {'value': arg_string, 'choices': ', '.join(map(str, action.choices))} msg = _('invalid choice: %(value)r (choose from %(choices)s)') - if self.suggest_on_error and isinstance(value, str): + if self.suggest_on_error: if all(isinstance(choice, str) for choice in action.choices): import difflib - suggestions = difflib.get_close_matches(value, action.choices, 1) + suggestions = difflib.get_close_matches(arg_string, action.choices, 1) if suggestions: args['closest'] = suggestions[0] msg = _('invalid choice: %(value)r, maybe you meant %(closest)r? ' diff --git a/Lib/test/test_argparse.py b/Lib/test/test_argparse.py index 5a6be1180c1a3e..20b566a455d063 100644 --- a/Lib/test/test_argparse.py +++ b/Lib/test/test_argparse.py @@ -1935,6 +1935,47 @@ def setUp(self): ('-x - -', NS(x=eq_bstdin, spam=eq_bstdin)), ] +class TestChoices(ParserTestCase): + """Test integer choices without conversion.""" + def to_dow(arg): + days = ["mo", "tu", "we", "th", "fr", "sa", "su"] + if arg in days: + return days.index(arg) + 1 + else: + return None + + argument_signatures = [ + Sig('when', + type=to_dow, choices=[1, 2, 3, 4, 5, 6, 7], + ) + ] + failures = ['now', '1'] + successes = [ + ('mo', NS(when=1)), + ('su', NS(when=7)), + ] + +class TestTypedChoices(TestChoices): + """Test a set of string choices that convert to weekdays""" + + argument_signatures = [ + Sig('when', + type=TestChoices.to_dow, + choices=["mo", "tu", "we" , "th", "fr", "sa", "su"], + convert_choices=True, + ) + ] + +class TestTypedChoicesNoFlag(TestChoices): + """Without the feature flag we fail""" + argument_signatures = [ + Sig('when', + type=TestChoices.to_dow, choices=["mo", "tu", "we" , "th", "fr", "sa", "su"], + ) + ] + failures = ['mo'] + successes = [] + class WFile(object): seen = set() @@ -5469,6 +5510,40 @@ def custom_type(string): version = '' +class TestHelpTypedChoices(HelpTestCase): + from datetime import date, timedelta + def to_date(arg): + if arg == "today": + return date.today() + elif arg == "tomorrow": + return date.today() + timedelta(days=1).date() + else: + return None + + parser_signature = Sig(prog='PROG') + argument_signatures = [ + Sig('when', + type=to_date, + choices=["today", "tomorrow"], + convert_choices=True + ), + ] + + usage = '''\ +usage: PROG [-h] {today,tomorrow} + ''' + help = usage + '''\ + +positional arguments: + {today,tomorrow} + +options: + -h, --help show this help message and exit + ''' + version = '' + + + class TestHelpUsageLongSubparserCommand(TestCase): """Test that subparser commands are formatted correctly in help""" maxDiff = None @@ -5860,7 +5935,8 @@ def test_optional(self): string = ( "Action(option_strings=['--foo', '-a', '-b'], dest='b', " "nargs='+', const=None, default=42, type='int', " - "choices=[1, 2, 3], required=False, help='HELP', " + "choices=[1, 2, 3], convert_choices=False, " + "required=False, help='HELP', " "metavar='METAVAR', deprecated=False)") self.assertStringEqual(option, string) @@ -5878,6 +5954,7 @@ def test_argument(self): string = ( "Action(option_strings=[], dest='x', nargs='?', " "const=None, default=2.5, type=%r, choices=[0.5, 1.5, 2.5], " + "convert_choices=False, " "required=True, help='H HH H', metavar='MV MV MV', " "deprecated=False)" % float) self.assertStringEqual(argument, string) diff --git a/Misc/NEWS.d/next/Library/2025-04-20-12-52-20.gh-issue-132558.MoFGmw.rst b/Misc/NEWS.d/next/Library/2025-04-20-12-52-20.gh-issue-132558.MoFGmw.rst new file mode 100644 index 00000000000000..10bdbae0bd1f54 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2025-04-20-12-52-20.gh-issue-132558.MoFGmw.rst @@ -0,0 +1,3 @@ +:mod:`argparse` Add option ``convert_choices`` to ``add_argument``. Combined with ``type``, allows ``choices`` +to be entered as strings and converted during argument checking. +