]> git.ipfire.org Git - thirdparty/Python/cpython.git/commitdiff
gh-66436: Improved prog default value for argparse.ArgumentParser (GH-124799)
authorSerhiy Storchaka <storchaka@gmail.com>
Tue, 1 Oct 2024 19:51:40 +0000 (22:51 +0300)
committerGitHub <noreply@github.com>
Tue, 1 Oct 2024 19:51:40 +0000 (22:51 +0300)
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>
Doc/library/argparse.rst
Doc/whatsnew/3.14.rst
Lib/argparse.py
Lib/test/test_argparse.py
Lib/test/test_calendar.py
Misc/NEWS.d/next/Library/2024-09-30-19-59-28.gh-issue-66436.4gYN_n.rst [new file with mode: 0644]

index a4683bccf651cdf2382532042a8afd8062ea969a..83d0a9ed7b1d0a4cf0a19031ddb74b5b0cf1afaf 100644 (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
 ^^^^^
index ffc001241ac5ecb5aaf6804350aee248ef02a99f..67d8d389b5808295613a118faac48a752997a151 100644 (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
 ---
index 874f271959c4fe2f47097a4097092413486543ed..4b12c2f0c6f8573a02a6dc23e069a0c2f02650ca 100644 (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
index a972ed0cc9053bdca792c44582fc8deedcd02c01..057379cec91ba9559659e4a4809b3ab865155fb0 100644 (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 = {}
index 1f9ffc5e9a5c33f7ea1d4a896968c33eb7ddcd03..f119d89c0ec39a09e1186569f83446128e8e434f 100644 (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()
diff --git a/Misc/NEWS.d/next/Library/2024-09-30-19-59-28.gh-issue-66436.4gYN_n.rst b/Misc/NEWS.d/next/Library/2024-09-30-19-59-28.gh-issue-66436.4gYN_n.rst
new file mode 100644 (file)
index 0000000..69a77b0
--- /dev/null
@@ -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.