This commit is contained in:
Iliyan Angelov
2025-12-06 03:27:35 +02:00
parent 7667eb5eda
commit 5a8ca3c475
2211 changed files with 28086 additions and 37066 deletions

View File

@@ -1,163 +1,93 @@
from __future__ import annotations
import collections
from collections.abc import Callable
import functools
import gc
import sys
import traceback
from typing import NamedTuple
from typing import TYPE_CHECKING
import warnings
from types import TracebackType
from typing import Any
from typing import Callable
from typing import Generator
from typing import Optional
from typing import Type
from _pytest.config import Config
from _pytest.nodes import Item
from _pytest.stash import StashKey
from _pytest.tracemalloc import tracemalloc_message
import pytest
if TYPE_CHECKING:
pass
# Copied from cpython/Lib/test/support/__init__.py, with modifications.
class catch_unraisable_exception:
"""Context manager catching unraisable exception using sys.unraisablehook.
if sys.version_info < (3, 11):
from exceptiongroup import ExceptionGroup
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: Optional["sys.UnraisableHookArgs"] = None
self._old_hook: Optional[Callable[["sys.UnraisableHookArgs"], Any]] = 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) -> "catch_unraisable_exception":
self._old_hook = sys.unraisablehook
sys.unraisablehook = self._hook
return self
def __exit__(
self,
exc_type: Optional[Type[BaseException]],
exc_val: Optional[BaseException],
exc_tb: Optional[TracebackType],
) -> None:
assert self._old_hook is not None
sys.unraisablehook = self._old_hook
self._old_hook = None
del self.unraisable
# This is a stash item and not a simple constant to allow pytester to override it.
gc_collect_iterations_key = StashKey[int]()
def gc_collect_harder(iterations: int) -> None:
for _ in range(iterations):
gc.collect()
class UnraisableMeta(NamedTuple):
msg: str
cause_msg: str
exc_value: BaseException | None
unraisable_exceptions: StashKey[collections.deque[UnraisableMeta | BaseException]] = (
StashKey()
)
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 = pop_unraisable()
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))
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
# 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:
raise errors[0]
if errors:
raise ExceptionGroup("multiple unraisable exception warnings", errors)
finally:
del errors, meta, hook_error
def cleanup(
*, config: Config, prev_hook: Callable[[sys.UnraisableHookArgs], object]
) -> None:
# A single collection doesn't necessarily collect everything.
# Constant determined experimentally by the Trio project.
gc_collect_iterations = config.stash.get(gc_collect_iterations_key, 5)
try:
try:
gc_collect_harder(gc_collect_iterations)
collect_unraisable(config)
finally:
sys.unraisablehook = prev_hook
finally:
del config.stash[unraisable_exceptions]
def unraisable_hook(
unraisable: sys.UnraisableHookArgs,
/,
*,
append: Callable[[UnraisableMeta | BaseException], object],
) -> None:
try:
# we need to compute these strings here as they might change after
# the unraisablehook finishes and before the metadata object is
# collected by a pytest hook
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(
unraisable.exc_type,
unraisable.exc_value,
unraisable.exc_traceback,
def unraisable_exception_runtest_hook() -> Generator[None, None, None]:
with catch_unraisable_exception() as cm:
yield
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,
)
)
)
tracemalloc_tb = "\n" + tracemalloc_message(unraisable.object)
msg = summary + traceback_message + tracemalloc_tb
cause_msg = summary + tracemalloc_tb
append(
UnraisableMeta(
msg=msg,
cause_msg=cause_msg,
exc_value=unraisable.exc_value,
)
)
except BaseException as 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
warnings.warn(pytest.PytestUnraisableExceptionWarning(msg))
def pytest_configure(config: Config) -> None:
prev_hook = sys.unraisablehook
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(hookwrapper=True, tryfirst=True)
def pytest_runtest_setup() -> Generator[None, None, None]:
yield from unraisable_exception_runtest_hook()
@pytest.hookimpl(trylast=True)
def pytest_runtest_setup(item: Item) -> None:
collect_unraisable(item.config)
@pytest.hookimpl(hookwrapper=True, tryfirst=True)
def pytest_runtest_call() -> Generator[None, None, None]:
yield from unraisable_exception_runtest_hook()
@pytest.hookimpl(trylast=True)
def pytest_runtest_call(item: Item) -> None:
collect_unraisable(item.config)
@pytest.hookimpl(trylast=True)
def pytest_runtest_teardown(item: Item) -> None:
collect_unraisable(item.config)
@pytest.hookimpl(hookwrapper=True, tryfirst=True)
def pytest_runtest_teardown() -> Generator[None, None, None]:
yield from unraisable_exception_runtest_hook()