Hi Floris, On Sun, Sep 05, 2010 at 22:42 +0100, Floris Bruynooghe wrote: > Hello Holger > > On Thu, Sep 02, 2010 at 11:27:22AM +0200, holger krekel wrote: > > would have the AST-transformation call back into a generic > > pytest-hook if an assertion fails. So one could customize > > "hasattr(x, y)" or "x in y" or "x == y == z" etc. and all > > such representation code would move to the py/_plugin/pytest_assertion.py > > plugin. That being said going for just comparisons right now > > is fine, let's not have more general thoughts hold us up. > > I wouldn't want to make this more difficult or incompatible though, so > I'd be interested in how you where thinking of doing this. I guess > this would be exposing the various .visit_*() methods in > _assertionnew.py in some way? If so I think there's also value in the > more simple pytest_assert_compare(op, left, right) I'm proposing here > (and others like "in", "is" etc) as they don't require having any AST > knowledge to implement, at a cost of slightly less flexibility.
Sure. The eventual callback that is to receive AST-nodes can remain internal and there is nothing from keeping us to offer both levels of hooks (the default impl for the low-level hook can just call the higher level ones). > > On Mon, Aug 16, 2010 at 14:51 +0100, Floris Bruynooghe wrote: > > > A possible api for the hook > > > could be: > > > > > > def pytest_assert_compare(op, left, right, left_repr, right_repr): > > > """Customise compare > > > > > > Return None for no custom compare, otherwise return a list of > > > strings. The strings will be joined by newlines but any newlines > > > *in* a string will be escaped. > > > """ > > > pass > > > > > > I guess the reprs are not really necessary if there's an easy way to > > > make them. It's just that I used them in my original patch. > > > > Hum, couldn't custom representations just mean that there is no > > "history" of where the object comes from? > > > > The hook looks otherwise good to me. > > I've made this hook pytest_assert_compare(op, left, right) now, as > I've found py.io.saferepr() which seems to do a good job. ok. > Not sure what you're referring too with the "history" comment. If you > mean that as soon as a specialised hook is found the previous > explanations (e.g. from Assert AST node) get lost I'd like to > disagree. It seems reasonable for the new hooks to just provide a > more detailed/specialised explanation of a particular part of the > failure rather then replace the entire explanation. Otherwise you > might also lose detail. Hum, ok. Let's see how this plays out with real failures. > > (I guess you are aware that any pytest-hook implementation can always > > choose to accept less than the available arguments). > > I wasn't actually, neat. > > > > There's also the question of who should truncate large amounts of data > > > (e.g. screenfulls of diffs): the hook itself, the caller of the hook > > > or _format_explanation()? Probably one of the first two to get rid of > > > the memory usage as soon as possible. > > > > If a hook returns something we should (probably) not further do anything > > with it in _format_explanation(). And making truncation the repsonsibility > > of the hook makes sense i think. > > ok > > > I am not sure about the general use cases, from my side: > > > > x == y > > x != y > > x in y > > > > are the interesting ones (with some of the objects being long > > lists, long strings etc.). so some hook for a "binary" relation > > makes sense, pytest_assert_binrepr(op) where op could be "==", > > "in" or "is" etc. > > To me it seems more logical to add a separate hook for each .visit_*() > method rather then put multiple together. But maybe that seems > artificial from a user point of view? I guess I prefer "pytest_assert_binop(op, val1, val2)" but keeping strictly to the visit relation also has merits. I guess my preference also comes from trying to avoid introducing too many hooks. > > Floris, i'd very much like to start using/having improved assertion > > representations. Happy to review patches or forks for inclusion. > > I've attached a new patch in which I attempt to use the hook system > and added a pytest_assert_comare(op, left, right) hook. I must admit > I don't fully understand the plugin/hook system so hope I did it > right [0]. Again I've not concentrated on the actual specific > comparisons, rather would like to get a reasonable idea of how good > the general approach is. Your hook implementation looks fine and [0] is correct. Please feel free to ask any questions regarding hook machinery here on the list. Are you aware of (the rather minimal) documentation here? http://codespeak.net/py/dist/test/customize.html#important-py-test-hooks > If you like this version I can create a fork on bitbucket and start > working on more/better hook implementations. Great, please do so. Two review notes regarding your patch already: * please try to avoid global imports in plugins, rather only import e.g. difflib/pprint where you actually use it. This is to try to keep interactive startup-time minimal, particularly wrt builtin plugins. * i suggest to put tests to testing/plugin/test_pytest_assertion.py (rather than testing/code/...) especially since the tested functionality is contained in the pytest_assertion.py plugin. For direct unit tests you may also directly do "from py._plugin.pytest_assertion import XYZ". best, holger > Regards > Floris > > > [0] I assume that py.test.config.hook.pytest_assert_compare is the > function to call and that it returns a list with the results of > each such function found, with the first element being the most > "specific" result. But I just figured that out using trial and > error rather then understand the plugin system. > > -- > Debian GNU/Linux -- The Power of Freedom > www.debian.org | www.gnu.org | www.kernel.org > diff --git a/py/_code/_assertionnew.py b/py/_code/_assertionnew.py > --- 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 result: > + 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 > --- a/py/_code/assertion.py > +++ b/py/_code/assertion.py > @@ -5,12 +5,20 @@ BuiltinAssertionError = py.builtin.built > > > 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 > --- a/py/_plugin/hookspec.py > +++ b/py/_plugin/hookspec.py > @@ -124,6 +124,19 @@ def pytest_sessionfinish(session, exitst > """ 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 > --- 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 > --- /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 > _______________________________________________ > py-dev mailing list > py-dev@codespeak.net > http://codespeak.net/mailman/listinfo/py-dev -- _______________________________________________ py-dev mailing list py-dev@codespeak.net http://codespeak.net/mailman/listinfo/py-dev