Skip to content

Commit

Permalink
pythongh-66436: Improved prog default value for argparse.ArgumentPars…
Browse files Browse the repository at this point in the history
…er (pythonGH-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>
  • Loading branch information
serhiy-storchaka and ncoghlan authored Oct 1, 2024
1 parent d150e4a commit 04bfea2
Show file tree
Hide file tree
Showing 6 changed files with 174 additions and 26 deletions.
41 changes: 32 additions & 9 deletions Doc/library/argparse.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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::

Expand All @@ -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
Expand All @@ -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`::

Expand All @@ -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.

Expand All @@ -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
^^^^^
Expand Down
7 changes: 7 additions & 0 deletions Doc/whatsnew/3.14.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
---
Expand Down
28 changes: 23 additions & 5 deletions Lib/argparse.py
Original file line number Diff line number Diff line change
Expand Up @@ -1697,6 +1697,28 @@ def add_mutually_exclusive_group(self, *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):
"""Object for parsing command line strings into Python objects.
Expand Down Expand Up @@ -1740,11 +1762,7 @@ def __init__(self,
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
Expand Down
118 changes: 107 additions & 11 deletions Lib/test/test_argparse.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import io
import operator
import os
import py_compile
import shutil
import stat
import sys
Expand All @@ -15,10 +16,16 @@
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
Expand Down Expand Up @@ -2780,8 +2787,6 @@ def setUp(self):
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()),
Expand Down Expand Up @@ -2871,11 +2876,10 @@ def test_subparser_parents_mutex(self):

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
Expand All @@ -2891,7 +2895,7 @@ def test_parent_help(self):
x:
-y Y
'''.format(progname, ' ' if progname else '' )))
'''))

def test_groups_parents(self):
parent = ErrorRaisingArgumentParser(add_help=False)
Expand All @@ -2901,15 +2905,14 @@ def test_groups_parents(self):
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
Expand All @@ -2921,7 +2924,7 @@ def test_groups_parents(self):
-w W
-x X
'''.format(progname, ' ' if progname else '' )))
'''))

def test_wrong_type_parents(self):
self.assertRaises(TypeError, ErrorRaisingArgumentParser, parents=[1])
Expand Down Expand Up @@ -6561,6 +6564,99 @@ def test_os_error(self):
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 = {}
Expand Down
2 changes: 1 addition & 1 deletion Lib/test/test_calendar.py
Original file line number Diff line number Diff line change
Expand Up @@ -985,7 +985,7 @@ def assertFailure(self, *args):
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()
Expand Down
Original file line number Diff line number Diff line change
@@ -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.

0 comments on commit 04bfea2

Please sign in to comment.