diff --git a/changelog/1764.improvement.rst b/changelog/1764.improvement.rst new file mode 100644 index 00000000000..0a25f39faf9 --- /dev/null +++ b/changelog/1764.improvement.rst @@ -0,0 +1 @@ +Improved argparse program name to show ``pytest``, ``python -m pytest``, or ``pytest.main()`` based on how pytest was invoked, making help and error messages clearer. diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index 21dc35219d8..eafd1822717 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -169,9 +169,33 @@ def print_usage_error(e: UsageError, file: TextIO) -> None: tw.line(f"ERROR: {msg}\n", red=True) +def _get_prog_name( + *, invoked_from_console: bool, _argv: list[str] | None = None +) -> str: + """Determine the appropriate program name for argparse based on invocation context. + + :param invoked_from_console: Whether pytest was invoked from the CLI entry point. + :param _argv: Optional argv list for testing; defaults to sys.argv. + :returns: The program name to display in help and error messages. + """ + if not invoked_from_console: + # Called programmatically via pytest.main() + return "pytest.main()" + + # Called from CLI - check if it's `python -m pytest` or direct `pytest` + argv = sys.argv if _argv is None else _argv + argv0 = argv[0] if argv else "" + # When running as `python -m pytest`, argv[0] is the path to __main__.py + if os.path.basename(argv0) == "__main__.py": + return "python -m pytest" + return "pytest" + + def main( args: list[str] | os.PathLike[str] | None = None, plugins: Sequence[str | _PluggyPlugin] | None = None, + *, + _invoked_from_console: bool = False, ) -> int | ExitCode: """Perform an in-process test run. @@ -188,11 +212,13 @@ def main( sys.stdout.write(f"pytest {__version__}\n") return ExitCode.OK + prog = _get_prog_name(invoked_from_console=_invoked_from_console) + old_pytest_version = os.environ.get("PYTEST_VERSION") try: os.environ["PYTEST_VERSION"] = __version__ try: - config = _prepareconfig(new_args, plugins) + config = _prepareconfig(new_args, plugins, prog=prog) except ConftestImportFailure as e: print_conftest_import_error(e, file=sys.stderr) return ExitCode.USAGE_ERROR @@ -222,7 +248,7 @@ def console_main() -> int: """ # https://docs.python.org/3/library/signal.html#note-on-sigpipe try: - code = main() + code = main(_invoked_from_console=True) sys.stdout.flush() return code except BrokenPipeError: @@ -308,6 +334,8 @@ def directory_arg(path: str, optname: str) -> str: def get_config( args: Iterable[str] | None = None, plugins: Sequence[str | _PluggyPlugin] | None = None, + *, + prog: str | None = None, ) -> Config: # Subsequent calls to main will create a fresh instance. pluginmanager = PytestPluginManager() @@ -316,7 +344,7 @@ def get_config( plugins=plugins, dir=pathlib.Path.cwd(), ) - config = Config(pluginmanager, invocation_params=invocation_params) + config = Config(pluginmanager, invocation_params=invocation_params, prog=prog) if invocation_params.args: # Handle any "-p no:plugin" args. @@ -342,6 +370,8 @@ def get_plugin_manager() -> PytestPluginManager: def _prepareconfig( args: list[str] | os.PathLike[str], plugins: Sequence[str | _PluggyPlugin] | None = None, + *, + prog: str | None = None, ) -> Config: if isinstance(args, os.PathLike): args = [os.fspath(args)] @@ -351,7 +381,7 @@ def _prepareconfig( ) raise TypeError(msg.format(args, type(args))) - initial_config = get_config(args, plugins) + initial_config = get_config(args, plugins, prog=prog) pluginmanager = initial_config.pluginmanager try: if plugins: @@ -1081,6 +1111,7 @@ def __init__( pluginmanager: PytestPluginManager, *, invocation_params: InvocationParams | None = None, + prog: str | None = None, ) -> None: if invocation_params is None: invocation_params = self.InvocationParams( @@ -1104,6 +1135,8 @@ def __init__( processopt=self._processopt, _ispytest=True, ) + if prog is not None: + self._parser.prog = prog self.pluginmanager = pluginmanager """The plugin manager handles plugin registration and hook invocation. diff --git a/testing/test_config.py b/testing/test_config.py index de11e3fa13a..239f733adb4 100644 --- a/testing/test_config.py +++ b/testing/test_config.py @@ -14,6 +14,7 @@ import _pytest._code from _pytest.config import _get_plugin_specs_as_list +from _pytest.config import _get_prog_name from _pytest.config import _iter_rewritable_modules from _pytest.config import _strtobool from _pytest.config import Config @@ -3072,3 +3073,83 @@ def test(): result = pytester.runpytest() assert result.ret == 0 + + +class TestProgName: + """Test program name display in help and error messages (issue #1764).""" + + def test_get_prog_name_programmatic_invocation(self) -> None: + """When invoked programmatically, prog should be 'pytest.main()'.""" + # Regardless of what argv[0] is, programmatic invocation should + # always show pytest.main() + assert ( + _get_prog_name(invoked_from_console=False, _argv=["setup.py", "test"]) + == "pytest.main()" + ) + assert ( + _get_prog_name(invoked_from_console=False, _argv=["my_script.py"]) + == "pytest.main()" + ) + + def test_get_prog_name_console_pytest(self) -> None: + """When invoked via 'pytest' CLI, prog should be 'pytest'.""" + assert ( + _get_prog_name( + invoked_from_console=True, _argv=["/usr/bin/pytest", "--help"] + ) + == "pytest" + ) + assert ( + _get_prog_name(invoked_from_console=True, _argv=["pytest", "-v"]) + == "pytest" + ) + + def test_get_prog_name_console_python_m_pytest(self) -> None: + """When invoked via 'python -m pytest', prog should be 'python -m pytest'.""" + # When running as python -m pytest, argv[0] is the path to __main__.py + assert ( + _get_prog_name( + invoked_from_console=True, + _argv=["/path/to/site-packages/pytest/__main__.py", "--help"], + ) + == "python -m pytest" + ) + assert ( + _get_prog_name(invoked_from_console=True, _argv=["__main__.py", "-v"]) + == "python -m pytest" + ) + + def test_get_prog_name_empty_argv(self) -> None: + """When argv is empty, should handle gracefully.""" + # Empty argv with console invocation should default to pytest + assert _get_prog_name(invoked_from_console=True, _argv=[]) == "pytest" + # Empty argv with programmatic invocation should show pytest.main() + assert _get_prog_name(invoked_from_console=False, _argv=[]) == "pytest.main()" + + def test_prog_in_error_message_programmatic(self, pytester: Pytester) -> None: + """Error messages should show 'pytest.main()' when called programmatically. + + runpytest_inprocess calls pytest.main() directly, so it should show + pytest.main() as the program name. + """ + result = pytester.runpytest_inprocess("--invalid-option-xyz") + result.stderr.fnmatch_lines(["*pytest.main(): error:*invalid-option-xyz*"]) + + def test_prog_in_error_message_cli(self, pytester: Pytester) -> None: + """Error messages should show 'python -m pytest' when called from CLI subprocess. + + runpytest_subprocess runs pytest via 'python -m pytest', so it should + show 'python -m pytest' as the program name. + """ + result = pytester.runpytest_subprocess("--invalid-option-xyz") + result.stderr.fnmatch_lines(["*python -m pytest: error:*invalid-option-xyz*"]) + + def test_prog_in_usage_programmatic(self, pytester: Pytester) -> None: + """Usage line should show 'pytest.main()' when called programmatically.""" + result = pytester.runpytest_inprocess("--help") + result.stdout.fnmatch_lines(["usage: pytest.main() *"]) + + def test_prog_in_usage_cli(self, pytester: Pytester) -> None: + """Usage line should show 'python -m pytest' when called from CLI subprocess.""" + result = pytester.runpytest_subprocess("--help") + result.stdout.fnmatch_lines(["usage: python -m pytest *"])