From 6365848af9a91dae36e7cf391a80b5d90e1fdbd7 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Sun, 3 Nov 2024 18:52:30 +0100 Subject: [PATCH 1/4] 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 195148a2ac8e890d80bf2f2e384a1304b8cd08b6 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Sun, 3 Nov 2024 21:37:43 +0100 Subject: [PATCH 2/4] WIP: use contextmanagers instead of yield from as it turns out, StopIteration is not transparent on the boundaries of generators --- src/_pytest/logging.py | 10 +++-- src/_pytest/threadexception.py | 18 ++++++--- src/_pytest/unraisableexception.py | 63 +++++++++++++++--------------- 3 files changed, 50 insertions(+), 41 deletions(-) diff --git a/src/_pytest/logging.py b/src/_pytest/logging.py index 08c826ff6d4..abb3bae2b17 100644 --- a/src/_pytest/logging.py +++ b/src/_pytest/logging.py @@ -811,6 +811,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 catching_logs( @@ -837,20 +838,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 c1ed80387aa..b311607e7fb 100644 --- a/src/_pytest/threadexception.py +++ b/src/_pytest/threadexception.py @@ -1,11 +1,13 @@ from __future__ import annotations +from contextlib import contextmanager 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 TYPE_CHECKING import warnings @@ -62,6 +64,7 @@ def __exit__( del self.args +@contextmanager def thread_exception_runtest_hook() -> Generator[None]: with catch_threading_exception() as cm: try: @@ -83,15 +86,18 @@ def thread_exception_runtest_hook() -> Generator[None]: @pytest.hookimpl(wrapper=True, trylast=True) -def pytest_runtest_setup() -> Generator[None]: - yield from thread_exception_runtest_hook() +def pytest_runtest_setup() -> Iterator[None]: + with thread_exception_runtest_hook(): + return (yield) @pytest.hookimpl(wrapper=True, tryfirst=True) -def pytest_runtest_call() -> Generator[None]: - yield from thread_exception_runtest_hook() +def pytest_runtest_call() -> Iterator[None]: + with thread_exception_runtest_hook(): + return (yield) @pytest.hookimpl(wrapper=True, tryfirst=True) -def pytest_runtest_teardown() -> Generator[None]: - yield from thread_exception_runtest_hook() +def pytest_runtest_teardown() -> Iterator[None]: + with thread_exception_runtest_hook(): + return (yield) diff --git a/src/_pytest/unraisableexception.py b/src/_pytest/unraisableexception.py index 77a2de20041..34804c100d0 100644 --- a/src/_pytest/unraisableexception.py +++ b/src/_pytest/unraisableexception.py @@ -5,7 +5,7 @@ from types import TracebackType from typing import Any from typing import Callable -from typing import Generator +from typing import Iterator from typing import TYPE_CHECKING import warnings @@ -38,15 +38,30 @@ class catch_unraisable_exception: # (to break a reference cycle) """ - def __init__(self) -> None: - self.unraisable: sys.UnraisableHookArgs | None = None - self._old_hook: Callable[[sys.UnraisableHookArgs], Any] | None = None + unraisable: sys.UnraisableHookArgs | None = None + _old_hook: Callable[[sys.UnraisableHookArgs], Any] | None = None def _hook(self, unraisable: sys.UnraisableHookArgs) -> None: # Storing unraisable.object can resurrect an object which is being # finalized. Storing unraisable.exc_value creates a reference cycle. self.unraisable = unraisable + 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 __enter__(self) -> Self: self._old_hook = sys.unraisablehook sys.unraisablehook = self._hook @@ -61,40 +76,24 @@ def __exit__( assert self._old_hook is not None sys.unraisablehook = self._old_hook self._old_hook = None - del self.unraisable - - -def unraisable_exception_runtest_hook() -> Generator[None]: - with catch_unraisable_exception() as cm: - try: - yield - finally: - if cm.unraisable: - if cm.unraisable.err_msg is not None: - err_msg = cm.unraisable.err_msg - else: - err_msg = "Exception ignored in" - msg = f"{err_msg}: {cm.unraisable.object!r}\n\n" - msg += "".join( - traceback.format_exception( - cm.unraisable.exc_type, - cm.unraisable.exc_value, - cm.unraisable.exc_traceback, - ) - ) - warnings.warn(pytest.PytestUnraisableExceptionWarning(msg)) + self._warn_if_triggered() + if "unraisable" in vars(self): + del self.unraisable @pytest.hookimpl(wrapper=True, tryfirst=True) -def pytest_runtest_setup() -> Generator[None]: - yield from unraisable_exception_runtest_hook() +def pytest_runtest_setup() -> Iterator[None]: + with catch_unraisable_exception(): + yield @pytest.hookimpl(wrapper=True, tryfirst=True) -def pytest_runtest_call() -> Generator[None]: - yield from unraisable_exception_runtest_hook() +def pytest_runtest_call() -> Iterator[None]: + with catch_unraisable_exception(): + yield @pytest.hookimpl(wrapper=True, tryfirst=True) -def pytest_runtest_teardown() -> Generator[None]: - yield from unraisable_exception_runtest_hook() +def pytest_runtest_teardown() -> Iterator[None]: + with catch_unraisable_exception(): + yield From f4da59b35d172343fcacd9acc77ba7e162de5c81 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 19 Dec 2024 10:45:26 +0000 Subject: [PATCH 3/4] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- testing/example_scripts/hook_exceptions/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 9d714435f63e163898a1e659e891f2dd61486c2d Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Fri, 10 Jan 2025 08:58:36 +0000 Subject: [PATCH 4/4] add tests for StopIteration processing --- testing/acceptance_test.py | 50 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/testing/acceptance_test.py b/testing/acceptance_test.py index ffd1dcce219..950b30093ae 100644 --- a/testing/acceptance_test.py +++ b/testing/acceptance_test.py @@ -1600,3 +1600,53 @@ 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 * =====================", + ] + )