Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog/1764.improvement.rst
Original file line number Diff line number Diff line change
@@ -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.
41 changes: 37 additions & 4 deletions src/_pytest/config/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would just make argv a required argument and pass sys.argv where needed instead of making it optional.

Also, since the list is not modified, should type is as Sequence[str].

) -> 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()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would drop this comment as it's pretty clear without it.

return "pytest.main()"

# Called from CLI - check if it's `python -m pytest` or direct `pytest`
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would drop this comment.

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"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems a bit odd to me, is it really necessary to distinguish python -m pytest from pytest? I think using pytest for both is OK.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

theres sys.path differences between the 2

return "pytest"


def main(
args: list[str] | os.PathLike[str] | None = None,
plugins: Sequence[str | _PluggyPlugin] | None = None,
*,
_invoked_from_console: bool = False,
Copy link

Copilot AI Jan 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new _invoked_from_console parameter is not documented in the docstring. Consider adding documentation for this parameter to help future maintainers understand its purpose, even though it's a private parameter.

Copilot uses AI. Check for mistakes.
Copy link
Member

@bluetech bluetech Jan 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This parameter leaks into the sphinx autodoc. That's not too bad, but since we already have console_main it makes me think maybe we should add a _main function (or with a better name...) that both console_main and main call, such that main is exclusively for programmatic use.

I'm loath to add yet another function to the initialization routines but technically it seems better and might be helpful for other things in the future.

) -> int | ExitCode:
"""Perform an in-process test run.

Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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()
Expand All @@ -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.
Expand All @@ -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)]
Expand All @@ -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:
Expand Down Expand Up @@ -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(
Expand All @@ -1104,6 +1135,8 @@ def __init__(
processopt=self._processopt,
_ispytest=True,
)
if prog is not None:
self._parser.prog = prog
Comment on lines +1138 to +1139
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wouldn't it be better to add a prog parameter to Parser's ctor, than to access its internals?

self.pluginmanager = pluginmanager
"""The plugin manager handles plugin registration and hook invocation.

Expand Down
81 changes: 81 additions & 0 deletions testing/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 *"])
Loading