Skip to content

Commit 37bd1e0

Browse files
committed
Show user assertion messages and instrospection together
User provided messages, or any valid expression given as second argument to the assert statement, are now shown in addition to the py.test introspection details. Formerly any user provided message would entirely replace the introspection details. Fixes issue549.
1 parent 9289d77 commit 37bd1e0

File tree

6 files changed

+280
-12
lines changed

6 files changed

+280
-12
lines changed

CHANGELOG

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ NEXT
99
other builds due to the extra argparse dependency. Fixes issue566.
1010
Thanks sontek.
1111

12+
- Implement issue549: user-provided assertion messages now no longer
13+
replace the py.test instrospection message but are shown in addition
14+
to them.
15+
1216
2.6.1
1317
-----------------------------------
1418

_pytest/assertion/rewrite.py

Lines changed: 118 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,33 @@ def rewrite_asserts(mod):
329329
_saferepr = py.io.saferepr
330330
from _pytest.assertion.util import format_explanation as _format_explanation # noqa
331331

332+
def _format_assertmsg(obj):
333+
"""Format the custom assertion message given.
334+
335+
For strings this simply replaces newlines with '\n~' so that
336+
util.format_explanation() will preserve them instead of escaping
337+
newlines. For other objects py.io.saferepr() is used first.
338+
339+
"""
340+
# reprlib appears to have a bug which means that if a string
341+
# contains a newline it gets escaped, however if an object has a
342+
# .__repr__() which contains newlines it does not get escaped.
343+
# However in either case we want to preserve the newline.
344+
if py.builtin._istext(obj) or py.builtin._isbytes(obj):
345+
s = obj
346+
is_repr = False
347+
else:
348+
s = py.io.saferepr(obj)
349+
is_repr = True
350+
if py.builtin._istext(s):
351+
t = py.builtin.text
352+
else:
353+
t = py.builtin.bytes
354+
s = s.replace(t("\n"), t("\n~"))
355+
if is_repr:
356+
s = s.replace(t("\\n"), t("\n~"))
357+
return s
358+
332359
def _should_repr_global_name(obj):
333360
return not hasattr(obj, "__name__") and not py.builtin.callable(obj)
334361

@@ -397,6 +424,56 @@ def _fix(node, lineno, col_offset):
397424

398425

399426
class AssertionRewriter(ast.NodeVisitor):
427+
"""Assertion rewriting implementation.
428+
429+
The main entrypoint is to call .run() with an ast.Module instance,
430+
this will then find all the assert statements and re-write them to
431+
provide intermediate values and a detailed assertion error. See
432+
http://pybites.blogspot.be/2011/07/behind-scenes-of-pytests-new-assertion.html
433+
for an overview of how this works.
434+
435+
The entry point here is .run() which will iterate over all the
436+
statenemts in an ast.Module and for each ast.Assert statement it
437+
finds call .visit() with it. Then .visit_Assert() takes over and
438+
is responsible for creating new ast statements to replace the
439+
original assert statement: it re-writes the test of an assertion
440+
to provide intermediate values and replace it with an if statement
441+
which raises an assertion error with a detailed explanation in
442+
case the expression is false.
443+
444+
For this .visit_Assert() uses the visitor pattern to visit all the
445+
AST nodes of the ast.Assert.test field, each visit call returning
446+
an AST node and the corresponding explanation string. During this
447+
state is kept in several instance attributes:
448+
449+
:statements: All the AST statements which will replace the assert
450+
statement.
451+
452+
:variables: This is populated by .variable() with each variable
453+
used by the statements so that they can all be set to None at
454+
the end of the statements.
455+
456+
:variable_counter: Counter to create new unique variables needed
457+
by statements. Variables are created using .variable() and
458+
have the form of "@py_assert0".
459+
460+
:on_failure: The AST statements which will be executed if the
461+
assertion test fails. This is the code which will construct
462+
the failure message and raises the AssertionError.
463+
464+
:explanation_specifiers: A dict filled by .explanation_param()
465+
with %-formatting placeholders and their corresponding
466+
expressions to use in the building of an assertion message.
467+
This is used by .pop_format_context() to build a message.
468+
469+
:stack: A stack of the explanation_specifiers dicts maintained by
470+
.push_format_context() and .pop_format_context() which allows
471+
to build another %-formatted string while already building one.
472+
473+
This state is reset on every new assert statement visited and used
474+
by the other visitors.
475+
476+
"""
400477

401478
def run(self, mod):
402479
"""Find all assert statements in *mod* and rewrite them."""
@@ -478,15 +555,41 @@ def builtin(self, name):
478555
return ast.Attribute(builtin_name, name, ast.Load())
479556

480557
def explanation_param(self, expr):
558+
"""Return a new named %-formatting placeholder for expr.
559+
560+
This creates a %-formatting placeholder for expr in the
561+
current formatting context, e.g. ``%(py0)s``. The placeholder
562+
and expr are placed in the current format context so that it
563+
can be used on the next call to .pop_format_context().
564+
565+
"""
481566
specifier = "py" + str(next(self.variable_counter))
482567
self.explanation_specifiers[specifier] = expr
483568
return "%(" + specifier + ")s"
484569

485570
def push_format_context(self):
571+
"""Create a new formatting context.
572+
573+
The format context is used for when an explanation wants to
574+
have a variable value formatted in the assertion message. In
575+
this case the value required can be added using
576+
.explanation_param(). Finally .pop_format_context() is used
577+
to format a string of %-formatted values as added by
578+
.explanation_param().
579+
580+
"""
486581
self.explanation_specifiers = {}
487582
self.stack.append(self.explanation_specifiers)
488583

489584
def pop_format_context(self, expl_expr):
585+
"""Format the %-formatted string with current format context.
586+
587+
The expl_expr should be an ast.Str instance constructed from
588+
the %-placeholders created by .explanation_param(). This will
589+
add the required code to format said string to .on_failure and
590+
return the ast.Name instance of the formatted string.
591+
592+
"""
490593
current = self.stack.pop()
491594
if self.stack:
492595
self.explanation_specifiers = self.stack[-1]
@@ -504,11 +607,15 @@ def generic_visit(self, node):
504607
return res, self.explanation_param(self.display(res))
505608

506609
def visit_Assert(self, assert_):
507-
if assert_.msg:
508-
# There's already a message. Don't mess with it.
509-
return [assert_]
610+
"""Return the AST statements to replace the ast.Assert instance.
611+
612+
This re-writes the test of an assertion to provide
613+
intermediate values and replace it with an if statement which
614+
raises an assertion error with a detailed explanation in case
615+
the expression is false.
616+
617+
"""
510618
self.statements = []
511-
self.cond_chain = ()
512619
self.variables = []
513620
self.variable_counter = itertools.count()
514621
self.stack = []
@@ -520,8 +627,13 @@ def visit_Assert(self, assert_):
520627
body = self.on_failure
521628
negation = ast.UnaryOp(ast.Not(), top_condition)
522629
self.statements.append(ast.If(negation, body, []))
523-
explanation = "assert " + explanation
524-
template = ast.Str(explanation)
630+
if assert_.msg:
631+
assertmsg = self.helper('format_assertmsg', assert_.msg)
632+
explanation = "\n>assert " + explanation
633+
else:
634+
assertmsg = ast.Str("")
635+
explanation = "assert " + explanation
636+
template = ast.BinOp(assertmsg, ast.Add(), ast.Str(explanation))
525637
msg = self.pop_format_context(template)
526638
fmt = self.helper("format_explanation", msg)
527639
err_name = ast.Name("AssertionError", ast.Load())

_pytest/assertion/util.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ def _split_explanation(explanation):
7373
raw_lines = (explanation or u('')).split('\n')
7474
lines = [raw_lines[0]]
7575
for l in raw_lines[1:]:
76-
if l.startswith('{') or l.startswith('}') or l.startswith('~'):
76+
if l and l[0] in ['{', '}', '~', '>']:
7777
lines.append(l)
7878
else:
7979
lines[-1] += '\\n' + l
@@ -103,13 +103,14 @@ def _format_lines(lines):
103103
stackcnt.append(0)
104104
result.append(u(' +') + u(' ')*(len(stack)-1) + s + line[1:])
105105
elif line.startswith('}'):
106-
assert line.startswith('}')
107106
stack.pop()
108107
stackcnt.pop()
109108
result[stack[-1]] += line[1:]
110109
else:
111-
assert line.startswith('~')
112-
result.append(u(' ')*len(stack) + line[1:])
110+
assert line[0] in ['~', '>']
111+
stack[-1] += 1
112+
indent = len(stack) if line.startswith('~') else len(stack) - 1
113+
result.append(u(' ')*indent + line[1:])
113114
assert len(stack) == 1
114115
return result
115116

doc/en/example/assertion/failure_demo.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,3 +211,27 @@ def test_try_finally(self):
211211
finally:
212212
x = 0
213213

214+
215+
class TestCustomAssertMsg:
216+
217+
def test_single_line(self):
218+
class A:
219+
a = 1
220+
b = 2
221+
assert A.a == b, "A.a appears not to be b"
222+
223+
def test_multiline(self):
224+
class A:
225+
a = 1
226+
b = 2
227+
assert A.a == b, "A.a appears not to be b\n" \
228+
"or does not appear to be b\none of those"
229+
230+
def test_custom_repr(self):
231+
class JSON:
232+
a = 1
233+
def __repr__(self):
234+
return "This is JSON\n{\n 'foo': 'bar'\n}"
235+
a = JSON()
236+
b = 2
237+
assert a.a == b, a

testing/test_assertion.py

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import py, pytest
55
import _pytest.assertion as plugin
66
from _pytest.assertion import reinterpret
7+
from _pytest.assertion import util
78
needsnewassert = pytest.mark.skipif("sys.version_info < (2,6)")
89

910

@@ -201,7 +202,7 @@ def test_mojibake(self):
201202

202203
class TestFormatExplanation:
203204

