Repository: pallets/click (python)

Issue:
detection of program name when run with `python -m`
The program name (referred to as `prog_name` or `info_name`) is the name Click uses in usage help text. If it's not specified, Click tries to detect it by looking at `sys.argv[0]`.

https://github.com/pallets/click/blob/dfb842d95a7c146d8fe8140c8100065410d95c4d/src/click/core.py#L797-L800

This works when the CLI is run as a script, either directly, or indirectly as an entry point, or as a module when the program is a single file, but fails when the program is a package and run with `python -m`. In that case, Python converts the `-m name` to the Python file within the package that would be executed. (The use of `__file__` there is also incorrect, as that will always point to `click/core.py`. There should never be a case where `sys.argv` is empty. `make_str` is probably not needed either.)

This leads to the following names:

* `python example.py`, executing a script directly - `example.py`
* `python -m example`, the single file module `example.py` - `example.py`
* `python -m example`, a package like `example/__main__.py` - `__main__.py`
* `python -m example.cli`, a submodule like `example/cli.py` - `cli.py`
* `example`, an entry point, with any of the layouts above - `example`

What's usually expected is that executing a script will print the Python file name (`example.py`), executing an entry point will print the entry point name (`example`), and executing a module or package will print the Python command (`python -m example`, `python -m example.cli`)

This leads to projects that expect to be run as either an entry point or `-m` writing their own wrapper around the CLI to control what name gets used. For example, Flask uses a wrapper that the entry point will call with no args, while `__main__.py` or `__name__ == "__main__"` will pass `as_module=True`.

```python
def main(as_module=False):
    cli.main(prog_name="python -m flask" if as_module else None)
```

Unfortunately, detecting whether `python -m` was used is really, really complicated. Luckily, I did the work already in Werkzeug's reloader: https://github.com/pallets/werkzeug/blob/102bcda52176db448d383a9ef1ccf7e406a379eb/src/werkzeug/_reloader.py#L59. It won't be quite the same in Click because we're not concerned with the exact path to the `python` command or script file, or with arguments, but that should be a good start. Write a function `detect_program_name()` to detect this and call it in place of the existing code.

Pull request:
detect program name with python -m
Fixes #1603

Patch:
diff --git a/CHANGES.rst b/CHANGES.rst
index d7a780d..79ae5d8 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -33,6 +33,8 @@ Unreleased
     its result callback. :issue:`1178`
 -   ``nargs == -1`` and ``nargs > 1`` is parsed and validated for
     values from environment variables and defaults. :issue:`729`
+-   Detect the program name when executing a module or package with
+    ``python -m name``. :issue:`1603`
 
 
 Version 7.1.2
diff --git a/src/click/core.py b/src/click/core.py
index 346350b..52f9cac 100644
--- a/src/click/core.py
+++ b/src/click/core.py
@@ -25,6 +25,7 @@ from .termui import style
 from .types import BOOL
 from .types import convert_type
 from .types import IntRange
+from .utils import _detect_program_name
 from .utils import echo
 from .utils import make_default_short_help
 from .utils import make_str
@@ -795,9 +796,7 @@ class BaseCommand:
             args = list(args)
 
         if prog_name is None:
-            prog_name = make_str(
-                os.path.basename(sys.argv[0] if sys.argv else __file__)
-            )
+            prog_name = _detect_program_name()
 
         # Hook for the Bash completion.  This only activates if the Bash
         # completion is actually enabled, otherwise this is quite a fast
diff --git a/src/click/utils.py b/src/click/utils.py
index bd9dd8e..0bff5c0 100644
--- a/src/click/utils.py
+++ b/src/click/utils.py
@@ -438,3 +438,52 @@ class PacifyFlushWrapper:
 
     def __getattr__(self, attr):
         return getattr(self.wrapped, attr)
+
+
+def _detect_program_name(path=None, _main=sys.modules["__main__"]):
+    """Determine the command used to run the program, for use in help
+    text. If a file or entry point was executed, the file name is
+    returned. If ``python -m`` was used to execute a module or package,
+    ``python -m name`` is returned.
+
+    This doesn't try to be too precise, the goal is to give a concise
+    name for help text. Files are only shown as their name without the
+    path. ``python`` is only shown for modules, and the full path to
+    ``sys.executable`` is not shown.
+
+    :param path: The Python file being executed. Python puts this in
+        ``sys.argv[0]``, which is used by default.
+    :param _main: The ``__main__`` module. This should only be passed
+        during internal testing.
+
+    .. versionadded:: 8.0
+        Based on command args detection in the Werkzeug reloader.
+
+    :meta private:
+    """
+    if not path:
+        path = sys.argv[0]
+
+    # The value of __package__ indicates how Python was called. It may
+    # not exist if a setuptools script is installed as an egg. It may be
+    # set incorrectly for entry points created with pip on Windows.
+    if getattr(_main, "__package__", None) is None or (
+        os.name == "nt"
+        and _main.__package__ == ""
+        and not os.path.exists(path)
+        and os.path.exists(f"{path}.exe")
+    ):
+        # Executed a file, like "python app.py".
+        return os.path.basename(path)
+
+    # Executed a module, like "python -m example".
+    # Rewritten by Python from "-m script" to "/path/to/script.py".
+    # Need to look at main module to determine how it was executed.
+    py_module = _main.__package__
+    name = os.path.splitext(os.path.basename(path))[0]
+
+    # A submodule like "example.cli".
+    if name != "__main__":
+        py_module = f"{py_module}.{name}"
+
+    return f"python -m {py_module.lstrip('.')}"


Test patch:
diff --git a/tests/test_utils.py b/tests/test_utils.py
index 0abd949..8b1277b 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -1,4 +1,5 @@
 import os
+import pathlib
 import stat
 import sys
 from io import StringIO
@@ -382,3 +383,32 @@ def test_iter_lazyfile(tmpdir):
         with click.utils.LazyFile(f.name) as lf:
             for e_line, a_line in zip(expected, lf):
                 assert e_line == a_line.strip()
+
+
+class MockMain:
+    __slots__ = "__package__"
+
+    def __init__(self, package_name):
+        self.__package__ = package_name
+
+
+@pytest.mark.parametrize(
+    ("path", "main", "expected"),
+    [
+        ("example.py", None, "example.py"),
+        (str(pathlib.Path("/foo/bar/example.py")), None, "example.py"),
+        ("example", None, "example"),
+        (
+            str(pathlib.Path("example/__main__.py")),
+            MockMain(".example"),
+            "python -m example",
+        ),
+        (
+            str(pathlib.Path("example/cli.py")),
+            MockMain(".example"),
+            "python -m example.cli",
+        ),
+    ],
+)
+def test_detect_program_name(path, main, expected):
+    assert click.utils._detect_program_name(path, _main=main) == expected