| |
| """Record warnings during test function execution.""" |
|
|
| from __future__ import annotations |
|
|
| from collections.abc import Callable |
| from collections.abc import Generator |
| from collections.abc import Iterator |
| from pprint import pformat |
| import re |
| from types import TracebackType |
| from typing import Any |
| from typing import final |
| from typing import overload |
| from typing import TYPE_CHECKING |
| from typing import TypeVar |
|
|
|
|
| if TYPE_CHECKING: |
| from typing_extensions import Self |
|
|
| import warnings |
|
|
| from _pytest.deprecated import check_ispytest |
| from _pytest.fixtures import fixture |
| from _pytest.outcomes import Exit |
| from _pytest.outcomes import fail |
|
|
|
|
| T = TypeVar("T") |
|
|
|
|
| @fixture |
| def recwarn() -> Generator[WarningsRecorder]: |
| """Return a :class:`WarningsRecorder` instance that records all warnings emitted by test functions. |
| |
| See :ref:`warnings` for information on warning categories. |
| """ |
| wrec = WarningsRecorder(_ispytest=True) |
| with wrec: |
| warnings.simplefilter("default") |
| yield wrec |
|
|
|
|
| @overload |
| def deprecated_call( |
| *, match: str | re.Pattern[str] | None = ... |
| ) -> WarningsRecorder: ... |
|
|
|
|
| @overload |
| def deprecated_call(func: Callable[..., T], *args: Any, **kwargs: Any) -> T: ... |
|
|
|
|
| def deprecated_call( |
| func: Callable[..., Any] | None = None, *args: Any, **kwargs: Any |
| ) -> WarningsRecorder | Any: |
| """Assert that code produces a ``DeprecationWarning`` or ``PendingDeprecationWarning`` or ``FutureWarning``. |
| |
| This function can be used as a context manager:: |
| |
| >>> import warnings |
| >>> def api_call_v2(): |
| ... warnings.warn('use v3 of this api', DeprecationWarning) |
| ... return 200 |
| |
| >>> import pytest |
| >>> with pytest.deprecated_call(): |
| ... assert api_call_v2() == 200 |
| |
| It can also be used by passing a function and ``*args`` and ``**kwargs``, |
| in which case it will ensure calling ``func(*args, **kwargs)`` produces one of |
| the warnings types above. The return value is the return value of the function. |
| |
| In the context manager form you may use the keyword argument ``match`` to assert |
| that the warning matches a text or regex. |
| |
| The context manager produces a list of :class:`warnings.WarningMessage` objects, |
| one for each warning raised. |
| """ |
| __tracebackhide__ = True |
| if func is not None: |
| args = (func, *args) |
| return warns( |
| (DeprecationWarning, PendingDeprecationWarning, FutureWarning), *args, **kwargs |
| ) |
|
|
|
|
| @overload |
| def warns( |
| expected_warning: type[Warning] | tuple[type[Warning], ...] = ..., |
| *, |
| match: str | re.Pattern[str] | None = ..., |
| ) -> WarningsChecker: ... |
|
|
|
|
| @overload |
| def warns( |
| expected_warning: type[Warning] | tuple[type[Warning], ...], |
| func: Callable[..., T], |
| *args: Any, |
| **kwargs: Any, |
| ) -> T: ... |
|
|
|
|
| def warns( |
| expected_warning: type[Warning] | tuple[type[Warning], ...] = Warning, |
| *args: Any, |
| match: str | re.Pattern[str] | None = None, |
| **kwargs: Any, |
| ) -> WarningsChecker | Any: |
| r"""Assert that code raises a particular class of warning. |
| |
| Specifically, the parameter ``expected_warning`` can be a warning class or tuple |
| of warning classes, and the code inside the ``with`` block must issue at least one |
| warning of that class or classes. |
| |
| This helper produces a list of :class:`warnings.WarningMessage` objects, one for |
| each warning emitted (regardless of whether it is an ``expected_warning`` or not). |
| Since pytest 8.0, unmatched warnings are also re-emitted when the context closes. |
| |
| This function can be used as a context manager:: |
| |
| >>> import pytest |
| >>> with pytest.warns(RuntimeWarning): |
| ... warnings.warn("my warning", RuntimeWarning) |
| |
| In the context manager form you may use the keyword argument ``match`` to assert |
| that the warning matches a text or regex:: |
| |
| >>> with pytest.warns(UserWarning, match='must be 0 or None'): |
| ... warnings.warn("value must be 0 or None", UserWarning) |
| |
| >>> with pytest.warns(UserWarning, match=r'must be \d+$'): |
| ... warnings.warn("value must be 42", UserWarning) |
| |
| >>> with pytest.warns(UserWarning): # catch re-emitted warning |
| ... with pytest.warns(UserWarning, match=r'must be \d+$'): |
| ... warnings.warn("this is not here", UserWarning) |
| Traceback (most recent call last): |
| ... |
| Failed: DID NOT WARN. No warnings of type ...UserWarning... were emitted... |
| |
| **Using with** ``pytest.mark.parametrize`` |
| |
| When using :ref:`pytest.mark.parametrize ref` it is possible to parametrize tests |
| such that some runs raise a warning and others do not. |
| |
| This could be achieved in the same way as with exceptions, see |
| :ref:`parametrizing_conditional_raising` for an example. |
| |
| """ |
| __tracebackhide__ = True |
| if not args: |
| if kwargs: |
| argnames = ", ".join(sorted(kwargs)) |
| raise TypeError( |
| f"Unexpected keyword arguments passed to pytest.warns: {argnames}" |
| "\nUse context-manager form instead?" |
| ) |
| return WarningsChecker(expected_warning, match_expr=match, _ispytest=True) |
| else: |
| func = args[0] |
| if not callable(func): |
| raise TypeError(f"{func!r} object (type: {type(func)}) must be callable") |
| with WarningsChecker(expected_warning, _ispytest=True): |
| return func(*args[1:], **kwargs) |
|
|
|
|
| class WarningsRecorder(warnings.catch_warnings): |
| """A context manager to record raised warnings. |
| |
| Each recorded warning is an instance of :class:`warnings.WarningMessage`. |
| |
| Adapted from `warnings.catch_warnings`. |
| |
| .. note:: |
| ``DeprecationWarning`` and ``PendingDeprecationWarning`` are treated |
| differently; see :ref:`ensuring_function_triggers`. |
| |
| """ |
|
|
| def __init__(self, *, _ispytest: bool = False) -> None: |
| check_ispytest(_ispytest) |
| super().__init__(record=True) |
| self._entered = False |
| self._list: list[warnings.WarningMessage] = [] |
|
|
| @property |
| def list(self) -> list[warnings.WarningMessage]: |
| """The list of recorded warnings.""" |
| return self._list |
|
|
| def __getitem__(self, i: int) -> warnings.WarningMessage: |
| """Get a recorded warning by index.""" |
| return self._list[i] |
|
|
| def __iter__(self) -> Iterator[warnings.WarningMessage]: |
| """Iterate through the recorded warnings.""" |
| return iter(self._list) |
|
|
| def __len__(self) -> int: |
| """The number of recorded warnings.""" |
| return len(self._list) |
|
|
| def pop(self, cls: type[Warning] = Warning) -> warnings.WarningMessage: |
| """Pop the first recorded warning which is an instance of ``cls``, |
| but not an instance of a child class of any other match. |
| Raises ``AssertionError`` if there is no match. |
| """ |
| best_idx: int | None = None |
| for i, w in enumerate(self._list): |
| if w.category == cls: |
| return self._list.pop(i) |
| if issubclass(w.category, cls) and ( |
| best_idx is None |
| or not issubclass(w.category, self._list[best_idx].category) |
| ): |
| best_idx = i |
| if best_idx is not None: |
| return self._list.pop(best_idx) |
| __tracebackhide__ = True |
| raise AssertionError(f"{cls!r} not found in warning list") |
|
|
| def clear(self) -> None: |
| """Clear the list of recorded warnings.""" |
| self._list[:] = [] |
|
|
| def __enter__(self) -> Self: |
| if self._entered: |
| __tracebackhide__ = True |
| raise RuntimeError(f"Cannot enter {self!r} twice") |
| _list = super().__enter__() |
| |
| assert _list is not None |
| self._list = _list |
| warnings.simplefilter("always") |
| return self |
|
|
| def __exit__( |
| self, |
| exc_type: type[BaseException] | None, |
| exc_val: BaseException | None, |
| exc_tb: TracebackType | None, |
| ) -> None: |
| if not self._entered: |
| __tracebackhide__ = True |
| raise RuntimeError(f"Cannot exit {self!r} without entering first") |
|
|
| super().__exit__(exc_type, exc_val, exc_tb) |
|
|
| |
| |
| self._entered = False |
|
|
|
|
| @final |
| class WarningsChecker(WarningsRecorder): |
| def __init__( |
| self, |
| expected_warning: type[Warning] | tuple[type[Warning], ...] = Warning, |
| match_expr: str | re.Pattern[str] | None = None, |
| *, |
| _ispytest: bool = False, |
| ) -> None: |
| check_ispytest(_ispytest) |
| super().__init__(_ispytest=True) |
|
|
| msg = "exceptions must be derived from Warning, not %s" |
| if isinstance(expected_warning, tuple): |
| for exc in expected_warning: |
| if not issubclass(exc, Warning): |
| raise TypeError(msg % type(exc)) |
| expected_warning_tup = expected_warning |
| elif isinstance(expected_warning, type) and issubclass( |
| expected_warning, Warning |
| ): |
| expected_warning_tup = (expected_warning,) |
| else: |
| raise TypeError(msg % type(expected_warning)) |
|
|
| self.expected_warning = expected_warning_tup |
| self.match_expr = match_expr |
|
|
| def matches(self, warning: warnings.WarningMessage) -> bool: |
| assert self.expected_warning is not None |
| return issubclass(warning.category, self.expected_warning) and bool( |
| self.match_expr is None or re.search(self.match_expr, str(warning.message)) |
| ) |
|
|
| def __exit__( |
| self, |
| exc_type: type[BaseException] | None, |
| exc_val: BaseException | None, |
| exc_tb: TracebackType | None, |
| ) -> None: |
| super().__exit__(exc_type, exc_val, exc_tb) |
|
|
| __tracebackhide__ = True |
|
|
| |
| |
| |
| |
| if exc_val is not None and ( |
| not isinstance(exc_val, Exception) |
| |
| or isinstance(exc_val, Exit) |
| ): |
| return |
|
|
| def found_str() -> str: |
| return pformat([record.message for record in self], indent=2) |
|
|
| try: |
| if not any(issubclass(w.category, self.expected_warning) for w in self): |
| fail( |
| f"DID NOT WARN. No warnings of type {self.expected_warning} were emitted.\n" |
| f" Emitted warnings: {found_str()}." |
| ) |
| elif not any(self.matches(w) for w in self): |
| fail( |
| f"DID NOT WARN. No warnings of type {self.expected_warning} matching the regex were emitted.\n" |
| f" Regex: {self.match_expr}\n" |
| f" Emitted warnings: {found_str()}." |
| ) |
| finally: |
| |
| for w in self: |
| if not self.matches(w): |
| warnings.warn_explicit( |
| message=w.message, |
| category=w.category, |
| filename=w.filename, |
| lineno=w.lineno, |
| module=w.__module__, |
| source=w.source, |
| ) |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| for w in self: |
| if type(w.message) is not UserWarning: |
| |
| |
| |
| continue |
| if not w.message.args: |
| |
| continue |
| msg = w.message.args[0] |
| if isinstance(msg, str): |
| continue |
| |
| |
| |
| raise TypeError( |
| f"Warning must be str or Warning, got {msg!r} (type {type(msg).__name__})" |
| ) |
|
|