From 98fcf2690fef1477596fdb9be5a313b1870bfcd4 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Sat, 1 Aug 2026 14:09:53 +0200 Subject: [PATCH 1/2] 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 Co-Authored-By: Claude Fable 5 Co-Authored-By: Claude Code --- changelog/993.breaking.rst | 6 ++ doc/en/reference/exit-codes.rst | 4 +- src/_pytest/config/__init__.py | 76 ++++++++++++++--- testing/acceptance_test.py | 140 +++++++++++++++++++++++++++++++- testing/python/collect.py | 3 +- testing/test_config.py | 31 ++++++- testing/test_pluginmanager.py | 38 ++++++--- testing/test_session.py | 3 +- 8 files changed, 268 insertions(+), 33 deletions(-) create mode 100644 changelog/993.breaking.rst diff --git a/changelog/993.breaking.rst b/changelog/993.breaking.rst new file mode 100644 index 00000000000..27896af84f1 --- /dev/null +++ b/changelog/993.breaking.rst @@ -0,0 +1,6 @@ +Plugin import failures at startup are now reported and classified instead of escaping as a raw traceback with the accidental exit code ``1``: + +* 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; +* 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. + +: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. diff --git a/doc/en/reference/exit-codes.rst b/doc/en/reference/exit-codes.rst index 485bd4fe20a..fff25c96ea0 100644 --- a/doc/en/reference/exit-codes.rst +++ b/doc/en/reference/exit-codes.rst @@ -8,8 +8,8 @@ Running ``pytest`` can result in seven different exit codes: :Exit code 0: All tests were collected and passed successfully :Exit code 1: Tests were collected and run but some of the tests failed :Exit code 2: Test execution was interrupted by the user -:Exit code 3: Internal error happened while executing tests -:Exit code 4: pytest command line usage error +:Exit code 3: Internal error happened while executing tests, or a plugin raised while importing +:Exit code 4: pytest command line usage error, including a plugin that cannot be found or a ``conftest.py`` that fails to import :Exit code 5: No tests were collected :Exit code 6: Maximum number of warnings exceeded (see :option:`--max-warnings`) diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index 8c6052bc4e2..75484f99128 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -142,7 +142,17 @@ def __str__(self) -> str: return f"{type(self.cause).__name__}: {self.cause} (from {self.path})" -def filter_traceback_for_conftest_import_failure( +class PluginImportFailure(Exception): + """A plugin was found, but raised while being imported. + + This is deliberately distinct from a plugin which could not be found at + all: not finding it means pytest was pointed at something that isn't there, + which is a :class:`UsageError`, while a plugin blowing up on import is a + defect in the plugin and reported as an internal error. + """ + + +def filter_traceback_for_import_failure( entry: _pytest._code.TracebackEntry, ) -> bool: """Filter tracebacks entries which point to pytest internals or importlib. @@ -153,13 +163,11 @@ def filter_traceback_for_conftest_import_failure( return filter_traceback(entry) and "importlib" not in str(entry.path).split(os.sep) -def print_conftest_import_error(e: ConftestImportFailure, file: TextIO) -> None: - exc_info = ExceptionInfo.from_exception(e.cause) +def _print_import_error(header: str, cause: BaseException, file: TextIO) -> None: + exc_info = ExceptionInfo.from_exception(cause) tw = TerminalWriter(file) - tw.line(f"ImportError while loading conftest '{e.path}'.", red=True) - exc_info.traceback = exc_info.traceback.filter( - filter_traceback_for_conftest_import_failure - ) + tw.line(header, red=True) + exc_info.traceback = exc_info.traceback.filter(filter_traceback_for_import_failure) exc_repr = ( exc_info.getrepr(style="short", chain=False) if exc_info.traceback @@ -170,6 +178,17 @@ def print_conftest_import_error(e: ConftestImportFailure, file: TextIO) -> None: tw.line(line.rstrip(), red=True) +def print_conftest_import_error(e: ConftestImportFailure, file: TextIO) -> None: + _print_import_error( + f"ImportError while loading conftest '{e.path}'.", e.cause, file + ) + + +def print_plugin_import_error(e: PluginImportFailure, file: TextIO) -> None: + assert e.__cause__ is not None, f"{e!r} must be raised `from` the original error" + _print_import_error(f'Error while loading plugin "{e}".', e.__cause__, file) + + def print_usage_error(e: UsageError, file: TextIO) -> None: tw = TerminalWriter(file) for msg in e.args: @@ -232,6 +251,9 @@ def _main( except ConftestImportFailure as e: print_conftest_import_error(e, file=sys.stderr) return ExitCode.USAGE_ERROR + except PluginImportFailure as e: + print_plugin_import_error(e, file=sys.stderr) + return ExitCode.INTERNAL_ERROR try: 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 # testing/test_config.py::test_disable_plugin_autoload. __import__(importspec) mod = sys.modules[importspec] - except ImportError as e: - raise ImportError( - f'Error importing plugin "{modname}": {e.args[0]}' - ).with_traceback(e.__traceback__) from e - except Skipped as e: self.skipped_plugins.append((modname, e.msg or "")) + except ModuleNotFoundError as e: + if _is_missing_module(e, importspec): + # The plugin itself is nowhere to be found - pytest was pointed + # at something which does not exist, so this is a usage error. + raise UsageError(f'Error importing plugin "{modname}": {e}') from e + # Some *other* module the plugin imports is missing: the plugin was + # found, so this is a defect in the plugin, not a usage error. + raise PluginImportFailure(modname) from e + except UsageError: + raise + except Exception as e: + raise PluginImportFailure(modname) from e else: self.register(mod, modname) + def load_setuptools_entrypoints(self, group: str, name: str | None = None) -> int: + """:meta private:""" + try: + return super().load_setuptools_entrypoints(group, name=name) + except UsageError: + raise + except Exception as e: + # An installed plugin which cannot be loaded is a defect in that + # plugin - the user did nothing wrong by having it installed. + raise PluginImportFailure(name or group) from e + + +def _is_missing_module(e: ModuleNotFoundError, importspec: str) -> bool: + """Whether ``e`` means that ``importspec`` itself could not be found. + + A ``ModuleNotFoundError`` naming some other module means the plugin was + located but one of its own imports is unsatisfied. + """ + if e.name is None: + return False + # A missing parent package also means importspec cannot be found. + return e.name == importspec or importspec.startswith(f"{e.name}.") + def _get_plugin_specs_as_list( specs: types.ModuleType | str | Sequence[str] | None, diff --git a/testing/acceptance_test.py b/testing/acceptance_test.py index f941cbe1921..3983bd0007d 100644 --- a/testing/acceptance_test.py +++ b/testing/acceptance_test.py @@ -13,6 +13,7 @@ import setuptools from _pytest.config import ExitCode +from _pytest.monkeypatch import MonkeyPatch from _pytest.pathlib import symlink_or_skip from _pytest.pytester import Pytester import pytest @@ -510,9 +511,10 @@ def test_plugins_given_as_strings( ) -> None: """Test that str values passed to main() as `plugins` arg are interpreted as module names to be imported and registered (#855).""" - with pytest.raises(ImportError) as excinfo: - pytest.main([str(pytester.path)], plugins=["invalid.module"]) - assert "invalid" in str(excinfo.value) + # A plugin which cannot be found is a usage error, reported through the + # return value rather than raised out of pytest.main() (#993). + ret = pytest.main([str(pytester.path)], plugins=["invalid.module"]) + assert ret == ExitCode.USAGE_ERROR p = pytester.path.joinpath("test_test_plugins_given_as_strings.py") p.write_text("def test_foo(): pass", encoding="utf-8") @@ -1088,6 +1090,138 @@ def main(): result.stdout.no_fnmatch_line("*INTERNALERROR>*") +class TestStartupPluginImportErrors: + """Exit codes for plugins which fail to load at startup (#993). + + A plugin which cannot be found means pytest was pointed at something which + is not there, which is a usage error; a plugin which is found but blows up + while importing is a defect in the plugin, reported as an internal error. + """ + + @pytest.fixture + def broken_plugin(self, pytester: Pytester) -> Pytester: + pytester.syspathinsert() + pytester.makepyfile(myplugin="raise ValueError('plugin is broken')") + pytester.makepyfile("def test_foo(): pass") + return pytester + + @pytest.fixture + def missing_plugin(self, pytester: Pytester) -> Pytester: + pytester.syspathinsert() + pytester.makepyfile("def test_foo(): pass") + return pytester + + def test_missing_via_cmdline(self, missing_plugin: Pytester) -> None: + result = missing_plugin.runpytest("-p", "nosuchplugin") + assert result.ret == ExitCode.USAGE_ERROR + result.stderr.fnmatch_lines(['*Error importing plugin "nosuchplugin"*']) + + def test_missing_via_conftest(self, missing_plugin: Pytester) -> None: + missing_plugin.makeconftest("pytest_plugins = ['nosuchplugin']") + result = missing_plugin.runpytest() + assert result.ret == ExitCode.USAGE_ERROR + + def test_missing_via_env( + self, missing_plugin: Pytester, monkeypatch: MonkeyPatch + ) -> None: + monkeypatch.setenv("PYTEST_PLUGINS", "nosuchplugin") + result = missing_plugin.runpytest() + assert result.ret == ExitCode.USAGE_ERROR + + def test_broken_via_cmdline(self, broken_plugin: Pytester) -> None: + result = broken_plugin.runpytest("-p", "myplugin") + assert result.ret == ExitCode.INTERNAL_ERROR + result.stderr.fnmatch_lines( + [ + 'Error while loading plugin "myplugin".', + "*myplugin.py:1: in *", + "E*ValueError: plugin is broken", + ] + ) + + def test_broken_via_conftest(self, broken_plugin: Pytester) -> None: + broken_plugin.makeconftest("pytest_plugins = ['myplugin']") + result = broken_plugin.runpytest() + assert result.ret == ExitCode.INTERNAL_ERROR + + def test_broken_via_env( + self, broken_plugin: Pytester, monkeypatch: MonkeyPatch + ) -> None: + monkeypatch.setenv("PYTEST_PLUGINS", "myplugin") + result = broken_plugin.runpytest() + assert result.ret == ExitCode.INTERNAL_ERROR + + def test_usage_error_passes_through(self, pytester: Pytester) -> None: + """A plugin raising UsageError at import keeps its usage-error semantics.""" + pytester.syspathinsert() + pytester.makepyfile( + myplugin="import pytest\nraise pytest.UsageError('config trouble')" + ) + result = pytester.runpytest("-p", "myplugin") + assert result.ret == ExitCode.USAGE_ERROR + result.stderr.fnmatch_lines(["ERROR: config trouble*"]) + + def test_broken_via_entry_point( + self, pytester: Pytester, monkeypatch: MonkeyPatch + ) -> None: + monkeypatch.delenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD", raising=False) + + class DummyEntryPoint: + name = "myplugin" + group = "pytest11" + + def load(self): + raise ValueError("plugin is broken") + + class Distribution: + version = "1.0" + files = ("foo.txt",) + metadata = {"name": "foo"} + entry_points = (DummyEntryPoint(),) + + monkeypatch.setattr( + importlib.metadata, "distributions", lambda: (Distribution(),) + ) + pytester.makepyfile("def test_foo(): pass") + result = pytester.runpytest() + assert result.ret == ExitCode.INTERNAL_ERROR + + def test_import_error_without_args(self, pytester: Pytester) -> None: + """A bare ``raise ImportError`` used to crash with an IndexError (#993).""" + pytester.syspathinsert() + pytester.makepyfile(myplugin="raise ImportError") + pytester.makepyfile("def test_foo(): pass") + result = pytester.runpytest("-p", "myplugin") + assert result.ret == ExitCode.INTERNAL_ERROR + result.stderr.no_fnmatch_line("*IndexError*") + result.stderr.fnmatch_lines(['Error while loading plugin "myplugin".']) + + def test_missing_dependency_is_not_a_usage_error(self, pytester: Pytester) -> None: + """The plugin was found; one of *its* imports is unsatisfied (#993).""" + pytester.syspathinsert() + pytester.makepyfile(myplugin="import nosuchdependency") + pytester.makepyfile("def test_foo(): pass") + result = pytester.runpytest("-p", "myplugin") + assert result.ret == ExitCode.INTERNAL_ERROR + + def test_missing_submodule_of_existing_package(self, pytester: Pytester) -> None: + """The package exists but the requested plugin module within it does not.""" + pytester.syspathinsert() + pytester.mkpydir("mypkg") + pytester.makepyfile("def test_foo(): pass") + result = pytester.runpytest("-p", "mypkg.nosuchmodule") + assert result.ret == ExitCode.USAGE_ERROR + + def test_conftest_import_failure_stays_a_usage_error( + self, pytester: Pytester + ) -> None: + """conftest.py is not a plugin; it keeps reporting a usage error (#993).""" + pytester.makeconftest("raise ValueError('conftest is broken')") + pytester.makepyfile("def test_foo(): pass") + result = pytester.runpytest() + assert result.ret == ExitCode.USAGE_ERROR + + def test_import_plugin_unicode_name(pytester: Pytester) -> None: pytester.makepyfile(myplugin="") pytester.makepyfile("def test(): pass") diff --git a/testing/python/collect.py b/testing/python/collect.py index c9023f98595..dddd15b8f67 100644 --- a/testing/python/collect.py +++ b/testing/python/collect.py @@ -8,6 +8,7 @@ import _pytest._code from _pytest.config import ExitCode +from _pytest.config.exceptions import UsageError from _pytest.main import Session from _pytest.monkeypatch import MonkeyPatch from _pytest.nodes import Collector @@ -80,7 +81,7 @@ def test_syntax_error_in_module(self, pytester: Pytester) -> None: def test_module_considers_pluginmanager_at_import(self, pytester: Pytester) -> None: modcol = pytester.getmodulecol("pytest_plugins='xasdlkj',") - with pytest.raises(ImportError): + with pytest.raises(UsageError): modcol.obj() def test_invalid_test_module_name(self, pytester: Pytester) -> None: diff --git a/testing/test_config.py b/testing/test_config.py index 2a05deb3146..dad1653e299 100644 --- a/testing/test_config.py +++ b/testing/test_config.py @@ -22,6 +22,7 @@ from _pytest.config import console_main from _pytest.config import ExitCode from _pytest.config import parse_warning_filter +from _pytest.config import PluginImportFailure from _pytest.config.argparsing import get_ini_default_for_type from _pytest.config.argparsing import Parser from _pytest.config.exceptions import UsageError @@ -1858,7 +1859,35 @@ def distributions(): return (Distribution(),) monkeypatch.setattr(importlib.metadata, "distributions", distributions) - with pytest.raises(ImportError): + with pytest.raises(PluginImportFailure) as excinfo: + pytester.parseconfig() + assert "Don't hide me!" in str(excinfo.value.__cause__) + + +def test_setuptools_usage_error_passes_through( + pytester: Pytester, monkeypatch: MonkeyPatch +) -> None: + """A UsageError from an entry-point plugin is not reclassified (#993).""" + monkeypatch.delenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD", raising=False) + + class DummyEntryPoint: + name = "mytestplugin" + group = "pytest11" + + def load(self): + raise UsageError("bad usage") + + class Distribution: + version = "1.0" + files = ("foo.txt",) + metadata = {"name": "foo"} + entry_points = (DummyEntryPoint(),) + + def distributions(): + return (Distribution(),) + + monkeypatch.setattr(importlib.metadata, "distributions", distributions) + with pytest.raises(UsageError, match="bad usage"): pytester.parseconfig() diff --git a/testing/test_pluginmanager.py b/testing/test_pluginmanager.py index 70c1cde2821..03887de61d9 100644 --- a/testing/test_pluginmanager.py +++ b/testing/test_pluginmanager.py @@ -7,8 +7,11 @@ import sys import types +from _pytest._code import ExceptionInfo +from _pytest.config import _is_missing_module from _pytest.config import Config from _pytest.config import ExitCode +from _pytest.config import PluginImportFailure from _pytest.config import PytestPluginManager from _pytest.config.exceptions import UsageError from _pytest.main import Session @@ -252,13 +255,22 @@ def test_traceback(): test_traceback() """ ) - with pytest.raises(ImportError) as excinfo: + with pytest.raises(PluginImportFailure) as excinfo: pytestpm.import_plugin("qwe") - assert str(excinfo.value).endswith( - 'Error importing plugin "qwe": Not possible to import: ☺' - ) - assert "in test_traceback" in str(excinfo.traceback[-1]) + assert excinfo.value.args == ("qwe",) + # The original error and traceback must stay reachable through the cause, + # otherwise there is no way to tell where in the plugin things went wrong. + assert excinfo.value.__cause__ is not None + assert str(excinfo.value.__cause__) == "Not possible to import: ☺" + cause = ExceptionInfo.from_exception(excinfo.value.__cause__) + assert "in test_traceback" in str(cause.traceback[-1]) + + +def test_is_missing_module_without_name() -> None: + """A ModuleNotFoundError raised without a module name cannot be attributed + to the requested plugin, so it counts as a defect in the plugin.""" + assert not _is_missing_module(ModuleNotFoundError("boom"), "myplugin") class TestPytestPluginManager: @@ -322,7 +334,7 @@ def test_consider_env_fails_to_import( self, monkeypatch: MonkeyPatch, pytestpm: PytestPluginManager ) -> None: monkeypatch.setenv("PYTEST_PLUGINS", "nonexisting", prepend=",") - with pytest.raises(ImportError): + with pytest.raises(UsageError): pytestpm.consider_env() def test_consider_env_entry_point_name( @@ -458,9 +470,9 @@ def test_hello(pytestconfig): def test_import_plugin_importname( self, pytester: Pytester, pytestpm: PytestPluginManager ) -> None: - with pytest.raises(ImportError): + with pytest.raises(UsageError): pytestpm.import_plugin("qweqwex.y") - with pytest.raises(ImportError): + with pytest.raises(UsageError): pytestpm.import_plugin("pytest_qweqwx.y") pytester.syspathinsert() @@ -480,9 +492,9 @@ def test_import_plugin_importname( def test_import_plugin_dotted_name( self, pytester: Pytester, pytestpm: PytestPluginManager ) -> None: - with pytest.raises(ImportError): + with pytest.raises(UsageError): pytestpm.import_plugin("qweqwex.y") - with pytest.raises(ImportError): + with pytest.raises(UsageError): pytestpm.import_plugin("pytest_qweqwex.y") pytester.syspathinsert() @@ -503,17 +515,17 @@ def test_consider_conftest_deps( root=pytester.path, consider_namespace_packages=False, ) - with pytest.raises(ImportError): + with pytest.raises(UsageError): pytestpm.consider_conftest(mod, registration_name="unused") class TestPytestPluginManagerBootstrapping: def test_preparse_args(self, pytestpm: PytestPluginManager) -> None: - with pytest.raises(ImportError): + with pytest.raises(UsageError): pytestpm.consider_preparse(["xyz", "-p", "hello123"]) # Handles -p without space (#3532). - with pytest.raises(ImportError) as excinfo: + with pytest.raises(UsageError) as excinfo: pytestpm.consider_preparse(["-phello123"]) assert '"hello123"' in excinfo.value.args[0] pytestpm.consider_preparse(["-pno:hello123"]) diff --git a/testing/test_session.py b/testing/test_session.py index be1e66112d7..94738837831 100644 --- a/testing/test_session.py +++ b/testing/test_session.py @@ -2,6 +2,7 @@ from __future__ import annotations from _pytest.config import ExitCode +from _pytest.config.exceptions import UsageError from _pytest.monkeypatch import MonkeyPatch from _pytest.pytester import Pytester import pytest @@ -254,7 +255,7 @@ def test_minus_x_overridden_by_maxfail(self, pytester: Pytester) -> None: def test_plugin_specify(pytester: Pytester) -> None: - with pytest.raises(ImportError): + with pytest.raises(UsageError): pytester.parseconfig("-p", "nqweotexistent") # pytest.raises(ImportError, # "config.do_configure(config)" From 3e02d6b705c6dd2e8d5ae3abdfe75e7e5341a7b1 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Thu, 27 Aug 2026 00:24:11 +0200 Subject: [PATCH 2/2] Modernize ConftestImportFailure to use the exception chain Give ConftestImportFailure the same shape PluginImportFailure just got: a bare Exception subclass whose argument is the conftest path, with the original error carried by `raise ... from` on __cause__ instead of a hand-rolled `cause` attribute duplicating it. The custom __init__ and __str__ go away; str(e) is the path, which is exactly what the two message consumers want. The `cause` attribute dates to 8.0 (e1074f9c3), which replaced the old excinfo triplet without deprecation; the class is private to _pytest.config and a code search finds no external consumers, only vendored copies of pytest itself. No user-visible output changes. Co-Authored-By: Claude Fable 5 Co-Authored-By: Claude Code --- changelog/14943.misc.rst | 1 + src/_pytest/config/__init__.py | 23 ++++++++--------------- src/_pytest/debugging.py | 3 ++- src/_pytest/nodes.py | 3 ++- testing/test_config.py | 12 +++++------- 5 files changed, 18 insertions(+), 24 deletions(-) create mode 100644 changelog/14943.misc.rst diff --git a/changelog/14943.misc.rst b/changelog/14943.misc.rst new file mode 100644 index 00000000000..365d5fa6432 --- /dev/null +++ b/changelog/14943.misc.rst @@ -0,0 +1 @@ +``ConftestImportFailure`` no longer carries a ``cause`` attribute; the original error is available as ``__cause__`` via the regular exception chain. diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index 75484f99128..a0cbbf7578d 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -129,17 +129,11 @@ class ExitCode(enum.IntEnum): class ConftestImportFailure(Exception): - def __init__( - self, - path: pathlib.Path, - *, - cause: Exception, - ) -> None: - self.path = path - self.cause = cause + """A conftest.py raised while being imported. - def __str__(self) -> str: - return f"{type(self.cause).__name__}: {self.cause} (from {self.path})" + The path of the failing conftest is the exception argument; the original + error is chained as ``__cause__``. + """ class PluginImportFailure(Exception): @@ -179,9 +173,8 @@ def _print_import_error(header: str, cause: BaseException, file: TextIO) -> None def print_conftest_import_error(e: ConftestImportFailure, file: TextIO) -> None: - _print_import_error( - f"ImportError while loading conftest '{e.path}'.", e.cause, file - ) + assert e.__cause__ is not None, f"{e!r} must be raised `from` the original error" + _print_import_error(f"ImportError while loading conftest '{e}'.", e.__cause__, file) def print_plugin_import_error(e: PluginImportFailure, file: TextIO) -> None: @@ -793,7 +786,7 @@ def _importconftest( ) except Exception as e: assert e.__traceback__ is not None - raise ConftestImportFailure(conftestpath, cause=e) from e + raise ConftestImportFailure(conftestpath) from e self._check_non_top_pytest_plugins(mod, conftestpath) @@ -1748,7 +1741,7 @@ def parse(self, args: list[str], addopts: bool = True) -> None: # we don't want to prevent --help/--version to work # so just let it pass and print a warning at the end self.issue_config_time_warning( - PytestConfigWarning(f"could not load initial conftests: {e.path}"), + PytestConfigWarning(f"could not load initial conftests: {e}"), stacklevel=2, ) else: diff --git a/src/_pytest/debugging.py b/src/_pytest/debugging.py index b256f83c8bf..09121876d31 100644 --- a/src/_pytest/debugging.py +++ b/src/_pytest/debugging.py @@ -382,7 +382,8 @@ def _postmortem_exc_or_tb( elif isinstance(excinfo.value, ConftestImportFailure): # A config.ConftestImportFailure is not useful for post_mortem. # Use the underlying exception instead: - cause = excinfo.value.cause + cause = excinfo.value.__cause__ + assert cause is not None if get_exc: return cause diff --git a/src/_pytest/nodes.py b/src/_pytest/nodes.py index c6d245de6c9..2233833e038 100644 --- a/src/_pytest/nodes.py +++ b/src/_pytest/nodes.py @@ -412,7 +412,8 @@ def _repr_failure_py( from _pytest.fixtures import FixtureLookupError if isinstance(excinfo.value, ConftestImportFailure): - excinfo = ExceptionInfo.from_exception(excinfo.value.cause) + assert excinfo.value.__cause__ is not None + excinfo = ExceptionInfo.from_exception(excinfo.value.__cause__) if isinstance(excinfo.value, fail.Exception): if not excinfo.value.pytrace: style = "value" diff --git a/testing/test_config.py b/testing/test_config.py index dad1653e299..75e08a56c39 100644 --- a/testing/test_config.py +++ b/testing/test_config.py @@ -3038,17 +3038,15 @@ def test_pytest_plugins_in_non_top_level_conftest_unsupported_no_false_positives def test_conftest_import_error_repr(tmp_path: Path) -> None: - """`ConftestImportFailure` should use a short error message and readable - path to the failed conftest.py file.""" + """`ConftestImportFailure` carries the failed conftest.py path as its + argument and the original error as its cause.""" path = tmp_path.joinpath("foo/conftest.py") - with pytest.raises( - ConftestImportFailure, - match=re.escape(f"RuntimeError: some error (from {path})"), - ): + with pytest.raises(ConftestImportFailure, match=re.escape(str(path))) as excinfo: try: raise RuntimeError("some error") except Exception as exc: - raise ConftestImportFailure(path, cause=exc) from exc + raise ConftestImportFailure(path) from exc + assert str(excinfo.value.__cause__) == "some error" def test_strtobool() -> None: