Skip to content

Commit 5d85267

Browse files
Merge pull request #14824 from RonnyPfannschmidt/plugin-import-error-exit-codes-993
Classify plugin startup import failures (#993)
2 parents 59e7f3a + 98fcf26 commit 5d85267

8 files changed

Lines changed: 268 additions & 33 deletions

File tree

changelog/993.breaking.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 :class:`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 :class:`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, f"{e!r} must be raised `from` the original error"
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: 137 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,138 @@ 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_usage_error_passes_through(self, pytester: Pytester) -> None:
1155+
"""A plugin raising UsageError at import keeps its usage-error semantics."""
1156+
pytester.syspathinsert()
1157+
pytester.makepyfile(
1158+
myplugin="import pytest\nraise pytest.UsageError('config trouble')"
1159+
)
1160+
result = pytester.runpytest("-p", "myplugin")
1161+
assert result.ret == ExitCode.USAGE_ERROR
1162+
result.stderr.fnmatch_lines(["ERROR: config trouble*"])
1163+
1164+
def test_broken_via_entry_point(
1165+
self, pytester: Pytester, monkeypatch: MonkeyPatch
1166+
) -> None:
1167+
monkeypatch.delenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD", raising=False)
1168+
1169+
class DummyEntryPoint:
1170+
name = "myplugin"
1171+
group = "pytest11"
1172+
1173+
def load(self):
1174+
raise ValueError("plugin is broken")
1175+
1176+
class Distribution:
1177+
version = "1.0"
1178+
files = ("foo.txt",)
1179+
metadata = {"name": "foo"}
1180+
entry_points = (DummyEntryPoint(),)
1181+
1182+
monkeypatch.setattr(
1183+
importlib.metadata, "distributions", lambda: (Distribution(),)
1184+
)
1185+
pytester.makepyfile("def test_foo(): pass")
1186+
result = pytester.runpytest()
1187+
assert result.ret == ExitCode.INTERNAL_ERROR
1188+
1189+
def test_import_error_without_args(self, pytester: Pytester) -> None:
1190+
"""A bare ``raise ImportError`` used to crash with an IndexError (#993)."""
1191+
pytester.syspathinsert()
1192+
pytester.makepyfile(myplugin="raise ImportError")
1193+
pytester.makepyfile("def test_foo(): pass")
1194+
result = pytester.runpytest("-p", "myplugin")
1195+
assert result.ret == ExitCode.INTERNAL_ERROR
1196+
result.stderr.no_fnmatch_line("*IndexError*")
1197+
result.stderr.fnmatch_lines(['Error while loading plugin "myplugin".'])
1198+
1199+
def test_missing_dependency_is_not_a_usage_error(self, pytester: Pytester) -> None:
1200+
"""The plugin was found; one of *its* imports is unsatisfied (#993)."""
1201+
pytester.syspathinsert()
1202+
pytester.makepyfile(myplugin="import nosuchdependency")
1203+
pytester.makepyfile("def test_foo(): pass")
1204+
result = pytester.runpytest("-p", "myplugin")
1205+
assert result.ret == ExitCode.INTERNAL_ERROR
1206+
1207+
def test_missing_submodule_of_existing_package(self, pytester: Pytester) -> None:
1208+
"""The package exists but the requested plugin module within it does not."""
1209+
pytester.syspathinsert()
1210+
pytester.mkpydir("mypkg")
1211+
pytester.makepyfile("def test_foo(): pass")
1212+
result = pytester.runpytest("-p", "mypkg.nosuchmodule")
1213+
assert result.ret == ExitCode.USAGE_ERROR
1214+
1215+
def test_conftest_import_failure_stays_a_usage_error(
1216+
self, pytester: Pytester
1217+
) -> None:
1218+
"""conftest.py is not a plugin; it keeps reporting a usage error (#993)."""
1219+
pytester.makeconftest("raise ValueError('conftest is broken')")
1220+
pytester.makepyfile("def test_foo(): pass")
1221+
result = pytester.runpytest()
1222+
assert result.ret == ExitCode.USAGE_ERROR
1223+
1224+
10911225
def test_import_plugin_unicode_name(pytester: Pytester) -> None:
10921226
pytester.makepyfile(myplugin="")
10931227
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: 30 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
@@ -1858,7 +1859,35 @@ def distributions():
18581859
return (Distribution(),)
18591860

18601861
monkeypatch.setattr(importlib.metadata, "distributions", distributions)
1861-
with pytest.raises(ImportError):
1862+
with pytest.raises(PluginImportFailure) as excinfo:
1863+
pytester.parseconfig()
1864+
assert "Don't hide me!" in str(excinfo.value.__cause__)
1865+
1866+
1867+
def test_setuptools_usage_error_passes_through(
1868+
pytester: Pytester, monkeypatch: MonkeyPatch
1869+
) -> None:
1870+
"""A UsageError from an entry-point plugin is not reclassified (#993)."""
1871+
monkeypatch.delenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD", raising=False)
1872+
1873+
class DummyEntryPoint:
1874+
name = "mytestplugin"
1875+
group = "pytest11"
1876+
1877+
def load(self):
1878+
raise UsageError("bad usage")
1879+
1880+
class Distribution:
1881+
version = "1.0"
1882+
files = ("foo.txt",)
1883+
metadata = {"name": "foo"}
1884+
entry_points = (DummyEntryPoint(),)
1885+
1886+
def distributions():
1887+
return (Distribution(),)
1888+
1889+
monkeypatch.setattr(importlib.metadata, "distributions", distributions)
1890+
with pytest.raises(UsageError, match="bad usage"):
18621891
pytester.parseconfig()
18631892

18641893

0 commit comments

Comments
 (0)