From 434282e17f5f1f4fcc1464a0a0921cf19804bdd7 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Sat, 2 Mar 2024 22:43:56 +0200 Subject: [PATCH] fixtures: use exception group when multiple finalizers raise in fixture teardown Previously, if more than one fixture finalizer raised, only the first was reported, and the other errors were lost. Use an exception group to report them all. This is similar to the change we made in node teardowns (in `SetupState`). --- changelog/12047.improvement.rst | 2 ++ src/_pytest/fixtures.py | 45 ++++++++++++++++++--------------- testing/python/fixtures.py | 15 ++++++++--- 3 files changed, 38 insertions(+), 24 deletions(-) create mode 100644 changelog/12047.improvement.rst diff --git a/changelog/12047.improvement.rst b/changelog/12047.improvement.rst new file mode 100644 index 000000000..e9ad5eddc --- /dev/null +++ b/changelog/12047.improvement.rst @@ -0,0 +1,2 @@ +When multiple finalizers of a fixture raise an exception, now all exceptions are reported as an exception group. +Previously, only the first exception was reported. diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index 0a505d65a..b619dc358 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -7,6 +7,7 @@ import functools import inspect import os from pathlib import Path +import sys from typing import AbstractSet from typing import Any from typing import Callable @@ -67,6 +68,10 @@ from _pytest.scope import HIGH_SCOPES from _pytest.scope import Scope +if sys.version_info[:2] < (3, 11): + from exceptiongroup import BaseExceptionGroup + + if TYPE_CHECKING: from typing import Deque @@ -1017,27 +1022,25 @@ class FixtureDef(Generic[FixtureValue]): self._finalizers.append(finalizer) def finish(self, request: SubRequest) -> None: - exc = None - try: - while self._finalizers: - try: - func = self._finalizers.pop() - func() - except BaseException as e: - # XXX Only first exception will be seen by user, - # ideally all should be reported. - if exc is None: - exc = e - if exc: - raise exc - finally: - ihook = request.node.ihook - ihook.pytest_fixture_post_finalizer(fixturedef=self, request=request) - # Even if finalization fails, we invalidate the cached fixture - # value and remove all finalizers because they may be bound methods - # which will keep instances alive. - self.cached_result = None - self._finalizers.clear() + exceptions: List[BaseException] = [] + while self._finalizers: + fin = self._finalizers.pop() + try: + fin() + except BaseException as e: + exceptions.append(e) + node = request.node + node.ihook.pytest_fixture_post_finalizer(fixturedef=self, request=request) + # Even if finalization fails, we invalidate the cached fixture + # value and remove all finalizers because they may be bound methods + # which will keep instances alive. + self.cached_result = None + self._finalizers.clear() + if len(exceptions) == 1: + raise exceptions[0] + elif len(exceptions) > 1: + msg = f'errors while tearing down fixture "{self.argname}" of {node}' + raise BaseExceptionGroup(msg, exceptions[::-1]) def execute(self, request: SubRequest) -> FixtureValue: # Get required arguments and register our own finish() diff --git a/testing/python/fixtures.py b/testing/python/fixtures.py index 299e411a6..6edff6ecd 100644 --- a/testing/python/fixtures.py +++ b/testing/python/fixtures.py @@ -932,8 +932,9 @@ class TestRequestBasic: self, pytester: Pytester ) -> None: """ - Ensure exceptions raised during teardown by a finalizer are suppressed - until all finalizers are called, re-raising the first exception (#2440) + Ensure exceptions raised during teardown by finalizers are suppressed + until all finalizers are called, then re-reaised together in an + exception group (#2440) """ pytester.makepyfile( """ @@ -960,8 +961,16 @@ class TestRequestBasic: """ ) result = pytester.runpytest() + result.assert_outcomes(passed=2, errors=1) result.stdout.fnmatch_lines( - ["*Exception: Error in excepts fixture", "* 2 passed, 1 error in *"] + [ + ' | *ExceptionGroup: errors while tearing down fixture "subrequest" of (2 sub-exceptions)', # noqa: E501 + " +-+---------------- 1 ----------------", + " | Exception: Error in something fixture", + " +---------------- 2 ----------------", + " | Exception: Error in excepts fixture", + " +------------------------------------", + ], ) def test_request_getmodulepath(self, pytester: Pytester) -> None: