From 040b787df8049e2ab34309347c16fa25fa93f340 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Sun, 3 Nov 2024 18:52:30 +0100 Subject: [PATCH 01/10] prepare example test for stopiteration passover issue --- .../hook_exceptions/conftest.py | 10 +++ .../hook_exceptions/pytest.ini | 0 .../hook_exceptions/test_stop_iteration.py | 87 +++++++++++++++++++ 3 files changed, 97 insertions(+) create mode 100644 testing/example_scripts/hook_exceptions/conftest.py create mode 100644 testing/example_scripts/hook_exceptions/pytest.ini create mode 100644 testing/example_scripts/hook_exceptions/test_stop_iteration.py diff --git a/testing/example_scripts/hook_exceptions/conftest.py b/testing/example_scripts/hook_exceptions/conftest.py new file mode 100644 index 00000000000..8c147db0d81 --- /dev/null +++ b/testing/example_scripts/hook_exceptions/conftest.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +from typing import Iterator + +import pytest + + +@pytest.hookimpl(wrapper=True) +def pytest_runtest_call() -> Iterator[None]: + yield diff --git a/testing/example_scripts/hook_exceptions/pytest.ini b/testing/example_scripts/hook_exceptions/pytest.ini new file mode 100644 index 00000000000..e69de29bb2d diff --git a/testing/example_scripts/hook_exceptions/test_stop_iteration.py b/testing/example_scripts/hook_exceptions/test_stop_iteration.py new file mode 100644 index 00000000000..b1af7d6423e --- /dev/null +++ b/testing/example_scripts/hook_exceptions/test_stop_iteration.py @@ -0,0 +1,87 @@ +""" +test example file exposing mltiple issues with corutine exception passover in case of stopiteration + +the stdlib contextmanager implementation explicitly catches +and reshapes in case a StopIteration was send in and is raised out +""" + +from __future__ import annotations + +from collections.abc import Iterator +from contextlib import contextmanager + +import pluggy + + +def test_stop() -> None: + raise StopIteration() + + +hookspec = pluggy.HookspecMarker("myproject") +hookimpl = pluggy.HookimplMarker("myproject") + + +class MySpec: + """A hook specification namespace.""" + + @hookspec + def myhook(self, arg1: int, arg2: int) -> int: # type: ignore[empty-body] + """My special little hook that you can customize.""" + + +class Plugin_1: + """A hook implementation namespace.""" + + @hookimpl + def myhook(self, arg1: int, arg2: int) -> int: + print("inside Plugin_1.myhook()") + raise StopIteration() + + +class Plugin_2: + """A 2nd hook implementation namespace.""" + + @hookimpl(wrapper=True) + def myhook(self) -> Iterator[None]: + return (yield) + + +def try_pluggy() -> None: + # create a manager and add the spec + pm = pluggy.PluginManager("myproject") + pm.add_hookspecs(MySpec) + + # register plugins + pm.register(Plugin_1()) + pm.register(Plugin_2()) + + # call our ``myhook`` hook + results = pm.hook.myhook(arg1=1, arg2=2) + print(results) + + +@contextmanager +def my_cm() -> Iterator[None]: + try: + yield + except Exception as e: + print(e) + raise StopIteration() + + +def inner() -> None: + with my_cm(): + raise StopIteration() + + +def try_context() -> None: + inner() + + +mains = {"pluggy": try_pluggy, "context": try_context} + +if __name__ == "__main__": + import sys + + if len(sys.argv) == 2: + mains[sys.argv[1]]() From 24b1cd8f1cbc18174810f7f57111a6dfbdc7fac2 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Sun, 3 Nov 2024 21:37:43 +0100 Subject: [PATCH 02/10] WIP: use contextmanagers instead of yield from as it turns out, StopIteration is not transparent on the boundaries of generators # Conflicts: # src/_pytest/threadexception.py # src/_pytest/unraisableexception.py --- src/_pytest/logging.py | 10 +++++++--- src/_pytest/threadexception.py | 6 ++++++ src/_pytest/unraisableexception.py | 18 +++++++++++++++++- 3 files changed, 30 insertions(+), 4 deletions(-) diff --git a/src/_pytest/logging.py b/src/_pytest/logging.py index ca5fbda6fcc..e4fed579d21 100644 --- a/src/_pytest/logging.py +++ b/src/_pytest/logging.py @@ -809,6 +809,7 @@ def pytest_runtest_logstart(self) -> None: def pytest_runtest_logreport(self) -> None: self.log_cli_handler.set_when("logreport") + @contextmanager def _runtest_for(self, item: nodes.Item, when: str) -> Generator[None]: """Implement the internals of the pytest_runtest_xxx() hooks.""" with ( @@ -838,20 +839,23 @@ def pytest_runtest_setup(self, item: nodes.Item) -> Generator[None]: empty: dict[str, list[logging.LogRecord]] = {} item.stash[caplog_records_key] = empty - yield from self._runtest_for(item, "setup") + with self._runtest_for(item, "setup"): + yield @hookimpl(wrapper=True) def pytest_runtest_call(self, item: nodes.Item) -> Generator[None]: self.log_cli_handler.set_when("call") - yield from self._runtest_for(item, "call") + with self._runtest_for(item, "call"): + yield @hookimpl(wrapper=True) def pytest_runtest_teardown(self, item: nodes.Item) -> Generator[None]: self.log_cli_handler.set_when("teardown") try: - yield from self._runtest_for(item, "teardown") + with self._runtest_for(item, "teardown"): + yield finally: del item.stash[caplog_records_key] del item.stash[caplog_handler_key] diff --git a/src/_pytest/threadexception.py b/src/_pytest/threadexception.py index eb57783be26..e5e667e59a3 100644 --- a/src/_pytest/threadexception.py +++ b/src/_pytest/threadexception.py @@ -1,11 +1,17 @@ from __future__ import annotations +from contextlib import contextmanager import collections from collections.abc import Callable import functools import sys import threading import traceback +from types import TracebackType +from typing import Any +from typing import Callable +from typing import Generator +from typing import Iterator from typing import NamedTuple from typing import TYPE_CHECKING import warnings diff --git a/src/_pytest/unraisableexception.py b/src/_pytest/unraisableexception.py index 7826aeccd12..b30cefffa5d 100644 --- a/src/_pytest/unraisableexception.py +++ b/src/_pytest/unraisableexception.py @@ -42,7 +42,23 @@ class UnraisableMeta(NamedTuple): ) -def collect_unraisable(config: Config) -> None: +def _warn_if_triggered(self) -> None: + if self.unraisable: + if self.unraisable.err_msg is not None: + err_msg = self.unraisable.err_msg + else: + err_msg = "Exception ignored in" + msg = f"{err_msg}: {self.unraisable.object!r}\n\n" + msg += "".join( + traceback.format_exception( + self.unraisable.exc_type, + self.unraisable.exc_value, + self.unraisable.exc_traceback, + ) + ) + warnings.warn(pytest.PytestUnraisableExceptionWarning(msg)) + + def collect_unraisable(config: Config) -> None: pop_unraisable = config.stash[unraisable_exceptions].pop errors: list[pytest.PytestUnraisableExceptionWarning | RuntimeError] = [] meta = None From 4f9cf8d199245fbcf4256ac02d869129eb0742ed Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Thu, 27 Mar 2025 08:26:25 +0100 Subject: [PATCH 03/10] fixup rebase --- src/_pytest/unraisableexception.py | 18 +----------------- 1 file changed, 1 insertion(+), 17 deletions(-) diff --git a/src/_pytest/unraisableexception.py b/src/_pytest/unraisableexception.py index b30cefffa5d..7826aeccd12 100644 --- a/src/_pytest/unraisableexception.py +++ b/src/_pytest/unraisableexception.py @@ -42,23 +42,7 @@ class UnraisableMeta(NamedTuple): ) -def _warn_if_triggered(self) -> None: - if self.unraisable: - if self.unraisable.err_msg is not None: - err_msg = self.unraisable.err_msg - else: - err_msg = "Exception ignored in" - msg = f"{err_msg}: {self.unraisable.object!r}\n\n" - msg += "".join( - traceback.format_exception( - self.unraisable.exc_type, - self.unraisable.exc_value, - self.unraisable.exc_traceback, - ) - ) - warnings.warn(pytest.PytestUnraisableExceptionWarning(msg)) - - def collect_unraisable(config: Config) -> None: +def collect_unraisable(config: Config) -> None: pop_unraisable = config.stash[unraisable_exceptions].pop errors: list[pytest.PytestUnraisableExceptionWarning | RuntimeError] = [] meta = None From c69bb2a0ad0f0f38422931925b725de9838345b9 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Thu, 27 Mar 2025 08:43:24 +0100 Subject: [PATCH 04/10] rebase fixup more --- src/_pytest/threadexception.py | 6 ------ testing/example_scripts/hook_exceptions/conftest.py | 2 +- 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/src/_pytest/threadexception.py b/src/_pytest/threadexception.py index e5e667e59a3..eb57783be26 100644 --- a/src/_pytest/threadexception.py +++ b/src/_pytest/threadexception.py @@ -1,17 +1,11 @@ from __future__ import annotations -from contextlib import contextmanager import collections from collections.abc import Callable import functools import sys import threading import traceback -from types import TracebackType -from typing import Any -from typing import Callable -from typing import Generator -from typing import Iterator from typing import NamedTuple from typing import TYPE_CHECKING import warnings diff --git a/testing/example_scripts/hook_exceptions/conftest.py b/testing/example_scripts/hook_exceptions/conftest.py index 8c147db0d81..40b72b85294 100644 --- a/testing/example_scripts/hook_exceptions/conftest.py +++ b/testing/example_scripts/hook_exceptions/conftest.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Iterator +from collections.abc import Iterator import pytest From 1e67ca5f295e46e4beaba6115b3f3c11f090a834 Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Sat, 31 May 2025 18:19:54 +0100 Subject: [PATCH 05/10] add tests --- testing/acceptance_test.py | 45 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/testing/acceptance_test.py b/testing/acceptance_test.py index 2d653b8e2a5..377d01314f2 100644 --- a/testing/acceptance_test.py +++ b/testing/acceptance_test.py @@ -1606,3 +1606,48 @@ def test_no_terminal_plugin(pytester: Pytester) -> None: pytester.makepyfile("def test(): assert 1 == 2") result = pytester.runpytest("-pno:terminal", "-s") assert result.ret == ExitCode.TESTS_FAILED + +def test_stop_iteration_from_collect(pytester: Pytester) -> None: + pytester.makepyfile(test_it="raise StopIteration('hello')") + result = pytester.runpytest() + assert result.ret == ExitCode.INTERRUPTED + result.assert_outcomes(failed=0, passed=0, errors=1) + result.stdout.fnmatch_lines( + [ + "=========================== short test summary info ============================", + "ERROR test_it.py - StopIteration: hello", + "!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!", + "=============================== 1 error in * ===============================", + ] + ) + + +def test_stop_iteration_runtest_protocol(pytester: Pytester) -> None: + pytester.makepyfile( + test_it=""" + import pytest + @pytest.fixture + def fail_setup(): + raise StopIteration(1) + def test_fail_setup(fail_setup): + pass + def test_fail_teardown(request): + def stop_iteration(): + raise StopIteration(2) + request.addfinalizer(stop_iteration) + def test_fail_call(): + raise StopIteration(3) + """ + ) + result = pytester.runpytest() + assert result.ret == ExitCode.TESTS_FAILED + result.assert_outcomes(failed=1, passed=1, errors=2) + result.stdout.fnmatch_lines( + [ + "=========================== short test summary info ============================", + "FAILED test_it.py::test_fail_call - StopIteration: 3", + "ERROR test_it.py::test_fail_setup - StopIteration: 1", + "ERROR test_it.py::test_fail_teardown - StopIteration: 2", + "==================== 1 failed, 1 passed, 2 errors in * =====================", + ] + ) From 1701ce109bbbcc5929b9587735ae7230edb71b3e Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 31 May 2025 17:20:21 +0000 Subject: [PATCH 06/10] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- testing/acceptance_test.py | 1 + 1 file changed, 1 insertion(+) diff --git a/testing/acceptance_test.py b/testing/acceptance_test.py index 377d01314f2..5d29880c0af 100644 --- a/testing/acceptance_test.py +++ b/testing/acceptance_test.py @@ -1607,6 +1607,7 @@ def test_no_terminal_plugin(pytester: Pytester) -> None: result = pytester.runpytest("-pno:terminal", "-s") assert result.ret == ExitCode.TESTS_FAILED + def test_stop_iteration_from_collect(pytester: Pytester) -> None: pytester.makepyfile(test_it="raise StopIteration('hello')") result = pytester.runpytest() From d6d20a5c8ea696bc33a1b52700e2e43d68e34a5b Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Sat, 31 May 2025 18:20:59 +0100 Subject: [PATCH 07/10] remove hook_exceptions examples - these are tests now --- .../hook_exceptions/conftest.py | 10 --- .../hook_exceptions/pytest.ini | 0 .../hook_exceptions/test_stop_iteration.py | 87 ------------------- 3 files changed, 97 deletions(-) delete mode 100644 testing/example_scripts/hook_exceptions/conftest.py delete mode 100644 testing/example_scripts/hook_exceptions/pytest.ini delete mode 100644 testing/example_scripts/hook_exceptions/test_stop_iteration.py diff --git a/testing/example_scripts/hook_exceptions/conftest.py b/testing/example_scripts/hook_exceptions/conftest.py deleted file mode 100644 index 40b72b85294..00000000000 --- a/testing/example_scripts/hook_exceptions/conftest.py +++ /dev/null @@ -1,10 +0,0 @@ -from __future__ import annotations - -from collections.abc import Iterator - -import pytest - - -@pytest.hookimpl(wrapper=True) -def pytest_runtest_call() -> Iterator[None]: - yield diff --git a/testing/example_scripts/hook_exceptions/pytest.ini b/testing/example_scripts/hook_exceptions/pytest.ini deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/testing/example_scripts/hook_exceptions/test_stop_iteration.py b/testing/example_scripts/hook_exceptions/test_stop_iteration.py deleted file mode 100644 index b1af7d6423e..00000000000 --- a/testing/example_scripts/hook_exceptions/test_stop_iteration.py +++ /dev/null @@ -1,87 +0,0 @@ -""" -test example file exposing mltiple issues with corutine exception passover in case of stopiteration - -the stdlib contextmanager implementation explicitly catches -and reshapes in case a StopIteration was send in and is raised out -""" - -from __future__ import annotations - -from collections.abc import Iterator -from contextlib import contextmanager - -import pluggy - - -def test_stop() -> None: - raise StopIteration() - - -hookspec = pluggy.HookspecMarker("myproject") -hookimpl = pluggy.HookimplMarker("myproject") - - -class MySpec: - """A hook specification namespace.""" - - @hookspec - def myhook(self, arg1: int, arg2: int) -> int: # type: ignore[empty-body] - """My special little hook that you can customize.""" - - -class Plugin_1: - """A hook implementation namespace.""" - - @hookimpl - def myhook(self, arg1: int, arg2: int) -> int: - print("inside Plugin_1.myhook()") - raise StopIteration() - - -class Plugin_2: - """A 2nd hook implementation namespace.""" - - @hookimpl(wrapper=True) - def myhook(self) -> Iterator[None]: - return (yield) - - -def try_pluggy() -> None: - # create a manager and add the spec - pm = pluggy.PluginManager("myproject") - pm.add_hookspecs(MySpec) - - # register plugins - pm.register(Plugin_1()) - pm.register(Plugin_2()) - - # call our ``myhook`` hook - results = pm.hook.myhook(arg1=1, arg2=2) - print(results) - - -@contextmanager -def my_cm() -> Iterator[None]: - try: - yield - except Exception as e: - print(e) - raise StopIteration() - - -def inner() -> None: - with my_cm(): - raise StopIteration() - - -def try_context() -> None: - inner() - - -mains = {"pluggy": try_pluggy, "context": try_context} - -if __name__ == "__main__": - import sys - - if len(sys.argv) == 2: - mains[sys.argv[1]]() From e6211c217d7f4b718c3ea459dd056da079132548 Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Sat, 31 May 2025 18:22:56 +0100 Subject: [PATCH 08/10] add changelog --- changelog/12929.bugfix.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog/12929.bugfix.rst diff --git a/changelog/12929.bugfix.rst b/changelog/12929.bugfix.rst new file mode 100644 index 00000000000..fcf490d83e2 --- /dev/null +++ b/changelog/12929.bugfix.rst @@ -0,0 +1 @@ +Handle StopIteration from test cases, setup and teardown correctly. From a617f94d3928a8879952e41d6625da406202c991 Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Sat, 31 May 2025 18:36:17 +0100 Subject: [PATCH 09/10] handle different numbers of === --- testing/acceptance_test.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/testing/acceptance_test.py b/testing/acceptance_test.py index 5d29880c0af..f640bc35db2 100644 --- a/testing/acceptance_test.py +++ b/testing/acceptance_test.py @@ -1615,10 +1615,10 @@ def test_stop_iteration_from_collect(pytester: Pytester) -> None: result.assert_outcomes(failed=0, passed=0, errors=1) result.stdout.fnmatch_lines( [ - "=========================== short test summary info ============================", + "=* short test summary info =*", "ERROR test_it.py - StopIteration: hello", "!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!", - "=============================== 1 error in * ===============================", + "=* 1 error in * =*", ] ) @@ -1645,10 +1645,10 @@ def test_fail_call(): result.assert_outcomes(failed=1, passed=1, errors=2) result.stdout.fnmatch_lines( [ - "=========================== short test summary info ============================", + "=* short test summary info =*", "FAILED test_it.py::test_fail_call - StopIteration: 3", "ERROR test_it.py::test_fail_setup - StopIteration: 1", "ERROR test_it.py::test_fail_teardown - StopIteration: 2", - "==================== 1 failed, 1 passed, 2 errors in * =====================", + "=* 1 failed, 1 passed, 2 errors in * =*", ] ) From 7e3f5e1868b85db84e7d53c0bb9b8c054a37d21b Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Sat, 31 May 2025 18:45:32 +0100 Subject: [PATCH 10/10] handle different numbers of !!! --- testing/acceptance_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/testing/acceptance_test.py b/testing/acceptance_test.py index f640bc35db2..4948e3ff8ae 100644 --- a/testing/acceptance_test.py +++ b/testing/acceptance_test.py @@ -1617,7 +1617,7 @@ def test_stop_iteration_from_collect(pytester: Pytester) -> None: [ "=* short test summary info =*", "ERROR test_it.py - StopIteration: hello", - "!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!", + "!* Interrupted: 1 error during collection !*", "=* 1 error in * =*", ] )