From 5df76411076db978a7e4e23513e27ad8212dbf28 Mon Sep 17 00:00:00 2001 From: Aleksandr Brodin Date: Tue, 26 Mar 2024 11:17:59 +0700 Subject: [PATCH 1/4] add exception spec to pytest_fixture_post_finalizer hook --- src/_pytest/fixtures.py | 18 ++++++++---- src/_pytest/hookspec.py | 6 +++- testing/python/fixtures.py | 57 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 74 insertions(+), 7 deletions(-) diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index 5303948c4..03b042127 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -1023,17 +1023,23 @@ class FixtureDef(Generic[FixtureValue]): except BaseException as e: exceptions.append(e) node = request.node - node.ihook.pytest_fixture_post_finalizer(fixturedef=self, request=request) + if len(exceptions) == 1: + final_exception = exceptions[0] + elif len(exceptions) > 1: + msg = f'errors while tearing down fixture "{self.argname}" of {node}' + final_exception = BaseExceptionGroup(msg, exceptions[::-1]) + else: + final_exception = None + node.ihook.pytest_fixture_post_finalizer( + fixturedef=self, request=request, exception=final_exception + ) # 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]) + if final_exception: + raise final_exception def execute(self, request: SubRequest) -> FixtureValue: """Return the value of this fixture, executing it if not cached.""" diff --git a/src/_pytest/hookspec.py b/src/_pytest/hookspec.py index db55bd82d..ad01893bc 100644 --- a/src/_pytest/hookspec.py +++ b/src/_pytest/hookspec.py @@ -861,7 +861,9 @@ def pytest_fixture_setup( def pytest_fixture_post_finalizer( - fixturedef: "FixtureDef[Any]", request: "SubRequest" + fixturedef: "FixtureDef[Any]", + request: "SubRequest", + exception: "BaseException | None", ) -> None: """Called after fixture teardown, but before the cache is cleared, so the fixture result ``fixturedef.cached_result`` is still available (not @@ -871,6 +873,8 @@ def pytest_fixture_post_finalizer( The fixture definition object. :param request: The fixture request object. + :param exception: + The list of exceptions received at the end of the fixtures. Use in conftest plugins ======================= diff --git a/testing/python/fixtures.py b/testing/python/fixtures.py index 12ca6e926..2fe35800f 100644 --- a/testing/python/fixtures.py +++ b/testing/python/fixtures.py @@ -4014,6 +4014,63 @@ def test_pytest_fixture_setup_and_post_finalizer_hook(pytester: Pytester) -> Non ) +def test_exceptions_in_pytest_fixture_setup_and_post_finalizer_hook( + pytester: Pytester, +) -> None: + pytester.makeconftest( + """ + import pytest + @pytest.hookimpl(hookwrapper=True) + def pytest_fixture_setup(fixturedef): + result = yield + print('SETUP EXCEPTION in {0}: {1}'.format(fixturedef.argname, result.exception)) + def pytest_fixture_post_finalizer(fixturedef, exception): + print('TEARDOWN EXCEPTION in {0}: {1}'.format(fixturedef.argname, exception)) + """ + ) + pytester.makepyfile( + **{ + "tests/test_fixture_exceptions.py": """ + import pytest + + @pytest.fixture(scope='module') + def module_teardown_exeption(): + yield + raise ValueError('exeption in module_teardown_exeption') + + @pytest.fixture() + def func_teardown_exeption(): + yield + raise ValueError('exeption in func_teardown_exeption') + + @pytest.fixture() + def func_setup_exeption(): + raise ValueError('exeption in func_setup_exeption') + + @pytest.mark.usefixtures( + 'module_teardown_exeption', + 'func_teardown_exeption', + 'func_setup_exeption', + ) + def test_func(): + pass + """, + } + ) + result = pytester.runpytest("-s") + assert result.ret == 1 + result.stdout.fnmatch_lines( + [ + "*SETUP EXCEPTION in module_teardown_exeption: None*", + "*SETUP EXCEPTION in func_teardown_exeption: None*", + "*SETUP EXCEPTION in func_setup_exeption: exeption in func_setup_exeption*", + "*TEARDOWN EXCEPTION in func_setup_exeption: None*", + "*TEARDOWN EXCEPTION in func_teardown_exeption: exeption in func_teardown_exeption*", + "*TEARDOWN EXCEPTION in module_teardown_exeption: exeption in module_teardown_exeption*", + ] + ) + + class TestScopeOrdering: """Class of tests that ensure fixtures are ordered based on their scopes (#2405)""" From 108f99086d59bae49b7bbe29d972133c569898c1 Mon Sep 17 00:00:00 2001 From: Aleksandr Brodin Date: Tue, 26 Mar 2024 15:38:24 +0700 Subject: [PATCH 2/4] update exception spec description --- src/_pytest/hookspec.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/_pytest/hookspec.py b/src/_pytest/hookspec.py index ad01893bc..221c0c8a8 100644 --- a/src/_pytest/hookspec.py +++ b/src/_pytest/hookspec.py @@ -874,7 +874,7 @@ def pytest_fixture_post_finalizer( :param request: The fixture request object. :param exception: - The list of exceptions received at the end of the fixtures. + An exception raised in the finalisation of the fixtures. Use in conftest plugins ======================= From 86a3ed6ae4c969cdb423b7c31fd79194061a8996 Mon Sep 17 00:00:00 2001 From: Aleksandr Brodin Date: Wed, 27 Mar 2024 10:13:11 +0700 Subject: [PATCH 3/4] move fixture finalizing to standalone hook --- src/_pytest/fixtures.py | 49 +++++++++++++++++++------------------- src/_pytest/hookspec.py | 22 ++++++++++++++--- testing/python/fixtures.py | 8 ++++--- 3 files changed, 49 insertions(+), 30 deletions(-) diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index 03b042127..f94bbcd26 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -1015,31 +1015,8 @@ class FixtureDef(Generic[FixtureValue]): self._finalizers.append(finalizer) def finish(self, request: SubRequest) -> None: - exceptions: List[BaseException] = [] - while self._finalizers: - fin = self._finalizers.pop() - try: - fin() - except BaseException as e: - exceptions.append(e) node = request.node - if len(exceptions) == 1: - final_exception = exceptions[0] - elif len(exceptions) > 1: - msg = f'errors while tearing down fixture "{self.argname}" of {node}' - final_exception = BaseExceptionGroup(msg, exceptions[::-1]) - else: - final_exception = None - node.ihook.pytest_fixture_post_finalizer( - fixturedef=self, request=request, exception=final_exception - ) - # 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 final_exception: - raise final_exception + node.ihook.pytest_fixture_teardown(fixturedef=self, request=request) def execute(self, request: SubRequest) -> FixtureValue: """Return the value of this fixture, executing it if not cached.""" @@ -1151,6 +1128,30 @@ def pytest_fixture_setup( return result +def pytest_fixture_teardown( + fixturedef: FixtureDef[FixtureValue], request: SubRequest +) -> None: + exceptions: List[BaseException] = [] + while fixturedef._finalizers: + fin = fixturedef._finalizers.pop() + try: + fin() + except BaseException as e: + exceptions.append(e) + node = request.node + node.ihook.pytest_fixture_post_finalizer(fixturedef=fixturedef, 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. + fixturedef.cached_result = None + fixturedef._finalizers.clear() + if len(exceptions) == 1: + raise exceptions[0] + elif len(exceptions) > 1: + msg = f'errors while tearing down fixture "{fixturedef.argname}" of {node}' + raise BaseExceptionGroup(msg, exceptions[::-1]) + + def wrap_function_to_error_out_if_called_directly( function: FixtureFunction, fixture_marker: "FixtureFunctionMarker", diff --git a/src/_pytest/hookspec.py b/src/_pytest/hookspec.py index 221c0c8a8..6df64b9b0 100644 --- a/src/_pytest/hookspec.py +++ b/src/_pytest/hookspec.py @@ -860,10 +860,28 @@ def pytest_fixture_setup( """ +def pytest_fixture_teardown( + fixturedef: "FixtureDef[Any]", request: "SubRequest" +) -> None: + """Perform fixture teardown execution. + + :param fixturdef: + The fixture definition object. + :param request: + The fixture request object. + + Use in conftest plugins + ======================= + + Any conftest file can implement this hook. For a given fixture, only + conftest files in the fixture scope's directory and its parent directories + are consulted. + """ + + def pytest_fixture_post_finalizer( fixturedef: "FixtureDef[Any]", request: "SubRequest", - exception: "BaseException | None", ) -> None: """Called after fixture teardown, but before the cache is cleared, so the fixture result ``fixturedef.cached_result`` is still available (not @@ -873,8 +891,6 @@ def pytest_fixture_post_finalizer( The fixture definition object. :param request: The fixture request object. - :param exception: - An exception raised in the finalisation of the fixtures. Use in conftest plugins ======================= diff --git a/testing/python/fixtures.py b/testing/python/fixtures.py index 2fe35800f..06c9c2c1f 100644 --- a/testing/python/fixtures.py +++ b/testing/python/fixtures.py @@ -4014,7 +4014,7 @@ def test_pytest_fixture_setup_and_post_finalizer_hook(pytester: Pytester) -> Non ) -def test_exceptions_in_pytest_fixture_setup_and_post_finalizer_hook( +def test_exceptions_in_pytest_fixture_setup_and_pytest_fixture_teardown( pytester: Pytester, ) -> None: pytester.makeconftest( @@ -4024,8 +4024,10 @@ def test_exceptions_in_pytest_fixture_setup_and_post_finalizer_hook( def pytest_fixture_setup(fixturedef): result = yield print('SETUP EXCEPTION in {0}: {1}'.format(fixturedef.argname, result.exception)) - def pytest_fixture_post_finalizer(fixturedef, exception): - print('TEARDOWN EXCEPTION in {0}: {1}'.format(fixturedef.argname, exception)) + @pytest.hookimpl(hookwrapper=True) + def pytest_fixture_teardown(fixturedef): + result = yield + print('TEARDOWN EXCEPTION in {0}: {1}'.format(fixturedef.argname, result.exception)) """ ) pytester.makepyfile( From 28169e96bf7da5e09110b298ffd3563b420c5ab4 Mon Sep 17 00:00:00 2001 From: Aleksandr Brodin Date: Wed, 10 Apr 2024 17:28:07 +0700 Subject: [PATCH 4/4] update hooks reference --- doc/en/reference/reference.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/doc/en/reference/reference.rst b/doc/en/reference/reference.rst index c9d7aeb55..c8abff682 100644 --- a/doc/en/reference/reference.rst +++ b/doc/en/reference/reference.rst @@ -771,6 +771,8 @@ Session related reporting hooks: .. autofunction:: pytest_terminal_summary .. hook:: pytest_fixture_setup .. autofunction:: pytest_fixture_setup +.. hook:: pytest_fixture_teardown +.. autofunction:: pytest_fixture_teardown .. hook:: pytest_fixture_post_finalizer .. autofunction:: pytest_fixture_post_finalizer .. hook:: pytest_warning_recorded