From 426169faf5bfda7dea07fb7660a24c418c8b8965 Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Tue, 9 Sep 2025 13:00:45 +0300 Subject: [PATCH] gh-138697: Fix inferring dest from a single-dash long option in argparse If a short option and a single-dash long option are passed to add_argument(), dest is now inferred from the single-dash long option. --- Doc/library/argparse.rst | 16 ++++++++------ Doc/whatsnew/3.15.rst | 7 +++++++ Lib/argparse.py | 21 ++++++++++--------- Lib/test/test_argparse.py | 21 ++++++++++++++----- ...-09-09-13-00-42.gh-issue-138697.QVwJw_.rst | 4 ++++ 5 files changed, 48 insertions(+), 21 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2025-09-09-13-00-42.gh-issue-138697.QVwJw_.rst diff --git a/Doc/library/argparse.rst b/Doc/library/argparse.rst index 79e15994491eff..a93366723abea8 100644 --- a/Doc/library/argparse.rst +++ b/Doc/library/argparse.rst @@ -1313,8 +1313,8 @@ attribute is determined by the ``dest`` keyword argument of For optional argument actions, the value of ``dest`` is normally inferred from the option strings. :class:`ArgumentParser` generates the value of ``dest`` by -taking the first long option string and stripping away the initial ``--`` -string. If no long option strings were supplied, ``dest`` will be derived from +taking the first long option string and stripping away the initial ``-`` +characters. If no long option strings were supplied, ``dest`` will be derived from the first short option string by stripping the initial ``-`` character. Any internal ``-`` characters will be converted to ``_`` characters to make sure the string is a valid attribute name. The examples below illustrate this @@ -1322,11 +1322,12 @@ behavior:: >>> parser = argparse.ArgumentParser() >>> parser.add_argument('-f', '--foo-bar', '--foo') + >>> parser.add_argument('-q', '-quz') >>> parser.add_argument('-x', '-y') - >>> parser.parse_args('-f 1 -x 2'.split()) - Namespace(foo_bar='1', x='2') - >>> parser.parse_args('--foo 1 -y 2'.split()) - Namespace(foo_bar='1', x='2') + >>> parser.parse_args('-f 1 -q 2 -x 3'.split()) + Namespace(foo_bar='1', quz='2', x='3') + >>> parser.parse_args('--foo 1 -quz 2 -y 3'.split()) + Namespace(foo_bar='1', quz='2', x='2') ``dest`` allows a custom attribute name to be provided:: @@ -1335,6 +1336,9 @@ behavior:: >>> parser.parse_args('--foo XXX'.split()) Namespace(bar='XXX') +.. versionchanged:: next + Single-dash long option now takes precedence over short options. + .. _deprecated: diff --git a/Doc/whatsnew/3.15.rst b/Doc/whatsnew/3.15.rst index 56ef80c068634a..5b79a02de8e7cd 100644 --- a/Doc/whatsnew/3.15.rst +++ b/Doc/whatsnew/3.15.rst @@ -730,6 +730,13 @@ Porting to Python 3.15 * :meth:`~mmap.mmap.resize` has been removed on platforms that don't support the underlying syscall, instead of raising a :exc:`SystemError`. +* If a short option and a single-dash long option are passed to + :meth:`argparse.ArgumentParser.add_argument`, *dest* is inferred from + the single-dash long option. For example, in ``add_argument('-f', '-foo')``, + *dest* is now ``'foo'`` instead of ``'f'``. + Pass an explicit *dest* argument to preserve the old behavior. + (Contributed by Serhiy Storchaka in :gh:`138697`.) + Deprecated C APIs ----------------- diff --git a/Lib/argparse.py b/Lib/argparse.py index 2144c81886ad19..01712eb13eddd4 100644 --- a/Lib/argparse.py +++ b/Lib/argparse.py @@ -1668,22 +1668,23 @@ def _get_optional_kwargs(self, *args, **kwargs): raise ValueError( f'invalid option string {option_string!r}: ' f'must start with a character {self.prefix_chars!r}') - - # strings starting with two prefix characters are long options option_strings.append(option_string) - if len(option_string) > 1 and option_string[1] in self.prefix_chars: - long_option_strings.append(option_string) # infer destination, '--foo-bar' -> 'foo_bar' and '-x' -> 'x' dest = kwargs.pop('dest', None) if dest is None: - if long_option_strings: - dest_option_string = long_option_strings[0] - else: - dest_option_string = option_strings[0] - dest = dest_option_string.lstrip(self.prefix_chars) + short_option_dest = None + for option_string in option_strings: + if len(option_string) > 2: + # long option: '--foo' or '-foo' -> 'foo' + dest = option_string.lstrip(self.prefix_chars) + break + # short option: '-x' -> 'x' + if not short_option_dest: + short_option_dest = option_string.lstrip(self.prefix_chars) + dest = dest or short_option_dest if not dest: - msg = f'dest= is required for options like {option_string!r}' + msg = f'dest= is required for options like {repr(option_strings)[1:-1]}' raise TypeError(msg) dest = dest.replace('-', '_') diff --git a/Lib/test/test_argparse.py b/Lib/test/test_argparse.py index fc73174d98cd6f..d6112a68295375 100644 --- a/Lib/test/test_argparse.py +++ b/Lib/test/test_argparse.py @@ -580,13 +580,22 @@ class TestOptionalsShortLong(ParserTestCase): class TestOptionalsDest(ParserTestCase): """Tests various means of setting destination""" - argument_signatures = [Sig('--foo-bar'), Sig('--baz', dest='zabbaz')] + argument_signatures = [ + Sig('-x', '--foo-bar'), + Sig('--baz', dest='zabbaz'), + Sig('-y', '-qux'), + Sig('-z'), + ] failures = ['a'] successes = [ - ('--foo-bar f', NS(foo_bar='f', zabbaz=None)), - ('--baz g', NS(foo_bar=None, zabbaz='g')), - ('--foo-bar h --baz i', NS(foo_bar='h', zabbaz='i')), - ('--baz j --foo-bar k', NS(foo_bar='k', zabbaz='j')), + ('--foo-bar f', NS(foo_bar='f', zabbaz=None, qux=None, z=None)), + ('-x f', NS(foo_bar='f', zabbaz=None, qux=None, z=None)), + ('--baz g', NS(foo_bar=None, zabbaz='g', qux=None, z=None)), + ('--foo-bar h --baz i', NS(foo_bar='h', zabbaz='i', qux=None, z=None)), + ('--baz j --foo-bar k', NS(foo_bar='k', zabbaz='j', qux=None, z=None)), + ('-qux l', NS(foo_bar=None, zabbaz=None, qux='l', z=None)), + ('-y l', NS(foo_bar=None, zabbaz=None, qux='l', z=None)), + ('-z m', NS(foo_bar=None, zabbaz=None, qux=None, z='m')), ] @@ -5608,6 +5617,8 @@ def test_invalid_option_strings(self): self.assertTypeError('-', errmsg='dest= is required') self.assertTypeError('--', errmsg='dest= is required') self.assertTypeError('---', errmsg='dest= is required') + self.assertTypeError('-', '--', '---', + errmsg="dest= is required for options like '-', '--', '---'") def test_invalid_prefix(self): self.assertValueError('--foo', '+foo', diff --git a/Misc/NEWS.d/next/Library/2025-09-09-13-00-42.gh-issue-138697.QVwJw_.rst b/Misc/NEWS.d/next/Library/2025-09-09-13-00-42.gh-issue-138697.QVwJw_.rst new file mode 100644 index 00000000000000..35aaa7cf70f2fd --- /dev/null +++ b/Misc/NEWS.d/next/Library/2025-09-09-13-00-42.gh-issue-138697.QVwJw_.rst @@ -0,0 +1,4 @@ +Fix inferring *dest* from a single-dash long option in :mod:`argparse`. If a +short option and a single-dash long option are passed to +:meth:`!add_argument`, *dest* is now inferred from the single-dash long +option.