Skip to content

Commit e9dd3df

Browse files
Enhance errors for exception/warnings matching (#8508)
Co-authored-by: Florian Bruhin <[email protected]>
1 parent 3297bb2 commit e9dd3df

File tree

6 files changed

+53
-50
lines changed

6 files changed

+53
-50
lines changed

changelog/8508.improvement.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Introduce multiline display for warning matching via :py:func:`pytest.warns` and
2+
enhance match comparison for :py:func:`_pytest._code.ExceptionInfo.match` as returned by :py:func:`pytest.raises`.

src/_pytest/_code/code.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -672,10 +672,11 @@ def match(self, regexp: Union[str, Pattern[str]]) -> "Literal[True]":
672672
If it matches `True` is returned, otherwise an `AssertionError` is raised.
673673
"""
674674
__tracebackhide__ = True
675-
msg = "Regex pattern {!r} does not match {!r}."
676-
if regexp == str(self.value):
677-
msg += " Did you mean to `re.escape()` the regex?"
678-
assert re.search(regexp, str(self.value)), msg.format(regexp, str(self.value))
675+
value = str(self.value)
676+
msg = f"Regex pattern did not match.\n Regex: {regexp!r}\n Input: {value!r}"
677+
if regexp == value:
678+
msg += "\n Did you mean to `re.escape()` the regex?"
679+
assert re.search(regexp, value), msg
679680
# Return True to allow for "assert excinfo.match()".
680681
return True
681682

src/_pytest/recwarn.py

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Record warnings during test function execution."""
22
import re
33
import warnings
4+
from pprint import pformat
45
from types import TracebackType
56
from typing import Any
67
from typing import Callable
@@ -142,10 +143,11 @@ def warns(
142143
__tracebackhide__ = True
143144
if not args:
144145
if kwargs:
145-
msg = "Unexpected keyword arguments passed to pytest.warns: "
146-
msg += ", ".join(sorted(kwargs))
147-
msg += "\nUse context-manager form instead?"
148-
raise TypeError(msg)
146+
argnames = ", ".join(sorted(kwargs))
147+
raise TypeError(
148+
f"Unexpected keyword arguments passed to pytest.warns: {argnames}"
149+
"\nUse context-manager form instead?"
150+
)
149151
return WarningsChecker(expected_warning, match_expr=match, _ispytest=True)
150152
else:
151153
func = args[0]
@@ -191,7 +193,7 @@ def pop(self, cls: Type[Warning] = Warning) -> "warnings.WarningMessage":
191193
if issubclass(w.category, cls):
192194
return self._list.pop(i)
193195
__tracebackhide__ = True
194-
raise AssertionError("%r not found in warning list" % cls)
196+
raise AssertionError(f"{cls!r} not found in warning list")
195197

196198
def clear(self) -> None:
197199
"""Clear the list of recorded warnings."""
@@ -202,7 +204,7 @@ def clear(self) -> None:
202204
def __enter__(self) -> "WarningsRecorder": # type: ignore
203205
if self._entered:
204206
__tracebackhide__ = True
205-
raise RuntimeError("Cannot enter %r twice" % self)
207+
raise RuntimeError(f"Cannot enter {self!r} twice")
206208
_list = super().__enter__()
207209
# record=True means it's None.
208210
assert _list is not None
@@ -218,7 +220,7 @@ def __exit__(
218220
) -> None:
219221
if not self._entered:
220222
__tracebackhide__ = True
221-
raise RuntimeError("Cannot exit %r without entering first" % self)
223+
raise RuntimeError(f"Cannot exit {self!r} without entering first")
222224

223225
super().__exit__(exc_type, exc_val, exc_tb)
224226

@@ -268,16 +270,17 @@ def __exit__(
268270

269271
__tracebackhide__ = True
270272

273+
def found_str():
274+
return pformat([record.message for record in self], indent=2)
275+
271276
# only check if we're not currently handling an exception
272277
if exc_type is None and exc_val is None and exc_tb is None:
273278
if self.expected_warning is not None:
274279
if not any(issubclass(r.category, self.expected_warning) for r in self):
275280
__tracebackhide__ = True
276281
fail(
277-
"DID NOT WARN. No warnings of type {} were emitted. "
278-
"The list of emitted warnings is: {}.".format(
279-
self.expected_warning, [each.message for each in self]
280-
)
282+
f"DID NOT WARN. No warnings of type {self.expected_warning} were emitted.\n"
283+
f"The list of emitted warnings is: {found_str()}."
281284
)
282285
elif self.match_expr is not None:
283286
for r in self:
@@ -286,11 +289,8 @@ def __exit__(
286289
break
287290
else:
288291
fail(
289-
"DID NOT WARN. No warnings of type {} matching"
290-
" ('{}') were emitted. The list of emitted warnings"
291-
" is: {}.".format(
292-
self.expected_warning,
293-
self.match_expr,
294-
[each.message for each in self],
295-
)
292+
f"""\
293+
DID NOT WARN. No warnings of type {self.expected_warning} matching the regex were emitted.
294+
Regex: {self.match_expr}
295+
Emitted warnings: {found_str()}"""
296296
)

testing/code/test_excinfo.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -420,18 +420,20 @@ def test_division_zero():
420420
excinfo.match(r'[123]+')
421421
"""
422422
)
423-
result = pytester.runpytest()
423+
result = pytester.runpytest("--tb=short")
424424
assert result.ret != 0
425425

426-
exc_msg = "Regex pattern '[[]123[]]+' does not match 'division by zero'."
427-
result.stdout.fnmatch_lines([f"E * AssertionError: {exc_msg}"])
426+
match = [
427+
r"E .* AssertionError: Regex pattern did not match.",
428+
r"E .* Regex: '\[123\]\+'",
429+
r"E .* Input: 'division by zero'",
430+
]
431+
result.stdout.re_match_lines(match)
428432
result.stdout.no_fnmatch_line("*__tracebackhide__ = True*")
429433

430434
result = pytester.runpytest("--fulltrace")
431435
assert result.ret != 0
432-
result.stdout.fnmatch_lines(
433-
["*__tracebackhide__ = True*", f"E * AssertionError: {exc_msg}"]
434-
)
436+
result.stdout.re_match_lines([r".*__tracebackhide__ = True.*", *match])
435437

436438

437439
class TestFormattedExcinfo:

testing/python/raises.py

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -191,10 +191,12 @@ def test_raises_match(self) -> None:
191191
int("asdf")
192192

193193
msg = "with base 16"
194-
expr = "Regex pattern {!r} does not match \"invalid literal for int() with base 10: 'asdf'\".".format(
195-
msg
194+
expr = (
195+
"Regex pattern did not match.\n"
196+
f" Regex: {msg!r}\n"
197+
" Input: \"invalid literal for int() with base 10: 'asdf'\""
196198
)
197-
with pytest.raises(AssertionError, match=re.escape(expr)):
199+
with pytest.raises(AssertionError, match="(?m)" + re.escape(expr)):
198200
with pytest.raises(ValueError, match=msg):
199201
int("asdf", base=10)
200202

@@ -217,7 +219,7 @@ def test_match_failure_string_quoting(self):
217219
with pytest.raises(AssertionError, match="'foo"):
218220
raise AssertionError("'bar")
219221
(msg,) = excinfo.value.args
220-
assert msg == 'Regex pattern "\'foo" does not match "\'bar".'
222+
assert msg == '''Regex pattern did not match.\n Regex: "'foo"\n Input: "'bar"'''
221223

222224
def test_match_failure_exact_string_message(self):
223225
message = "Oh here is a message with (42) numbers in parameters"
@@ -226,9 +228,10 @@ def test_match_failure_exact_string_message(self):
226228
raise AssertionError(message)
227229
(msg,) = excinfo.value.args
228230
assert msg == (
229-
"Regex pattern 'Oh here is a message with (42) numbers in "
230-
"parameters' does not match 'Oh here is a message with (42) "
231-
"numbers in parameters'. Did you mean to `re.escape()` the regex?"
231+
"Regex pattern did not match.\n"
232+
" Regex: 'Oh here is a message with (42) numbers in parameters'\n"
233+
" Input: 'Oh here is a message with (42) numbers in parameters'\n"
234+
" Did you mean to `re.escape()` the regex?"
232235
)
233236

234237
def test_raises_match_wrong_type(self):

testing/test_recwarn.py

Lines changed: 10 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import re
21
import warnings
32
from typing import Optional
43

@@ -263,23 +262,23 @@ def test_as_contextmanager(self) -> None:
263262
with pytest.warns(RuntimeWarning):
264263
warnings.warn("user", UserWarning)
265264
excinfo.match(
266-
r"DID NOT WARN. No warnings of type \(.+RuntimeWarning.+,\) were emitted. "
265+
r"DID NOT WARN. No warnings of type \(.+RuntimeWarning.+,\) were emitted.\n"
267266
r"The list of emitted warnings is: \[UserWarning\('user',?\)\]."
268267
)
269268

270269
with pytest.raises(pytest.fail.Exception) as excinfo:
271270
with pytest.warns(UserWarning):
272271
warnings.warn("runtime", RuntimeWarning)
273272
excinfo.match(
274-
r"DID NOT WARN. No warnings of type \(.+UserWarning.+,\) were emitted. "
275-
r"The list of emitted warnings is: \[RuntimeWarning\('runtime',?\)\]."
273+
r"DID NOT WARN. No warnings of type \(.+UserWarning.+,\) were emitted.\n"
274+
r"The list of emitted warnings is: \[RuntimeWarning\('runtime',?\)]."
276275
)
277276

278277
with pytest.raises(pytest.fail.Exception) as excinfo:
279278
with pytest.warns(UserWarning):
280279
pass
281280
excinfo.match(
282-
r"DID NOT WARN. No warnings of type \(.+UserWarning.+,\) were emitted. "
281+
r"DID NOT WARN. No warnings of type \(.+UserWarning.+,\) were emitted.\n"
283282
r"The list of emitted warnings is: \[\]."
284283
)
285284

@@ -289,18 +288,14 @@ def test_as_contextmanager(self) -> None:
289288
warnings.warn("runtime", RuntimeWarning)
290289
warnings.warn("import", ImportWarning)
291290

292-
message_template = (
293-
"DID NOT WARN. No warnings of type {0} were emitted. "
294-
"The list of emitted warnings is: {1}."
295-
)
296-
excinfo.match(
297-
re.escape(
298-
message_template.format(
299-
warning_classes, [each.message for each in warninfo]
300-
)
301-
)
291+
messages = [each.message for each in warninfo]
292+
expected_str = (
293+
f"DID NOT WARN. No warnings of type {warning_classes} were emitted.\n"
294+
f"The list of emitted warnings is: {messages}."
302295
)
303296

297+
assert str(excinfo.value) == expected_str
298+
304299
def test_record(self) -> None:
305300
with pytest.warns(UserWarning) as record:
306301
warnings.warn("user", UserWarning)

0 commit comments

Comments
 (0)