Skip to content

Commit 363aea6

Browse files
RonnyPfannschmidtClaude Opus 5
andcommitted
Classify plugin startup import failures (#993)
A plugin failing to load during startup escaped `_main` as an unhandled exception: Python printed a raw traceback and exited 1, which is indistinguishable from EXIT_TESTSFAILED. Meanwhile a conftest.py failing to import already returned EXIT_USAGEERROR, which is the inconsistency #993 was filed about. Split the failure into the two things it can actually mean: - the plugin cannot be found at all -- pytest was pointed at something which is not there, so this is a usage error (exit 4), matching what conftest.py import failures already do. - the plugin was found but raised while importing -- including a missing transitive dependency and a broken pytest11 entry point -- which is a defect in the plugin rather than a misuse of pytest, so it is reported as an internal error (exit 3). The plugin traceback is preserved in both the report and the exception chain (PluginImportFailure is always raised `from` the original error); losing it was the main objection to the earlier attempt in #7290. Side effects: - pytest.main() now returns these exit codes instead of propagating the exception to its caller, matching its documented contract. - a bare `raise ImportError` in a plugin no longer crashes pytest's own internals with `IndexError: tuple index out of range` from `e.args[0]`. conftest.py import failures are deliberately left alone and keep returning exit 4. Co-Authored-By: Claude Opus 5 <ai@anthropic.com> Co-Authored-By: Claude Fable 5 <ai@anthropic.com> Co-Authored-By: Claude Code <ai@anthropic.com>
1 parent d45fc1c commit 363aea6

8 files changed

Lines changed: 224 additions & 33 deletions

File tree

changelog/993.bugfix.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
Plugin import failures at startup are now reported and classified instead of escaping as a raw traceback with the accidental exit code ``1``:
2+
3+
* a plugin that cannot be found -- via ``-p``, ``pytest_plugins`` or ``PYTEST_PLUGINS`` -- exits with :attr:`ExitCode.USAGE_ERROR <pytest.ExitCode.USAGE_ERROR>` (``4``), like a ``conftest.py`` that fails to import;
4+
* a plugin that is found but raises while importing -- including a broken ``pytest11`` entry point or a missing plugin dependency -- exits with :attr:`ExitCode.INTERNAL_ERROR <pytest.ExitCode.INTERNAL_ERROR>` (``3``), with the traceback preserved.
5+
6+
:func:`pytest.main` now returns these codes instead of raising ``ImportError``. Also fixed an ``IndexError`` from pytest's own internals when the plugin raised an exception with no arguments.

doc/en/reference/exit-codes.rst

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ Running ``pytest`` can result in seven different exit codes:
88
:Exit code 0: All tests were collected and passed successfully
99
:Exit code 1: Tests were collected and run but some of the tests failed
1010
:Exit code 2: Test execution was interrupted by the user
11-
:Exit code 3: Internal error happened while executing tests
12-
:Exit code 4: pytest command line usage error
11+
:Exit code 3: Internal error happened while executing tests, or a plugin raised while importing
12+
:Exit code 4: pytest command line usage error, including a plugin that cannot be found or a ``conftest.py`` that fails to import
1313
:Exit code 5: No tests were collected
1414
:Exit code 6: Maximum number of warnings exceeded (see :option:`--max-warnings`)
1515

src/_pytest/config/__init__.py

Lines changed: 64 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,17 @@ def __str__(self) -> str:
142142
return f"{type(self.cause).__name__}: {self.cause} (from {self.path})"
143143

144144

145-
def filter_traceback_for_conftest_import_failure(
145+
class PluginImportFailure(Exception):
146+
"""A plugin was found, but raised while being imported.
147+
148+
This is deliberately distinct from a plugin which could not be found at
149+
all: not finding it means pytest was pointed at something that isn't there,
150+
which is a :class:`UsageError`, while a plugin blowing up on import is a
151+
defect in the plugin and reported as an internal error.
152+
"""
153+
154+
155+
def filter_traceback_for_import_failure(
146156
entry: _pytest._code.TracebackEntry,
147157
) -> bool:
148158
"""Filter tracebacks entries which point to pytest internals or importlib.
@@ -153,13 +163,11 @@ def filter_traceback_for_conftest_import_failure(
153163
return filter_traceback(entry) and "importlib" not in str(entry.path).split(os.sep)
154164

155165

156-
def print_conftest_import_error(e: ConftestImportFailure, file: TextIO) -> None:
157-
exc_info = ExceptionInfo.from_exception(e.cause)
166+
def _print_import_error(header: str, cause: BaseException, file: TextIO) -> None:
167+
exc_info = ExceptionInfo.from_exception(cause)
158168
tw = TerminalWriter(file)
159-
tw.line(f"ImportError while loading conftest '{e.path}'.", red=True)
160-
exc_info.traceback = exc_info.traceback.filter(
161-
filter_traceback_for_conftest_import_failure
162-
)
169+
tw.line(header, red=True)
170+
exc_info.traceback = exc_info.traceback.filter(filter_traceback_for_import_failure)
163171
exc_repr = (
164172
exc_info.getrepr(style="short", chain=False)
165173
if exc_info.traceback
@@ -170,6 +178,17 @@ def print_conftest_import_error(e: ConftestImportFailure, file: TextIO) -> None:
170178
tw.line(line.rstrip(), red=True)
171179

172180

181+
def print_conftest_import_error(e: ConftestImportFailure, file: TextIO) -> None:
182+
_print_import_error(
183+
f"ImportError while loading conftest '{e.path}'.", e.cause, file
184+
)
185+
186+
187+
def print_plugin_import_error(e: PluginImportFailure, file: TextIO) -> None:
188+
assert e.__cause__ is not None # always raised with `from`
189+
_print_import_error(f'Error while loading plugin "{e}".', e.__cause__, file)
190+
191+
173192
def print_usage_error(e: UsageError, file: TextIO) -> None:
174193
tw = TerminalWriter(file)
175194
for msg in e.args:
@@ -232,6 +251,9 @@ def _main(
232251
except ConftestImportFailure as e:
233252
print_conftest_import_error(e, file=sys.stderr)
234253
return ExitCode.USAGE_ERROR
254+
except PluginImportFailure as e:
255+
print_plugin_import_error(e, file=sys.stderr)
256+
return ExitCode.INTERNAL_ERROR
235257

236258
try:
237259
ret: ExitCode | int = config.hook.pytest_cmdline_main(config=config)
@@ -924,16 +946,46 @@ def import_plugin(self, modname: str, consider_entry_points: bool = False) -> No
924946
# testing/test_config.py::test_disable_plugin_autoload.
925947
__import__(importspec)
926948
mod = sys.modules[importspec]
927-
except ImportError as e:
928-
raise ImportError(
929-
f'Error importing plugin "{modname}": {e.args[0]}'
930-
).with_traceback(e.__traceback__) from e
931-
932949
except Skipped as e:
933950
self.skipped_plugins.append((modname, e.msg or ""))
951+
except ModuleNotFoundError as e:
952+
if _is_missing_module(e, importspec):
953+
# The plugin itself is nowhere to be found - pytest was pointed
954+
# at something which does not exist, so this is a usage error.
955+
raise UsageError(f'Error importing plugin "{modname}": {e}') from e
956+
# Some *other* module the plugin imports is missing: the plugin was
957+
# found, so this is a defect in the plugin, not a usage error.
958+
raise PluginImportFailure(modname) from e
959+
except UsageError:
960+
raise
961+
except Exception as e:
962+
raise PluginImportFailure(modname) from e
934963
else:
935964
self.register(mod, modname)
936965

966+
def load_setuptools_entrypoints(self, group: str, name: str | None = None) -> int:
967+
""":meta private:"""
968+
try:
969+
return super().load_setuptools_entrypoints(group, name=name)
970+
except UsageError:
971+
raise
972+
except Exception as e:
973+
# An installed plugin which cannot be loaded is a defect in that
974+
# plugin - the user did nothing wrong by having it installed.
975+
raise PluginImportFailure(name or group) from e
976+
977+
978+
def _is_missing_module(e: ModuleNotFoundError, importspec: str) -> bool:
979+
"""Whether ``e`` means that ``importspec`` itself could not be found.
980+
981+
A ``ModuleNotFoundError`` naming some other module means the plugin was
982+
located but one of its own imports is unsatisfied.
983+
"""
984+
if e.name is None:
985+
return False
986+
# A missing parent package also means importspec cannot be found.
987+
return e.name == importspec or importspec.startswith(f"{e.name}.")
988+
937989

938990
def _get_plugin_specs_as_list(
939991
specs: types.ModuleType | str | Sequence[str] | None,

testing/acceptance_test.py

Lines changed: 127 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import setuptools
1414

1515
from _pytest.config import ExitCode
16+
from _pytest.monkeypatch import MonkeyPatch
1617
from _pytest.pathlib import symlink_or_skip
1718
from _pytest.pytester import Pytester
1819
import pytest
@@ -510,9 +511,10 @@ def test_plugins_given_as_strings(
510511
) -> None:
511512
"""Test that str values passed to main() as `plugins` arg are
512513
interpreted as module names to be imported and registered (#855)."""
513-
with pytest.raises(ImportError) as excinfo:
514-
pytest.main([str(pytester.path)], plugins=["invalid.module"])
515-
assert "invalid" in str(excinfo.value)
514+
# A plugin which cannot be found is a usage error, reported through the
515+
# return value rather than raised out of pytest.main() (#993).
516+
ret = pytest.main([str(pytester.path)], plugins=["invalid.module"])
517+
assert ret == ExitCode.USAGE_ERROR
516518

517519
p = pytester.path.joinpath("test_test_plugins_given_as_strings.py")
518520
p.write_text("def test_foo(): pass", encoding="utf-8")
@@ -1088,6 +1090,128 @@ def main():
10881090
result.stdout.no_fnmatch_line("*INTERNALERROR>*")
10891091

10901092

1093+
class TestStartupPluginImportErrors:
1094+
"""Exit codes for plugins which fail to load at startup (#993).
1095+
1096+
A plugin which cannot be found means pytest was pointed at something which
1097+
is not there, which is a usage error; a plugin which is found but blows up
1098+
while importing is a defect in the plugin, reported as an internal error.
1099+
"""
1100+
1101+
@pytest.fixture
1102+
def broken_plugin(self, pytester: Pytester) -> Pytester:
1103+
pytester.syspathinsert()
1104+
pytester.makepyfile(myplugin="raise ValueError('plugin is broken')")
1105+
pytester.makepyfile("def test_foo(): pass")
1106+
return pytester
1107+
1108+
@pytest.fixture
1109+
def missing_plugin(self, pytester: Pytester) -> Pytester:
1110+
pytester.syspathinsert()
1111+
pytester.makepyfile("def test_foo(): pass")
1112+
return pytester
1113+
1114+
def test_missing_via_cmdline(self, missing_plugin: Pytester) -> None:
1115+
result = missing_plugin.runpytest("-p", "nosuchplugin")
1116+
assert result.ret == ExitCode.USAGE_ERROR
1117+
result.stderr.fnmatch_lines(['*Error importing plugin "nosuchplugin"*'])
1118+
1119+
def test_missing_via_conftest(self, missing_plugin: Pytester) -> None:
1120+
missing_plugin.makeconftest("pytest_plugins = ['nosuchplugin']")
1121+
result = missing_plugin.runpytest()
1122+
assert result.ret == ExitCode.USAGE_ERROR
1123+
1124+
def test_missing_via_env(
1125+
self, missing_plugin: Pytester, monkeypatch: MonkeyPatch
1126+
) -> None:
1127+
monkeypatch.setenv("PYTEST_PLUGINS", "nosuchplugin")
1128+
result = missing_plugin.runpytest()
1129+
assert result.ret == ExitCode.USAGE_ERROR
1130+
1131+
def test_broken_via_cmdline(self, broken_plugin: Pytester) -> None:
1132+
result = broken_plugin.runpytest("-p", "myplugin")
1133+
assert result.ret == ExitCode.INTERNAL_ERROR
1134+
result.stderr.fnmatch_lines(
1135+
[
1136+
'Error while loading plugin "myplugin".',
1137+
"*myplugin.py:1: in <module>*",
1138+
"E*ValueError: plugin is broken",
1139+
]
1140+
)
1141+
1142+
def test_broken_via_conftest(self, broken_plugin: Pytester) -> None:
1143+
broken_plugin.makeconftest("pytest_plugins = ['myplugin']")
1144+
result = broken_plugin.runpytest()
1145+
assert result.ret == ExitCode.INTERNAL_ERROR
1146+
1147+
def test_broken_via_env(
1148+
self, broken_plugin: Pytester, monkeypatch: MonkeyPatch
1149+
) -> None:
1150+
monkeypatch.setenv("PYTEST_PLUGINS", "myplugin")
1151+
result = broken_plugin.runpytest()
1152+
assert result.ret == ExitCode.INTERNAL_ERROR
1153+
1154+
def test_broken_via_entry_point(
1155+
self, pytester: Pytester, monkeypatch: MonkeyPatch
1156+
) -> None:
1157+
monkeypatch.delenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD", raising=False)
1158+
1159+
class DummyEntryPoint:
1160+
name = "myplugin"
1161+
group = "pytest11"
1162+
1163+
def load(self):
1164+
raise ValueError("plugin is broken")
1165+
1166+
class Distribution:
1167+
version = "1.0"
1168+
files = ("foo.txt",)
1169+
metadata = {"name": "foo"}
1170+
entry_points = (DummyEntryPoint(),)
1171+
1172+
monkeypatch.setattr(
1173+
importlib.metadata, "distributions", lambda: (Distribution(),)
1174+
)
1175+
pytester.makepyfile("def test_foo(): pass")
1176+
result = pytester.runpytest()
1177+
assert result.ret == ExitCode.INTERNAL_ERROR
1178+
1179+
def test_import_error_without_args(self, pytester: Pytester) -> None:
1180+
"""A bare ``raise ImportError`` used to crash with an IndexError (#993)."""
1181+
pytester.syspathinsert()
1182+
pytester.makepyfile(myplugin="raise ImportError")
1183+
pytester.makepyfile("def test_foo(): pass")
1184+
result = pytester.runpytest("-p", "myplugin")
1185+
assert result.ret == ExitCode.INTERNAL_ERROR
1186+
result.stderr.no_fnmatch_line("*IndexError*")
1187+
result.stderr.fnmatch_lines(['Error while loading plugin "myplugin".'])
1188+
1189+
def test_missing_dependency_is_not_a_usage_error(self, pytester: Pytester) -> None:
1190+
"""The plugin was found; one of *its* imports is unsatisfied (#993)."""
1191+
pytester.syspathinsert()
1192+
pytester.makepyfile(myplugin="import nosuchdependency")
1193+
pytester.makepyfile("def test_foo(): pass")
1194+
result = pytester.runpytest("-p", "myplugin")
1195+
assert result.ret == ExitCode.INTERNAL_ERROR
1196+
1197+
def test_missing_submodule_of_existing_package(self, pytester: Pytester) -> None:
1198+
"""The package exists but the requested plugin module within it does not."""
1199+
pytester.syspathinsert()
1200+
pytester.mkpydir("mypkg")
1201+
pytester.makepyfile("def test_foo(): pass")
1202+
result = pytester.runpytest("-p", "mypkg.nosuchmodule")
1203+
assert result.ret == ExitCode.USAGE_ERROR
1204+
1205+
def test_conftest_import_failure_stays_a_usage_error(
1206+
self, pytester: Pytester
1207+
) -> None:
1208+
"""conftest.py is not a plugin; it keeps reporting a usage error (#993)."""
1209+
pytester.makeconftest("raise ValueError('conftest is broken')")
1210+
pytester.makepyfile("def test_foo(): pass")
1211+
result = pytester.runpytest()
1212+
assert result.ret == ExitCode.USAGE_ERROR
1213+
1214+
10911215
def test_import_plugin_unicode_name(pytester: Pytester) -> None:
10921216
pytester.makepyfile(myplugin="")
10931217
pytester.makepyfile("def test(): pass")

testing/python/collect.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import _pytest._code
1010
from _pytest.config import ExitCode
11+
from _pytest.config.exceptions import UsageError
1112
from _pytest.main import Session
1213
from _pytest.monkeypatch import MonkeyPatch
1314
from _pytest.nodes import Collector
@@ -80,7 +81,7 @@ def test_syntax_error_in_module(self, pytester: Pytester) -> None:
8081

8182
def test_module_considers_pluginmanager_at_import(self, pytester: Pytester) -> None:
8283
modcol = pytester.getmodulecol("pytest_plugins='xasdlkj',")
83-
with pytest.raises(ImportError):
84+
with pytest.raises(UsageError):
8485
modcol.obj()
8586

8687
def test_invalid_test_module_name(self, pytester: Pytester) -> None:

testing/test_config.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from _pytest.config import console_main
2323
from _pytest.config import ExitCode
2424
from _pytest.config import parse_warning_filter
25+
from _pytest.config import PluginImportFailure
2526
from _pytest.config.argparsing import get_ini_default_for_type
2627
from _pytest.config.argparsing import Parser
2728
from _pytest.config.exceptions import UsageError
@@ -1834,8 +1835,9 @@ def distributions():
18341835
return (Distribution(),)
18351836

18361837
monkeypatch.setattr(importlib.metadata, "distributions", distributions)
1837-
with pytest.raises(ImportError):
1838+
with pytest.raises(PluginImportFailure) as excinfo:
18381839
pytester.parseconfig()
1840+
assert "Don't hide me!" in str(excinfo.value.__cause__)
18391841

18401842

18411843
def test_importlib_metadata_broken_distribution(

0 commit comments

Comments
 (0)