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 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 usage_ The string describing the program usage
description_ A brief description of what the program does description_ A brief description of what the program does
epilog_ Additional description of the program after the argument help 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 as keyword arguments. Each parameter has its own more detailed description
below, but in short they are: below, but in short they are:
* prog_ - The name of the program (default: * prog_ - The name of the program (default: generated from the ``__main__``
``os.path.basename(sys.argv[0])``) module attributes and ``sys.argv[0]``)
* usage_ - The string describing the program usage (default: generated from * usage_ - The string describing the program usage (default: generated from
arguments added to parser) arguments added to parser)
@ -268,10 +268,18 @@ The following sections describe how each of these are used.
prog prog
^^^^ ^^^^
By default, :class:`ArgumentParser` objects use the base name By default, :class:`ArgumentParser` calculates the name of the program
(see :func:`os.path.basename`) of ``sys.argv[0]`` to determine to display in help messages depending on the way the Python inerpreter was run:
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 * 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 used to invoke the program on the command line. For example, consider a file
named ``myprogram.py`` with the following code:: named ``myprogram.py`` with the following code::
@ -281,7 +289,7 @@ named ``myprogram.py`` with the following code::
args = parser.parse_args() args = parser.parse_args()
The help for this program will display ``myprogram.py`` as the program name 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 .. 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 -h, --help show this help message and exit
--foo FOO foo help --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 To change this default behavior, another value can be supplied using the
``prog=`` argument to :class:`ArgumentParser`:: ``prog=`` argument to :class:`ArgumentParser`::
@ -309,7 +328,8 @@ To change this default behavior, another value can be supplied using the
options: options:
-h, --help show this help message and exit -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 ``prog=`` argument, is available to help messages using the ``%(prog)s`` format
specifier. specifier.
@ -324,6 +344,9 @@ specifier.
-h, --help show this help message and exit -h, --help show this help message and exit
--foo FOO foo of the myprogram program --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 usage
^^^^^ ^^^^^

View File

@ -202,6 +202,13 @@ New Modules
Improved 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 ast
--- ---

View File

@ -1697,6 +1697,28 @@ class _MutuallyExclusiveGroup(_ArgumentGroup):
return super().add_mutually_exclusive_group(*args, **kwargs) 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): class ArgumentParser(_AttributeHolder, _ActionsContainer):
"""Object for parsing command line strings into Python objects. """Object for parsing command line strings into Python objects.
@ -1740,11 +1762,7 @@ class ArgumentParser(_AttributeHolder, _ActionsContainer):
argument_default=argument_default, argument_default=argument_default,
conflict_handler=conflict_handler) conflict_handler=conflict_handler)
# default setting for prog self.prog = _prog_name(prog)
if prog is None:
prog = _os.path.basename(_sys.argv[0])
self.prog = prog
self.usage = usage self.usage = usage
self.epilog = epilog self.epilog = epilog
self.formatter_class = formatter_class self.formatter_class = formatter_class

View File

@ -6,6 +6,7 @@ import inspect
import io import io
import operator import operator
import os import os
import py_compile
import shutil import shutil
import stat import stat
import sys import sys
@ -15,10 +16,16 @@ import unittest
import argparse import argparse
import warnings 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 from unittest import mock
py = os.path.basename(sys.executable)
class StdIOBuffer(io.TextIOWrapper): class StdIOBuffer(io.TextIOWrapper):
'''Replacement for writable io.StringIO that behaves more like real file '''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('-a', action='store_true')
group.add_argument('-b', action='store_true') group.add_argument('-b', action='store_true')
self.main_program = os.path.basename(sys.argv[0])
def test_single_parent(self): def test_single_parent(self):
parser = ErrorRaisingArgumentParser(parents=[self.wxyz_parent]) parser = ErrorRaisingArgumentParser(parents=[self.wxyz_parent])
self.assertEqual(parser.parse_args('-y 1 2 --w 3'.split()), self.assertEqual(parser.parse_args('-y 1 2 --w 3'.split()),
@ -2871,11 +2876,10 @@ class TestParentParsers(TestCase):
def test_parent_help(self): def test_parent_help(self):
parents = [self.abcd_parent, self.wxyz_parent] parents = [self.abcd_parent, self.wxyz_parent]
parser = ErrorRaisingArgumentParser(parents=parents) parser = ErrorRaisingArgumentParser(prog='PROG', parents=parents)
parser_help = parser.format_help() parser_help = parser.format_help()
progname = self.main_program
self.assertEqual(parser_help, textwrap.dedent('''\ 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: positional arguments:
a a
@ -2891,7 +2895,7 @@ class TestParentParsers(TestCase):
x: x:
-y Y -y Y
'''.format(progname, ' ' if progname else '' ))) '''))
def test_groups_parents(self): def test_groups_parents(self):
parent = ErrorRaisingArgumentParser(add_help=False) parent = ErrorRaisingArgumentParser(add_help=False)
@ -2901,15 +2905,14 @@ class TestParentParsers(TestCase):
m = parent.add_mutually_exclusive_group() m = parent.add_mutually_exclusive_group()
m.add_argument('-y') m.add_argument('-y')
m.add_argument('-z') m.add_argument('-z')
parser = ErrorRaisingArgumentParser(parents=[parent]) parser = ErrorRaisingArgumentParser(prog='PROG', parents=[parent])
self.assertRaises(ArgumentParserError, parser.parse_args, self.assertRaises(ArgumentParserError, parser.parse_args,
['-y', 'Y', '-z', 'Z']) ['-y', 'Y', '-z', 'Z'])
parser_help = parser.format_help() parser_help = parser.format_help()
progname = self.main_program
self.assertEqual(parser_help, textwrap.dedent('''\ 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: options:
-h, --help show this help message and exit -h, --help show this help message and exit
@ -2921,7 +2924,7 @@ class TestParentParsers(TestCase):
-w W -w W
-x X -x X
'''.format(progname, ' ' if progname else '' ))) '''))
def test_wrong_type_parents(self): def test_wrong_type_parents(self):
self.assertRaises(TypeError, ErrorRaisingArgumentParser, parents=[1]) self.assertRaises(TypeError, ErrorRaisingArgumentParser, parents=[1])
@ -6561,6 +6564,99 @@ class TestExitOnError(TestCase):
self.parser.parse_args, ['@no-such-file']) 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(): def tearDownModule():
# Remove global references to avoid looking like we have refleaks. # Remove global references to avoid looking like we have refleaks.
RFile.seen = {} RFile.seen = {}

View File

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