From cbcf7b87fc8f701d2f93ef94459c19e2444d9112 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Thu, 27 Aug 2026 00:24:11 +0200 Subject: [PATCH] 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 60ba5f54a89..3a3a743474e 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -132,17 +132,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): @@ -182,9 +176,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) @@ -1796,7 +1789,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 2b510907122..47af905d673 100644 --- a/src/_pytest/nodes.py +++ b/src/_pytest/nodes.py @@ -434,7 +434,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 282e66409f7..6cf2ad3b848 100644 --- a/testing/test_config.py +++ b/testing/test_config.py @@ -3122,17 +3122,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: