Skip to content

Commit b08dfb6

Browse files
RonnyPfannschmidtCursor AIclaude
committed
fix: show correct program name in argparse help/errors
Display 'pytest', 'python -m pytest', or 'pytest.main()' based on how pytest was invoked, fixing confusing error messages when calling pytest.main() programmatically. Fixes #1764 Co-authored-by: Cursor AI <ai@cursor.sh> Co-authored-by: Anthropic Claude Opus 4 <claude@anthropic.com>
1 parent 3d27ab9 commit b08dfb6

3 files changed

Lines changed: 119 additions & 4 deletions

File tree

changelog/1764.improvement.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
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.

src/_pytest/config/__init__.py

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -169,9 +169,33 @@ def print_usage_error(e: UsageError, file: TextIO) -> None:
169169
tw.line(f"ERROR: {msg}\n", red=True)
170170

171171

172+
def _get_prog_name(
173+
*, invoked_from_console: bool, _argv: list[str] | None = None
174+
) -> str:
175+
"""Determine the appropriate program name for argparse based on invocation context.
176+
177+
:param invoked_from_console: Whether pytest was invoked from the CLI entry point.
178+
:param _argv: Optional argv list for testing; defaults to sys.argv.
179+
:returns: The program name to display in help and error messages.
180+
"""
181+
if not invoked_from_console:
182+
# Called programmatically via pytest.main()
183+
return "pytest.main()"
184+
185+
# Called from CLI - check if it's `python -m pytest` or direct `pytest`
186+
argv = sys.argv if _argv is None else _argv
187+
argv0 = argv[0] if argv else ""
188+
# When running as `python -m pytest`, argv[0] is the path to __main__.py
189+
if os.path.basename(argv0) == "__main__.py":
190+
return "python -m pytest"
191+
return "pytest"
192+
193+
172194
def main(
173195
args: list[str] | os.PathLike[str] | None = None,
174196
plugins: Sequence[str | _PluggyPlugin] | None = None,
197+
*,
198+
_invoked_from_console: bool = False,
175199
) -> int | ExitCode:
176200
"""Perform an in-process test run.
177201
@@ -188,11 +212,13 @@ def main(
188212
sys.stdout.write(f"pytest {__version__}\n")
189213
return ExitCode.OK
190214

215+
prog = _get_prog_name(invoked_from_console=_invoked_from_console)
216+
191217
old_pytest_version = os.environ.get("PYTEST_VERSION")
192218
try:
193219
os.environ["PYTEST_VERSION"] = __version__
194220
try:
195-
config = _prepareconfig(new_args, plugins)
221+
config = _prepareconfig(new_args, plugins, prog=prog)
196222
except ConftestImportFailure as e:
197223
print_conftest_import_error(e, file=sys.stderr)
198224
return ExitCode.USAGE_ERROR
@@ -222,7 +248,7 @@ def console_main() -> int:
222248
"""
223249
# https://docs.python.org/3/library/signal.html#note-on-sigpipe
224250
try:
225-
code = main()
251+
code = main(_invoked_from_console=True)
226252
sys.stdout.flush()
227253
return code
228254
except BrokenPipeError:
@@ -308,6 +334,8 @@ def directory_arg(path: str, optname: str) -> str:
308334
def get_config(
309335
args: Iterable[str] | None = None,
310336
plugins: Sequence[str | _PluggyPlugin] | None = None,
337+
*,
338+
prog: str | None = None,
311339
) -> Config:
312340
# Subsequent calls to main will create a fresh instance.
313341
pluginmanager = PytestPluginManager()
@@ -316,7 +344,7 @@ def get_config(
316344
plugins=plugins,
317345
dir=pathlib.Path.cwd(),
318346
)
319-
config = Config(pluginmanager, invocation_params=invocation_params)
347+
config = Config(pluginmanager, invocation_params=invocation_params, prog=prog)
320348

321349
if invocation_params.args:
322350
# Handle any "-p no:plugin" args.
@@ -342,6 +370,8 @@ def get_plugin_manager() -> PytestPluginManager:
342370
def _prepareconfig(
343371
args: list[str] | os.PathLike[str],
344372
plugins: Sequence[str | _PluggyPlugin] | None = None,
373+
*,
374+
prog: str | None = None,
345375
) -> Config:
346376
if isinstance(args, os.PathLike):
347377
args = [os.fspath(args)]
@@ -351,7 +381,7 @@ def _prepareconfig(
351381
)
352382
raise TypeError(msg.format(args, type(args)))
353383

354-
initial_config = get_config(args, plugins)
384+
initial_config = get_config(args, plugins, prog=prog)
355385
pluginmanager = initial_config.pluginmanager
356386
try:
357387
if plugins:
@@ -1081,6 +1111,7 @@ def __init__(
10811111
pluginmanager: PytestPluginManager,
10821112
*,
10831113
invocation_params: InvocationParams | None = None,
1114+
prog: str | None = None,
10841115
) -> None:
10851116
if invocation_params is None:
10861117
invocation_params = self.InvocationParams(
@@ -1104,6 +1135,8 @@ def __init__(
11041135
processopt=self._processopt,
11051136
_ispytest=True,
11061137
)
1138+
if prog is not None:
1139+
self._parser.prog = prog
11071140
self.pluginmanager = pluginmanager
11081141
"""The plugin manager handles plugin registration and hook invocation.
11091142

testing/test_config.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
import _pytest._code
1616
from _pytest.config import _get_plugin_specs_as_list
17+
from _pytest.config import _get_prog_name
1718
from _pytest.config import _iter_rewritable_modules
1819
from _pytest.config import _strtobool
1920
from _pytest.config import Config
@@ -3072,3 +3073,83 @@ def test():
30723073

30733074
result = pytester.runpytest()
30743075
assert result.ret == 0
3076+
3077+
3078+
class TestProgName:
3079+
"""Test program name display in help and error messages (issue #1764)."""
3080+
3081+
def test_get_prog_name_programmatic_invocation(self) -> None:
3082+
"""When invoked programmatically, prog should be 'pytest.main()'."""
3083+
# Regardless of what argv[0] is, programmatic invocation should
3084+
# always show pytest.main()
3085+
assert (
3086+
_get_prog_name(invoked_from_console=False, _argv=["setup.py", "test"])
3087+
== "pytest.main()"
3088+
)
3089+
assert (
3090+
_get_prog_name(invoked_from_console=False, _argv=["my_script.py"])
3091+
== "pytest.main()"
3092+
)
3093+
3094+
def test_get_prog_name_console_pytest(self) -> None:
3095+
"""When invoked via 'pytest' CLI, prog should be 'pytest'."""
3096+
assert (
3097+
_get_prog_name(
3098+
invoked_from_console=True, _argv=["/usr/bin/pytest", "--help"]
3099+
)
3100+
== "pytest"
3101+
)
3102+
assert (
3103+
_get_prog_name(invoked_from_console=True, _argv=["pytest", "-v"])
3104+
== "pytest"
3105+
)
3106+
3107+
def test_get_prog_name_console_python_m_pytest(self) -> None:
3108+
"""When invoked via 'python -m pytest', prog should be 'python -m pytest'."""
3109+
# When running as python -m pytest, argv[0] is the path to __main__.py
3110+
assert (
3111+
_get_prog_name(
3112+
invoked_from_console=True,
3113+
_argv=["/path/to/site-packages/pytest/__main__.py", "--help"],
3114+
)
3115+
== "python -m pytest"
3116+
)
3117+
assert (
3118+
_get_prog_name(invoked_from_console=True, _argv=["__main__.py", "-v"])
3119+
== "python -m pytest"
3120+
)
3121+
3122+
def test_get_prog_name_empty_argv(self) -> None:
3123+
"""When argv is empty, should handle gracefully."""
3124+
# Empty argv with console invocation should default to pytest
3125+
assert _get_prog_name(invoked_from_console=True, _argv=[]) == "pytest"
3126+
# Empty argv with programmatic invocation should show pytest.main()
3127+
assert _get_prog_name(invoked_from_console=False, _argv=[]) == "pytest.main()"
3128+
3129+
def test_prog_in_error_message_programmatic(self, pytester: Pytester) -> None:
3130+
"""Error messages should show 'pytest.main()' when called programmatically.
3131+
3132+
runpytest_inprocess calls pytest.main() directly, so it should show
3133+
pytest.main() as the program name.
3134+
"""
3135+
result = pytester.runpytest_inprocess("--invalid-option-xyz")
3136+
result.stderr.fnmatch_lines(["*pytest.main(): error:*invalid-option-xyz*"])
3137+
3138+
def test_prog_in_error_message_cli(self, pytester: Pytester) -> None:
3139+
"""Error messages should show 'python -m pytest' when called from CLI subprocess.
3140+
3141+
runpytest_subprocess runs pytest via 'python -m pytest', so it should
3142+
show 'python -m pytest' as the program name.
3143+
"""
3144+
result = pytester.runpytest_subprocess("--invalid-option-xyz")
3145+
result.stderr.fnmatch_lines(["*python -m pytest: error:*invalid-option-xyz*"])
3146+
3147+
def test_prog_in_usage_programmatic(self, pytester: Pytester) -> None:
3148+
"""Usage line should show 'pytest.main()' when called programmatically."""
3149+
result = pytester.runpytest_inprocess("--help")
3150+
result.stdout.fnmatch_lines(["usage: pytest.main() *"])
3151+
3152+
def test_prog_in_usage_cli(self, pytester: Pytester) -> None:
3153+
"""Usage line should show 'python -m pytest' when called from CLI subprocess."""
3154+
result = pytester.runpytest_subprocess("--help")
3155+
result.stdout.fnmatch_lines(["usage: python -m pytest *"])

0 commit comments

Comments
 (0)