unraisableexception.py 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163
  1. from __future__ import annotations
  2. import collections
  3. from collections.abc import Callable
  4. import functools
  5. import gc
  6. import sys
  7. import traceback
  8. from typing import NamedTuple
  9. from typing import TYPE_CHECKING
  10. import warnings
  11. from _pytest.config import Config
  12. from _pytest.nodes import Item
  13. from _pytest.stash import StashKey
  14. from _pytest.tracemalloc import tracemalloc_message
  15. import pytest
  16. if TYPE_CHECKING:
  17. pass
  18. if sys.version_info < (3, 11):
  19. from exceptiongroup import ExceptionGroup
  20. # This is a stash item and not a simple constant to allow pytester to override it.
  21. gc_collect_iterations_key = StashKey[int]()
  22. def gc_collect_harder(iterations: int) -> None:
  23. for _ in range(iterations):
  24. gc.collect()
  25. class UnraisableMeta(NamedTuple):
  26. msg: str
  27. cause_msg: str
  28. exc_value: BaseException | None
  29. unraisable_exceptions: StashKey[collections.deque[UnraisableMeta | BaseException]] = (
  30. StashKey()
  31. )
  32. def collect_unraisable(config: Config) -> None:
  33. pop_unraisable = config.stash[unraisable_exceptions].pop
  34. errors: list[pytest.PytestUnraisableExceptionWarning | RuntimeError] = []
  35. meta = None
  36. hook_error = None
  37. try:
  38. while True:
  39. try:
  40. meta = pop_unraisable()
  41. except IndexError:
  42. break
  43. if isinstance(meta, BaseException):
  44. hook_error = RuntimeError("Failed to process unraisable exception")
  45. hook_error.__cause__ = meta
  46. errors.append(hook_error)
  47. continue
  48. msg = meta.msg
  49. try:
  50. warnings.warn(pytest.PytestUnraisableExceptionWarning(msg))
  51. except pytest.PytestUnraisableExceptionWarning as e:
  52. # This except happens when the warning is treated as an error (e.g. `-Werror`).
  53. if meta.exc_value is not None:
  54. # Exceptions have a better way to show the traceback, but
  55. # warnings do not, so hide the traceback from the msg and
  56. # set the cause so the traceback shows up in the right place.
  57. e.args = (meta.cause_msg,)
  58. e.__cause__ = meta.exc_value
  59. errors.append(e)
  60. if len(errors) == 1:
  61. raise errors[0]
  62. if errors:
  63. raise ExceptionGroup("multiple unraisable exception warnings", errors)
  64. finally:
  65. del errors, meta, hook_error
  66. def cleanup(
  67. *, config: Config, prev_hook: Callable[[sys.UnraisableHookArgs], object]
  68. ) -> None:
  69. # A single collection doesn't necessarily collect everything.
  70. # Constant determined experimentally by the Trio project.
  71. gc_collect_iterations = config.stash.get(gc_collect_iterations_key, 5)
  72. try:
  73. try:
  74. gc_collect_harder(gc_collect_iterations)
  75. collect_unraisable(config)
  76. finally:
  77. sys.unraisablehook = prev_hook
  78. finally:
  79. del config.stash[unraisable_exceptions]
  80. def unraisable_hook(
  81. unraisable: sys.UnraisableHookArgs,
  82. /,
  83. *,
  84. append: Callable[[UnraisableMeta | BaseException], object],
  85. ) -> None:
  86. try:
  87. # we need to compute these strings here as they might change after
  88. # the unraisablehook finishes and before the metadata object is
  89. # collected by a pytest hook
  90. err_msg = (
  91. "Exception ignored in" if unraisable.err_msg is None else unraisable.err_msg
  92. )
  93. summary = f"{err_msg}: {unraisable.object!r}"
  94. traceback_message = "\n\n" + "".join(
  95. traceback.format_exception(
  96. unraisable.exc_type,
  97. unraisable.exc_value,
  98. unraisable.exc_traceback,
  99. )
  100. )
  101. tracemalloc_tb = "\n" + tracemalloc_message(unraisable.object)
  102. msg = summary + traceback_message + tracemalloc_tb
  103. cause_msg = summary + tracemalloc_tb
  104. append(
  105. UnraisableMeta(
  106. msg=msg,
  107. cause_msg=cause_msg,
  108. exc_value=unraisable.exc_value,
  109. )
  110. )
  111. except BaseException as e:
  112. append(e)
  113. # Raising this will cause the exception to be logged twice, once in our
  114. # collect_unraisable and once by the unraisablehook calling machinery
  115. # which is fine - this should never happen anyway and if it does
  116. # it should probably be reported as a pytest bug.
  117. raise
  118. def pytest_configure(config: Config) -> None:
  119. prev_hook = sys.unraisablehook
  120. deque: collections.deque[UnraisableMeta | BaseException] = collections.deque()
  121. config.stash[unraisable_exceptions] = deque
  122. config.add_cleanup(functools.partial(cleanup, config=config, prev_hook=prev_hook))
  123. sys.unraisablehook = functools.partial(unraisable_hook, append=deque.append)
  124. @pytest.hookimpl(trylast=True)
  125. def pytest_runtest_setup(item: Item) -> None:
  126. collect_unraisable(item.config)
  127. @pytest.hookimpl(trylast=True)
  128. def pytest_runtest_call(item: Item) -> None:
  129. collect_unraisable(item.config)
  130. @pytest.hookimpl(trylast=True)
  131. def pytest_runtest_teardown(item: Item) -> None:
  132. collect_unraisable(item.config)