[Pytest-commit] commit/pytest: hpk42: Merged in flub/pytest (pull request #207)

commits-noreply at bitbucket.org commits-noreply at bitbucket.org
Mon Sep 22 19:16:15 CEST 2014


1 new commit in pytest:

https://bitbucket.org/hpk42/pytest/commits/95428e0c36e1/
Changeset:   95428e0c36e1
User:        hpk42
Date:        2014-09-22 17:16:10+00:00
Summary:     Merged in flub/pytest (pull request #207)

Show both user assertion msg as explanation (issue549)
Affected #:  7 files

diff -r 6b38010df14cc3c57c5034313a1edace19451d52 -r 95428e0c36e169596f8370c6663a99e960c01612 CHANGELOG
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -42,6 +42,10 @@
   other builds due to the extra argparse dependency.  Fixes issue566.
   Thanks sontek.
 
+- Implement issue549: user-provided assertion messages now no longer
+  replace the py.test instrospection message but are shown in addition
+  to them.
+
 2.6.1
 -----------------------------------
 

diff -r 6b38010df14cc3c57c5034313a1edace19451d52 -r 95428e0c36e169596f8370c6663a99e960c01612 _pytest/assertion/rewrite.py
--- a/_pytest/assertion/rewrite.py
+++ b/_pytest/assertion/rewrite.py
@@ -351,6 +351,33 @@
 
 from _pytest.assertion.util import format_explanation as _format_explanation # noqa
 
+def _format_assertmsg(obj):
+    """Format the custom assertion message given.
+
+    For strings this simply replaces newlines with '\n~' so that
+    util.format_explanation() will preserve them instead of escaping
+    newlines.  For other objects py.io.saferepr() is used first.
+
+    """
+    # reprlib appears to have a bug which means that if a string
+    # contains a newline it gets escaped, however if an object has a
+    # .__repr__() which contains newlines it does not get escaped.
+    # However in either case we want to preserve the newline.
+    if py.builtin._istext(obj) or py.builtin._isbytes(obj):
+        s = obj
+        is_repr = False
+    else:
+        s = py.io.saferepr(obj)
+        is_repr = True
+    if py.builtin._istext(s):
+        t = py.builtin.text
+    else:
+        t = py.builtin.bytes
+    s = s.replace(t("\n"), t("\n~"))
+    if is_repr:
+        s = s.replace(t("\\n"), t("\n~"))
+    return s
+
 def _should_repr_global_name(obj):
     return not hasattr(obj, "__name__") and not py.builtin.callable(obj)
 
@@ -419,6 +446,56 @@
 
 
 class AssertionRewriter(ast.NodeVisitor):
+    """Assertion rewriting implementation.
+
+    The main entrypoint is to call .run() with an ast.Module instance,
+    this will then find all the assert statements and re-write them to
+    provide intermediate values and a detailed assertion error.  See
+    http://pybites.blogspot.be/2011/07/behind-scenes-of-pytests-new-assertion.html
+    for an overview of how this works.
+
+    The entry point here is .run() which will iterate over all the
+    statenemts in an ast.Module and for each ast.Assert statement it
+    finds call .visit() with it.  Then .visit_Assert() takes over and
+    is responsible for creating new ast statements to replace the
+    original assert statement: it re-writes the test of an assertion
+    to provide intermediate values and replace it with an if statement
+    which raises an assertion error with a detailed explanation in
+    case the expression is false.
+
+    For this .visit_Assert() uses the visitor pattern to visit all the
+    AST nodes of the ast.Assert.test field, each visit call returning
+    an AST node and the corresponding explanation string.  During this
+    state is kept in several instance attributes:
+
+    :statements: All the AST statements which will replace the assert
+       statement.
+
+    :variables: This is populated by .variable() with each variable
+       used by the statements so that they can all be set to None at
+       the end of the statements.
+
+    :variable_counter: Counter to create new unique variables needed
+       by statements.  Variables are created using .variable() and
+       have the form of "@py_assert0".
+
+    :on_failure: The AST statements which will be executed if the
+       assertion test fails.  This is the code which will construct
+       the failure message and raises the AssertionError.
+
+    :explanation_specifiers: A dict filled by .explanation_param()
+       with %-formatting placeholders and their corresponding
+       expressions to use in the building of an assertion message.
+       This is used by .pop_format_context() to build a message.
+
+    :stack: A stack of the explanation_specifiers dicts maintained by
+       .push_format_context() and .pop_format_context() which allows
+       to build another %-formatted string while already building one.
+
+    This state is reset on every new assert statement visited and used
+    by the other visitors.
+
+    """
 
     def run(self, mod):
         """Find all assert statements in *mod* and rewrite them."""
@@ -500,15 +577,41 @@
         return ast.Attribute(builtin_name, name, ast.Load())
 
     def explanation_param(self, expr):
+        """Return a new named %-formatting placeholder for expr.
+
+        This creates a %-formatting placeholder for expr in the
+        current formatting context, e.g. ``%(py0)s``.  The placeholder
+        and expr are placed in the current format context so that it
+        can be used on the next call to .pop_format_context().
+
+        """
         specifier = "py" + str(next(self.variable_counter))
         self.explanation_specifiers[specifier] = expr
         return "%(" + specifier + ")s"
 
     def push_format_context(self):
+        """Create a new formatting context.
+
+        The format context is used for when an explanation wants to
+        have a variable value formatted in the assertion message.  In
+        this case the value required can be added using
+        .explanation_param().  Finally .pop_format_context() is used
+        to format a string of %-formatted values as added by
+        .explanation_param().
+
+        """
         self.explanation_specifiers = {}
         self.stack.append(self.explanation_specifiers)
 
     def pop_format_context(self, expl_expr):
+        """Format the %-formatted string with current format context.
+
+        The expl_expr should be an ast.Str instance constructed from
+        the %-placeholders created by .explanation_param().  This will
+        add the required code to format said string to .on_failure and
+        return the ast.Name instance of the formatted string.
+
+        """
         current = self.stack.pop()
         if self.stack:
             self.explanation_specifiers = self.stack[-1]
@@ -526,11 +629,15 @@
         return res, self.explanation_param(self.display(res))
 
     def visit_Assert(self, assert_):
-        if assert_.msg:
-            # There's already a message. Don't mess with it.
-            return [assert_]
+        """Return the AST statements to replace the ast.Assert instance.
+
+        This re-writes the test of an assertion to provide
+        intermediate values and replace it with an if statement which
+        raises an assertion error with a detailed explanation in case
+        the expression is false.
+
+        """
         self.statements = []
-        self.cond_chain = ()
         self.variables = []
         self.variable_counter = itertools.count()
         self.stack = []
@@ -542,8 +649,13 @@
         body = self.on_failure
         negation = ast.UnaryOp(ast.Not(), top_condition)
         self.statements.append(ast.If(negation, body, []))
-        explanation = "assert " + explanation
-        template = ast.Str(explanation)
+        if assert_.msg:
+            assertmsg = self.helper('format_assertmsg', assert_.msg)
+            explanation = "\n>assert " + explanation
+        else:
+            assertmsg = ast.Str("")
+            explanation = "assert " + explanation
+        template = ast.BinOp(assertmsg, ast.Add(), ast.Str(explanation))
         msg = self.pop_format_context(template)
         fmt = self.helper("format_explanation", msg)
         err_name = ast.Name("AssertionError", ast.Load())

diff -r 6b38010df14cc3c57c5034313a1edace19451d52 -r 95428e0c36e169596f8370c6663a99e960c01612 _pytest/assertion/util.py
--- a/_pytest/assertion/util.py
+++ b/_pytest/assertion/util.py
@@ -73,7 +73,7 @@
     raw_lines = (explanation or u('')).split('\n')
     lines = [raw_lines[0]]
     for l in raw_lines[1:]:
-        if l.startswith('{') or l.startswith('}') or l.startswith('~'):
+        if l and l[0] in ['{', '}', '~', '>']:
             lines.append(l)
         else:
             lines[-1] += '\\n' + l
@@ -103,13 +103,14 @@
             stackcnt.append(0)
             result.append(u(' +') + u('  ')*(len(stack)-1) + s + line[1:])
         elif line.startswith('}'):
-            assert line.startswith('}')
             stack.pop()
             stackcnt.pop()
             result[stack[-1]] += line[1:]
         else:
-            assert line.startswith('~')
-            result.append(u('  ')*len(stack) + line[1:])
+            assert line[0] in ['~', '>']
+            stack[-1] += 1
+            indent = len(stack) if line.startswith('~') else len(stack) - 1
+            result.append(u('  ')*indent + line[1:])
     assert len(stack) == 1
     return result
 

diff -r 6b38010df14cc3c57c5034313a1edace19451d52 -r 95428e0c36e169596f8370c6663a99e960c01612 doc/en/example/assertion/failure_demo.py
--- a/doc/en/example/assertion/failure_demo.py
+++ b/doc/en/example/assertion/failure_demo.py
@@ -211,3 +211,27 @@
         finally:
             x = 0
 
+
+class TestCustomAssertMsg:
+
+    def test_single_line(self):
+        class A:
+            a = 1
+        b = 2
+        assert A.a == b, "A.a appears not to be b"
+
+    def test_multiline(self):
+        class A:
+            a = 1
+        b = 2
+        assert A.a == b, "A.a appears not to be b\n" \
+            "or does not appear to be b\none of those"
+
+    def test_custom_repr(self):
+        class JSON:
+            a = 1
+            def __repr__(self):
+                return "This is JSON\n{\n  'foo': 'bar'\n}"
+        a = JSON()
+        b = 2
+        assert a.a == b, a

diff -r 6b38010df14cc3c57c5034313a1edace19451d52 -r 95428e0c36e169596f8370c6663a99e960c01612 doc/en/example/assertion/test_failures.py
--- a/doc/en/example/assertion/test_failures.py
+++ b/doc/en/example/assertion/test_failures.py
@@ -9,6 +9,6 @@
     failure_demo.copy(testdir.tmpdir.join(failure_demo.basename))
     result = testdir.runpytest(target)
     result.stdout.fnmatch_lines([
-        "*39 failed*"
+        "*42 failed*"
     ])
     assert result.ret != 0

diff -r 6b38010df14cc3c57c5034313a1edace19451d52 -r 95428e0c36e169596f8370c6663a99e960c01612 testing/test_assertion.py
--- a/testing/test_assertion.py
+++ b/testing/test_assertion.py
@@ -4,6 +4,7 @@
 import py, pytest
 import _pytest.assertion as plugin
 from _pytest.assertion import reinterpret
+from _pytest.assertion import util
 needsnewassert = pytest.mark.skipif("sys.version_info < (2,6)")
 
 
@@ -201,7 +202,7 @@
 
 class TestFormatExplanation:
 
-    def test_speical_chars_full(self, testdir):
+    def test_special_chars_full(self, testdir):
         # Issue 453, for the bug this would raise IndexError
         testdir.makepyfile("""
             def test_foo():
@@ -213,6 +214,83 @@
             "*AssertionError*",
         ])
 
+    def test_fmt_simple(self):
+        expl = 'assert foo'
+        assert util.format_explanation(expl) == 'assert foo'
+
+    def test_fmt_where(self):
+        expl = '\n'.join(['assert 1',
+                          '{1 = foo',
+                          '} == 2'])
+        res = '\n'.join(['assert 1 == 2',
+                         ' +  where 1 = foo'])
+        assert util.format_explanation(expl) == res
+
+    def test_fmt_and(self):
+        expl = '\n'.join(['assert 1',
+                          '{1 = foo',
+                          '} == 2',
+                          '{2 = bar',
+                          '}'])
+        res = '\n'.join(['assert 1 == 2',
+                         ' +  where 1 = foo',
+                         ' +  and   2 = bar'])
+        assert util.format_explanation(expl) == res
+
+    def test_fmt_where_nested(self):
+        expl = '\n'.join(['assert 1',
+                          '{1 = foo',
+                          '{foo = bar',
+                          '}',
+                          '} == 2'])
+        res = '\n'.join(['assert 1 == 2',
+                         ' +  where 1 = foo',
+                         ' +    where foo = bar'])
+        assert util.format_explanation(expl) == res
+
+    def test_fmt_newline(self):
+        expl = '\n'.join(['assert "foo" == "bar"',
+                          '~- foo',
+                          '~+ bar'])
+        res = '\n'.join(['assert "foo" == "bar"',
+                         '  - foo',
+                         '  + bar'])
+        assert util.format_explanation(expl) == res
+
+    def test_fmt_newline_escaped(self):
+        expl = '\n'.join(['assert foo == bar',
+                          'baz'])
+        res = 'assert foo == bar\\nbaz'
+        assert util.format_explanation(expl) == res
+
+    def test_fmt_newline_before_where(self):
+        expl = '\n'.join(['the assertion message here',
+                          '>assert 1',
+                          '{1 = foo',
+                          '} == 2',
+                          '{2 = bar',
+                          '}'])
+        res = '\n'.join(['the assertion message here',
+                         'assert 1 == 2',
+                         ' +  where 1 = foo',
+                         ' +  and   2 = bar'])
+        assert util.format_explanation(expl) == res
+
+    def test_fmt_multi_newline_before_where(self):
+        expl = '\n'.join(['the assertion',
+                          '~message here',
+                          '>assert 1',
+                          '{1 = foo',
+                          '} == 2',
+                          '{2 = bar',
+                          '}'])
+        res = '\n'.join(['the assertion',
+                         '  message here',
+                         'assert 1 == 2',
+                         ' +  where 1 = foo',
+                         ' +  and   2 = bar'])
+        assert util.format_explanation(expl) == res
+
 
 def test_python25_compile_issue257(testdir):
     testdir.makepyfile("""

diff -r 6b38010df14cc3c57c5034313a1edace19451d52 -r 95428e0c36e169596f8370c6663a99e960c01612 testing/test_assertrewrite.py
--- a/testing/test_assertrewrite.py
+++ b/testing/test_assertrewrite.py
@@ -121,7 +121,56 @@
     def test_assert_already_has_message(self):
         def f():
             assert False, "something bad!"
-        assert getmsg(f) == "AssertionError: something bad!"
+        assert getmsg(f) == "AssertionError: something bad!\nassert False"
+
+    def test_assertion_message(self, testdir):
+        testdir.makepyfile("""
+            def test_foo():
+                assert 1 == 2, "The failure message"
+        """)
+        result = testdir.runpytest()
+        assert result.ret == 1
+        result.stdout.fnmatch_lines([
+            "*AssertionError*The failure message*",
+            "*assert 1 == 2*",
+        ])
+
+    def test_assertion_message_multiline(self, testdir):
+        testdir.makepyfile("""
+            def test_foo():
+                assert 1 == 2, "A multiline\\nfailure message"
+        """)
+        result = testdir.runpytest()
+        assert result.ret == 1
+        result.stdout.fnmatch_lines([
+            "*AssertionError*A multiline*",
+            "*failure message*",
+            "*assert 1 == 2*",
+        ])
+
+    def test_assertion_message_tuple(self, testdir):
+        testdir.makepyfile("""
+            def test_foo():
+                assert 1 == 2, (1, 2)
+        """)
+        result = testdir.runpytest()
+        assert result.ret == 1
+        result.stdout.fnmatch_lines([
+            "*AssertionError*%s*" % repr((1, 2)),
+            "*assert 1 == 2*",
+        ])
+
+    def test_assertion_message_expr(self, testdir):
+        testdir.makepyfile("""
+            def test_foo():
+                assert 1 == 2, 1 + 2
+        """)
+        result = testdir.runpytest()
+        assert result.ret == 1
+        result.stdout.fnmatch_lines([
+            "*AssertionError*3*",
+            "*assert 1 == 2*",
+        ])
 
     def test_boolop(self):
         def f():

Repository URL: https://bitbucket.org/hpk42/pytest/

--

This is a commit notification from bitbucket.org. You are receiving
this because you have the service enabled, addressing the recipient of
this email.


More information about the pytest-commit mailing list