gh-66436: Improved prog default value for argparse.ArgumentParser (GH-124799)

It can now have one of three forms:

* basename(argv0) -- for simple scripts
* python arv0 -- for directories, ZIP files, etc
* python -m module -- for imported modules

Co-authored-by: Alyssa Coghlan <ncoghlan@gmail.com>
This commit is contained in:
Serhiy Storchaka 2024-10-01 22:51:40 +03:00 committed by GitHub
parent d150e4abcf
commit 04bfea2d26
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 174 additions and 26 deletions

View File

@ -30,7 +30,7 @@ Quick Links for ArgumentParser
========================= =========================================================================================================== ==================================================================================
Name Description Values
========================= =========================================================================================================== ==================================================================================
prog_ The name of the program Defaults to ``os.path.basename(sys.argv[0])``
prog_ The name of the program
usage_ The string describing the program usage
description_ A brief description of what the program does
epilog_ Additional description of the program after the argument help
@ -214,8 +214,8 @@ ArgumentParser objects
as keyword arguments. Each parameter has its own more detailed description
below, but in short they are:
* prog_ - The name of the program (default:
``os.path.basename(sys.argv[0])``)
* prog_ - The name of the program (default: generated from the ``__main__``
module attributes and ``sys.argv[0]``)
* usage_ - The string describing the program usage (default: generated from
arguments added to parser)
@ -268,10 +268,18 @@ The following sections describe how each of these are used.
prog
^^^^
By default, :class:`ArgumentParser` objects use the base name
(see :func:`os.path.basename`) of ``sys.argv[0]`` to determine
how to display the name of the program in help messages. This default is almost
always desirable because it will make the help messages match the name that was
By default, :class:`ArgumentParser` calculates the name of the program
to display in help messages depending on the way the Python inerpreter was run:
* The :func:`base name <os.path.basename>` of ``sys.argv[0]`` if a file was
passed as argument.
* The Python interpreter name followed by ``sys.argv[0]`` if a directory or
a zipfile was passed as argument.
* The Python interpreter name followed by ``-m`` followed by the
module or package name if the :option:`-m` option was used.
This default is almost
always desirable because it will make the help messages match the string that was
used to invoke the program on the command line. For example, consider a file
named ``myprogram.py`` with the following code::
@ -281,7 +289,7 @@ named ``myprogram.py`` with the following code::
args = parser.parse_args()
The help for this program will display ``myprogram.py`` as the program name
(regardless of where the program was invoked from):
(regardless of where the program was invoked from) if it is run as a script:
.. code-block:: shell-session
@ -299,6 +307,17 @@ The help for this program will display ``myprogram.py`` as the program name
-h, --help show this help message and exit
--foo FOO foo help
If it is executed via the :option:`-m` option, the help will display a corresponding command line:
.. code-block:: shell-session
$ /usr/bin/python3 -m subdir.myprogram --help
usage: python3 -m subdir.myprogram [-h] [--foo FOO]
options:
-h, --help show this help message and exit
--foo FOO foo help
To change this default behavior, another value can be supplied using the
``prog=`` argument to :class:`ArgumentParser`::
@ -309,7 +328,8 @@ To change this default behavior, another value can be supplied using the
options:
-h, --help show this help message and exit
Note that the program name, whether determined from ``sys.argv[0]`` or from the
Note that the program name, whether determined from ``sys.argv[0]``,
from the ``__main__`` module attributes or from the
``prog=`` argument, is available to help messages using the ``%(prog)s`` format
specifier.
@ -324,6 +344,9 @@ specifier.
-h, --help show this help message and exit
--foo FOO foo of the myprogram program
.. versionchanged:: 3.14
The default ``prog`` value now reflects how ``__main__`` was actually executed,
rather than always being ``os.path.basename(sys.argv[0])``.
usage
^^^^^

View File

@ -202,6 +202,13 @@ New Modules
Improved Modules
================
argparse
--------
* The default value of the :ref:`program name <prog>` for
:class:`argparse.ArgumentParser` now reflects the way the Python
interpreter was instructed to find the ``__main__`` module code.
(Contributed by Serhiy Storchaka and Alyssa Coghlan in :gh:`66436`.)
ast
---

View File

