Skip to content

Make pytest.warns() re-emit all non-matching warnings #10937

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Jul 1, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AUTHORS
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,7 @@ Raphael Pierzina
Rafal Semik
Raquel Alegre
Ravi Chandra
Reagan Lee
Robert Holt
Roberto Aldera
Roberto Polli
Expand Down
7 changes: 7 additions & 0 deletions changelog/9288.breaking.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
:func:`pytest.warns <warns>` now re-emits unmatched warnings when the context
closes -- previously it would consume all warnings, hiding those that were not
matched by the function.

While this is a new feature, we decided to announce this as a breaking change
because many test suites are configured to error-out on warnings, and will
therefore fail on the newly-re-emitted warnings.
69 changes: 44 additions & 25 deletions src/_pytest/recwarn.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,10 +117,10 @@ def warns( # noqa: F811
warning of that class or classes.

This helper produces a list of :class:`warnings.WarningMessage` objects, one for
each warning raised (regardless of whether it is an ``expected_warning`` or not).
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, which will capture all the raised
warnings inside it::
This function can be used as a context manager::

>>> import pytest
>>> with pytest.warns(RuntimeWarning):
Expand All @@ -135,8 +135,9 @@ def warns( # noqa: F811
>>> with pytest.warns(UserWarning, match=r'must be \d+$'):
... warnings.warn("value must be 42", UserWarning)

>>> with pytest.warns(UserWarning, match=r'must be \d+$'):
... warnings.warn("this is not here", 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...
Expand Down Expand Up @@ -277,6 +278,12 @@ def __init__(
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: Optional[Type[BaseException]],
Expand All @@ -287,27 +294,39 @@ def __exit__(

__tracebackhide__ = True

if self.expected_warning is None:
# nothing to do in this deprecated case, see WARNS_NONE_ARG above
return

if not (exc_type is None and exc_val is None and exc_tb is None):
# We currently ignore missing warnings if an exception is active.
# TODO: fix this, because it means things are surprisingly order-sensitive.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure what this TODO means here... can we clarify it? Also not clear if this is something meant to be done still in this PR, or later.

Copy link
Member

@Zac-HD Zac-HD Jun 29, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, that's a note-to-self about rebasing #11129, which I'll do immediately after this is merged.

return

def found_str():
return pformat([record.message for record in self], indent=2)

# 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()}."
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(
str(w.message),
w.message.__class__, # type: ignore[arg-type]
w.filename,
w.lineno,
module=w.__module__,
source=w.source,
)
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()}"""
)
133 changes: 88 additions & 45 deletions testing/test_recwarn.py
Original file line number Diff line number Diff line change
Expand Up @@ -203,19 +203,21 @@ def test_deprecated_call_specificity(self) -> None:
def f():
warnings.warn(warning("hi"))

with pytest.raises(pytest.fail.Exception):
pytest.deprecated_call(f)
with pytest.raises(pytest.fail.Exception):
with pytest.deprecated_call():
f()
with pytest.warns(warning):
with pytest.raises(pytest.fail.Exception):
pytest.deprecated_call(f)
with pytest.raises(pytest.fail.Exception):
with pytest.deprecated_call():
f()

def test_deprecated_call_supports_match(self) -> None:
with pytest.deprecated_call(match=r"must be \d+$"):
warnings.warn("value must be 42", DeprecationWarning)

with pytest.raises(pytest.fail.Exception):
with pytest.deprecated_call(match=r"must be \d+$"):
warnings.warn("this is not here", DeprecationWarning)
with pytest.deprecated_call():
with pytest.raises(pytest.fail.Exception, match="DID NOT WARN"):
with pytest.deprecated_call(match=r"must be \d+$"):
warnings.warn("this is not here", DeprecationWarning)


class TestWarns:
Expand All @@ -227,8 +229,9 @@ def test_check_callable(self) -> None:
def test_several_messages(self) -> None:
# different messages, b/c Python suppresses multiple identical warnings
pytest.warns(RuntimeWarning, lambda: warnings.warn("w1", RuntimeWarning))
with pytest.raises(pytest.fail.Exception):
pytest.warns(UserWarning, lambda: warnings.warn("w2", RuntimeWarning))
with pytest.warns(RuntimeWarning):
with pytest.raises(pytest.fail.Exception):
pytest.warns(UserWarning, lambda: warnings.warn("w2", RuntimeWarning))
pytest.warns(RuntimeWarning, lambda: warnings.warn("w3", RuntimeWarning))

def test_function(self) -> None:
Expand All @@ -243,13 +246,14 @@ def test_warning_tuple(self) -> None:
pytest.warns(
(RuntimeWarning, SyntaxWarning), lambda: warnings.warn("w2", SyntaxWarning)
)
pytest.raises(
pytest.fail.Exception,
lambda: pytest.warns(
(RuntimeWarning, SyntaxWarning),
lambda: warnings.warn("w3", UserWarning),
),
)
with pytest.warns():
pytest.raises(
pytest.fail.Exception,
lambda: pytest.warns(
(RuntimeWarning, SyntaxWarning),
lambda: warnings.warn("w3", UserWarning),
),
)

def test_as_contextmanager(self) -> None:
with pytest.warns(RuntimeWarning):
Expand All @@ -258,40 +262,43 @@ def test_as_contextmanager(self) -> None:
with pytest.warns(UserWarning):
warnings.warn("user", UserWarning)

with pytest.raises(pytest.fail.Exception) as excinfo:
with pytest.warns(RuntimeWarning):
warnings.warn("user", UserWarning)
with pytest.warns():
with pytest.raises(pytest.fail.Exception) as excinfo:
with pytest.warns(RuntimeWarning):
warnings.warn("user", UserWarning)
excinfo.match(
r"DID NOT WARN. No warnings of type \(.+RuntimeWarning.+,\) were emitted.\n"
r"The list of emitted warnings is: \[UserWarning\('user',?\)\]."
r" Emitted warnings: \[UserWarning\('user',?\)\]."
)

with pytest.raises(pytest.fail.Exception) as excinfo:
with pytest.warns(UserWarning):
warnings.warn("runtime", RuntimeWarning)
with pytest.warns():
with pytest.raises(pytest.fail.Exception) as excinfo:
with pytest.warns(UserWarning):
warnings.warn("runtime", RuntimeWarning)
excinfo.match(
r"DID NOT WARN. No warnings of type \(.+UserWarning.+,\) were emitted.\n"
r"The list of emitted warnings is: \[RuntimeWarning\('runtime',?\)]."
r" Emitted warnings: \[RuntimeWarning\('runtime',?\)]."
)

with pytest.raises(pytest.fail.Exception) as excinfo:
with pytest.warns(UserWarning):
pass
excinfo.match(
r"DID NOT WARN. No warnings of type \(.+UserWarning.+,\) were emitted.\n"
r"The list of emitted warnings is: \[\]."
r" Emitted warnings: \[\]."
)

warning_classes = (UserWarning, FutureWarning)
with pytest.raises(pytest.fail.Exception) as excinfo:
with pytest.warns(warning_classes) as warninfo:
warnings.warn("runtime", RuntimeWarning)
warnings.warn("import", ImportWarning)
with pytest.warns():
with pytest.raises(pytest.fail.Exception) as excinfo:
with pytest.warns(warning_classes) as warninfo:
warnings.warn("runtime", RuntimeWarning)
warnings.warn("import", ImportWarning)

messages = [each.message for each in warninfo]
expected_str = (
f"DID NOT WARN. No warnings of type {warning_classes} were emitted.\n"
f"The list of emitted warnings is: {messages}."
f" Emitted warnings: {messages}."
)

assert str(excinfo.value) == expected_str
Expand Down Expand Up @@ -367,25 +374,31 @@ def test_match_regex(self) -> None:
with pytest.warns(UserWarning, match=r"must be \d+$"):
warnings.warn("value must be 42", UserWarning)

with pytest.raises(pytest.fail.Exception):
with pytest.warns(UserWarning, match=r"must be \d+$"):
warnings.warn("this is not here", UserWarning)
with pytest.warns():
with pytest.raises(pytest.fail.Exception):
with pytest.warns(UserWarning, match=r"must be \d+$"):
warnings.warn("this is not here", UserWarning)

with pytest.raises(pytest.fail.Exception):
with pytest.warns(FutureWarning, match=r"must be \d+$"):
warnings.warn("value must be 42", UserWarning)
with pytest.warns():
with pytest.raises(pytest.fail.Exception):
with pytest.warns(FutureWarning, match=r"must be \d+$"):
warnings.warn("value must be 42", UserWarning)

def test_one_from_multiple_warns(self) -> None:
with pytest.warns(UserWarning, match=r"aaa"):
warnings.warn("cccccccccc", UserWarning)
warnings.warn("bbbbbbbbbb", UserWarning)
warnings.warn("aaaaaaaaaa", UserWarning)
with pytest.warns():
with pytest.raises(pytest.fail.Exception, match="DID NOT WARN"):
with pytest.warns(UserWarning, match=r"aaa"):
with pytest.warns(UserWarning, match=r"aaa"):
warnings.warn("cccccccccc", UserWarning)
warnings.warn("bbbbbbbbbb", UserWarning)
warnings.warn("aaaaaaaaaa", UserWarning)

def test_none_of_multiple_warns(self) -> None:
with pytest.raises(pytest.fail.Exception):
with pytest.warns(UserWarning, match=r"aaa"):
warnings.warn("bbbbbbbbbb", UserWarning)
warnings.warn("cccccccccc", UserWarning)
with pytest.warns():
with pytest.raises(pytest.fail.Exception, match="DID NOT WARN"):
with pytest.warns(UserWarning, match=r"aaa"):
warnings.warn("bbbbbbbbbb", UserWarning)
warnings.warn("cccccccccc", UserWarning)

@pytest.mark.filterwarnings("ignore")
def test_can_capture_previously_warned(self) -> None:
Expand All @@ -403,3 +416,33 @@ def test_warns_context_manager_with_kwargs(self) -> None:
with pytest.warns(UserWarning, foo="bar"): # type: ignore
pass
assert "Unexpected keyword arguments" in str(excinfo.value)

def test_re_emit_single(self) -> None:
with pytest.warns(DeprecationWarning):
with pytest.warns(UserWarning):
warnings.warn("user warning", UserWarning)
warnings.warn("some deprecation warning", DeprecationWarning)

def test_re_emit_multiple(self) -> None:
with pytest.warns(UserWarning):
warnings.warn("first warning", UserWarning)
warnings.warn("second warning", UserWarning)

def test_re_emit_match_single(self) -> None:
with pytest.warns(DeprecationWarning):
with pytest.warns(UserWarning, match="user warning"):
warnings.warn("user warning", UserWarning)
warnings.warn("some deprecation warning", DeprecationWarning)

def test_re_emit_match_multiple(self) -> None:
with warnings.catch_warnings():
warnings.simplefilter("error") # if anything is re-emitted
with pytest.warns(UserWarning, match="user warning"):
warnings.warn("first user warning", UserWarning)
warnings.warn("second user warning", UserWarning)

def test_re_emit_non_match_single(self) -> None:
with pytest.warns(UserWarning, match="v2 warning"):
with pytest.warns(UserWarning, match="v1 warning"):
warnings.warn("v1 warning", UserWarning)
warnings.warn("non-matching v2 warning", UserWarning)