204-
def test_speical_chars_full(self, testdir):
205+
def test_special_chars_full(self, testdir):
205206
# Issue 453, for the bug this would raise IndexError
206207
testdir.makepyfile("""
207208
def test_foo():
@@ -213,6 +214,83 @@ def test_foo():
213214
"*AssertionError*",
214215
])
215216

217+
def test_fmt_simple(self):
218+
expl = 'assert foo'
219+
assert util.format_explanation(expl) == 'assert foo'
220+
221+
def test_fmt_where(self):
222+
expl = '\n'.join(['assert 1',
223+
'{1 = foo',
224+
'} == 2'])
225+
res = '\n'.join(['assert 1 == 2',
226+
' + where 1 = foo'])
227+
assert util.format_explanation(expl) == res
228+
229+
def test_fmt_and(self):
230+
expl = '\n'.join(['assert 1',
231+
'{1 = foo',
232+
'} == 2',
233+
'{2 = bar',
234+
'}'])
235+
res = '\n'.join(['assert 1 == 2',
236+
' + where 1 = foo',
237+
' + and 2 = bar'])
238+
assert util.format_explanation(expl) == res
239+
240+
def test_fmt_where_nested(self):
241+
expl = '\n'.join(['assert 1',
242+
'{1 = foo',
243+
'{foo = bar',
244+
'}',
245+
'} == 2'])
246+
res = '\n'.join(['assert 1 == 2',
247+
' + where 1 = foo',
248+
' + where foo = bar'])
249+
assert util.format_explanation(expl) == res
250+
251+
def test_fmt_newline(self):
252+
expl = '\n'.join(['assert "foo" == "bar"',
253+
'~- foo',
254+
'~+ bar'])
255+
res = '\n'.join(['assert "foo" == "bar"',
256+
' - foo',
257+
' + bar'])
258+
assert util.format_explanation(expl) == res
259+
260+
def test_fmt_newline_escaped(self):
261+
expl = '\n'.join(['assert foo == bar',
262+
'baz'])
263+
res = 'assert foo == bar\\nbaz'
264+
assert util.format_explanation(expl) == res
265+
266+
def test_fmt_newline_before_where(self):
267+
expl = '\n'.join(['the assertion message here',
268+
'>assert 1',
269+
'{1 = foo',
270+
'} == 2',
271+
'{2 = bar',
272+
'}'])
273+
res = '\n'.join(['the assertion message here',
274+
'assert 1 == 2',
275+
' + where 1 = foo',
276+
' + and 2 = bar'])
277+
assert util.format_explanation(expl) == res
278+
279+
def test_fmt_multi_newline_before_where(self):
280+
expl = '\n'.join(['the assertion',
281+
'~message here',
282+
'>assert 1',
283+
'{1 = foo',
284+
'} == 2',
285+
'{2 = bar',
286+
'}'])
287+
res = '\n'.join(['the assertion',
288+
' message here',
289+
'assert 1 == 2',
290+
' + where 1 = foo',
291+
' + and 2 = bar'])
292+
assert util.format_explanation(expl) == res
293+
216294

217295
def test_python25_compile_issue257(testdir):
218296
testdir.makepyfile("""

testing/test_assertrewrite.py

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,56 @@ class X(object):
121121
def test_assert_already_has_message(self):
122122
def f():
123123
assert False, "something bad!"
124-
assert getmsg(f) == "AssertionError: something bad!"
124+
assert getmsg(f) == "AssertionError: something bad!\nassert False"
125+
126+
def test_assertion_message(self, testdir):
127+
testdir.makepyfile("""
128+
def test_foo():
129+
assert 1 == 2, "The failure message"
130+
""")
131+
result = testdir.runpytest()
132+
assert result.ret == 1
133+
result.stdout.fnmatch_lines([
134+
"*AssertionError*The failure message*",
135+
"*assert 1 == 2*",
136+
])
137+
138+
def test_assertion_message_multiline(self, testdir):
139+
testdir.makepyfile("""
140+
def test_foo():
141+
assert 1 == 2, "A multiline\\nfailure message"
142+
""")
143+
result = testdir.runpytest()
144+
assert result.ret == 1
145+
result.stdout.fnmatch_lines([
146+
"*AssertionError*A multiline*",
147+
"*failure message*",
148+
"*assert 1 == 2*",
149+
])
150+
151+
def test_assertion_message_tuple(self, testdir):
152+
testdir.makepyfile("""
153+
def test_foo():
154+
assert 1 == 2, (1, 2)
155+
""")
156+
result = testdir.runpytest()
157+
assert result.ret == 1
158+
result.stdout.fnmatch_lines([
159+
"*AssertionError*%s*" % repr((1, 2)),
160+
"*assert 1 == 2*",
161+
])
162+
163+
def test_assertion_message_expr(self, testdir):
164+
testdir.makepyfile("""
165+
def test_foo():
166+
assert 1 == 2, 1 + 2
167+
""")
168+
result = testdir.runpytest()
169+
assert result.ret == 1
170+
result.stdout.fnmatch_lines([
171+
"*AssertionError*3*",
172+
"*assert 1 == 2*",
173+
])
125174

126175
def test_boolop(self):
127176
def f():

0 commit comments

Comments
 (0)