@ -1697,6 +1697,28 @@ class _MutuallyExclusiveGroup(_ArgumentGroup):
return super().add_mutually_exclusive_group(*args, **kwargs)
def _prog_name(prog=None):
if prog is not None:
return prog
arg0 = _sys.argv[0]
try:
modspec = _sys.modules['__main__'].__spec__
except (KeyError, AttributeError):
# possibly PYTHONSTARTUP or -X presite or other weird edge case
# no good answer here, so fall back to the default
modspec = None
if modspec is None:
# simple script
return _os.path.basename(arg0)
py = _os.path.basename(_sys.executable)
if modspec.name != '__main__':
# imported module or package
modname = modspec.name.removesuffix('.__main__')
return f'{py} -m {modname}'
# directory or ZIP file
return f'{py} {arg0}'
class ArgumentParser(_AttributeHolder, _ActionsContainer):
"""Object for parsing command line strings into Python objects.
@ -1740,11 +1762,7 @@ class ArgumentParser(_AttributeHolder, _ActionsContainer):
argument_default=argument_default,
conflict_handler=conflict_handler)
# default setting for prog
if prog is None:
prog = _os.path.basename(_sys.argv[0])
self.prog = prog
self.prog = _prog_name(prog)
self.usage = usage
self.epilog = epilog
self.formatter_class = formatter_class

View File

@ -6,6 +6,7 @@ import inspect
import io
import operator
import os
import py_compile
import shutil
import stat
import sys
@ -15,10 +16,16 @@ import unittest
import argparse
import warnings
from test.support import os_helper, captured_stderr
from test.support import captured_stderr
from test.support import import_helper
from test.support import os_helper
from test.support import script_helper
from unittest import mock
py = os.path.basename(sys.executable)
class StdIOBuffer(io.TextIOWrapper):
'''Replacement for writable io.StringIO that behaves more like real file
@ -2780,8 +2787,6 @@ class TestParentParsers(TestCase):
group.add_argument('-a', action='store_true')
group.add_argument('-b', action='store_true')
self.main_program = os.path.basename(sys.argv[0])
def test_single_parent(self):
parser = ErrorRaisingArgumentParser(parents=[self.wxyz_parent])
self.assertEqual(parser.parse_args('-y 1 2 --w 3'.split()),
@ -2871,11 +2876,10 @@ class TestParentParsers(TestCase):
def test_parent_help(self):
parents = [self.abcd_parent, self.wxyz_parent]
parser = ErrorRaisingArgumentParser(parents=parents)
parser = ErrorRaisingArgumentParser(prog='PROG', parents=parents)
parser_help = parser.format_help()
progname = self.main_program
self.assertEqual(parser_help, textwrap.dedent('''\
usage: {}{}[-h] [-b B] [--d D] [--w W] [-y Y] a z
usage: PROG [-h] [-b B] [--d D] [--w W] [-y Y] a z
positional arguments:
a
@ -2891,7 +2895,7 @@ class TestParentParsers(TestCase):
x:
-y Y
'''.format(progname, ' ' if progname else '' )))
'''))
def test_groups_parents(self):
parent = ErrorRaisingArgumentParser(add_help=False)
@ -2901,15 +2905,14 @@ class TestParentParsers(TestCase):
m = parent.add_mutually_exclusive_group()
m.add_argument('-y')
m.add_argument('-z')
parser = ErrorRaisingArgumentParser(parents=[parent])
parser = ErrorRaisingArgumentParser(prog='PROG', parents=[parent])
self.assertRaises(ArgumentParserError, parser.parse_args,
['-y', 'Y', '-z', 'Z'])
parser_help = parser.format_help()
progname = self.main_program
self.assertEqual(parser_help, textwrap.dedent('''\
usage: {}{}[-h] [-w W] [-x X] [-y Y | -z Z]
usage: PROG [-h] [-w W] [-x X] [-y Y | -z Z]
options:
-h, --help show this help message and exit
@ -2921,7 +2924,7 @@ class TestParentParsers(TestCase):
-w W
-x X
'''.format(progname, ' ' if progname else '' )))
'''))
def test_wrong_type_parents(self):
self.assertRaises(TypeError, ErrorRaisingArgumentParser, parents=[1])
@ -6561,6 +6564,99 @@ class TestExitOnError(TestCase):
self.parser.parse_args, ['@no-such-file'])
class TestProgName(TestCase):
source = textwrap.dedent('''\
import argparse
parser = argparse.ArgumentParser()
parser.parse_args()
''')
def setUp(self):
self.dirname = 'package' + os_helper.FS_NONASCII
self.addCleanup(os_helper.rmtree, self.dirname)
os.mkdir(self.dirname)
def make_script(self, dirname, basename, *, compiled=False):
script_name = script_helper.make_script(dirname, basename, self.source)
if not compiled:
return script_name
py_compile.compile(script_name, doraise=True)
os.remove(script_name)
pyc_file = import_helper.make_legacy_pyc(script_name)
return pyc_file
def make_zip_script(self, script_name, name_in_zip=None):
zip_name, _ = script_helper.make_zip_script(self.dirname, 'test_zip',
script_name, name_in_zip)
return zip_name
def check_usage(self, expected, *args, **kwargs):
res = script_helper.assert_python_ok('-Xutf8', *args, '-h', **kwargs)
self.assertEqual(res.out.splitlines()[0].decode(),
f'usage: {expected} [-h]')
def test_script(self, compiled=False):
basename = os_helper.TESTFN
script_name = self.make_script(self.dirname, basename, compiled=compiled)
self.check_usage(os.path.basename(script_name), script_name, '-h')
def test_script_compiled(self):
self.test_script(compiled=True)
def test_directory(self, compiled=False):
dirname = os.path.join(self.dirname, os_helper.TESTFN)
os.mkdir(dirname)
self.make_script(dirname, '__main__', compiled=compiled)
self.check_usage(f'{py} {dirname}', dirname)
dirname2 = os.path.join(os.curdir, dirname)
self.check_usage(f'{py} {dirname2}', dirname2)
def test_directory_compiled(self):
self.test_directory(compiled=True)
def test_module(self, compiled=False):
basename = 'module' + os_helper.FS_NONASCII
modulename = f'{self.dirname}.{basename}'
self.make_script(self.dirname, basename, compiled=compiled)
self.check_usage(f'{py} -m {modulename}',
'-m', modulename, PYTHONPATH=os.curdir)
def test_module_compiled(self):
self.test_module(compiled=True)
def test_package(self, compiled=False):
basename = 'subpackage' + os_helper.FS_NONASCII
packagename = f'{self.dirname}.{basename}'
subdirname = os.path.join(self.dirname, basename)
os.mkdir(subdirname)
self.make_script(subdirname, '__main__', compiled=compiled)
self.check_usage(f'{py} -m {packagename}',
'-m', packagename, PYTHONPATH=os.curdir)
self.check_usage(f'{py} -m {packagename}',
'-m', packagename + '.__main__', PYTHONPATH=os.curdir)
def test_package_compiled(self):
self.test_package(compiled=True)
def test_zipfile(self, compiled=False):
script_name = self.make_script(self.dirname, '__main__', compiled=compiled)
zip_name = self.make_zip_script(script_name)
self.check_usage(f'{py} {zip_name}', zip_name)
def test_zipfile_compiled(self):
self.test_zipfile(compiled=True)
def test_directory_in_zipfile(self, compiled=False):
script_name = self.make_script(self.dirname, '__main__', compiled=compiled)
name_in_zip = 'package/subpackage/__main__' + ('.py', '.pyc')[compiled]
zip_name = self.make_zip_script(script_name, name_in_zip)
dirname = os.path.join(zip_name, 'package', 'subpackage')
self.check_usage(f'{py} {dirname}', dirname)
def test_directory_in_zipfile_compiled(self):
self.test_directory_in_zipfile(compiled=True)
def tearDownModule():
# Remove global references to avoid looking like we have refleaks.
RFile.seen = {}

View File

@ -985,7 +985,7 @@ class CommandLineTestCase(unittest.TestCase):
def test_help(self):
stdout = self.run_cmd_ok('-h')
self.assertIn(b'usage:', stdout)
self.assertIn(b'calendar.py', stdout)
self.assertIn(b' -m calendar ', stdout)
self.assertIn(b'--help', stdout)
# special case: stdout but sys.exit()

View File

@ -0,0 +1,4 @@
Improved :ref:`prog` default value for :class:`argparse.ArgumentParser`. It
will now include the name of the Python executable along with the module or
package name, or the path to a directory, ZIP file, or directory within a
ZIP file if the code was run that way.