recwarn.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367
  1. # mypy: allow-untyped-defs
  2. """Record warnings during test function execution."""
  3. from __future__ import annotations
  4. from collections.abc import Callable
  5. from collections.abc import Generator
  6. from collections.abc import Iterator
  7. from pprint import pformat
  8. import re
  9. from types import TracebackType
  10. from typing import Any
  11. from typing import final
  12. from typing import overload
  13. from typing import TYPE_CHECKING
  14. from typing import TypeVar
  15. if TYPE_CHECKING:
  16. from typing_extensions import Self
  17. import warnings
  18. from _pytest.deprecated import check_ispytest
  19. from _pytest.fixtures import fixture
  20. from _pytest.outcomes import Exit
  21. from _pytest.outcomes import fail
  22. T = TypeVar("T")
  23. @fixture
  24. def recwarn() -> Generator[WarningsRecorder]:
  25. """Return a :class:`WarningsRecorder` instance that records all warnings emitted by test functions.
  26. See :ref:`warnings` for information on warning categories.
  27. """
  28. wrec = WarningsRecorder(_ispytest=True)
  29. with wrec:
  30. warnings.simplefilter("default")
  31. yield wrec
  32. @overload
  33. def deprecated_call(
  34. *, match: str | re.Pattern[str] | None = ...
  35. ) -> WarningsRecorder: ...
  36. @overload
  37. def deprecated_call(func: Callable[..., T], *args: Any, **kwargs: Any) -> T: ...
  38. def deprecated_call(
  39. func: Callable[..., Any] | None = None, *args: Any, **kwargs: Any
  40. ) -> WarningsRecorder | Any:
  41. """Assert that code produces a ``DeprecationWarning`` or ``PendingDeprecationWarning`` or ``FutureWarning``.
  42. This function can be used as a context manager::
  43. >>> import warnings
  44. >>> def api_call_v2():
  45. ... warnings.warn('use v3 of this api', DeprecationWarning)
  46. ... return 200
  47. >>> import pytest
  48. >>> with pytest.deprecated_call():
  49. ... assert api_call_v2() == 200
  50. It can also be used by passing a function and ``*args`` and ``**kwargs``,
  51. in which case it will ensure calling ``func(*args, **kwargs)`` produces one of
  52. the warnings types above. The return value is the return value of the function.
  53. In the context manager form you may use the keyword argument ``match`` to assert
  54. that the warning matches a text or regex.
  55. The context manager produces a list of :class:`warnings.WarningMessage` objects,
  56. one for each warning raised.
  57. """
  58. __tracebackhide__ = True
  59. if func is not None:
  60. args = (func, *args)
  61. return warns(
  62. (DeprecationWarning, PendingDeprecationWarning, FutureWarning), *args, **kwargs
  63. )
  64. @overload
  65. def warns(
  66. expected_warning: type[Warning] | tuple[type[Warning], ...] = ...,
  67. *,
  68. match: str | re.Pattern[str] | None = ...,
  69. ) -> WarningsChecker: ...
  70. @overload
  71. def warns(
  72. expected_warning: type[Warning] | tuple[type[Warning], ...],
  73. func: Callable[..., T],
  74. *args: Any,
  75. **kwargs: Any,
  76. ) -> T: ...
  77. def warns(
  78. expected_warning: type[Warning] | tuple[type[Warning], ...] = Warning,
  79. *args: Any,
  80. match: str | re.Pattern[str] | None = None,
  81. **kwargs: Any,
  82. ) -> WarningsChecker | Any:
  83. r"""Assert that code raises a particular class of warning.
  84. Specifically, the parameter ``expected_warning`` can be a warning class or tuple
  85. of warning classes, and the code inside the ``with`` block must issue at least one
  86. warning of that class or classes.
  87. This helper produces a list of :class:`warnings.WarningMessage` objects, one for
  88. each warning emitted (regardless of whether it is an ``expected_warning`` or not).
  89. Since pytest 8.0, unmatched warnings are also re-emitted when the context closes.
  90. This function can be used as a context manager::
  91. >>> import pytest
  92. >>> with pytest.warns(RuntimeWarning):
  93. ... warnings.warn("my warning", RuntimeWarning)
  94. In the context manager form you may use the keyword argument ``match`` to assert
  95. that the warning matches a text or regex::
  96. >>> with pytest.warns(UserWarning, match='must be 0 or None'):
  97. ... warnings.warn("value must be 0 or None", UserWarning)
  98. >>> with pytest.warns(UserWarning, match=r'must be \d+$'):
  99. ... warnings.warn("value must be 42", UserWarning)
  100. >>> with pytest.warns(UserWarning): # catch re-emitted warning
  101. ... with pytest.warns(UserWarning, match=r'must be \d+$'):
  102. ... warnings.warn("this is not here", UserWarning)
  103. Traceback (most recent call last):
  104. ...
  105. Failed: DID NOT WARN. No warnings of type ...UserWarning... were emitted...
  106. **Using with** ``pytest.mark.parametrize``
  107. When using :ref:`pytest.mark.parametrize ref` it is possible to parametrize tests
  108. such that some runs raise a warning and others do not.
  109. This could be achieved in the same way as with exceptions, see
  110. :ref:`parametrizing_conditional_raising` for an example.
  111. """
  112. __tracebackhide__ = True
  113. if not args:
  114. if kwargs:
  115. argnames = ", ".join(sorted(kwargs))
  116. raise TypeError(
  117. f"Unexpected keyword arguments passed to pytest.warns: {argnames}"
  118. "\nUse context-manager form instead?"
  119. )
  120. return WarningsChecker(expected_warning, match_expr=match, _ispytest=True)
  121. else:
  122. func = args[0]
  123. if not callable(func):
  124. raise TypeError(f"{func!r} object (type: {type(func)}) must be callable")
  125. with WarningsChecker(expected_warning, _ispytest=True):
  126. return func(*args[1:], **kwargs)
  127. class WarningsRecorder(warnings.catch_warnings):
  128. """A context manager to record raised warnings.
  129. Each recorded warning is an instance of :class:`warnings.WarningMessage`.
  130. Adapted from `warnings.catch_warnings`.
  131. .. note::
  132. ``DeprecationWarning`` and ``PendingDeprecationWarning`` are treated
  133. differently; see :ref:`ensuring_function_triggers`.
  134. """
  135. def __init__(self, *, _ispytest: bool = False) -> None:
  136. check_ispytest(_ispytest)
  137. super().__init__(record=True)
  138. self._entered = False
  139. self._list: list[warnings.WarningMessage] = []
  140. @property
  141. def list(self) -> list[warnings.WarningMessage]:
  142. """The list of recorded warnings."""
  143. return self._list
  144. def __getitem__(self, i: int) -> warnings.WarningMessage:
  145. """Get a recorded warning by index."""
  146. return self._list[i]
  147. def __iter__(self) -> Iterator[warnings.WarningMessage]:
  148. """Iterate through the recorded warnings."""
  149. return iter(self._list)
  150. def __len__(self) -> int:
  151. """The number of recorded warnings."""
  152. return len(self._list)
  153. def pop(self, cls: type[Warning] = Warning) -> warnings.WarningMessage:
  154. """Pop the first recorded warning which is an instance of ``cls``,
  155. but not an instance of a child class of any other match.
  156. Raises ``AssertionError`` if there is no match.
  157. """
  158. best_idx: int | None = None
  159. for i, w in enumerate(self._list):
  160. if w.category == cls:
  161. return self._list.pop(i) # exact match, stop looking
  162. if issubclass(w.category, cls) and (
  163. best_idx is None
  164. or not issubclass(w.category, self._list[best_idx].category)
  165. ):
  166. best_idx = i
  167. if best_idx is not None:
  168. return self._list.pop(best_idx)
  169. __tracebackhide__ = True
  170. raise AssertionError(f"{cls!r} not found in warning list")
  171. def clear(self) -> None:
  172. """Clear the list of recorded warnings."""
  173. self._list[:] = []
  174. # Type ignored because we basically want the `catch_warnings` generic type
  175. # parameter to be ourselves but that is not possible(?).
  176. def __enter__(self) -> Self: # type: ignore[override]
  177. if self._entered:
  178. __tracebackhide__ = True
  179. raise RuntimeError(f"Cannot enter {self!r} twice")
  180. _list = super().__enter__()
  181. # record=True means it's None.
  182. assert _list is not None
  183. self._list = _list
  184. warnings.simplefilter("always")
  185. return self
  186. def __exit__(
  187. self,
  188. exc_type: type[BaseException] | None,
  189. exc_val: BaseException | None,
  190. exc_tb: TracebackType | None,
  191. ) -> None:
  192. if not self._entered:
  193. __tracebackhide__ = True
  194. raise RuntimeError(f"Cannot exit {self!r} without entering first")
  195. super().__exit__(exc_type, exc_val, exc_tb)
  196. # Built-in catch_warnings does not reset entered state so we do it
  197. # manually here for this context manager to become reusable.
  198. self._entered = False
  199. @final
  200. class WarningsChecker(WarningsRecorder):
  201. def __init__(
  202. self,
  203. expected_warning: type[Warning] | tuple[type[Warning], ...] = Warning,
  204. match_expr: str | re.Pattern[str] | None = None,
  205. *,
  206. _ispytest: bool = False,
  207. ) -> None:
  208. check_ispytest(_ispytest)
  209. super().__init__(_ispytest=True)
  210. msg = "exceptions must be derived from Warning, not %s"
  211. if isinstance(expected_warning, tuple):
  212. for exc in expected_warning:
  213. if not issubclass(exc, Warning):
  214. raise TypeError(msg % type(exc))
  215. expected_warning_tup = expected_warning
  216. elif isinstance(expected_warning, type) and issubclass(
  217. expected_warning, Warning
  218. ):
  219. expected_warning_tup = (expected_warning,)
  220. else:
  221. raise TypeError(msg % type(expected_warning))
  222. self.expected_warning = expected_warning_tup
  223. self.match_expr = match_expr
  224. def matches(self, warning: warnings.WarningMessage) -> bool:
  225. assert self.expected_warning is not None
  226. return issubclass(warning.category, self.expected_warning) and bool(
  227. self.match_expr is None or re.search(self.match_expr, str(warning.message))
  228. )
  229. def __exit__(
  230. self,
  231. exc_type: type[BaseException] | None,
  232. exc_val: BaseException | None,
  233. exc_tb: TracebackType | None,
  234. ) -> None:
  235. super().__exit__(exc_type, exc_val, exc_tb)
  236. __tracebackhide__ = True
  237. # BaseExceptions like pytest.{skip,fail,xfail,exit} or Ctrl-C within
  238. # pytest.warns should *not* trigger "DID NOT WARN" and get suppressed
  239. # when the warning doesn't happen. Control-flow exceptions should always
  240. # propagate.
  241. if exc_val is not None and (
  242. not isinstance(exc_val, Exception)
  243. # Exit is an Exception, not a BaseException, for some reason.
  244. or isinstance(exc_val, Exit)
  245. ):
  246. return
  247. def found_str() -> str:
  248. return pformat([record.message for record in self], indent=2)
  249. try:
  250. if not any(issubclass(w.category, self.expected_warning) for w in self):
  251. fail(
  252. f"DID NOT WARN. No warnings of type {self.expected_warning} were emitted.\n"
  253. f" Emitted warnings: {found_str()}."
  254. )
  255. elif not any(self.matches(w) for w in self):
  256. fail(
  257. f"DID NOT WARN. No warnings of type {self.expected_warning} matching the regex were emitted.\n"
  258. f" Regex: {self.match_expr}\n"
  259. f" Emitted warnings: {found_str()}."
  260. )
  261. finally:
  262. # Whether or not any warnings matched, we want to re-emit all unmatched warnings.
  263. for w in self:
  264. if not self.matches(w):
  265. warnings.warn_explicit(
  266. message=w.message,
  267. category=w.category,
  268. filename=w.filename,
  269. lineno=w.lineno,
  270. module=w.__module__,
  271. source=w.source,
  272. )
  273. # Currently in Python it is possible to pass other types than an
  274. # `str` message when creating `Warning` instances, however this
  275. # causes an exception when :func:`warnings.filterwarnings` is used
  276. # to filter those warnings. See
  277. # https://github.com/python/cpython/issues/103577 for a discussion.
  278. # While this can be considered a bug in CPython, we put guards in
  279. # pytest as the error message produced without this check in place
  280. # is confusing (#10865).
  281. for w in self:
  282. if type(w.message) is not UserWarning:
  283. # If the warning was of an incorrect type then `warnings.warn()`
  284. # creates a UserWarning. Any other warning must have been specified
  285. # explicitly.
  286. continue
  287. if not w.message.args:
  288. # UserWarning() without arguments must have been specified explicitly.
  289. continue
  290. msg = w.message.args[0]
  291. if isinstance(msg, str):
  292. continue
  293. # It's possible that UserWarning was explicitly specified, and
  294. # its first argument was not a string. But that case can't be
  295. # distinguished from an invalid type.
  296. raise TypeError(
  297. f"Warning must be str or Warning, got {msg!r} (type {type(msg).__name__})"
  298. )