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,29 +1,25 @@
# mypy: allow-untyped-defs
"""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
import warnings
from pprint import pformat
from types import TracebackType
from typing import Any
from typing import final
from typing import overload
from typing import TYPE_CHECKING
from typing import Callable
from typing import Generator
from typing import Iterator
from typing import List
from typing import Optional
from typing import Pattern
from typing import Tuple
from typing import Type
from typing import TypeVar
from typing import Union
if TYPE_CHECKING:
from typing_extensions import Self
import warnings
from _pytest.compat import final
from _pytest.compat import overload
from _pytest.deprecated import check_ispytest
from _pytest.deprecated import WARNS_NONE_ARG
from _pytest.fixtures import fixture
from _pytest.outcomes import Exit
from _pytest.outcomes import fail
@@ -31,10 +27,11 @@ T = TypeVar("T")
@fixture
def recwarn() -> Generator[WarningsRecorder]:
def recwarn() -> Generator["WarningsRecorder", None, None]:
"""Return a :class:`WarningsRecorder` instance that records all warnings emitted by test functions.
See :ref:`warnings` for information on warning categories.
See https://docs.pytest.org/en/latest/how-to/capture-warnings.html for information
on warning categories.
"""
wrec = WarningsRecorder(_ispytest=True)
with wrec:
@@ -44,18 +41,22 @@ def recwarn() -> Generator[WarningsRecorder]:
@overload
def deprecated_call(
*, match: str | re.Pattern[str] | None = ...
) -> WarningsRecorder: ...
*, match: Optional[Union[str, Pattern[str]]] = ...
) -> "WarningsRecorder":
...
@overload
def deprecated_call(func: Callable[..., T], *args: Any, **kwargs: Any) -> T: ...
def deprecated_call( # noqa: F811
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``.
def deprecated_call( # noqa: F811
func: Optional[Callable[..., Any]] = None, *args: Any, **kwargs: Any
) -> Union["WarningsRecorder", Any]:
"""Assert that code produces a ``DeprecationWarning`` or ``PendingDeprecationWarning``.
This function can be used as a context manager::
@@ -80,46 +81,46 @@ def deprecated_call(
"""
__tracebackhide__ = True
if func is not None:
args = (func, *args)
return warns(
(DeprecationWarning, PendingDeprecationWarning, FutureWarning), *args, **kwargs
)
args = (func,) + args
return warns((DeprecationWarning, PendingDeprecationWarning), *args, **kwargs)
@overload
def warns(
expected_warning: type[Warning] | tuple[type[Warning], ...] = ...,
expected_warning: Union[Type[Warning], Tuple[Type[Warning], ...]] = ...,
*,
match: str | re.Pattern[str] | None = ...,
) -> WarningsChecker: ...
match: Optional[Union[str, Pattern[str]]] = ...,
) -> "WarningsChecker":
...
@overload
def warns(
expected_warning: type[Warning] | tuple[type[Warning], ...],
def warns( # noqa: F811
expected_warning: Union[Type[Warning], Tuple[Type[Warning], ...]],
func: Callable[..., T],
*args: Any,
**kwargs: Any,
) -> T: ...
) -> T:
...
def warns(
expected_warning: type[Warning] | tuple[type[Warning], ...] = Warning,
def warns( # noqa: F811
expected_warning: Union[Type[Warning], Tuple[Type[Warning], ...]] = Warning,
*args: Any,
match: str | re.Pattern[str] | None = None,
match: Optional[Union[str, Pattern[str]]] = None,
**kwargs: Any,
) -> WarningsChecker | Any:
) -> Union["WarningsChecker", Any]:
r"""Assert that code raises a particular class of warning.
Specifically, the parameter ``expected_warning`` can be a warning class or tuple
Specifically, the parameter ``expected_warning`` can be a warning class or sequence
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.
each warning raised (regardless of whether it is an ``expected_warning`` or not).
This function can be used as a context manager::
This function can be used as a context manager, which will capture all the raised
warnings inside it::
>>> import pytest
>>> with pytest.warns(RuntimeWarning):
@@ -134,9 +135,8 @@ def warns(
>>> 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)
>>> 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...
@@ -167,7 +167,7 @@ def warns(
return func(*args[1:], **kwargs)
class WarningsRecorder(warnings.catch_warnings):
class WarningsRecorder(warnings.catch_warnings): # type:ignore[type-arg]
"""A context manager to record raised warnings.
Each recorded warning is an instance of :class:`warnings.WarningMessage`.
@@ -182,20 +182,21 @@ class WarningsRecorder(warnings.catch_warnings):
def __init__(self, *, _ispytest: bool = False) -> None:
check_ispytest(_ispytest)
super().__init__(record=True)
# Type ignored due to the way typeshed handles warnings.catch_warnings.
super().__init__(record=True) # type: ignore[call-arg]
self._entered = False
self._list: list[warnings.WarningMessage] = []
self._list: List[warnings.WarningMessage] = []
@property
def list(self) -> list[warnings.WarningMessage]:
def list(self) -> List["warnings.WarningMessage"]:
"""The list of recorded warnings."""
return self._list
def __getitem__(self, i: int) -> warnings.WarningMessage:
def __getitem__(self, i: int) -> "warnings.WarningMessage":
"""Get a recorded warning by index."""
return self._list[i]
def __iter__(self) -> Iterator[warnings.WarningMessage]:
def __iter__(self) -> Iterator["warnings.WarningMessage"]:
"""Iterate through the recorded warnings."""
return iter(self._list)
@@ -203,22 +204,11 @@ class WarningsRecorder(warnings.catch_warnings):
"""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
def pop(self, cls: Type[Warning] = Warning) -> "warnings.WarningMessage":
"""Pop the first recorded warning, raise exception if not exists."""
for i, w in enumerate(self._list):
if w.category == cls:
return self._list.pop(i) # exact match, stop looking
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)
if issubclass(w.category, cls):
return self._list.pop(i)
__tracebackhide__ = True
raise AssertionError(f"{cls!r} not found in warning list")
@@ -226,9 +216,9 @@ class WarningsRecorder(warnings.catch_warnings):
"""Clear the list of recorded warnings."""
self._list[:] = []
# Type ignored because we basically want the `catch_warnings` generic type
# parameter to be ourselves but that is not possible(?).
def __enter__(self) -> Self: # type: ignore[override]
# Type ignored because it doesn't exactly warnings.catch_warnings.__enter__
# -- it returns a List but we only emulate one.
def __enter__(self) -> "WarningsRecorder": # type: ignore
if self._entered:
__tracebackhide__ = True
raise RuntimeError(f"Cannot enter {self!r} twice")
@@ -241,9 +231,9 @@ class WarningsRecorder(warnings.catch_warnings):
def __exit__(
self,
exc_type: type[BaseException] | None,
exc_val: BaseException | None,
exc_tb: TracebackType | None,
exc_type: Optional[Type[BaseException]],
exc_val: Optional[BaseException],
exc_tb: Optional[TracebackType],
) -> None:
if not self._entered:
__tracebackhide__ = True
@@ -260,8 +250,10 @@ class WarningsRecorder(warnings.catch_warnings):
class WarningsChecker(WarningsRecorder):
def __init__(
self,
expected_warning: type[Warning] | tuple[type[Warning], ...] = Warning,
match_expr: str | re.Pattern[str] | None = None,
expected_warning: Optional[
Union[Type[Warning], Tuple[Type[Warning], ...]]
] = Warning,
match_expr: Optional[Union[str, Pattern[str]]] = None,
*,
_ispytest: bool = False,
) -> None:
@@ -269,14 +261,15 @@ class WarningsChecker(WarningsRecorder):
super().__init__(_ispytest=True)
msg = "exceptions must be derived from Warning, not %s"
if isinstance(expected_warning, tuple):
if expected_warning is None:
warnings.warn(WARNS_NONE_ARG, stacklevel=4)
expected_warning_tup = None
elif 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
):
elif issubclass(expected_warning, Warning):
expected_warning_tup = (expected_warning,)
else:
raise TypeError(msg % type(expected_warning))
@@ -284,84 +277,37 @@ class WarningsChecker(WarningsRecorder):
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,
exc_type: Optional[Type[BaseException]],
exc_val: Optional[BaseException],
exc_tb: Optional[TracebackType],
) -> None:
super().__exit__(exc_type, exc_val, exc_tb)
__tracebackhide__ = True
# BaseExceptions like pytest.{skip,fail,xfail,exit} or Ctrl-C within
# pytest.warns should *not* trigger "DID NOT WARN" and get suppressed
# when the warning doesn't happen. Control-flow exceptions should always
# propagate.
if exc_val is not None and (
not isinstance(exc_val, Exception)
# Exit is an Exception, not a BaseException, for some reason.
or isinstance(exc_val, Exit)
):
return
def found_str() -> str:
def found_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:
# Whether or not any warnings matched, we want to re-emit all unmatched warnings.
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,
# only check if we're not currently handling an exception
if exc_type is None and exc_val is None and exc_tb is None:
if self.expected_warning is not None:
if not any(issubclass(r.category, self.expected_warning) for r in self):
__tracebackhide__ = True
fail(
f"DID NOT WARN. No warnings of type {self.expected_warning} were emitted.\n"
f"The list of emitted warnings is: {found_str()}."
)
# Currently in Python it is possible to pass other types than an
# `str` message when creating `Warning` instances, however this
# causes an exception when :func:`warnings.filterwarnings` is used
# to filter those warnings. See
# https://github.com/python/cpython/issues/103577 for a discussion.
# While this can be considered a bug in CPython, we put guards in
# pytest as the error message produced without this check in place
# is confusing (#10865).
for w in self:
if type(w.message) is not UserWarning:
# If the warning was of an incorrect type then `warnings.warn()`
# creates a UserWarning. Any other warning must have been specified
# explicitly.
continue
if not w.message.args:
# UserWarning() without arguments must have been specified explicitly.
continue
msg = w.message.args[0]
if isinstance(msg, str):
continue
# It's possible that UserWarning was explicitly specified, and
# its first argument was not a string. But that case can't be
# distinguished from an invalid type.
raise TypeError(
f"Warning must be str or Warning, got {msg!r} (type {type(msg).__name__})"
)
elif self.match_expr is not None:
for r in self:
if issubclass(r.category, self.expected_warning):
if re.compile(self.match_expr).search(str(r.message)):
break
else:
fail(
f"""\
DID NOT WARN. No warnings of type {self.expected_warning} matching the regex were emitted.
Regex: {self.match_expr}
Emitted warnings: {found_str()}"""
)