Enhance errors for exception/warnings matching (#8508)
Co-authored-by: Florian Bruhin <me@the-compiler.org>
This commit is contained in:
parent
3297bb24a9
commit
e9dd3dffab
|
@ -0,0 +1,2 @@
|
||||||
|
Introduce multiline display for warning matching via :py:func:`pytest.warns` and
|
||||||
|
enhance match comparison for :py:func:`_pytest._code.ExceptionInfo.match` as returned by :py:func:`pytest.raises`.
|
|
@ -672,10 +672,11 @@ class ExceptionInfo(Generic[E]):
|
||||||
If it matches `True` is returned, otherwise an `AssertionError` is raised.
|
If it matches `True` is returned, otherwise an `AssertionError` is raised.
|
||||||
"""
|
"""
|
||||||
__tracebackhide__ = True
|
__tracebackhide__ = True
|
||||||
msg = "Regex pattern {!r} does not match {!r}."
|
value = str(self.value)
|
||||||
if regexp == str(self.value):
|
msg = f"Regex pattern did not match.\n Regex: {regexp!r}\n Input: {value!r}"
|
||||||
msg += " Did you mean to `re.escape()` the regex?"
|
if regexp == value:
|
||||||
assert re.search(regexp, str(self.value)), msg.format(regexp, str(self.value))
|
msg += "\n Did you mean to `re.escape()` the regex?"
|
||||||
|
assert re.search(regexp, value), msg
|
||||||
# Return True to allow for "assert excinfo.match()".
|
# Return True to allow for "assert excinfo.match()".
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
"""Record warnings during test function execution."""
|
"""Record warnings during test function execution."""
|
||||||
import re
|
import re
|
||||||
import warnings
|
import warnings
|
||||||
|
from pprint import pformat
|
||||||
from types import TracebackType
|
from types import TracebackType
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
|
@ -142,10 +143,11 @@ def warns(
|
||||||
__tracebackhide__ = True
|
__tracebackhide__ = True
|
||||||
if not args:
|
if not args:
|
||||||
if kwargs:
|
if kwargs:
|
||||||
msg = "Unexpected keyword arguments passed to pytest.warns: "
|
argnames = ", ".join(sorted(kwargs))
|
||||||
msg += ", ".join(sorted(kwargs))
|
raise TypeError(
|
||||||
msg += "\nUse context-manager form instead?"
|
f"Unexpected keyword arguments passed to pytest.warns: {argnames}"
|
||||||
raise TypeError(msg)
|
"\nUse context-manager form instead?"
|
||||||
|
)
|
||||||
return WarningsChecker(expected_warning, match_expr=match, _ispytest=True)
|
return WarningsChecker(expected_warning, match_expr=match, _ispytest=True)
|
||||||
else:
|
else:
|
||||||
func = args[0]
|
func = args[0]
|
||||||
|
@ -191,7 +193,7 @@ class WarningsRecorder(warnings.catch_warnings):
|
||||||
if issubclass(w.category, cls):
|
if issubclass(w.category, cls):
|
||||||
return self._list.pop(i)
|
return self._list.pop(i)
|
||||||
__tracebackhide__ = True
|
__tracebackhide__ = True
|
||||||
raise AssertionError("%r not found in warning list" % cls)
|
raise AssertionError(f"{cls!r} not found in warning list")
|
||||||
|
|
||||||
def clear(self) -> None:
|
def clear(self) -> None:
|
||||||
"""Clear the list of recorded warnings."""
|
"""Clear the list of recorded warnings."""
|
||||||
|
@ -202,7 +204,7 @@ class WarningsRecorder(warnings.catch_warnings):
|
||||||
def __enter__(self) -> "WarningsRecorder": # type: ignore
|
def __enter__(self) -> "WarningsRecorder": # type: ignore
|
||||||
if self._entered:
|
if self._entered:
|
||||||
__tracebackhide__ = True
|
__tracebackhide__ = True
|
||||||
raise RuntimeError("Cannot enter %r twice" % self)
|
raise RuntimeError(f"Cannot enter {self!r} twice")
|
||||||
_list = super().__enter__()
|
_list = super().__enter__()
|
||||||
# record=True means it's None.
|
# record=True means it's None.
|
||||||
assert _list is not None
|
assert _list is not None
|
||||||
|
@ -218,7 +220,7 @@ class WarningsRecorder(warnings.catch_warnings):
|
||||||
) -> None:
|
) -> None:
|
||||||
if not self._entered:
|
if not self._entered:
|
||||||
__tracebackhide__ = True
|
__tracebackhide__ = True
|
||||||
raise RuntimeError("Cannot exit %r without entering first" % self)
|
raise RuntimeError(f"Cannot exit {self!r} without entering first")
|
||||||
|
|
||||||
super().__exit__(exc_type, exc_val, exc_tb)
|
super().__exit__(exc_type, exc_val, exc_tb)
|
||||||
|
|
||||||
|
@ -268,16 +270,17 @@ class WarningsChecker(WarningsRecorder):
|
||||||
|
|
||||||
__tracebackhide__ = True
|
__tracebackhide__ = True
|
||||||
|
|
||||||
|
def found_str():
|
||||||
|
return pformat([record.message for record in self], indent=2)
|
||||||
|
|
||||||
# only check if we're not currently handling an exception
|
# 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 exc_type is None and exc_val is None and exc_tb is None:
|
||||||
if self.expected_warning is not None:
|
if self.expected_warning is not None:
|
||||||
if not any(issubclass(r.category, self.expected_warning) for r in self):
|
if not any(issubclass(r.category, self.expected_warning) for r in self):
|
||||||
__tracebackhide__ = True
|
__tracebackhide__ = True
|
||||||
fail(
|
fail(
|
||||||
"DID NOT WARN. No warnings of type {} were emitted. "
|
f"DID NOT WARN. No warnings of type {self.expected_warning} were emitted.\n"
|
||||||
"The list of emitted warnings is: {}.".format(
|
f"The list of emitted warnings is: {found_str()}."
|
||||||
self.expected_warning, [each.message for each in self]
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
elif self.match_expr is not None:
|
elif self.match_expr is not None:
|
||||||
for r in self:
|
for r in self:
|
||||||
|
@ -286,11 +289,8 @@ class WarningsChecker(WarningsRecorder):
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
fail(
|
fail(
|
||||||
"DID NOT WARN. No warnings of type {} matching"
|
f"""\
|
||||||
" ('{}') were emitted. The list of emitted warnings"
|
DID NOT WARN. No warnings of type {self.expected_warning} matching the regex were emitted.
|
||||||
" is: {}.".format(
|
Regex: {self.match_expr}
|
||||||
self.expected_warning,
|
Emitted warnings: {found_str()}"""
|
||||||
self.match_expr,
|
|
||||||
[each.message for each in self],
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
|
@ -420,18 +420,20 @@ def test_match_raises_error(pytester: Pytester) -> None:
|
||||||
excinfo.match(r'[123]+')
|
excinfo.match(r'[123]+')
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
result = pytester.runpytest()
|
result = pytester.runpytest("--tb=short")
|
||||||
assert result.ret != 0
|
assert result.ret != 0
|
||||||
|
|
||||||
exc_msg = "Regex pattern '[[]123[]]+' does not match 'division by zero'."
|
match = [
|
||||||
result.stdout.fnmatch_lines([f"E * AssertionError: {exc_msg}"])
|
r"E .* AssertionError: Regex pattern did not match.",
|
||||||
|
r"E .* Regex: '\[123\]\+'",
|
||||||
|
r"E .* Input: 'division by zero'",
|
||||||
|
]
|
||||||
|
result.stdout.re_match_lines(match)
|
||||||
result.stdout.no_fnmatch_line("*__tracebackhide__ = True*")
|
result.stdout.no_fnmatch_line("*__tracebackhide__ = True*")
|
||||||
|
|
||||||
result = pytester.runpytest("--fulltrace")
|
result = pytester.runpytest("--fulltrace")
|
||||||
assert result.ret != 0
|
assert result.ret != 0
|
||||||
result.stdout.fnmatch_lines(
|
result.stdout.re_match_lines([r".*__tracebackhide__ = True.*", *match])
|
||||||
["*__tracebackhide__ = True*", f"E * AssertionError: {exc_msg}"]
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestFormattedExcinfo:
|
class TestFormattedExcinfo:
|
||||||
|
|
|
@ -191,10 +191,12 @@ class TestRaises:
|
||||||
int("asdf")
|
int("asdf")
|
||||||
|
|
||||||
msg = "with base 16"
|
msg = "with base 16"
|
||||||
expr = "Regex pattern {!r} does not match \"invalid literal for int() with base 10: 'asdf'\".".format(
|
expr = (
|
||||||
msg
|
"Regex pattern did not match.\n"
|
||||||
|
f" Regex: {msg!r}\n"
|
||||||
|
" Input: \"invalid literal for int() with base 10: 'asdf'\""
|
||||||
)
|
)
|
||||||
with pytest.raises(AssertionError, match=re.escape(expr)):
|
with pytest.raises(AssertionError, match="(?m)" + re.escape(expr)):
|
||||||
with pytest.raises(ValueError, match=msg):
|
with pytest.raises(ValueError, match=msg):
|
||||||
int("asdf", base=10)
|
int("asdf", base=10)
|
||||||
|
|
||||||
|
@ -217,7 +219,7 @@ class TestRaises:
|
||||||
with pytest.raises(AssertionError, match="'foo"):
|
with pytest.raises(AssertionError, match="'foo"):
|
||||||
raise AssertionError("'bar")
|
raise AssertionError("'bar")
|
||||||
(msg,) = excinfo.value.args
|
(msg,) = excinfo.value.args
|
||||||
assert msg == 'Regex pattern "\'foo" does not match "\'bar".'
|
assert msg == '''Regex pattern did not match.\n Regex: "'foo"\n Input: "'bar"'''
|
||||||
|
|
||||||
def test_match_failure_exact_string_message(self):
|
def test_match_failure_exact_string_message(self):
|
||||||
message = "Oh here is a message with (42) numbers in parameters"
|
message = "Oh here is a message with (42) numbers in parameters"
|
||||||
|
@ -226,9 +228,10 @@ class TestRaises:
|
||||||
raise AssertionError(message)
|
raise AssertionError(message)
|
||||||
(msg,) = excinfo.value.args
|
(msg,) = excinfo.value.args
|
||||||
assert msg == (
|
assert msg == (
|
||||||
"Regex pattern 'Oh here is a message with (42) numbers in "
|
"Regex pattern did not match.\n"
|
||||||
"parameters' does not match 'Oh here is a message with (42) "
|
" Regex: 'Oh here is a message with (42) numbers in parameters'\n"
|
||||||
"numbers in parameters'. Did you mean to `re.escape()` the regex?"
|
" Input: 'Oh here is a message with (42) numbers in parameters'\n"
|
||||||
|
" Did you mean to `re.escape()` the regex?"
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_raises_match_wrong_type(self):
|
def test_raises_match_wrong_type(self):
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
import re
|
|
||||||
import warnings
|
import warnings
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
@ -263,7 +262,7 @@ class TestWarns:
|
||||||
with pytest.warns(RuntimeWarning):
|
with pytest.warns(RuntimeWarning):
|
||||||
warnings.warn("user", UserWarning)
|
warnings.warn("user", UserWarning)
|
||||||
excinfo.match(
|
excinfo.match(
|
||||||
r"DID NOT WARN. No warnings of type \(.+RuntimeWarning.+,\) were emitted. "
|
r"DID NOT WARN. No warnings of type \(.+RuntimeWarning.+,\) were emitted.\n"
|
||||||
r"The list of emitted warnings is: \[UserWarning\('user',?\)\]."
|
r"The list of emitted warnings is: \[UserWarning\('user',?\)\]."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -271,15 +270,15 @@ class TestWarns:
|
||||||
with pytest.warns(UserWarning):
|
with pytest.warns(UserWarning):
|
||||||
warnings.warn("runtime", RuntimeWarning)
|
warnings.warn("runtime", RuntimeWarning)
|
||||||
excinfo.match(
|
excinfo.match(
|
||||||
r"DID NOT WARN. No warnings of type \(.+UserWarning.+,\) were emitted. "
|
r"DID NOT WARN. No warnings of type \(.+UserWarning.+,\) were emitted.\n"
|
||||||
r"The list of emitted warnings is: \[RuntimeWarning\('runtime',?\)\]."
|
r"The list of emitted warnings is: \[RuntimeWarning\('runtime',?\)]."
|
||||||
)
|
)
|
||||||
|
|
||||||
with pytest.raises(pytest.fail.Exception) as excinfo:
|
with pytest.raises(pytest.fail.Exception) as excinfo:
|
||||||
with pytest.warns(UserWarning):
|
with pytest.warns(UserWarning):
|
||||||
pass
|
pass
|
||||||
excinfo.match(
|
excinfo.match(
|
||||||
r"DID NOT WARN. No warnings of type \(.+UserWarning.+,\) were emitted. "
|
r"DID NOT WARN. No warnings of type \(.+UserWarning.+,\) were emitted.\n"
|
||||||
r"The list of emitted warnings is: \[\]."
|
r"The list of emitted warnings is: \[\]."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -289,18 +288,14 @@ class TestWarns:
|
||||||
warnings.warn("runtime", RuntimeWarning)
|
warnings.warn("runtime", RuntimeWarning)
|
||||||
warnings.warn("import", ImportWarning)
|
warnings.warn("import", ImportWarning)
|
||||||
|
|
||||||
message_template = (
|
messages = [each.message for each in warninfo]
|
||||||
"DID NOT WARN. No warnings of type {0} were emitted. "
|
expected_str = (
|
||||||
"The list of emitted warnings is: {1}."
|
f"DID NOT WARN. No warnings of type {warning_classes} were emitted.\n"
|
||||||
)
|
f"The list of emitted warnings is: {messages}."
|
||||||
excinfo.match(
|
|
||||||
re.escape(
|
|
||||||
message_template.format(
|
|
||||||
warning_classes, [each.message for each in warninfo]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
assert str(excinfo.value) == expected_str
|
||||||
|
|
||||||
def test_record(self) -> None:
|
def test_record(self) -> None:
|
||||||
with pytest.warns(UserWarning) as record:
|
with pytest.warns(UserWarning) as record:
|
||||||
warnings.warn("user", UserWarning)
|
warnings.warn("user", UserWarning)
|
||||||
|
|
Loading…
Reference in New Issue