From 85594a787147a9aa03267734572add4ac02bbc58 Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Wed, 13 Nov 2024 11:51:44 +0000 Subject: [PATCH 01/33] unraisablehook enhancements --- src/_pytest/unraisableexception.py | 137 +++++++++++++++-------------- 1 file changed, 70 insertions(+), 67 deletions(-) diff --git a/src/_pytest/unraisableexception.py b/src/_pytest/unraisableexception.py index 77a2de20041..570610ed2eb 100644 --- a/src/_pytest/unraisableexception.py +++ b/src/_pytest/unraisableexception.py @@ -1,88 +1,91 @@ from __future__ import annotations +import collections +import functools +import gc import sys import traceback -from types import TracebackType -from typing import Any from typing import Callable from typing import Generator from typing import TYPE_CHECKING import warnings +from _pytest.config import Config import pytest if TYPE_CHECKING: - from typing_extensions import Self - - -# Copied from cpython/Lib/test/support/__init__.py, with modifications. -class catch_unraisable_exception: - """Context manager catching unraisable exception using sys.unraisablehook. - - Storing the exception value (cm.unraisable.exc_value) creates a reference - cycle. The reference cycle is broken explicitly when the context manager - exits. - - Storing the object (cm.unraisable.object) can resurrect it if it is set to - an object which is being finalized. Exiting the context manager clears the - stored object. - - Usage: - with catch_unraisable_exception() as cm: - # code creating an "unraisable exception" - ... - # check the unraisable exception: use cm.unraisable - ... - # cm.unraisable attribute no longer exists at this point - # (to break a reference cycle) - """ - - def __init__(self) -> None: - self.unraisable: sys.UnraisableHookArgs | None = None - self._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 __enter__(self) -> Self: - self._old_hook = sys.unraisablehook - sys.unraisablehook = self._hook - return self - - def __exit__( - self, - exc_type: type[BaseException] | None, - exc_val: BaseException | None, - exc_tb: TracebackType | None, - ) -> None: - assert self._old_hook is not None - sys.unraisablehook = self._old_hook - self._old_hook = None - del self.unraisable + pass + +if sys.version_info < (3, 11): + from exceptiongroup import ExceptionGroup 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, - ) + try: + yield + finally: + collect_unraisable() + + +_unraisable_exceptions: collections.deque[tuple[str, sys.UnraisableHookArgs]] = ( + collections.deque() +) + + +def collect_unraisable() -> None: + errors = [] + unraisable = None + try: + while True: + try: + object_repr, unraisable = _unraisable_exceptions.pop() + except IndexError: + break + + if unraisable.err_msg is not None: + err_msg = unraisable.err_msg + else: + err_msg = "Exception ignored in" + msg = f"{err_msg}: {object_repr}\n\n" + msg += "".join( + traceback.format_exception( + unraisable.exc_type, + unraisable.exc_value, + unraisable.exc_traceback, ) + ) + try: warnings.warn(pytest.PytestUnraisableExceptionWarning(msg)) + except pytest.PytestUnraisableExceptionWarning as e: + e.__cause__ = unraisable.exc_value + errors.append(e) + + if len(errors) == 1: + raise errors[0] + else: + raise ExceptionGroup("multiple unraisable exception warnings", errors) + finally: + del errors, unraisable + + +def _cleanup(prev_hook: Callable[[sys.UnraisableHookArgs], object]) -> None: + try: + for i in range(5): + gc.collect() + collect_unraisable() + finally: + sys.unraisablehook = prev_hook + + +def unraisable_hook(unraisable: sys.UnraisableHookArgs) -> None: + _unraisable_exceptions.append((repr(unraisable.object), unraisable)) + + +def pytest_configure(config: Config) -> None: + prev_hook = sys.unraisablehook + config.add_cleanup(functools.partial(_cleanup, prev_hook)) + sys.unraisablehook = unraisable_hook @pytest.hookimpl(wrapper=True, tryfirst=True) From b79f652c624657741c50d27983e6a6fbcb0dc164 Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Wed, 13 Nov 2024 11:55:14 +0000 Subject: [PATCH 02/33] only raise errors if there are any --- src/_pytest/unraisableexception.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/_pytest/unraisableexception.py b/src/_pytest/unraisableexception.py index 570610ed2eb..3d4f8677772 100644 --- a/src/_pytest/unraisableexception.py +++ b/src/_pytest/unraisableexception.py @@ -63,7 +63,7 @@ def collect_unraisable() -> None: if len(errors) == 1: raise errors[0] - else: + if errors: raise ExceptionGroup("multiple unraisable exception warnings", errors) finally: del errors, unraisable From 194f9ec64767850123fb61454d71fdae087ec314 Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Wed, 13 Nov 2024 12:07:44 +0000 Subject: [PATCH 03/33] cause the traceback to only show up once --- src/_pytest/unraisableexception.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/_pytest/unraisableexception.py b/src/_pytest/unraisableexception.py index 3d4f8677772..398d1bd4f3d 100644 --- a/src/_pytest/unraisableexception.py +++ b/src/_pytest/unraisableexception.py @@ -48,7 +48,7 @@ def collect_unraisable() -> None: else: err_msg = "Exception ignored in" msg = f"{err_msg}: {object_repr}\n\n" - msg += "".join( + traceback_message = msg + "".join( traceback.format_exception( unraisable.exc_type, unraisable.exc_value, @@ -56,8 +56,14 @@ def collect_unraisable() -> None: ) ) try: - warnings.warn(pytest.PytestUnraisableExceptionWarning(msg)) + warnings.warn( + pytest.PytestUnraisableExceptionWarning(traceback_message) + ) except pytest.PytestUnraisableExceptionWarning as e: + # exceptions have a better way to show the traceback, but + # warnings do not, so hide the traceback from the msg and + # set the cause so the traceback shows up in the right place + e.args = (msg,) e.__cause__ = unraisable.exc_value errors.append(e) From c78f84b88ed4fea7f5cc782fc1173ec23975df93 Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Wed, 13 Nov 2024 12:37:08 +0000 Subject: [PATCH 04/33] include tracemalloc message in unraisablehook failures --- src/_pytest/unraisableexception.py | 73 +++++++++++++++++++++++------- 1 file changed, 56 insertions(+), 17 deletions(-) diff --git a/src/_pytest/unraisableexception.py b/src/_pytest/unraisableexception.py index 398d1bd4f3d..6f1214b8fce 100644 --- a/src/_pytest/unraisableexception.py +++ b/src/_pytest/unraisableexception.py @@ -7,6 +7,7 @@ import traceback from typing import Callable from typing import Generator +from typing import NamedTuple from typing import TYPE_CHECKING import warnings @@ -21,6 +22,35 @@ from exceptiongroup import ExceptionGroup +def _tracemalloc_msg(source: object) -> str: + if source is None: + return "" + + try: + import tracemalloc + except ImportError: + return "" + + tb = tracemalloc.get_object_traceback(source) + if tb is not None: + formatted_tb = "\n".join(tb.format()) + # Use a leading new line to better separate the (large) output + # from the traceback to the previous warning text. + return f"\nObject allocated at:\n{formatted_tb}" + # No need for a leading new line. + url = "https://docs.pytest.org/en/stable/how-to/capture-warnings.html#resource-warnings" + return ( + "Enable tracemalloc to get traceback where the object was allocated.\n" + f"See {url} for more info." + ) + + +class UnraisableMeta(NamedTuple): + object_repr: str + tracemalloc_tb: str + unraisable: sys.UnraisableHookArgs + + def unraisable_exception_runtest_hook() -> Generator[None]: try: yield @@ -28,43 +58,43 @@ def unraisable_exception_runtest_hook() -> Generator[None]: collect_unraisable() -_unraisable_exceptions: collections.deque[tuple[str, sys.UnraisableHookArgs]] = ( - collections.deque() -) +_unraisable_exceptions: collections.deque[UnraisableMeta] = collections.deque() def collect_unraisable() -> None: errors = [] - unraisable = None + meta = None try: while True: try: - object_repr, unraisable = _unraisable_exceptions.pop() + meta = _unraisable_exceptions.pop() except IndexError: break - if unraisable.err_msg is not None: - err_msg = unraisable.err_msg + if meta.unraisable.err_msg is not None: + err_msg = meta.unraisable.err_msg else: err_msg = "Exception ignored in" - msg = f"{err_msg}: {object_repr}\n\n" - traceback_message = msg + "".join( + msg = f"{err_msg}: {meta.object_repr}" + traceback_message = "\n\n" + "".join( traceback.format_exception( - unraisable.exc_type, - unraisable.exc_value, - unraisable.exc_traceback, + meta.unraisable.exc_type, + meta.unraisable.exc_value, + meta.unraisable.exc_traceback, ) ) try: warnings.warn( - pytest.PytestUnraisableExceptionWarning(traceback_message) + pytest.PytestUnraisableExceptionWarning( + msg + traceback_message + meta.tracemalloc_tb + ) ) except pytest.PytestUnraisableExceptionWarning as e: # exceptions have a better way to show the traceback, but # warnings do not, so hide the traceback from the msg and # set the cause so the traceback shows up in the right place - e.args = (msg,) - e.__cause__ = unraisable.exc_value + e.args = (msg + meta.tracemalloc_tb,) + e.__cause__ = meta.unraisable.exc_value errors.append(e) if len(errors) == 1: @@ -72,7 +102,7 @@ def collect_unraisable() -> None: if errors: raise ExceptionGroup("multiple unraisable exception warnings", errors) finally: - del errors, unraisable + del errors, meta def _cleanup(prev_hook: Callable[[sys.UnraisableHookArgs], object]) -> None: @@ -85,7 +115,16 @@ def _cleanup(prev_hook: Callable[[sys.UnraisableHookArgs], object]) -> None: def unraisable_hook(unraisable: sys.UnraisableHookArgs) -> None: - _unraisable_exceptions.append((repr(unraisable.object), unraisable)) + _unraisable_exceptions.append( + UnraisableMeta( + # we need to compute these strings here as they might change after + # the unraisablehook finishes and before the unraisable object is + # collected by a hook + object_repr=repr(unraisable.object), + tracemalloc_tb=_tracemalloc_msg(unraisable.object), + unraisable=unraisable, + ) + ) def pytest_configure(config: Config) -> None: From 7f30217514867e58525634460fd1e1c5905d1cda Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Wed, 13 Nov 2024 14:14:41 +0000 Subject: [PATCH 05/33] fix tests --- src/_pytest/unraisableexception.py | 11 ++++------- testing/test_unraisableexception.py | 9 ++++++--- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/_pytest/unraisableexception.py b/src/_pytest/unraisableexception.py index 6f1214b8fce..376f6ea372f 100644 --- a/src/_pytest/unraisableexception.py +++ b/src/_pytest/unraisableexception.py @@ -75,7 +75,7 @@ def collect_unraisable() -> None: err_msg = meta.unraisable.err_msg else: err_msg = "Exception ignored in" - msg = f"{err_msg}: {meta.object_repr}" + summary = f"{err_msg}: {meta.object_repr}" traceback_message = "\n\n" + "".join( traceback.format_exception( meta.unraisable.exc_type, @@ -83,17 +83,14 @@ def collect_unraisable() -> None: meta.unraisable.exc_traceback, ) ) + msg = summary + traceback_message + meta.tracemalloc_tb try: - warnings.warn( - pytest.PytestUnraisableExceptionWarning( - msg + traceback_message + meta.tracemalloc_tb - ) - ) + warnings.warn(pytest.PytestUnraisableExceptionWarning(msg)) except pytest.PytestUnraisableExceptionWarning as e: # exceptions have a better way to show the traceback, but # warnings do not, so hide the traceback from the msg and # set the cause so the traceback shows up in the right place - e.args = (msg + meta.tracemalloc_tb,) + e.args = (summary + meta.tracemalloc_tb,) e.__cause__ = meta.unraisable.exc_value errors.append(e) diff --git a/testing/test_unraisableexception.py b/testing/test_unraisableexception.py index a15c754d067..84f89a42628 100644 --- a/testing/test_unraisableexception.py +++ b/testing/test_unraisableexception.py @@ -36,7 +36,8 @@ def test_2(): pass " ", " Traceback (most recent call last):", " ValueError: del is broken", - " ", + " Enable tracemalloc to get traceback where the object was allocated.", + " See https* for more info.", " warnings.warn(pytest.PytestUnraisableExceptionWarning(msg))", ] ) @@ -73,7 +74,8 @@ def test_2(): pass " ", " Traceback (most recent call last):", " ValueError: del is broken", - " ", + " Enable tracemalloc to get traceback where the object was allocated.", + " See https* for more info.", " warnings.warn(pytest.PytestUnraisableExceptionWarning(msg))", ] ) @@ -111,7 +113,8 @@ def test_2(): pass " ", " Traceback (most recent call last):", " ValueError: del is broken", - " ", + " Enable tracemalloc to get traceback where the object was allocated.", + " See https* for more info.", " warnings.warn(pytest.PytestUnraisableExceptionWarning(msg))", ] ) From d2045ceefc8346551e87c212d69f9f0cb96aeb4f Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Wed, 13 Nov 2024 14:28:47 +0000 Subject: [PATCH 06/33] avoid keeping unraisable.object alive while waiting for collection --- src/_pytest/unraisableexception.py | 46 ++++++++++++++++-------------- 1 file changed, 25 insertions(+), 21 deletions(-) diff --git a/src/_pytest/unraisableexception.py b/src/_pytest/unraisableexception.py index 376f6ea372f..5121896ffc5 100644 --- a/src/_pytest/unraisableexception.py +++ b/src/_pytest/unraisableexception.py @@ -46,9 +46,9 @@ def _tracemalloc_msg(source: object) -> str: class UnraisableMeta(NamedTuple): - object_repr: str - tracemalloc_tb: str - unraisable: sys.UnraisableHookArgs + msg: str + cause_msg: str + exc_value: BaseException | None def unraisable_exception_runtest_hook() -> Generator[None]: @@ -71,27 +71,15 @@ def collect_unraisable() -> None: except IndexError: break - if meta.unraisable.err_msg is not None: - err_msg = meta.unraisable.err_msg - else: - err_msg = "Exception ignored in" - summary = f"{err_msg}: {meta.object_repr}" - traceback_message = "\n\n" + "".join( - traceback.format_exception( - meta.unraisable.exc_type, - meta.unraisable.exc_value, - meta.unraisable.exc_traceback, - ) - ) - msg = summary + traceback_message + meta.tracemalloc_tb + msg = meta.msg try: warnings.warn(pytest.PytestUnraisableExceptionWarning(msg)) except pytest.PytestUnraisableExceptionWarning as e: # exceptions have a better way to show the traceback, but # warnings do not, so hide the traceback from the msg and # set the cause so the traceback shows up in the right place - e.args = (summary + meta.tracemalloc_tb,) - e.__cause__ = meta.unraisable.exc_value + e.args = (meta.cause_msg,) + e.__cause__ = meta.exc_value errors.append(e) if len(errors) == 1: @@ -112,14 +100,30 @@ def _cleanup(prev_hook: Callable[[sys.UnraisableHookArgs], object]) -> None: def unraisable_hook(unraisable: sys.UnraisableHookArgs) -> None: + if unraisable.err_msg is not None: + err_msg = unraisable.err_msg + else: + err_msg = "Exception ignored in" + summary = f"{err_msg}: {unraisable.object!r}" + traceback_message = "\n\n" + "".join( + traceback.format_exception( + unraisable.exc_type, + unraisable.exc_value, + unraisable.exc_traceback, + ) + ) + tracemalloc_tb = _tracemalloc_msg(unraisable.object) + msg = summary + traceback_message + tracemalloc_tb + cause_msg = summary + tracemalloc_tb + _unraisable_exceptions.append( UnraisableMeta( # we need to compute these strings here as they might change after # the unraisablehook finishes and before the unraisable object is # collected by a hook - object_repr=repr(unraisable.object), - tracemalloc_tb=_tracemalloc_msg(unraisable.object), - unraisable=unraisable, + msg=msg, + cause_msg=cause_msg, + exc_value=unraisable.exc_value, ) ) From 7e7c965b52d05d10ce9e30734774109a4eb4381f Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Wed, 13 Nov 2024 14:29:39 +0000 Subject: [PATCH 07/33] use the formatted traceback if there's no exc_value --- src/_pytest/unraisableexception.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/_pytest/unraisableexception.py b/src/_pytest/unraisableexception.py index 5121896ffc5..297263e7fc2 100644 --- a/src/_pytest/unraisableexception.py +++ b/src/_pytest/unraisableexception.py @@ -75,11 +75,12 @@ def collect_unraisable() -> None: try: warnings.warn(pytest.PytestUnraisableExceptionWarning(msg)) except pytest.PytestUnraisableExceptionWarning as e: - # exceptions have a better way to show the traceback, but - # warnings do not, so hide the traceback from the msg and - # set the cause so the traceback shows up in the right place - e.args = (meta.cause_msg,) - e.__cause__ = meta.exc_value + if meta.exc_value is not None: + # exceptions have a better way to show the traceback, but + # warnings do not, so hide the traceback from the msg and + # set the cause so the traceback shows up in the right place + e.args = (meta.cause_msg,) + e.__cause__ = meta.exc_value errors.append(e) if len(errors) == 1: From d540adaa90f46cf09a0d53d3fae4dcf4633e8b6f Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Tue, 19 Nov 2024 10:08:38 +0000 Subject: [PATCH 08/33] handle failures in unraisable plugin hook --- src/_pytest/unraisableexception.py | 66 ++++++++++++++++++------------ 1 file changed, 39 insertions(+), 27 deletions(-) diff --git a/src/_pytest/unraisableexception.py b/src/_pytest/unraisableexception.py index 297263e7fc2..dd5870e4250 100644 --- a/src/_pytest/unraisableexception.py +++ b/src/_pytest/unraisableexception.py @@ -58,12 +58,15 @@ def unraisable_exception_runtest_hook() -> Generator[None]: collect_unraisable() -_unraisable_exceptions: collections.deque[UnraisableMeta] = collections.deque() +_unraisable_exceptions: collections.deque[UnraisableMeta | BaseException] = ( + collections.deque() +) def collect_unraisable() -> None: - errors = [] + errors: list[pytest.PytestUnraisableExceptionWarning | RuntimeError] = [] meta = None + hook_error = None try: while True: try: @@ -71,6 +74,12 @@ def collect_unraisable() -> None: except IndexError: break + if isinstance(meta, BaseException): + hook_error = RuntimeError("Failed to process unraisable exception") + hook_error.__cause__ = meta + errors.append(hook_error) + continue + msg = meta.msg try: warnings.warn(pytest.PytestUnraisableExceptionWarning(msg)) @@ -88,7 +97,7 @@ def collect_unraisable() -> None: if errors: raise ExceptionGroup("multiple unraisable exception warnings", errors) finally: - del errors, meta + del errors, meta, hook_error def _cleanup(prev_hook: Callable[[sys.UnraisableHookArgs], object]) -> None: @@ -101,32 +110,35 @@ def _cleanup(prev_hook: Callable[[sys.UnraisableHookArgs], object]) -> None: def unraisable_hook(unraisable: sys.UnraisableHookArgs) -> None: - if unraisable.err_msg is not None: - err_msg = unraisable.err_msg - else: - err_msg = "Exception ignored in" - summary = f"{err_msg}: {unraisable.object!r}" - traceback_message = "\n\n" + "".join( - traceback.format_exception( - unraisable.exc_type, - unraisable.exc_value, - unraisable.exc_traceback, + try: + if unraisable.err_msg is not None: + err_msg = unraisable.err_msg + else: + err_msg = "Exception ignored in" + summary = f"{err_msg}: {unraisable.object!r}" + traceback_message = "\n\n" + "".join( + traceback.format_exception( + unraisable.exc_type, + unraisable.exc_value, + unraisable.exc_traceback, + ) ) - ) - tracemalloc_tb = _tracemalloc_msg(unraisable.object) - msg = summary + traceback_message + tracemalloc_tb - cause_msg = summary + tracemalloc_tb - - _unraisable_exceptions.append( - UnraisableMeta( - # we need to compute these strings here as they might change after - # the unraisablehook finishes and before the unraisable object is - # collected by a hook - msg=msg, - cause_msg=cause_msg, - exc_value=unraisable.exc_value, + tracemalloc_tb = _tracemalloc_msg(unraisable.object) + msg = summary + traceback_message + tracemalloc_tb + cause_msg = summary + tracemalloc_tb + + _unraisable_exceptions.append( + UnraisableMeta( + # we need to compute these strings here as they might change after + # the unraisablehook finishes and before the unraisable object is + # collected by a hook + msg=msg, + cause_msg=cause_msg, + exc_value=unraisable.exc_value, + ) ) - ) + except BaseException as e: + _unraisable_exceptions.append(e) def pytest_configure(config: Config) -> None: From a285cd526ffeb00fa9502700aaf198ed410eb531 Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Wed, 20 Nov 2024 09:08:26 +0000 Subject: [PATCH 09/33] refactor tracemalloc message --- src/_pytest/tracemalloc.py | 24 ++++++++++++++++++++++++ src/_pytest/unraisableexception.py | 26 ++------------------------ src/_pytest/warnings.py | 26 ++++---------------------- 3 files changed, 30 insertions(+), 46 deletions(-) create mode 100644 src/_pytest/tracemalloc.py diff --git a/src/_pytest/tracemalloc.py b/src/_pytest/tracemalloc.py new file mode 100644 index 00000000000..5d0b19855c7 --- /dev/null +++ b/src/_pytest/tracemalloc.py @@ -0,0 +1,24 @@ +from __future__ import annotations + + +def tracemalloc_message(source: object) -> str: + if source is None: + return "" + + try: + import tracemalloc + except ImportError: + return "" + + tb = tracemalloc.get_object_traceback(source) + if tb is not None: + formatted_tb = "\n".join(tb.format()) + # Use a leading new line to better separate the (large) output + # from the traceback to the previous warning text. + return f"\nObject allocated at:\n{formatted_tb}" + # No need for a leading new line. + url = "https://docs.pytest.org/en/stable/how-to/capture-warnings.html#resource-warnings" + return ( + "Enable tracemalloc to get traceback where the object was allocated.\n" + f"See {url} for more info." + ) diff --git a/src/_pytest/unraisableexception.py b/src/_pytest/unraisableexception.py index dd5870e4250..f267a746c03 100644 --- a/src/_pytest/unraisableexception.py +++ b/src/_pytest/unraisableexception.py @@ -12,6 +12,7 @@ import warnings from _pytest.config import Config +from _pytest.tracemalloc import tracemalloc_message import pytest @@ -22,29 +23,6 @@ from exceptiongroup import ExceptionGroup -def _tracemalloc_msg(source: object) -> str: - if source is None: - return "" - - try: - import tracemalloc - except ImportError: - return "" - - tb = tracemalloc.get_object_traceback(source) - if tb is not None: - formatted_tb = "\n".join(tb.format()) - # Use a leading new line to better separate the (large) output - # from the traceback to the previous warning text. - return f"\nObject allocated at:\n{formatted_tb}" - # No need for a leading new line. - url = "https://docs.pytest.org/en/stable/how-to/capture-warnings.html#resource-warnings" - return ( - "Enable tracemalloc to get traceback where the object was allocated.\n" - f"See {url} for more info." - ) - - class UnraisableMeta(NamedTuple): msg: str cause_msg: str @@ -123,7 +101,7 @@ def unraisable_hook(unraisable: sys.UnraisableHookArgs) -> None: unraisable.exc_traceback, ) ) - tracemalloc_tb = _tracemalloc_msg(unraisable.object) + tracemalloc_tb = tracemalloc_message(unraisable.object) msg = summary + traceback_message + tracemalloc_tb cause_msg = summary + tracemalloc_tb diff --git a/src/_pytest/warnings.py b/src/_pytest/warnings.py index eeb4772649d..ba20fde041f 100644 --- a/src/_pytest/warnings.py +++ b/src/_pytest/warnings.py @@ -13,6 +13,7 @@ from _pytest.main import Session from _pytest.nodes import Item from _pytest.terminal import TerminalReporter +from _pytest.tracemalloc import tracemalloc_message import pytest @@ -76,32 +77,13 @@ def catch_warnings_for_item( def warning_record_to_str(warning_message: warnings.WarningMessage) -> str: """Convert a warnings.WarningMessage to a string.""" - warn_msg = warning_message.message - msg = warnings.formatwarning( - str(warn_msg), + return warnings.formatwarning( + str(warning_message.message), warning_message.category, warning_message.filename, warning_message.lineno, warning_message.line, - ) - if warning_message.source is not None: - try: - import tracemalloc - except ImportError: - pass - else: - tb = tracemalloc.get_object_traceback(warning_message.source) - if tb is not None: - formatted_tb = "\n".join(tb.format()) - # Use a leading new line to better separate the (large) output - # from the traceback to the previous warning text. - msg += f"\nObject allocated at:\n{formatted_tb}" - else: - # No need for a leading new line. - url = "https://docs.pytest.org/en/stable/how-to/capture-warnings.html#resource-warnings" - msg += "Enable tracemalloc to get traceback where the object was allocated.\n" - msg += f"See {url} for more info." - return msg + ) + tracemalloc_message(warning_message.source) @pytest.hookimpl(wrapper=True, tryfirst=True) From f853b2c8830c74021a0143ce5cc06f001c07f802 Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Wed, 20 Nov 2024 09:28:35 +0000 Subject: [PATCH 10/33] test multiple errors --- testing/test_unraisableexception.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/testing/test_unraisableexception.py b/testing/test_unraisableexception.py index 84f89a42628..8919371fb3f 100644 --- a/testing/test_unraisableexception.py +++ b/testing/test_unraisableexception.py @@ -139,3 +139,32 @@ def test_2(): pass result = pytester.runpytest() assert result.ret == pytest.ExitCode.TESTS_FAILED assert result.parseoutcomes() == {"passed": 1, "failed": 1} + + +@pytest.mark.filterwarnings("error::pytest.PytestUnraisableExceptionWarning") +def test_unraisable_warning_multiple_errors(pytester: Pytester) -> None: + pytester.makepyfile( + test_it=f""" + class BrokenDel: + def __init__(self, msg: str): + self.msg = msg + + def __del__(self) -> None: + raise ValueError(self.msg) + + def test_it() -> None: + BrokenDel("del is broken 1") + BrokenDel("del is broken 2") + {"import gc; gc.collect()" * PYPY} + + def test_2(): pass + """ + ) + result = pytester.runpytest() + assert result.ret == pytest.ExitCode.TESTS_FAILED + assert result.parseoutcomes() == {"passed": 1, "failed": 1} + result.stdout.fnmatch_lines( + [ + " | ExceptionGroup: multiple unraisable exception warnings (2 sub-exceptions)" + ] + ) From 34b3683fe7d8f2de09d2f7b9021c3acded10e3fb Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Wed, 20 Nov 2024 09:45:40 +0000 Subject: [PATCH 11/33] hide from the branch coverage check --- src/_pytest/unraisableexception.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/_pytest/unraisableexception.py b/src/_pytest/unraisableexception.py index f267a746c03..93cd2d8b6e3 100644 --- a/src/_pytest/unraisableexception.py +++ b/src/_pytest/unraisableexception.py @@ -89,10 +89,9 @@ def _cleanup(prev_hook: Callable[[sys.UnraisableHookArgs], object]) -> None: def unraisable_hook(unraisable: sys.UnraisableHookArgs) -> None: try: - if unraisable.err_msg is not None: - err_msg = unraisable.err_msg - else: - err_msg = "Exception ignored in" + err_msg = ( + "Exception ignored in" if unraisable.err_msg is None else unraisable.err_msg + ) summary = f"{err_msg}: {unraisable.object!r}" traceback_message = "\n\n" + "".join( traceback.format_exception( From 04705b46674280eabdb8a6f394251542dc06770d Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Wed, 20 Nov 2024 09:55:26 +0000 Subject: [PATCH 12/33] test failure in unraisable processing --- testing/test_unraisableexception.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/testing/test_unraisableexception.py b/testing/test_unraisableexception.py index 8919371fb3f..a1041402276 100644 --- a/testing/test_unraisableexception.py +++ b/testing/test_unraisableexception.py @@ -1,6 +1,7 @@ from __future__ import annotations import sys +from unittest import mock from _pytest.pytester import Pytester import pytest @@ -168,3 +169,31 @@ def test_2(): pass " | ExceptionGroup: multiple unraisable exception warnings (2 sub-exceptions)" ] ) + + +def test_unraisable_collection_failure(pytester: Pytester) -> None: + pytester.makepyfile( + test_it=f""" + class BrokenDel: + def __del__(self): + raise ValueError("del is broken") + + def test_it(): + obj = BrokenDel() + del obj + {"import gc; gc.collect()" * PYPY} + + def test_2(): pass + """ + ) + + class MyError(BaseException): + pass + + with mock.patch("traceback.format_exception", side_effect=MyError): + result = pytester.runpytest() + assert result.ret == 1 + assert result.parseoutcomes() == {"passed": 1, "failed": 1} + result.stdout.fnmatch_lines( + ["E RuntimeError: Failed to process unraisable exception"] + ) From 4f8aeef42970c0390000eedb9b4268d5c76f7dc4 Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Wed, 20 Nov 2024 10:10:06 +0000 Subject: [PATCH 13/33] test that errors due to cyclic grabage are collected eventually --- testing/test_unraisableexception.py | 33 +++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/testing/test_unraisableexception.py b/testing/test_unraisableexception.py index a1041402276..f3203d2f3f3 100644 --- a/testing/test_unraisableexception.py +++ b/testing/test_unraisableexception.py @@ -1,5 +1,6 @@ from __future__ import annotations +import gc import sys from unittest import mock @@ -197,3 +198,35 @@ class MyError(BaseException): result.stdout.fnmatch_lines( ["E RuntimeError: Failed to process unraisable exception"] ) + + +def test_create_task_unraisable(pytester: Pytester) -> None: + pytester.makepyfile( + test_it=""" + import pytest + + class BrokenDel: + def __init__(self): + self.self = self # make a reference cycle + + def __del__(self): + raise ValueError("del is broken") + + def test_it(): + BrokenDel() + """ + ) + + was_enabled = gc.isenabled() + gc.disable() + try: + result = pytester.runpytest() + finally: + if was_enabled: + gc.enable() + + # TODO: should be a test failure or error + assert result.ret == pytest.ExitCode.INTERNAL_ERROR + + assert result.parseoutcomes() == {"passed": 1} + result.stderr.fnmatch_lines("ValueError: del is broken") From 90aa8003f57131ad8f676ed0034ace3c7ed25576 Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Wed, 20 Nov 2024 10:37:28 +0000 Subject: [PATCH 14/33] handle errors on <3.10 --- testing/test_unraisableexception.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/testing/test_unraisableexception.py b/testing/test_unraisableexception.py index f3203d2f3f3..ec097611f53 100644 --- a/testing/test_unraisableexception.py +++ b/testing/test_unraisableexception.py @@ -167,7 +167,7 @@ def test_2(): pass assert result.parseoutcomes() == {"passed": 1, "failed": 1} result.stdout.fnmatch_lines( [ - " | ExceptionGroup: multiple unraisable exception warnings (2 sub-exceptions)" + " | *ExceptionGroup: multiple unraisable exception warnings (2 sub-exceptions)" ] ) From 055e673fe8ac759d0e7619d0a39e1026152b1e4e Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Wed, 20 Nov 2024 10:46:35 +0000 Subject: [PATCH 15/33] use trylast=True instead of a wrapper --- src/_pytest/unraisableexception.py | 26 +++++++++----------------- 1 file changed, 9 insertions(+), 17 deletions(-) diff --git a/src/_pytest/unraisableexception.py b/src/_pytest/unraisableexception.py index 93cd2d8b6e3..bc20f00c359 100644 --- a/src/_pytest/unraisableexception.py +++ b/src/_pytest/unraisableexception.py @@ -6,7 +6,6 @@ import sys import traceback from typing import Callable -from typing import Generator from typing import NamedTuple from typing import TYPE_CHECKING import warnings @@ -29,13 +28,6 @@ class UnraisableMeta(NamedTuple): exc_value: BaseException | None -def unraisable_exception_runtest_hook() -> Generator[None]: - try: - yield - finally: - collect_unraisable() - - _unraisable_exceptions: collections.deque[UnraisableMeta | BaseException] = ( collections.deque() ) @@ -124,16 +116,16 @@ def pytest_configure(config: Config) -> None: sys.unraisablehook = unraisable_hook -@pytest.hookimpl(wrapper=True, tryfirst=True) -def pytest_runtest_setup() -> Generator[None]: - yield from unraisable_exception_runtest_hook() +@pytest.hookimpl(trylast=True) +def pytest_runtest_setup() -> None: + collect_unraisable() -@pytest.hookimpl(wrapper=True, tryfirst=True) -def pytest_runtest_call() -> Generator[None]: - yield from unraisable_exception_runtest_hook() +@pytest.hookimpl(trylast=True) +def pytest_runtest_call() -> None: + collect_unraisable() -@pytest.hookimpl(wrapper=True, tryfirst=True) -def pytest_runtest_teardown() -> Generator[None]: - yield from unraisable_exception_runtest_hook() +@pytest.hookimpl(trylast=True) +def pytest_runtest_teardown() -> None: + collect_unraisable() From 3d6a023e5e48e0f73cd4dc1b7863840fc4d5bd7e Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Wed, 20 Nov 2024 11:00:29 +0000 Subject: [PATCH 16/33] Update testing/test_unraisableexception.py --- testing/test_unraisableexception.py | 1 + 1 file changed, 1 insertion(+) diff --git a/testing/test_unraisableexception.py b/testing/test_unraisableexception.py index ec097611f53..e5bb5ea5149 100644 --- a/testing/test_unraisableexception.py +++ b/testing/test_unraisableexception.py @@ -201,6 +201,7 @@ class MyError(BaseException): def test_create_task_unraisable(pytester: Pytester) -> None: + # see: https://github.com/pytest-dev/pytest/issues/10404 pytester.makepyfile( test_it=""" import pytest From 02ada4bef9456fd68c1ee325a0e20b6750a02ea6 Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Wed, 20 Nov 2024 20:12:59 +0000 Subject: [PATCH 17/33] Update src/_pytest/unraisableexception.py Co-authored-by: Ran Benita --- src/_pytest/unraisableexception.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/_pytest/unraisableexception.py b/src/_pytest/unraisableexception.py index bc20f00c359..cf559e0e76b 100644 --- a/src/_pytest/unraisableexception.py +++ b/src/_pytest/unraisableexception.py @@ -54,6 +54,7 @@ def collect_unraisable() -> None: try: warnings.warn(pytest.PytestUnraisableExceptionWarning(msg)) except pytest.PytestUnraisableExceptionWarning as e: + # This except happens when the warning is treated as an error (e.g. `-Werror`). if meta.exc_value is not None: # exceptions have a better way to show the traceback, but # warnings do not, so hide the traceback from the msg and From 84e731109f5fbfa387682665e9ba818a1c9678dc Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Thu, 21 Nov 2024 10:09:59 +0000 Subject: [PATCH 18/33] add newsfragment --- changelog/12958.improvement.rst | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 changelog/12958.improvement.rst diff --git a/changelog/12958.improvement.rst b/changelog/12958.improvement.rst new file mode 100644 index 00000000000..f6cded7c9a8 --- /dev/null +++ b/changelog/12958.improvement.rst @@ -0,0 +1,9 @@ +A number of unraisablehook plugin enhancements: + +* set the unraisablehook as early as possible and unset it as late as possible, to collect the most possible unraisable exceptions (please let me know if there's an earlier or later hook I can use) +* call the garbage collector just before unsetting the unraisablehook, to collect any straggling exceptions +* collect multiple unraisable exceptions per test phase +* report the tracemalloc allocation traceback, if available! +* avoid using a generator based hook to allow handling StopIteration in test failures +* report the unraisable exception as the cause of the PytestUnraisableExceptionWarning exception if raised. +* compute the repr of the unraisable.object in the unraisablehook so you get the latest information if available, and should help with resurrection of the object From 086c41890211320d7bab08192e84fb05fef4219b Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Thu, 21 Nov 2024 10:39:31 +0000 Subject: [PATCH 19/33] refactor gc_collect_harder --- src/_pytest/unraisableexception.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/_pytest/unraisableexception.py b/src/_pytest/unraisableexception.py index cf559e0e76b..1bf7b0adc8d 100644 --- a/src/_pytest/unraisableexception.py +++ b/src/_pytest/unraisableexception.py @@ -22,6 +22,12 @@ from exceptiongroup import ExceptionGroup +def gc_collect_harder() -> None: + # constant determined experimentally by the Trio project + for _ in range(5): + gc.collect() + + class UnraisableMeta(NamedTuple): msg: str cause_msg: str @@ -73,8 +79,7 @@ def collect_unraisable() -> None: def _cleanup(prev_hook: Callable[[sys.UnraisableHookArgs], object]) -> None: try: - for i in range(5): - gc.collect() + gc_collect_harder() collect_unraisable() finally: sys.unraisablehook = prev_hook From 6a76066b0ca32647ff3cfce54dc47b2b411580a1 Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Thu, 21 Nov 2024 10:46:18 +0000 Subject: [PATCH 20/33] cleanup args/kwargs --- src/_pytest/unraisableexception.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/_pytest/unraisableexception.py b/src/_pytest/unraisableexception.py index 1bf7b0adc8d..ce5d75f0e15 100644 --- a/src/_pytest/unraisableexception.py +++ b/src/_pytest/unraisableexception.py @@ -77,7 +77,7 @@ def collect_unraisable() -> None: del errors, meta, hook_error -def _cleanup(prev_hook: Callable[[sys.UnraisableHookArgs], object]) -> None: +def _cleanup(*, prev_hook: Callable[[sys.UnraisableHookArgs], object]) -> None: try: gc_collect_harder() collect_unraisable() @@ -85,7 +85,7 @@ def _cleanup(prev_hook: Callable[[sys.UnraisableHookArgs], object]) -> None: sys.unraisablehook = prev_hook -def unraisable_hook(unraisable: sys.UnraisableHookArgs) -> None: +def unraisable_hook(unraisable: sys.UnraisableHookArgs, /) -> None: try: err_msg = ( "Exception ignored in" if unraisable.err_msg is None else unraisable.err_msg @@ -118,7 +118,7 @@ def unraisable_hook(unraisable: sys.UnraisableHookArgs) -> None: def pytest_configure(config: Config) -> None: prev_hook = sys.unraisablehook - config.add_cleanup(functools.partial(_cleanup, prev_hook)) + config.add_cleanup(functools.partial(_cleanup, prev_hook=prev_hook)) sys.unraisablehook = unraisable_hook From 2cc6648de1aec77c792ae1ea772ee6df4f53b70f Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Thu, 21 Nov 2024 10:52:09 +0000 Subject: [PATCH 21/33] make unraisablehook last-resort exception handling as simple as possible the sorts of errors possible include _unraisable_exceptions being removed from the unraisablehook plugin module dict, so we bind append as a local --- src/_pytest/unraisableexception.py | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/src/_pytest/unraisableexception.py b/src/_pytest/unraisableexception.py index ce5d75f0e15..59c16dbc963 100644 --- a/src/_pytest/unraisableexception.py +++ b/src/_pytest/unraisableexception.py @@ -85,7 +85,12 @@ def _cleanup(*, prev_hook: Callable[[sys.UnraisableHookArgs], object]) -> None: sys.unraisablehook = prev_hook -def unraisable_hook(unraisable: sys.UnraisableHookArgs, /) -> None: +def unraisable_hook( + unraisable: sys.UnraisableHookArgs, + /, + *, + append: Callable[[UnraisableMeta | BaseException], object], +) -> None: try: err_msg = ( "Exception ignored in" if unraisable.err_msg is None else unraisable.err_msg @@ -102,7 +107,7 @@ def unraisable_hook(unraisable: sys.UnraisableHookArgs, /) -> None: msg = summary + traceback_message + tracemalloc_tb cause_msg = summary + tracemalloc_tb - _unraisable_exceptions.append( + append( UnraisableMeta( # we need to compute these strings here as they might change after # the unraisablehook finishes and before the unraisable object is @@ -113,13 +118,20 @@ def unraisable_hook(unraisable: sys.UnraisableHookArgs, /) -> None: ) ) except BaseException as e: - _unraisable_exceptions.append(e) + append(e) + # raising this will cause the exception to be logged twice, once in our + # collect_unraisable and once by the unraisablehook calling machinery + # which is fine - this should never happen anyway and if it does + # it should probably be reported as a pytest bug. + raise def pytest_configure(config: Config) -> None: prev_hook = sys.unraisablehook config.add_cleanup(functools.partial(_cleanup, prev_hook=prev_hook)) - sys.unraisablehook = unraisable_hook + sys.unraisablehook = functools.partial( + unraisable_hook, append=_unraisable_exceptions.append + ) @pytest.hookimpl(trylast=True) From 77c51269b4281da205f8b1bda603c0d1ba1748e5 Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Thu, 21 Nov 2024 11:01:37 +0000 Subject: [PATCH 22/33] use assert_outcomes --- testing/test_unraisableexception.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/testing/test_unraisableexception.py b/testing/test_unraisableexception.py index e5bb5ea5149..dc96ea6409b 100644 --- a/testing/test_unraisableexception.py +++ b/testing/test_unraisableexception.py @@ -29,7 +29,7 @@ def test_2(): pass ) result = pytester.runpytest() assert result.ret == 0 - assert result.parseoutcomes() == {"passed": 2, "warnings": 1} + result.assert_outcomes(passed=2, warnings=1) result.stdout.fnmatch_lines( [ "*= warnings summary =*", @@ -67,7 +67,7 @@ def test_2(): pass ) result = pytester.runpytest() assert result.ret == 0 - assert result.parseoutcomes() == {"passed": 2, "warnings": 1} + result.assert_outcomes(passed=2, warnings=1) result.stdout.fnmatch_lines( [ "*= warnings summary =*", @@ -106,7 +106,7 @@ def test_2(): pass ) result = pytester.runpytest() assert result.ret == 0 - assert result.parseoutcomes() == {"passed": 2, "warnings": 1} + result.assert_outcomes(passed=2, warnings=1) result.stdout.fnmatch_lines( [ "*= warnings summary =*", @@ -140,7 +140,7 @@ def test_2(): pass ) result = pytester.runpytest() assert result.ret == pytest.ExitCode.TESTS_FAILED - assert result.parseoutcomes() == {"passed": 1, "failed": 1} + result.assert_outcomes(passed=1, failed=1) @pytest.mark.filterwarnings("error::pytest.PytestUnraisableExceptionWarning") @@ -164,7 +164,7 @@ def test_2(): pass ) result = pytester.runpytest() assert result.ret == pytest.ExitCode.TESTS_FAILED - assert result.parseoutcomes() == {"passed": 1, "failed": 1} + result.assert_outcomes(passed=1, failed=1) result.stdout.fnmatch_lines( [ " | *ExceptionGroup: multiple unraisable exception warnings (2 sub-exceptions)" @@ -194,7 +194,7 @@ class MyError(BaseException): with mock.patch("traceback.format_exception", side_effect=MyError): result = pytester.runpytest() assert result.ret == 1 - assert result.parseoutcomes() == {"passed": 1, "failed": 1} + result.assert_outcomes(passed=1, failed=1) result.stdout.fnmatch_lines( ["E RuntimeError: Failed to process unraisable exception"] ) @@ -229,5 +229,5 @@ def test_it(): # TODO: should be a test failure or error assert result.ret == pytest.ExitCode.INTERNAL_ERROR - assert result.parseoutcomes() == {"passed": 1} + result.assert_outcomes(passed=1) result.stderr.fnmatch_lines("ValueError: del is broken") From 4741611dc5c3e10c23030ef4d3d7c96f3686221b Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Thu, 21 Nov 2024 20:58:09 +0000 Subject: [PATCH 23/33] Update changelog/12958.improvement.rst Co-authored-by: Bruno Oliveira --- changelog/12958.improvement.rst | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/changelog/12958.improvement.rst b/changelog/12958.improvement.rst index f6cded7c9a8..ee8dc8c0710 100644 --- a/changelog/12958.improvement.rst +++ b/changelog/12958.improvement.rst @@ -1,9 +1,9 @@ -A number of unraisablehook plugin enhancements: +A number of :ref:`unraisable ` enhancements: -* set the unraisablehook as early as possible and unset it as late as possible, to collect the most possible unraisable exceptions (please let me know if there's an earlier or later hook I can use) -* call the garbage collector just before unsetting the unraisablehook, to collect any straggling exceptions -* collect multiple unraisable exceptions per test phase -* report the tracemalloc allocation traceback, if available! -* avoid using a generator based hook to allow handling StopIteration in test failures -* report the unraisable exception as the cause of the PytestUnraisableExceptionWarning exception if raised. -* compute the repr of the unraisable.object in the unraisablehook so you get the latest information if available, and should help with resurrection of the object +* Set the unraisable hook as early as possible and unset it as late as possible, to collect the most possible number of unraisable exceptions. +* Call the garbage collector just before unsetting the unraisable hook, to collect any straggling exceptions. +* Collect multiple unraisable exceptions per test phase. +* Report the :mod:`tracemalloc` allocation traceback (if available). +* Avoid using a generator based hook to allow handling :class:`StopIteration` in test failures. +* Report the unraisable exception as the cause of the :class:`pytest.PytestUnraisableExceptionWarning` exception if raised. +* Compute the ``repr`` of the unraisable object in the unraisable hook so you get the latest information if available, and should help with resurrection of the object. From a554f2a59151f3c582858c0595745914f75d4b52 Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Thu, 21 Nov 2024 20:58:30 +0000 Subject: [PATCH 24/33] Update src/_pytest/unraisableexception.py Co-authored-by: Bruno Oliveira --- src/_pytest/unraisableexception.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/_pytest/unraisableexception.py b/src/_pytest/unraisableexception.py index 59c16dbc963..1a65d00aa1d 100644 --- a/src/_pytest/unraisableexception.py +++ b/src/_pytest/unraisableexception.py @@ -23,7 +23,7 @@ def gc_collect_harder() -> None: - # constant determined experimentally by the Trio project + # Constant determined experimentally by the Trio project. for _ in range(5): gc.collect() From f33fb85e1c738864782187edb73c633775104bb4 Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Thu, 21 Nov 2024 20:59:00 +0000 Subject: [PATCH 25/33] Update src/_pytest/unraisableexception.py Co-authored-by: Bruno Oliveira --- src/_pytest/unraisableexception.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/_pytest/unraisableexception.py b/src/_pytest/unraisableexception.py index 1a65d00aa1d..17abccb9595 100644 --- a/src/_pytest/unraisableexception.py +++ b/src/_pytest/unraisableexception.py @@ -62,9 +62,9 @@ def collect_unraisable() -> None: except pytest.PytestUnraisableExceptionWarning as e: # This except happens when the warning is treated as an error (e.g. `-Werror`). if meta.exc_value is not None: - # exceptions have a better way to show the traceback, but + # Exceptions have a better way to show the traceback, but # warnings do not, so hide the traceback from the msg and - # set the cause so the traceback shows up in the right place + # set the cause so the traceback shows up in the right place. e.args = (meta.cause_msg,) e.__cause__ = meta.exc_value errors.append(e) From af130589677d82ebb111f69be27dc31c301ac707 Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Thu, 21 Nov 2024 20:59:14 +0000 Subject: [PATCH 26/33] Update src/_pytest/unraisableexception.py Co-authored-by: Bruno Oliveira --- src/_pytest/unraisableexception.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/_pytest/unraisableexception.py b/src/_pytest/unraisableexception.py index 17abccb9595..2ae4e07b4e9 100644 --- a/src/_pytest/unraisableexception.py +++ b/src/_pytest/unraisableexception.py @@ -119,7 +119,7 @@ def unraisable_hook( ) except BaseException as e: append(e) - # raising this will cause the exception to be logged twice, once in our + # Raising this will cause the exception to be logged twice, once in our # collect_unraisable and once by the unraisablehook calling machinery # which is fine - this should never happen anyway and if it does # it should probably be reported as a pytest bug. From 69d0b225fab2c20df38c327282444e6afe7e4989 Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Fri, 22 Nov 2024 17:23:45 +0000 Subject: [PATCH 27/33] remove private prefix --- src/_pytest/unraisableexception.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/_pytest/unraisableexception.py b/src/_pytest/unraisableexception.py index 2ae4e07b4e9..0467deec580 100644 --- a/src/_pytest/unraisableexception.py +++ b/src/_pytest/unraisableexception.py @@ -34,7 +34,7 @@ class UnraisableMeta(NamedTuple): exc_value: BaseException | None -_unraisable_exceptions: collections.deque[UnraisableMeta | BaseException] = ( +unraisable_exceptions: collections.deque[UnraisableMeta | BaseException] = ( collections.deque() ) @@ -46,7 +46,7 @@ def collect_unraisable() -> None: try: while True: try: - meta = _unraisable_exceptions.pop() + meta = unraisable_exceptions.pop() except IndexError: break @@ -77,7 +77,7 @@ def collect_unraisable() -> None: del errors, meta, hook_error -def _cleanup(*, prev_hook: Callable[[sys.UnraisableHookArgs], object]) -> None: +def cleanup(*, prev_hook: Callable[[sys.UnraisableHookArgs], object]) -> None: try: gc_collect_harder() collect_unraisable() @@ -128,9 +128,9 @@ def unraisable_hook( def pytest_configure(config: Config) -> None: prev_hook = sys.unraisablehook - config.add_cleanup(functools.partial(_cleanup, prev_hook=prev_hook)) + config.add_cleanup(functools.partial(cleanup, prev_hook=prev_hook)) sys.unraisablehook = functools.partial( - unraisable_hook, append=_unraisable_exceptions.append + unraisable_hook, append=unraisable_exceptions.append ) From b0978e0ab8350def35fe404eab39fcc056b50928 Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Fri, 22 Nov 2024 17:32:29 +0000 Subject: [PATCH 28/33] put unraisable deque in config.stash --- src/_pytest/unraisableexception.py | 37 +++++++++++++++++------------- 1 file changed, 21 insertions(+), 16 deletions(-) diff --git a/src/_pytest/unraisableexception.py b/src/_pytest/unraisableexception.py index 0467deec580..97283c702a2 100644 --- a/src/_pytest/unraisableexception.py +++ b/src/_pytest/unraisableexception.py @@ -11,6 +11,8 @@ import warnings from _pytest.config import Config +from _pytest.nodes import Item +from _pytest.stash import StashKey from _pytest.tracemalloc import tracemalloc_message import pytest @@ -34,19 +36,20 @@ class UnraisableMeta(NamedTuple): exc_value: BaseException | None -unraisable_exceptions: collections.deque[UnraisableMeta | BaseException] = ( - collections.deque() +unraisable_exceptions: StashKey[collections.deque[UnraisableMeta | BaseException]] = ( + StashKey() ) -def collect_unraisable() -> None: +def collect_unraisable(config: Config) -> None: + pop_unraisable = config.stash[unraisable_exceptions].pop errors: list[pytest.PytestUnraisableExceptionWarning | RuntimeError] = [] meta = None hook_error = None try: while True: try: - meta = unraisable_exceptions.pop() + meta = pop_unraisable() except IndexError: break @@ -77,10 +80,12 @@ def collect_unraisable() -> None: del errors, meta, hook_error -def cleanup(*, prev_hook: Callable[[sys.UnraisableHookArgs], object]) -> None: +def cleanup( + *, config: Config, prev_hook: Callable[[sys.UnraisableHookArgs], object] +) -> None: try: gc_collect_harder() - collect_unraisable() + collect_unraisable(config) finally: sys.unraisablehook = prev_hook @@ -128,22 +133,22 @@ def unraisable_hook( def pytest_configure(config: Config) -> None: prev_hook = sys.unraisablehook - config.add_cleanup(functools.partial(cleanup, prev_hook=prev_hook)) - sys.unraisablehook = functools.partial( - unraisable_hook, append=unraisable_exceptions.append - ) + deque: collections.deque[UnraisableMeta | BaseException] = collections.deque() + config.stash[unraisable_exceptions] = deque + config.add_cleanup(functools.partial(cleanup, config=config, prev_hook=prev_hook)) + sys.unraisablehook = functools.partial(unraisable_hook, append=deque.append) @pytest.hookimpl(trylast=True) -def pytest_runtest_setup() -> None: - collect_unraisable() +def pytest_runtest_setup(item: Item) -> None: + collect_unraisable(item.config) @pytest.hookimpl(trylast=True) -def pytest_runtest_call() -> None: - collect_unraisable() +def pytest_runtest_call(item: Item) -> None: + collect_unraisable(item.config) @pytest.hookimpl(trylast=True) -def pytest_runtest_teardown() -> None: - collect_unraisable() +def pytest_runtest_teardown(item: Item) -> None: + collect_unraisable(item.config) From 740bafb19224a837f0a0a734f075dfea1baa04a4 Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Fri, 22 Nov 2024 17:34:19 +0000 Subject: [PATCH 29/33] cleanup stash when finished --- src/_pytest/unraisableexception.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/_pytest/unraisableexception.py b/src/_pytest/unraisableexception.py index 97283c702a2..0db9b39f433 100644 --- a/src/_pytest/unraisableexception.py +++ b/src/_pytest/unraisableexception.py @@ -84,10 +84,13 @@ def cleanup( *, config: Config, prev_hook: Callable[[sys.UnraisableHookArgs], object] ) -> None: try: - gc_collect_harder() - collect_unraisable(config) + try: + gc_collect_harder() + collect_unraisable(config) + finally: + sys.unraisablehook = prev_hook finally: - sys.unraisablehook = prev_hook + del config.stash[unraisable_exceptions] def unraisable_hook( From 5cbe5475dd98af391e6f8c13be20ee29118b6fbf Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 28 Nov 2024 22:58:47 +0000 Subject: [PATCH 30/33] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/_pytest/unraisableexception.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/_pytest/unraisableexception.py b/src/_pytest/unraisableexception.py index e2561e02961..0fa03be5e3e 100644 --- a/src/_pytest/unraisableexception.py +++ b/src/_pytest/unraisableexception.py @@ -1,18 +1,13 @@ from __future__ import annotations import collections +from collections.abc import Callable import functools import gc import sys import traceback from typing import Callable from typing import NamedTuple -from collections.abc import Callable -from collections.abc import Generator -import sys -import traceback -from types import TracebackType -from typing import Any from typing import TYPE_CHECKING import warnings From 2741b18941476177ce7c7b67fe540da813df4e69 Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Thu, 28 Nov 2024 23:04:44 +0000 Subject: [PATCH 31/33] Apply suggestions from code review Co-authored-by: Ran Benita --- src/_pytest/unraisableexception.py | 3 ++- testing/test_unraisableexception.py | 3 +++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/_pytest/unraisableexception.py b/src/_pytest/unraisableexception.py index 0fa03be5e3e..884e3c058a3 100644 --- a/src/_pytest/unraisableexception.py +++ b/src/_pytest/unraisableexception.py @@ -26,6 +26,7 @@ def gc_collect_harder() -> None: + # A single collection doesn't necessarily collect everything. # Constant determined experimentally by the Trio project. for _ in range(5): gc.collect() @@ -114,7 +115,7 @@ def unraisable_hook( ) tracemalloc_tb = tracemalloc_message(unraisable.object) msg = summary + traceback_message + tracemalloc_tb - cause_msg = summary + tracemalloc_tb + cause_msg = summary + "\n" + tracemalloc_tb append( UnraisableMeta( diff --git a/testing/test_unraisableexception.py b/testing/test_unraisableexception.py index dc96ea6409b..70248e1c122 100644 --- a/testing/test_unraisableexception.py +++ b/testing/test_unraisableexception.py @@ -38,6 +38,7 @@ def test_2(): pass " ", " Traceback (most recent call last):", " ValueError: del is broken", + " ", " Enable tracemalloc to get traceback where the object was allocated.", " See https* for more info.", " warnings.warn(pytest.PytestUnraisableExceptionWarning(msg))", @@ -76,6 +77,7 @@ def test_2(): pass " ", " Traceback (most recent call last):", " ValueError: del is broken", + " ", " Enable tracemalloc to get traceback where the object was allocated.", " See https* for more info.", " warnings.warn(pytest.PytestUnraisableExceptionWarning(msg))", @@ -115,6 +117,7 @@ def test_2(): pass " ", " Traceback (most recent call last):", " ValueError: del is broken", + " ", " Enable tracemalloc to get traceback where the object was allocated.", " See https* for more info.", " warnings.warn(pytest.PytestUnraisableExceptionWarning(msg))", From c41e7bd901a2f9dfafda3ee7ec6ad89435dda278 Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Thu, 28 Nov 2024 23:06:47 +0000 Subject: [PATCH 32/33] remove duplicate Callable import --- src/_pytest/unraisableexception.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/_pytest/unraisableexception.py b/src/_pytest/unraisableexception.py index 884e3c058a3..abd4aa19d91 100644 --- a/src/_pytest/unraisableexception.py +++ b/src/_pytest/unraisableexception.py @@ -6,7 +6,6 @@ import gc import sys import traceback -from typing import Callable from typing import NamedTuple from typing import TYPE_CHECKING import warnings From a0d79d9ed293e9d8a124d24e5be6e56e1390149b Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Fri, 29 Nov 2024 07:18:05 +0000 Subject: [PATCH 33/33] move the newline to the tracemalloc_message --- src/_pytest/unraisableexception.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/_pytest/unraisableexception.py b/src/_pytest/unraisableexception.py index abd4aa19d91..2bd7f07e862 100644 --- a/src/_pytest/unraisableexception.py +++ b/src/_pytest/unraisableexception.py @@ -112,9 +112,9 @@ def unraisable_hook( unraisable.exc_traceback, ) ) - tracemalloc_tb = tracemalloc_message(unraisable.object) + tracemalloc_tb = "\n" + tracemalloc_message(unraisable.object) msg = summary + traceback_message + tracemalloc_tb - cause_msg = summary + "\n" + tracemalloc_tb + cause_msg = summary + tracemalloc_tb append( UnraisableMeta(