Skip to content

Commit 04bfea2

Browse files
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 <[email protected]>
1 parent d150e4a commit 04bfea2

File tree

6 files changed

+174
-26
lines changed

6 files changed

+174
-26
lines changed

Doc/library/argparse.rst

Lines changed: 32 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ Quick Links for ArgumentParser
3030
========================= =========================================================================================================== ==================================================================================
3131
Name Description Values
3232
========================= =========================================================================================================== ==================================================================================
33-
prog_ The name of the program Defaults to ``os.path.basename(sys.argv[0])``
33+
prog_ The name of the program
3434
usage_ The string describing the program usage
3535
description_ A brief description of what the program does
3636
epilog_ Additional description of the program after the argument help
@@ -214,8 +214,8 @@ ArgumentParser objects
214214
as keyword arguments. Each parameter has its own more detailed description
215215
below, but in short they are:
216216

217-
* prog_ - The name of the program (default:
218-
``os.path.basename(sys.argv[0])``)
217+
* prog_ - The name of the program (default: generated from the ``__main__``
218+
module attributes and ``sys.argv[0]``)
219219

220220
* usage_ - The string describing the program usage (default: generated from
221221
arguments added to parser)
@@ -268,10 +268,18 @@ The following sections describe how each of these are used.
268268
prog
269269
^^^^
270270

271-
By default, :class:`ArgumentParser` objects use the base name
272-
(see :func:`os.path.basename`) of ``sys.argv[0]`` to determine
273-
how to display the name of the program in help messages. This default is almost
274-
always desirable because it will make the help messages match the name that was
271+
By default, :class:`ArgumentParser` calculates the name of the program
272+
to display in help messages depending on the way the Python inerpreter was run:
273+
274+
* The :func:`base name <os.path.basename>` of ``sys.argv[0]`` if a file was
275+
passed as argument.
276+
* The Python interpreter name followed by ``sys.argv[0]`` if a directory or
277+
a zipfile was passed as argument.
278+
* The Python interpreter name followed by ``-m`` followed by the
279+
module or package name if the :option:`-m` option was used.
280+
281+
This default is almost
282+
always desirable because it will make the help messages match the string that was
275283
used to invoke the program on the command line. For example, consider a file
276284
named ``myprogram.py`` with the following code::
277285

@@ -281,7 +289,7 @@ named ``myprogram.py`` with the following code::
281289
args = parser.parse_args()
282290

283291
The help for this program will display ``myprogram.py`` as the program name
284-
(regardless of where the program was invoked from):
292+
(regardless of where the program was invoked from) if it is run as a script:
285293

286294
.. code-block:: shell-session
287295
@@ -299,6 +307,17 @@ The help for this program will display ``myprogram.py`` as the program name
299307
-h, --help show this help message and exit
300308
--foo FOO foo help
301309
310+
If it is executed via the :option:`-m` option, the help will display a corresponding command line:
311+
312+
.. code-block:: shell-session
313+
314+
$ /usr/bin/python3 -m subdir.myprogram --help
315+
usage: python3 -m subdir.myprogram [-h] [--foo FOO]
316+
317+
options:
318+
-h, --help show this help message and exit
319+
--foo FOO foo help
320+
302321
To change this default behavior, another value can be supplied using the
303322
``prog=`` argument to :class:`ArgumentParser`::
304323

@@ -309,7 +328,8 @@ To change this default behavior, another value can be supplied using the
309328
options:
310329
-h, --help show this help message and exit
311330

312-
Note that the program name, whether determined from ``sys.argv[0]`` or from the
331+
Note that the program name, whether determined from ``sys.argv[0]``,
332+
from the ``__main__`` module attributes or from the
313333
``prog=`` argument, is available to help messages using the ``%(prog)s`` format
314334
specifier.
315335

@@ -324,6 +344,9 @@ specifier.
324344
-h, --help show this help message and exit
325345
--foo FOO foo of the myprogram program
326346

