From cd013746cfa054f5115c8a4b25293b102a17b230 Mon Sep 17 00:00:00 2001 From: Floris Bruynooghe Date: Mon, 6 Sep 2010 19:35:17 +0100 Subject: [PATCH 01/11] Initial patch as sent to py-dev With a small but disasterous typo fixed though. --HG-- branch : trunk --- py/_code/_assertionnew.py | 14 +++--- py/_code/assertion.py | 19 ++++++-- py/_plugin/hookspec.py | 13 ++++++ py/_plugin/pytest_assertion.py | 49 ++++++++++++++++++++ testing/code/test_assertionnew.py | 74 +++++++++++++++++++++++++++++++ 5 files changed, 160 insertions(+), 9 deletions(-) create mode 100644 testing/code/test_assertionnew.py diff --git a/py/_code/_assertionnew.py b/py/_code/_assertionnew.py index 0c7b0090e..a80ae898c 100644 --- a/py/_code/_assertionnew.py +++ b/py/_code/_assertionnew.py @@ -162,10 +162,7 @@ class DebugInterpreter(ast.NodeVisitor): def visit_Compare(self, comp): left = comp.left left_explanation, left_result = self.visit(left) - got_result = False for op, next_op in zip(comp.ops, comp.comparators): - if got_result and not result: - break next_explanation, next_result = self.visit(next_op) op_symbol = operator_map[op.__class__] explanation = "%s %s %s" % (left_explanation, op_symbol, @@ -177,9 +174,16 @@ class DebugInterpreter(ast.NodeVisitor): __exprinfo_right=next_result) except Exception: raise Failure(explanation) - else: - got_result = True + if not result: + break left_explanation, left_result = next_explanation, next_result + hook_result = py.test.config.hook.pytest_assert_compare( + op=op_symbol, left=left_result, right=next_result) + if hook_result: + for new_expl in hook_result: + if new_expl: + explanation = '\n~'.join(new_expl) + break return explanation, result def visit_BoolOp(self, boolop): diff --git a/py/_code/assertion.py b/py/_code/assertion.py index 2a2da9cfb..558ee740e 100644 --- a/py/_code/assertion.py +++ b/py/_code/assertion.py @@ -5,12 +5,20 @@ BuiltinAssertionError = py.builtin.builtins.AssertionError def _format_explanation(explanation): - # uck! See CallFunc for where \n{ and \n} escape sequences are used + """This formats an explanation + + Normally all embedded newlines are escaped, however there are + three exceptions: \n{, \n} and \n~. The first two are intended + cover nested explanations, see function and attribute explanations + for examples (.visit_Call(), visit_Attribute()). The last one is + for when one explanation needs to span multiple lines, e.g. when + displaying diffs. + """ raw_lines = (explanation or '').split('\n') - # escape newlines not followed by { and } + # escape newlines not followed by {, } and ~ lines = [raw_lines[0]] for l in raw_lines[1:]: - if l.startswith('{') or l.startswith('}'): + if l.startswith('{') or l.startswith('}') or l.startswith('~'): lines.append(l) else: lines[-1] += '\\n' + l @@ -28,11 +36,14 @@ def _format_explanation(explanation): stackcnt[-1] += 1 stackcnt.append(0) result.append(' +' + ' '*(len(stack)-1) + s + line[1:]) - else: + elif line.startswith('}'): assert line.startswith('}') stack.pop() stackcnt.pop() result[stack[-1]] += line[1:] + else: + assert line.startswith('~') + result.append(' '*len(stack) + line[1:]) assert len(stack) == 1 return '\n'.join(result) diff --git a/py/_plugin/hookspec.py b/py/_plugin/hookspec.py index 04e1bd0d2..f295211cd 100644 --- a/py/_plugin/hookspec.py +++ b/py/_plugin/hookspec.py @@ -123,6 +123,19 @@ def pytest_sessionstart(session): def pytest_sessionfinish(session, exitstatus): """ whole test run finishes. """ +# ------------------------------------------------------------------------- +# hooks for customising the assert methods +# ------------------------------------------------------------------------- + +def pytest_assert_compare(op, left, right): + """Customise compare assertion + + Return None or an empty list for no custom compare, otherwise + return a list of strings. The strings will be joined by newlines + but any newlines *in* as string will be escaped. Note that all + but the first line will be indented sligthly. + """ + # ------------------------------------------------------------------------- # hooks for influencing reporting (invoked from pytest_terminal) # ------------------------------------------------------------------------- diff --git a/py/_plugin/pytest_assertion.py b/py/_plugin/pytest_assertion.py index f18350e7c..5e9c2b81b 100644 --- a/py/_plugin/pytest_assertion.py +++ b/py/_plugin/pytest_assertion.py @@ -1,3 +1,6 @@ +import difflib +import pprint + import py import sys @@ -26,3 +29,49 @@ def warn_about_missing_assertion(): else: py.std.warnings.warn("Assertions are turned off!" " (are you using python -O?)") + + +def pytest_assert_compare(op, left, right): + """Make a specialised explanation for comapare equal""" + if op != '==' or type(left) != type(right): + return None + explanation = [] + left_repr = py.io.saferepr(left, maxsize=30) + right_repr = py.io.saferepr(right, maxsize=30) + explanation += ['%s == %s' % (left_repr, right_repr)] + issquence = lambda x: isinstance(x, (list, tuple)) + istext = lambda x: isinstance(x, basestring) + isdict = lambda x: isinstance(x, dict) + if istext(left): + explanation += [line.strip('\n') for line in + difflib.ndiff(left.splitlines(), right.splitlines())] + elif issquence(left): + explanation += _compare_eq_sequence(left, right) + elif isdict(left): + explanation += _pprint_diff(left, right) + else: + return None # No specialised knowledge + return explanation + + +def _compare_eq_sequence(left, right): + explanation = [] + for i in xrange(min(len(left), len(right))): + if left[i] != right[i]: + explanation += ['First differing item %s: %s != %s' % + (i, left[i], right[i])] + break + if len(left) > len(right): + explanation += ['Left contains more items, ' + 'first extra item: %s' % left[len(right)]] + elif len(left) < len(right): + explanation += ['Right contains more items, ' + 'first extra item: %s' % right[len(right)]] + return explanation + _pprint_diff(left, right) + + +def _pprint_diff(left, right): + """Make explanation using pprint and difflib""" + return [line.strip('\n') for line in + difflib.ndiff(pprint.pformat(left).splitlines(), + pprint.pformat(right).splitlines())] diff --git a/testing/code/test_assertionnew.py b/testing/code/test_assertionnew.py new file mode 100644 index 000000000..ebc0edf77 --- /dev/null +++ b/testing/code/test_assertionnew.py @@ -0,0 +1,74 @@ +import sys + +import py +from py._code._assertionnew import interpret + + +def getframe(): + """Return the frame of the caller as a py.code.Frame object""" + return py.code.Frame(sys._getframe(1)) + + +def setup_module(mod): + py.code.patch_builtins(assertion=True, compile=False) + + +def teardown_module(mod): + py.code.unpatch_builtins(assertion=True, compile=False) + + +def test_assert_simple(): + # Simply test that this way of testing works + a = 0 + b = 1 + r = interpret('assert a == b', getframe()) + assert r == 'assert 0 == 1' + + +def test_assert_list(): + r = interpret('assert [0, 1] == [0, 2]', getframe()) + msg = ('assert [0, 1] == [0, 2]\n' + ' First differing item 1: 1 != 2\n' + ' - [0, 1]\n' + ' ? ^\n' + ' + [0, 2]\n' + ' ? ^') + print r + assert r == msg + + +def test_assert_string(): + r = interpret('assert "foo and bar" == "foo or bar"', getframe()) + msg = ("assert 'foo and bar' == 'foo or bar'\n" + " - foo and bar\n" + " ? ^^^\n" + " + foo or bar\n" + " ? ^^") + print r + assert r == msg + + +def test_assert_multiline_string(): + a = 'foo\nand bar\nbaz' + b = 'foo\nor bar\nbaz' + r = interpret('assert a == b', getframe()) + msg = ("assert 'foo\\nand bar\\nbaz' == 'foo\\nor bar\\nbaz'\n" + ' foo\n' + ' - and bar\n' + ' + or bar\n' + ' baz') + print r + assert r == msg + + +def test_assert_dict(): + a = {'a': 0, 'b': 1} + b = {'a': 0, 'c': 2} + r = interpret('assert a == b', getframe()) + msg = ("assert {'a': 0, 'b': 1} == {'a': 0, 'c': 2}\n" + " - {'a': 0, 'b': 1}\n" + " ? ^ ^\n" + " + {'a': 0, 'c': 2}\n" + " ? ^ ^") + print r + assert r == msg From f194b16a09dfaca18fdf50061060efd28a4f4538 Mon Sep 17 00:00:00 2001 From: Floris Bruynooghe Date: Mon, 6 Sep 2010 19:46:58 +0100 Subject: [PATCH 02/11] Don't import difflib and pprint up-front Builtin plugins need to keep their import time to a minimum. Therefore it's better to delay importing till you really need it, i.e. use py.std.* in this case. --HG-- branch : trunk --- py/_plugin/pytest_assertion.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/py/_plugin/pytest_assertion.py b/py/_plugin/pytest_assertion.py index 5e9c2b81b..1e5452f07 100644 --- a/py/_plugin/pytest_assertion.py +++ b/py/_plugin/pytest_assertion.py @@ -1,6 +1,3 @@ -import difflib -import pprint - import py import sys @@ -44,7 +41,7 @@ def pytest_assert_compare(op, left, right): isdict = lambda x: isinstance(x, dict) if istext(left): explanation += [line.strip('\n') for line in - difflib.ndiff(left.splitlines(), right.splitlines())] + py.std.difflib.ndiff(left.splitlines(), right.splitlines())] elif issquence(left): explanation += _compare_eq_sequence(left, right) elif isdict(left): @@ -73,5 +70,5 @@ def _compare_eq_sequence(left, right): def _pprint_diff(left, right): """Make explanation using pprint and difflib""" return [line.strip('\n') for line in - difflib.ndiff(pprint.pformat(left).splitlines(), - pprint.pformat(right).splitlines())] + py.std.difflib.ndiff(py.std.pprint.pformat(left).splitlines(), + py.std.pprint.pformat(right).splitlines())] From 6fb56443a9a1d7a5424f04f53b97ae3611a48bf9 Mon Sep 17 00:00:00 2001 From: Floris Bruynooghe Date: Wed, 8 Sep 2010 22:21:52 +0100 Subject: [PATCH 03/11] Split the tests between the core and plugin The tests for _assertionnew are much better, the ones for pytest_assert_compare() are still not great. --HG-- branch : trunk --- testing/code/test_assertionnew.py | 88 ++++++++----------------- testing/plugin/test_pytest_assertion.py | 32 +++++++++ 2 files changed, 61 insertions(+), 59 deletions(-) diff --git a/testing/code/test_assertionnew.py b/testing/code/test_assertionnew.py index ebc0edf77..8511f8125 100644 --- a/testing/code/test_assertionnew.py +++ b/testing/code/test_assertionnew.py @@ -9,66 +9,36 @@ def getframe(): return py.code.Frame(sys._getframe(1)) -def setup_module(mod): - py.code.patch_builtins(assertion=True, compile=False) +def pytest_funcarg__hook(request): + class MockHook(object): + def __init__(self): + self.called = False + self.args = tuple() + self.kwargs = dict() + + def __call__(self, op, left, right): + self.called = True + self.op = op + self.left = left + self.right = right + return MockHook() -def teardown_module(mod): - py.code.unpatch_builtins(assertion=True, compile=False) +def test_pytest_assert_compare_called(monkeypatch, hook): + monkeypatch.setattr(py._plugin.pytest_assertion, + 'pytest_assert_compare', hook) + interpret('assert 0 == 1', getframe()) + assert hook.called -def test_assert_simple(): - # Simply test that this way of testing works - a = 0 - b = 1 - r = interpret('assert a == b', getframe()) - assert r == 'assert 0 == 1' - - -def test_assert_list(): - r = interpret('assert [0, 1] == [0, 2]', getframe()) - msg = ('assert [0, 1] == [0, 2]\n' - ' First differing item 1: 1 != 2\n' - ' - [0, 1]\n' - ' ? ^\n' - ' + [0, 2]\n' - ' ? ^') - print r - assert r == msg - - -def test_assert_string(): - r = interpret('assert "foo and bar" == "foo or bar"', getframe()) - msg = ("assert 'foo and bar' == 'foo or bar'\n" - " - foo and bar\n" - " ? ^^^\n" - " + foo or bar\n" - " ? ^^") - print r - assert r == msg - - -def test_assert_multiline_string(): - a = 'foo\nand bar\nbaz' - b = 'foo\nor bar\nbaz' - r = interpret('assert a == b', getframe()) - msg = ("assert 'foo\\nand bar\\nbaz' == 'foo\\nor bar\\nbaz'\n" - ' foo\n' - ' - and bar\n' - ' + or bar\n' - ' baz') - print r - assert r == msg - - -def test_assert_dict(): - a = {'a': 0, 'b': 1} - b = {'a': 0, 'c': 2} - r = interpret('assert a == b', getframe()) - msg = ("assert {'a': 0, 'b': 1} == {'a': 0, 'c': 2}\n" - " - {'a': 0, 'b': 1}\n" - " ? ^ ^\n" - " + {'a': 0, 'c': 2}\n" - " ? ^ ^") - print r - assert r == msg +def test_pytest_assert_compare_args(monkeypatch, hook): + print hook.called + monkeypatch.setattr(py._plugin.pytest_assertion, + 'pytest_assert_compare', hook) + interpret('assert [0, 1] == [0, 2]', getframe()) + print hook.called + print hook.left + print hook.right + assert hook.op == '==' + assert hook.left == [0, 1] + assert hook.right == [0, 2] diff --git a/testing/plugin/test_pytest_assertion.py b/testing/plugin/test_pytest_assertion.py index c76a93b4d..4c34a0797 100644 --- a/testing/plugin/test_pytest_assertion.py +++ b/testing/plugin/test_pytest_assertion.py @@ -1,3 +1,7 @@ +import py +import py._plugin.pytest_assertion as plugin + + def test_functional(testdir): testdir.makepyfile(""" def test_hello(): @@ -49,3 +53,31 @@ def test_traceback_failure(testdir): "*test_traceback_failure.py:4: AssertionError" ]) + +class Test_pytest_assert_compare: + def test_different_types(self): + assert plugin.pytest_assert_compare('==', [0, 1], 'foo') is None + + def test_summary(self): + summary = plugin.pytest_assert_compare('==', [0, 1], [0, 2])[0] + assert len(summary) < 65 + + def test_text_diff(self): + diff = plugin.pytest_assert_compare('==', 'spam', 'eggs')[1:] + assert '- spam' in diff + assert '+ eggs' in diff + + def test_multiline_text_diff(self): + left = 'foo\nspam\nbar' + right = 'foo\neggs\nbar' + diff = plugin.pytest_assert_compare('==', left, right) + assert '- spam' in diff + assert '+ eggs' in diff + + def test_list(self): + expl = plugin.pytest_assert_compare('==', [0, 1], [0, 2]) + assert len(expl) > 1 + + def test_dict(self): + expl = plugin.pytest_assert_compare('==', {'a': 0}, {'a': 1}) + assert len(expl) > 1 From 58169edc8e13b5d94fb7c51ddd26497ab55c9a3b Mon Sep 17 00:00:00 2001 From: Floris Bruynooghe Date: Thu, 16 Sep 2010 01:06:07 +0100 Subject: [PATCH 04/11] Add set comparison Also add a (too) simple mechanism too truncate too long explanations. --HG-- branch : trunk --- py/_plugin/pytest_assertion.py | 55 +++++++++++++++++++------ testing/plugin/test_pytest_assertion.py | 4 ++ 2 files changed, 46 insertions(+), 13 deletions(-) diff --git a/py/_plugin/pytest_assertion.py b/py/_plugin/pytest_assertion.py index 1e5452f07..8f2aa5b22 100644 --- a/py/_plugin/pytest_assertion.py +++ b/py/_plugin/pytest_assertion.py @@ -30,25 +30,39 @@ def warn_about_missing_assertion(): def pytest_assert_compare(op, left, right): """Make a specialised explanation for comapare equal""" - if op != '==' or type(left) != type(right): + if type(left) != type(right): return None - explanation = [] + left_repr = py.io.saferepr(left, maxsize=30) right_repr = py.io.saferepr(right, maxsize=30) - explanation += ['%s == %s' % (left_repr, right_repr)] + summary = '%s %s %s' % (left_repr, op, right_repr) + issquence = lambda x: isinstance(x, (list, tuple)) istext = lambda x: isinstance(x, basestring) isdict = lambda x: isinstance(x, dict) - if istext(left): - explanation += [line.strip('\n') for line in - py.std.difflib.ndiff(left.splitlines(), right.splitlines())] - elif issquence(left): - explanation += _compare_eq_sequence(left, right) - elif isdict(left): - explanation += _pprint_diff(left, right) - else: - return None # No specialised knowledge - return explanation + isset = lambda: isinstance(left, set) + + explanation = None + if op == '==': + if istext(left): + explanation = [line.strip('\n') for line in + py.std.difflib.ndiff(left.splitlines(), + right.splitlines())] + elif issquence(left): + explanation = _compare_eq_sequence(left, right) + elif isset(): + explanation = _compare_eq_set(left, right) + elif isdict(left): + explanation = _pprint_diff(left, right) + + if not explanation: + return None + + # Don't include pageloads of data, should be configurable + if len(''.join(explanation)) > 80*8: + explanation = ['Detailed information too verbose, truncated'] + + return [summary] + explanation def _compare_eq_sequence(left, right): @@ -72,3 +86,18 @@ def _pprint_diff(left, right): return [line.strip('\n') for line in py.std.difflib.ndiff(py.std.pprint.pformat(left).splitlines(), py.std.pprint.pformat(right).splitlines())] + + +def _compare_eq_set(left, right): + explanation = [] + diff_left = left - right + diff_right = right - left + if diff_left: + explanation.append('Extra items in the left set:') + for item in diff_left: + explanation.append(py.io.saferepr(item)) + if diff_right: + explanation.append('Extra items in the right set:') + for item in diff_right: + explanation.append(py.io.saferepr(item)) + return explanation diff --git a/testing/plugin/test_pytest_assertion.py b/testing/plugin/test_pytest_assertion.py index 4c34a0797..9ab007f29 100644 --- a/testing/plugin/test_pytest_assertion.py +++ b/testing/plugin/test_pytest_assertion.py @@ -81,3 +81,7 @@ class Test_pytest_assert_compare: def test_dict(self): expl = plugin.pytest_assert_compare('==', {'a': 0}, {'a': 1}) assert len(expl) > 1 + + def test_set(self): + expl = plugin.pytest_assert_compare('==', set([0, 1]), set([0, 2])) + assert len(expl) > 1 From 0af90e0962fde4532f773ac661cb63c051c38c37 Mon Sep 17 00:00:00 2001 From: Floris Bruynooghe Date: Thu, 16 Sep 2010 01:07:53 +0100 Subject: [PATCH 05/11] Add specialised explanations to the demo This currently breaks the test_failuers.py example as that file counts the number of failures in the demo. But this demo isn't fixed yet so we'll leave it for now. --HG-- branch : trunk --- doc/example/assertion/failure_demo.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/doc/example/assertion/failure_demo.py b/doc/example/assertion/failure_demo.py index 5fba99db7..b99253ee8 100644 --- a/doc/example/assertion/failure_demo.py +++ b/doc/example/assertion/failure_demo.py @@ -118,5 +118,25 @@ def test_dynamic_compile_shows_nicely(): module.foo() +class TestSpecialisedExplanations(object): + def test_eq_text(self): + assert 'spam' == 'eggs' + + def test_eq_similar_text(self): + assert 'foo 1 bar' == 'foo 2 bar' + + def test_eq_multiline_text(self): + assert 'foo\nspam\nbar' == 'foo\neggs\nbar' + + def test_eq_list(self): + assert [0, 1, 2] == [0, 1, 3] + + def test_eq_dict(self): + assert {'a': 0, 'b': 1} == {'a': 0, 'b': 2} + + def test_eq_set(self): + assert set([0, 10, 11, 12]) == set([0, 20, 21]) + + def globf(x): return x+1 From abab8f6f63783cbd22f30744d805e449881469d3 Mon Sep 17 00:00:00 2001 From: Floris Bruynooghe Date: Sat, 18 Sep 2010 13:03:28 +0100 Subject: [PATCH 06/11] Move all tests to test_pytest_assertion The py.code code is independent of any py.test specifics so we should avoid creating dependencies on py.test in those parts. --HG-- branch : trunk --- testing/code/test_assertionnew.py | 44 ------------------------ testing/plugin/test_pytest_assertion.py | 45 ++++++++++++++++++++++++- 2 files changed, 44 insertions(+), 45 deletions(-) delete mode 100644 testing/code/test_assertionnew.py diff --git a/testing/code/test_assertionnew.py b/testing/code/test_assertionnew.py deleted file mode 100644 index 8511f8125..000000000 --- a/testing/code/test_assertionnew.py +++ /dev/null @@ -1,44 +0,0 @@ -import sys - -import py -from py._code._assertionnew import interpret - - -def getframe(): - """Return the frame of the caller as a py.code.Frame object""" - return py.code.Frame(sys._getframe(1)) - - -def pytest_funcarg__hook(request): - class MockHook(object): - def __init__(self): - self.called = False - self.args = tuple() - self.kwargs = dict() - - def __call__(self, op, left, right): - self.called = True - self.op = op - self.left = left - self.right = right - return MockHook() - - -def test_pytest_assert_compare_called(monkeypatch, hook): - monkeypatch.setattr(py._plugin.pytest_assertion, - 'pytest_assert_compare', hook) - interpret('assert 0 == 1', getframe()) - assert hook.called - - -def test_pytest_assert_compare_args(monkeypatch, hook): - print hook.called - monkeypatch.setattr(py._plugin.pytest_assertion, - 'pytest_assert_compare', hook) - interpret('assert [0, 1] == [0, 2]', getframe()) - print hook.called - print hook.left - print hook.right - assert hook.op == '==' - assert hook.left == [0, 1] - assert hook.right == [0, 2] diff --git a/testing/plugin/test_pytest_assertion.py b/testing/plugin/test_pytest_assertion.py index 9ab007f29..596de0050 100644 --- a/testing/plugin/test_pytest_assertion.py +++ b/testing/plugin/test_pytest_assertion.py @@ -1,7 +1,30 @@ +import sys + import py +from py._code._assertionnew import interpret import py._plugin.pytest_assertion as plugin +def getframe(): + """Return the frame of the caller as a py.code.Frame object""" + return py.code.Frame(sys._getframe(1)) + + +def pytest_funcarg__hook(request): + class MockHook(object): + def __init__(self): + self.called = False + self.args = tuple() + self.kwargs = dict() + + def __call__(self, op, left, right): + self.called = True + self.op = op + self.left = left + self.right = right + return MockHook() + + def test_functional(testdir): testdir.makepyfile(""" def test_hello(): @@ -54,7 +77,27 @@ def test_traceback_failure(testdir): ]) -class Test_pytest_assert_compare: +def test_pytest_assert_compare_called(monkeypatch, hook): + monkeypatch.setattr(py._plugin.pytest_assertion, + 'pytest_assert_compare', hook) + interpret('assert 0 == 1', getframe()) + assert hook.called + + +def test_pytest_assert_compare_args(monkeypatch, hook): + print hook.called + monkeypatch.setattr(py._plugin.pytest_assertion, + 'pytest_assert_compare', hook) + interpret('assert [0, 1] == [0, 2]', getframe()) + print hook.called + print hook.left + print hook.right + assert hook.op == '==' + assert hook.left == [0, 1] + assert hook.right == [0, 2] + + +class TestAssertCompare: def test_different_types(self): assert plugin.pytest_assert_compare('==', [0, 1], 'foo') is None From b86207a6c1f0ad58a265cc206cff41e07809058f Mon Sep 17 00:00:00 2001 From: Floris Bruynooghe Date: Wed, 22 Sep 2010 00:26:12 +0100 Subject: [PATCH 07/11] Don't load py.test.config inside py._code._assertionnew Loading py.test.config triggers py.test initialisation while py.code should stay independent of py.test. By adding the hook as an attribute to py.test AssertionError py.code can get access to the hooks only when py.test is loaded already. --HG-- branch : trunk --- py/_code/_assertionnew.py | 23 +++++++++++++++-------- py/_plugin/pytest_assertion.py | 5 +++++ 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/py/_code/_assertionnew.py b/py/_code/_assertionnew.py index a80ae898c..192eecfce 100644 --- a/py/_code/_assertionnew.py +++ b/py/_code/_assertionnew.py @@ -108,10 +108,16 @@ unary_map = { class DebugInterpreter(ast.NodeVisitor): - """Interpret AST nodes to gleam useful debugging information.""" + """Interpret AST nodes to gleam useful debugging information. + + The _pytesthook attribute is used to detect if the py.test + pytest_assertion plugin is loaded and if so call it's hooks. + """ def __init__(self, frame): self.frame = frame + self._pytesthook = getattr(py.builtin.builtins.AssertionError, + "_pytesthook") def generic_visit(self, node): # Fallback when we don't have a special implementation. @@ -177,13 +183,14 @@ class DebugInterpreter(ast.NodeVisitor): if not result: break left_explanation, left_result = next_explanation, next_result - hook_result = py.test.config.hook.pytest_assert_compare( - op=op_symbol, left=left_result, right=next_result) - if hook_result: - for new_expl in hook_result: - if new_expl: - explanation = '\n~'.join(new_expl) - break + if self._pytesthook: + hook_result = self._pytesthook.pytest_assert_compare( + op=op_symbol, left=left_result, right=next_result) + if hook_result: + for new_expl in hook_result: + if new_expl: + explanation = '\n~'.join(new_expl) + break return explanation, result def visit_BoolOp(self, boolop): diff --git a/py/_plugin/pytest_assertion.py b/py/_plugin/pytest_assertion.py index 8f2aa5b22..2816671ab 100644 --- a/py/_plugin/pytest_assertion.py +++ b/py/_plugin/pytest_assertion.py @@ -8,10 +8,15 @@ def pytest_addoption(parser): help="disable python assert expression reinterpretation."), def pytest_configure(config): + # The _pytesthook attribute on the AssertionError is used by + # py._code._assertionnew to detect this plugin was loaded and in + # turn call the hooks defined here as part of the + # DebugInterpreter. if not config.getvalue("noassert") and not config.getvalue("nomagic"): warn_about_missing_assertion() config._oldassertion = py.builtin.builtins.AssertionError py.builtin.builtins.AssertionError = py.code._AssertionError + py.builtin.builtins.AssertionError._pytesthook = config.hook def pytest_unconfigure(config): if hasattr(config, '_oldassertion'): From ca84a5e8e0ca66600c13fbacf0358088f2e1a929 Mon Sep 17 00:00:00 2001 From: Floris Bruynooghe Date: Wed, 22 Sep 2010 00:56:39 +0100 Subject: [PATCH 08/11] Rename pytest_assert_compare to pytest_assert_binrepr Holger prefers to only have one hook and it also turns out that "in" is actually a ast.Compare node as well too. This also modifies the pytest_assert_binrepr hook slightly so that it's more accomodating to other operators then just compare (i.e. don't bail out as soon as the types of the operands differ). --HG-- branch : trunk --- py/_code/_assertionnew.py | 2 +- py/_plugin/hookspec.py | 11 ++++++----- py/_plugin/pytest_assertion.py | 22 +++++++++++----------- testing/plugin/test_pytest_assertion.py | 22 +++++++++++----------- 4 files changed, 29 insertions(+), 28 deletions(-) diff --git a/py/_code/_assertionnew.py b/py/_code/_assertionnew.py index 192eecfce..2c40a93dc 100644 --- a/py/_code/_assertionnew.py +++ b/py/_code/_assertionnew.py @@ -184,7 +184,7 @@ class DebugInterpreter(ast.NodeVisitor): break left_explanation, left_result = next_explanation, next_result if self._pytesthook: - hook_result = self._pytesthook.pytest_assert_compare( + hook_result = self._pytesthook.pytest_assert_binrepr( op=op_symbol, left=left_result, right=next_result) if hook_result: for new_expl in hook_result: diff --git a/py/_plugin/hookspec.py b/py/_plugin/hookspec.py index f295211cd..9f34c4598 100644 --- a/py/_plugin/hookspec.py +++ b/py/_plugin/hookspec.py @@ -127,13 +127,14 @@ def pytest_sessionfinish(session, exitstatus): # hooks for customising the assert methods # ------------------------------------------------------------------------- -def pytest_assert_compare(op, left, right): - """Customise compare assertion +def pytest_assert_binrepr(op, left, right): + """Customise explanation for binary operators - Return None or an empty list for no custom compare, otherwise + Return None or an empty list for no custom explanation, otherwise return a list of strings. The strings will be joined by newlines - but any newlines *in* as string will be escaped. Note that all - but the first line will be indented sligthly. + but any newlines *in* a string will be escaped. Note that all but + the first line will be indented sligthly, the intention is for the + first line to be a summary. """ # ------------------------------------------------------------------------- diff --git a/py/_plugin/pytest_assertion.py b/py/_plugin/pytest_assertion.py index 2816671ab..347f7edb7 100644 --- a/py/_plugin/pytest_assertion.py +++ b/py/_plugin/pytest_assertion.py @@ -33,32 +33,32 @@ def warn_about_missing_assertion(): " (are you using python -O?)") -def pytest_assert_compare(op, left, right): - """Make a specialised explanation for comapare equal""" - if type(left) != type(right): - return None - +def pytest_assert_binrepr(op, left, right): + """Make specialised explanations for some operators/operands""" left_repr = py.io.saferepr(left, maxsize=30) right_repr = py.io.saferepr(right, maxsize=30) summary = '%s %s %s' % (left_repr, op, right_repr) - issquence = lambda x: isinstance(x, (list, tuple)) + issequence = lambda x: isinstance(x, (list, tuple)) istext = lambda x: isinstance(x, basestring) isdict = lambda x: isinstance(x, dict) - isset = lambda: isinstance(left, set) + isset = lambda x: isinstance(x, set) explanation = None if op == '==': - if istext(left): + if istext(left) and istext(right): explanation = [line.strip('\n') for line in py.std.difflib.ndiff(left.splitlines(), right.splitlines())] - elif issquence(left): + elif issequence(left) and issequence(right): explanation = _compare_eq_sequence(left, right) - elif isset(): + elif isset(left) and isset(right): explanation = _compare_eq_set(left, right) - elif isdict(left): + elif isdict(left) and isdict(right): explanation = _pprint_diff(left, right) + elif op == 'in': + # XXX + pass if not explanation: return None diff --git a/testing/plugin/test_pytest_assertion.py b/testing/plugin/test_pytest_assertion.py index 596de0050..81796dd12 100644 --- a/testing/plugin/test_pytest_assertion.py +++ b/testing/plugin/test_pytest_assertion.py @@ -77,17 +77,17 @@ def test_traceback_failure(testdir): ]) -def test_pytest_assert_compare_called(monkeypatch, hook): +def test_pytest_assert_binrepr_called(monkeypatch, hook): monkeypatch.setattr(py._plugin.pytest_assertion, - 'pytest_assert_compare', hook) + 'pytest_assert_binrepr', hook) interpret('assert 0 == 1', getframe()) assert hook.called -def test_pytest_assert_compare_args(monkeypatch, hook): +def test_pytest_assert_binrepr_args(monkeypatch, hook): print hook.called monkeypatch.setattr(py._plugin.pytest_assertion, - 'pytest_assert_compare', hook) + 'pytest_assert_binrepr', hook) interpret('assert [0, 1] == [0, 2]', getframe()) print hook.called print hook.left @@ -99,32 +99,32 @@ def test_pytest_assert_compare_args(monkeypatch, hook): class TestAssertCompare: def test_different_types(self): - assert plugin.pytest_assert_compare('==', [0, 1], 'foo') is None + assert plugin.pytest_assert_binrepr('==', [0, 1], 'foo') is None def test_summary(self): - summary = plugin.pytest_assert_compare('==', [0, 1], [0, 2])[0] + summary = plugin.pytest_assert_binrepr('==', [0, 1], [0, 2])[0] assert len(summary) < 65 def test_text_diff(self): - diff = plugin.pytest_assert_compare('==', 'spam', 'eggs')[1:] + diff = plugin.pytest_assert_binrepr('==', 'spam', 'eggs')[1:] assert '- spam' in diff assert '+ eggs' in diff def test_multiline_text_diff(self): left = 'foo\nspam\nbar' right = 'foo\neggs\nbar' - diff = plugin.pytest_assert_compare('==', left, right) + diff = plugin.pytest_assert_binrepr('==', left, right) assert '- spam' in diff assert '+ eggs' in diff def test_list(self): - expl = plugin.pytest_assert_compare('==', [0, 1], [0, 2]) + expl = plugin.pytest_assert_binrepr('==', [0, 1], [0, 2]) assert len(expl) > 1 def test_dict(self): - expl = plugin.pytest_assert_compare('==', {'a': 0}, {'a': 1}) + expl = plugin.pytest_assert_binrepr('==', {'a': 0}, {'a': 1}) assert len(expl) > 1 def test_set(self): - expl = plugin.pytest_assert_compare('==', set([0, 1]), set([0, 2])) + expl = plugin.pytest_assert_binrepr('==', set([0, 1]), set([0, 2])) assert len(expl) > 1 From 56b955dfb572e2e76006bb69893c58e8b9d67028 Mon Sep 17 00:00:00 2001 From: Floris Bruynooghe Date: Wed, 22 Sep 2010 18:42:04 +0100 Subject: [PATCH 09/11] Make pytest_assert_binrepr work on python3 too --HG-- branch : trunk --- py/_plugin/pytest_assertion.py | 9 ++++++++- testing/plugin/test_pytest_assertion.py | 4 ---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/py/_plugin/pytest_assertion.py b/py/_plugin/pytest_assertion.py index 347f7edb7..226da79c7 100644 --- a/py/_plugin/pytest_assertion.py +++ b/py/_plugin/pytest_assertion.py @@ -33,6 +33,13 @@ def warn_about_missing_assertion(): " (are you using python -O?)") +# Provide basestring in python3 +try: + basestring = basestring +except NameError: + basestring = str + + def pytest_assert_binrepr(op, left, right): """Make specialised explanations for some operators/operands""" left_repr = py.io.saferepr(left, maxsize=30) @@ -72,7 +79,7 @@ def pytest_assert_binrepr(op, left, right): def _compare_eq_sequence(left, right): explanation = [] - for i in xrange(min(len(left), len(right))): + for i in range(min(len(left), len(right))): if left[i] != right[i]: explanation += ['First differing item %s: %s != %s' % (i, left[i], right[i])] diff --git a/testing/plugin/test_pytest_assertion.py b/testing/plugin/test_pytest_assertion.py index 81796dd12..9d750896f 100644 --- a/testing/plugin/test_pytest_assertion.py +++ b/testing/plugin/test_pytest_assertion.py @@ -85,13 +85,9 @@ def test_pytest_assert_binrepr_called(monkeypatch, hook): def test_pytest_assert_binrepr_args(monkeypatch, hook): - print hook.called monkeypatch.setattr(py._plugin.pytest_assertion, 'pytest_assert_binrepr', hook) interpret('assert [0, 1] == [0, 2]', getframe()) - print hook.called - print hook.left - print hook.right assert hook.op == '==' assert hook.left == [0, 1] assert hook.right == [0, 2] From c3166ee84a5b0529c5e1a4f4db391b0b82a65fd0 Mon Sep 17 00:00:00 2001 From: Floris Bruynooghe Date: Wed, 22 Sep 2010 18:52:07 +0100 Subject: [PATCH 10/11] Fix bug when the right list was longer then the left Thanks to Holger for finding this. --HG-- branch : trunk --- py/_plugin/pytest_assertion.py | 2 +- testing/plugin/test_pytest_assertion.py | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/py/_plugin/pytest_assertion.py b/py/_plugin/pytest_assertion.py index 226da79c7..9e6355f41 100644 --- a/py/_plugin/pytest_assertion.py +++ b/py/_plugin/pytest_assertion.py @@ -89,7 +89,7 @@ def _compare_eq_sequence(left, right): 'first extra item: %s' % left[len(right)]] elif len(left) < len(right): explanation += ['Right contains more items, ' - 'first extra item: %s' % right[len(right)]] + 'first extra item: %s' % right[len(left)]] return explanation + _pprint_diff(left, right) diff --git a/testing/plugin/test_pytest_assertion.py b/testing/plugin/test_pytest_assertion.py index 9d750896f..abada10b2 100644 --- a/testing/plugin/test_pytest_assertion.py +++ b/testing/plugin/test_pytest_assertion.py @@ -117,6 +117,12 @@ class TestAssertCompare: expl = plugin.pytest_assert_binrepr('==', [0, 1], [0, 2]) assert len(expl) > 1 + def test_list_different_lenghts(self): + expl = plugin.pytest_assert_binrepr('==', [0, 1], [0, 1, 2]) + assert len(expl) > 1 + expl = plugin.pytest_assert_binrepr('==', [0, 1, 2], [0, 1]) + assert len(expl) > 1 + def test_dict(self): expl = plugin.pytest_assert_binrepr('==', {'a': 0}, {'a': 1}) assert len(expl) > 1 From cd5676adc4db7c19dc08b0d905ce48c140b8574d Mon Sep 17 00:00:00 2001 From: Floris Bruynooghe Date: Thu, 30 Sep 2010 23:15:41 +0100 Subject: [PATCH 11/11] Truncate the text passed to difflib where possible This stops difflib from printing many lines which had no change in them anyway. It also avoids a bug in difflib which fails or hangs when there are many trailing lines which are all identical. --HG-- branch : trunk --- doc/example/assertion/failure_demo.py | 18 ++++++++++ py/_plugin/pytest_assertion.py | 52 +++++++++++++++++++-------- 2 files changed, 56 insertions(+), 14 deletions(-) diff --git a/doc/example/assertion/failure_demo.py b/doc/example/assertion/failure_demo.py index b99253ee8..f0266b82c 100644 --- a/doc/example/assertion/failure_demo.py +++ b/doc/example/assertion/failure_demo.py @@ -128,15 +128,33 @@ class TestSpecialisedExplanations(object): def test_eq_multiline_text(self): assert 'foo\nspam\nbar' == 'foo\neggs\nbar' + def test_eq_long_text(self): + a = '1'*100 + 'a' + '2'*100 + b = '1'*100 + 'b' + '2'*100 + assert a == b + + def test_eq_long_text_multiline(self): + a = '1\n'*100 + 'a' + '2\n'*100 + b = '1\n'*100 + 'b' + '2\n'*100 + assert a == b + def test_eq_list(self): assert [0, 1, 2] == [0, 1, 3] + def test_eq_list_long(self): + a = [0]*100 + [1] + [3]*100 + b = [0]*100 + [2] + [3]*100 + assert a == b + def test_eq_dict(self): assert {'a': 0, 'b': 1} == {'a': 0, 'b': 2} def test_eq_set(self): assert set([0, 10, 11, 12]) == set([0, 20, 21]) + def test_in_list(self): + assert 1 in [0, 2, 3, 4, 5] + def globf(x): return x+1 diff --git a/py/_plugin/pytest_assertion.py b/py/_plugin/pytest_assertion.py index 9e6355f41..b58b91985 100644 --- a/py/_plugin/pytest_assertion.py +++ b/py/_plugin/pytest_assertion.py @@ -54,18 +54,16 @@ def pytest_assert_binrepr(op, left, right): explanation = None if op == '==': if istext(left) and istext(right): - explanation = [line.strip('\n') for line in - py.std.difflib.ndiff(left.splitlines(), - right.splitlines())] + explanation = _diff_text(left, right) elif issequence(left) and issequence(right): explanation = _compare_eq_sequence(left, right) elif isset(left) and isset(right): explanation = _compare_eq_set(left, right) elif isdict(left) and isdict(right): - explanation = _pprint_diff(left, right) + explanation = _diff_text(py.std.pprint.pformat(left), + py.std.pprint.pformat(right)) elif op == 'in': - # XXX - pass + pass # XXX if not explanation: return None @@ -77,6 +75,38 @@ def pytest_assert_binrepr(op, left, right): return [summary] + explanation +def _diff_text(left, right): + """Return the explanation for the diff between text + + This will skip leading and trailing characters which are + identical to keep the diff minimal. + """ + explanation = [] + for i in range(min(len(left), len(right))): + if left[i] != right[i]: + break + if i > 42: + i -= 10 # Provide some context + explanation = ['Skipping %s identical ' + 'leading characters in diff' % i] + left = left[i:] + right = right[i:] + if len(left) == len(right): + for i in range(len(left)): + if left[-i] != right[-i]: + break + if i > 42: + i -= 10 # Provide some context + explanation += ['Skipping %s identical ' + 'trailing characters in diff' % i] + left = left[:-i] + right = right[:-i] + explanation += [line.strip('\n') + for line in py.std.difflib.ndiff(left.splitlines(), + right.splitlines())] + return explanation + + def _compare_eq_sequence(left, right): explanation = [] for i in range(min(len(left), len(right))): @@ -90,14 +120,8 @@ def _compare_eq_sequence(left, right): elif len(left) < len(right): explanation += ['Right contains more items, ' 'first extra item: %s' % right[len(left)]] - return explanation + _pprint_diff(left, right) - - -def _pprint_diff(left, right): - """Make explanation using pprint and difflib""" - return [line.strip('\n') for line in - py.std.difflib.ndiff(py.std.pprint.pformat(left).splitlines(), - py.std.pprint.pformat(right).splitlines())] + return explanation + _diff_text(py.std.pprint.pformat(left), + py.std.pprint.pformat(right)) def _compare_eq_set(left, right):