updates
This commit is contained in:
@@ -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()}"""
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user