347+
.. versionchanged:: 3.14
348+
The default ``prog`` value now reflects how ``__main__`` was actually executed,
349+
rather than always being ``os.path.basename(sys.argv[0])``.
327350

328351
usage
329352
^^^^^

Doc/whatsnew/3.14.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,13 @@ New Modules
202202
Improved Modules
203203
================
204204

205+
argparse
206+
--------
207+
208+
* The default value of the :ref:`program name <prog>` for
209+
:class:`argparse.ArgumentParser` now reflects the way the Python
210+
interpreter was instructed to find the ``__main__`` module code.
211+
(Contributed by Serhiy Storchaka and Alyssa Coghlan in :gh:`66436`.)
205212

206213
ast
207214
---

Lib/argparse.py

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1697,6 +1697,28 @@ def add_mutually_exclusive_group(self, *args, **kwargs):
16971697
return super().add_mutually_exclusive_group(*args, **kwargs)
16981698

16991699

1700+
def _prog_name(prog=None):
1701+
if prog is not None:
1702+
return prog
1703+
arg0 = _sys.argv[0]
1704+
try:
1705+
modspec = _sys.modules['__main__'].__spec__
1706+
except (KeyError, AttributeError):
1707+
# possibly PYTHONSTARTUP or -X presite or other weird edge case
1708+
# no good answer here, so fall back to the default
1709+
modspec = None
1710+
if modspec is None:
1711+
# simple script
1712+
return _os.path.basename(arg0)
1713+
py = _os.path.basename(_sys.executable)
1714+
if modspec.name != '__main__':
1715+
# imported module or package
1716+
modname = modspec.name.removesuffix('.__main__')
1717+
return f'{py} -m {modname}'
1718+
# directory or ZIP file
1719+
return f'{py} {arg0}'
1720+
1721+
17001722
class ArgumentParser(_AttributeHolder, _ActionsContainer):
17011723
"""Object for parsing command line strings into Python objects.
17021724
@@ -1740,11 +1762,7 @@ def __init__(self,
17401762
argument_default=argument_default,
17411763
conflict_handler=conflict_handler)
17421764

1743-
# default setting for prog
1744-
if prog is None:
1745-
prog = _os.path.basename(_sys.argv[0])
1746-
1747-
self.prog = prog
1765+
self.prog = _prog_name(prog)
17481766
self.usage = usage
17491767
self.epilog = epilog
17501768
self.formatter_class = formatter_class

Lib/test/test_argparse.py

Lines changed: 107 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import io
77
import operator
88
import os
9+
import py_compile
910
import shutil
1011
import stat
1112
import sys
@@ -15,10 +16,16 @@
1516
import argparse
1617
import warnings
1718

18-
from test.support import os_helper, captured_stderr
19+
from test.support import captured_stderr
20+
from test.support import import_helper
21+
from test.support import os_helper
22+
from test.support import script_helper
1923
from unittest import mock
2024

2125

26+
py = os.path.basename(sys.executable)
27+
28+
2229
class StdIOBuffer(io.TextIOWrapper):
2330
'''Replacement for writable io.StringIO that behaves more like real file
2431
@@ -2780,8 +2787,6 @@ def setUp(self):
27802787
group.add_argument('-a', action='store_true')
27812788
group.add_argument('-b', action='store_true')
27822789

2783-
self.main_program = os.path.basename(sys.argv[0])
2784-
27852790
def test_single_parent(self):
27862791
parser = ErrorRaisingArgumentParser(parents=[self.wxyz_parent])
27872792
self.assertEqual(parser.parse_args('-y 1 2 --w 3'.split()),
@@ -2871,11 +2876,10 @@ def test_subparser_parents_mutex(self):
28712876

28722877
def test_parent_help(self):
28732878
parents = [self.abcd_parent, self.wxyz_parent]
2874-
parser = ErrorRaisingArgumentParser(parents=parents)
2879+
parser = ErrorRaisingArgumentParser(prog='PROG', parents=parents)
28752880
parser_help = parser.format_help()
2876-
progname = self.main_program
28772881
self.assertEqual(parser_help, textwrap.dedent('''\
2878-
usage: {}{}[-h] [-b B] [--d D] [--w W] [-y Y] a z
2882+
usage: PROG [-h] [-b B] [--d D] [--w W] [-y Y] a z
28792883
28802884
positional arguments:
28812885
a
@@ -2891,7 +2895,7 @@ def test_parent_help(self):
28912895
28922896
x:
28932897
-y Y
2894-
'''.format(progname, ' ' if progname else '' )))
2898+
'''))
28952899

28962900
def test_groups_parents(self):
28972901
parent = ErrorRaisingArgumentParser(add_help=False)
@@ -2901,15 +2905,14 @@ def test_groups_parents(self):
29012905
m = parent.add_mutually_exclusive_group()
29022906
m.add_argument('-y')
29032907
m.add_argument('-z')
2904-
parser = ErrorRaisingArgumentParser(parents=[parent])
2908+
parser = ErrorRaisingArgumentParser(prog='PROG', parents=[parent])
29052909

29062910
self.assertRaises(ArgumentParserError, parser.parse_args,
29072911
['-y', 'Y', '-z', 'Z'])
29082912

29092913
parser_help = parser.format_help()
2910-
progname = self.main_program
29112914
self.assertEqual(parser_help, textwrap.dedent('''\
2912-
usage: {}{}[-h] [-w W] [-x X] [-y Y | -z Z]
2915+
usage: PROG [-h] [-w W] [-x X] [-y Y | -z Z]
29132916
29142917
options:
29152918
-h, --help show this help message and exit
@@ -2921,7 +2924,7 @@ def test_groups_parents(self):
29212924
29222925
-w W
29232926
-x X
2924-
'''.format(progname, ' ' if progname else '' )))
2927+
'''))
29252928

29262929
def test_wrong_type_parents(self):
29272930
self.assertRaises(TypeError, ErrorRaisingArgumentParser, parents=[1])
@@ -6561,6 +6564,99 @@ def test_os_error(self):
65616564
self.parser.parse_args, ['@no-such-file'])
65626565

65636566

6567+
class TestProgName(TestCase):
6568+
source = textwrap.dedent('''\
6569+
import argparse
6570+
parser = argparse.ArgumentParser()
6571+
parser.parse_args()
6572+
''')
6573+
6574+
def setUp(self):
6575+
self.dirname = 'package' + os_helper.FS_NONASCII
6576+
self.addCleanup(os_helper.rmtree, self.dirname)
6577+
os.mkdir(self.dirname)
6578+
6579+
def make_script(self, dirname, basename, *, compiled=False):
6580+
script_name = script_helper.make_script(dirname, basename, self.source)
6581+
if not compiled:
6582+
return script_name
6583+
py_compile.compile(script_name, doraise=True)
6584+
os.remove(script_name)
6585+
pyc_file = import_helper.make_legacy_pyc(script_name)
6586+
return pyc_file
6587+
6588+
def make_zip_script(self, script_name, name_in_zip=None):
6589+
zip_name, _ = script_helper.make_zip_script(self.dirname, 'test_zip',
6590+
script_name, name_in_zip)
6591+
return zip_name
6592+
6593+
def check_usage(self, expected, *args, **kwargs):
6594+
res = script_helper.assert_python_ok('-Xutf8', *args, '-h', **kwargs)
6595+
self.assertEqual(res.out.splitlines()[0].decode(),
6596+
f'usage: {expected} [-h]')
6597+
6598+
def test_script(self, compiled=False):
6599+
basename = os_helper.TESTFN
6600+
script_name = self.make_script(self.dirname, basename, compiled=compiled)
6601+
self.check_usage(os.path.basename(script_name), script_name, '-h')
6602+
6603+
def test_script_compiled(self):
6604+
self.test_script(compiled=True)
6605+
6606+
def test_directory(self, compiled=False):
6607+
dirname = os.path.join(self.dirname, os_helper.TESTFN)
6608+
os.mkdir(dirname)
6609+
self.make_script(dirname, '__main__', compiled=compiled)
6610+
self.check_usage(f'{py} {dirname}', dirname)
6611+
dirname2 = os.path.join(os.curdir, dirname)
6612+
self.check_usage(f'{py} {dirname2}', dirname2)
6613+
6614+
def test_directory_compiled(self):
6615+
self.test_directory(compiled=True)
6616+
6617+
def test_module(self, compiled=False):
6618+
basename = 'module' + os_helper.FS_NONASCII
6619+
modulename = f'{self.dirname}.{basename}'
6620+
self.make_script(self.dirname, basename, compiled=compiled)
6621+
self.check_usage(f'{py} -m {modulename}',
6622+
'-m', modulename, PYTHONPATH=os.curdir)
6623+
6624+
def test_module_compiled(self):
6625+
self.test_module(compiled=True)
6626+
6627+
def test_package(self, compiled=False):
6628+
basename = 'subpackage' + os_helper.FS_NONASCII
6629+
packagename = f'{self.dirname}.{basename}'
6630+
subdirname = os.path.join(self.dirname, basename)
6631+
os.mkdir(subdirname)
6632+
self.make_script(subdirname, '__main__', compiled=compiled)
6633+
self.check_usage(f'{py} -m {packagename}',
6634+
'-m', packagename, PYTHONPATH=os.curdir)
6635+
self.check_usage(f'{py} -m {packagename}',
6636+
'-m', packagename + '.__main__', PYTHONPATH=os.curdir)
6637+
6638+
def test_package_compiled(self):
6639+
self.test_package(compiled=True)
6640+
6641+
def test_zipfile(self, compiled=False):
6642+
script_name = self.make_script(self.dirname, '__main__', compiled=compiled)
6643+
zip_name = self.make_zip_script(script_name)
6644+
self.check_usage(f'{py} {zip_name}', zip_name)
6645+
6646+
def test_zipfile_compiled(self):
6647+
self.test_zipfile(compiled=True)
6648+
6649+
def test_directory_in_zipfile(self, compiled=False):
6650+
script_name = self.make_script(self.dirname, '__main__', compiled=compiled)
6651+
name_in_zip = 'package/subpackage/__main__' + ('.py', '.pyc')[compiled]
6652+
zip_name = self.make_zip_script(script_name, name_in_zip)
6653+
dirname = os.path.join(zip_name, 'package', 'subpackage')
6654+
self.check_usage(f'{py} {dirname}', dirname)
6655+
6656+
def test_directory_in_zipfile_compiled(self):
6657+
self.test_directory_in_zipfile(compiled=True)
6658+
6659+
65646660
def tearDownModule():
65656661
# Remove global references to avoid looking like we have refleaks.
65666662
RFile.seen = {}

Lib/test/test_calendar.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -985,7 +985,7 @@ def assertFailure(self, *args):
985985
def test_help(self):
986986
stdout = self.run_cmd_ok('-h')
987987
self.assertIn(b'usage:', stdout)
988-
self.assertIn(b'calendar.py', stdout)
988+
self.assertIn(b' -m calendar ', stdout)
989989
self.assertIn(b'--help', stdout)
990990

991991
# special case: stdout but sys.exit()
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Improved :ref:`prog` default value for :class:`argparse.ArgumentParser`. It
2+
will now include the name of the Python executable along with the module or
3+
package name, or the path to a directory, ZIP file, or directory within a
4+
ZIP file if the code was run that way.

0 commit comments

Comments
 (0)