diff --git a/AUTHORS b/AUTHORS index caecf7c88..dff48555e 100644 --- a/AUTHORS +++ b/AUTHORS @@ -75,6 +75,7 @@ Piotr Banaszkiewicz Punyashloka Biswal Ralf Schmitt Raphael Pierzina +Roman Bolshakov Ronny Pfannschmidt Ross Lawley Ryan Wooden diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 2bcd3223e..dfd5c2236 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -98,11 +98,13 @@ * Fix (`#649`_): parametrized test nodes cannot be specified to run on the command line. +* Fix (`#138`_): better reporting for python 3.3+ chained exceptions .. _#1437: https://github.com/pytest-dev/pytest/issues/1437 .. _#469: https://github.com/pytest-dev/pytest/issues/469 .. _#1431: https://github.com/pytest-dev/pytest/pull/1431 .. _#649: https://github.com/pytest-dev/pytest/issues/649 +.. _#138: https://github.com/pytest-dev/pytest/issues/138 .. _@asottile: https://github.com/asottile diff --git a/_pytest/_code/code.py b/_pytest/_code/code.py index 48c3da1a8..79fcf9f1c 100644 --- a/_pytest/_code/code.py +++ b/_pytest/_code/code.py @@ -608,12 +608,36 @@ class FormattedExcinfo(object): break return ReprTraceback(entries, extraline, style=self.style) - def repr_excinfo(self, excinfo): - reprtraceback = self.repr_traceback(excinfo) - reprcrash = excinfo._getreprcrash() - return ReprExceptionInfo(reprtraceback, reprcrash) -class TerminalRepr: + def repr_excinfo(self, excinfo): + if sys.version_info[0] < 3: + reprtraceback = self.repr_traceback(excinfo) + reprcrash = excinfo._getreprcrash() + + return ReprExceptionInfo(reprtraceback, reprcrash) + else: + repr_chain = [] + e = excinfo.value + descr = None + while e is not None: + reprtraceback = self.repr_traceback(excinfo) + reprcrash = excinfo._getreprcrash() + repr_chain += [(reprtraceback, reprcrash, descr)] + if e.__cause__ is not None: + e = e.__cause__ + excinfo = ExceptionInfo((type(e), e, e.__traceback__)) + descr = 'The above exception was the direct cause of the following exception:' + elif e.__context__ is not None: + e = e.__context__ + excinfo = ExceptionInfo((type(e), e, e.__traceback__)) + descr = 'During handling of the above exception, another exception occurred:' + else: + e = None + repr_chain.reverse() + return ExceptionChainRepr(repr_chain) + + +class TerminalRepr(object): def __str__(self): s = self.__unicode__() if sys.version_info[0] < 3: @@ -632,21 +656,47 @@ class TerminalRepr: return "<%s instance at %0x>" %(self.__class__, id(self)) -class ReprExceptionInfo(TerminalRepr): - def __init__(self, reprtraceback, reprcrash): - self.reprtraceback = reprtraceback - self.reprcrash = reprcrash +class ExceptionRepr(TerminalRepr): + def __init__(self): self.sections = [] def addsection(self, name, content, sep="-"): self.sections.append((name, content, sep)) def toterminal(self, tw): - self.reprtraceback.toterminal(tw) for name, content, sep in self.sections: tw.sep(sep, name) tw.line(content) + +class ExceptionChainRepr(ExceptionRepr): + def __init__(self, chain): + super(ExceptionChainRepr, self).__init__() + self.chain = chain + # reprcrash and reprtraceback of the outermost (the newest) exception + # in the chain + self.reprtraceback = chain[-1][0] + self.reprcrash = chain[-1][1] + + def toterminal(self, tw): + for element in self.chain: + element[0].toterminal(tw) + if element[2] is not None: + tw.line("") + tw.line(element[2], yellow=True) + super(ExceptionChainRepr, self).toterminal(tw) + + +class ReprExceptionInfo(ExceptionRepr): + def __init__(self, reprtraceback, reprcrash): + super(ReprExceptionInfo, self).__init__() + self.reprtraceback = reprtraceback + self.reprcrash = reprcrash + + def toterminal(self, tw): + self.reprtraceback.toterminal(tw) + super(ReprExceptionInfo, self).toterminal(tw) + class ReprTraceback(TerminalRepr): entrysep = "_ " diff --git a/_pytest/runner.py b/_pytest/runner.py index cde94c8c8..4cc2ef6ac 100644 --- a/_pytest/runner.py +++ b/_pytest/runner.py @@ -494,9 +494,13 @@ def importorskip(modname, minversion=None): """ __tracebackhide__ = True compile(modname, '', 'eval') # to catch syntaxerrors + should_skip = False try: __import__(modname) except ImportError: + # Do not raise chained exception here(#1485) + should_skip = True + if should_skip: skip("could not import %r" %(modname,)) mod = sys.modules[modname] if minversion is None: diff --git a/testing/code/test_excinfo.py b/testing/code/test_excinfo.py index c925b1d28..0280d1aa3 100644 --- a/testing/code/test_excinfo.py +++ b/testing/code/test_excinfo.py @@ -3,7 +3,8 @@ import _pytest import py import pytest -from _pytest._code.code import FormattedExcinfo, ReprExceptionInfo +from _pytest._code.code import (FormattedExcinfo, ReprExceptionInfo, + ExceptionChainRepr) queue = py.builtin._tryimport('queue', 'Queue') @@ -404,6 +405,8 @@ class TestFormattedExcinfo: excinfo = _pytest._code.ExceptionInfo() repr = pr.repr_excinfo(excinfo) assert repr.reprtraceback.reprentries[1].lines[0] == "> ???" + if py.std.sys.version_info[0] >= 3: + assert repr.chain[0][0].reprentries[1].lines[0] == "> ???" def test_repr_many_line_source_not_existing(self): pr = FormattedExcinfo() @@ -417,6 +420,8 @@ raise ValueError() excinfo = _pytest._code.ExceptionInfo() repr = pr.repr_excinfo(excinfo) assert repr.reprtraceback.reprentries[1].lines[0] == "> ???" + if py.std.sys.version_info[0] >= 3: + assert repr.chain[0][0].reprentries[1].lines[0] == "> ???" def test_repr_source_failing_fullsource(self): pr = FormattedExcinfo() @@ -449,6 +454,7 @@ raise ValueError() class FakeExcinfo(_pytest._code.ExceptionInfo): typename = "Foo" + value = Exception() def __init__(self): pass @@ -466,10 +472,15 @@ raise ValueError() fail = IOError() # noqa repr = pr.repr_excinfo(excinfo) assert repr.reprtraceback.reprentries[0].lines[0] == "> ???" + if py.std.sys.version_info[0] >= 3: + assert repr.chain[0][0].reprentries[0].lines[0] == "> ???" + fail = py.error.ENOENT # noqa repr = pr.repr_excinfo(excinfo) assert repr.reprtraceback.reprentries[0].lines[0] == "> ???" + if py.std.sys.version_info[0] >= 3: + assert repr.chain[0][0].reprentries[0].lines[0] == "> ???" def test_repr_local(self): @@ -656,6 +667,9 @@ raise ValueError() repr = p.repr_excinfo(excinfo) assert repr.reprtraceback assert len(repr.reprtraceback.reprentries) == len(reprtb.reprentries) + if py.std.sys.version_info[0] >= 3: + assert repr.chain[0][0] + assert len(repr.chain[0][0].reprentries) == len(reprtb.reprentries) assert repr.reprcrash.path.endswith("mod.py") assert repr.reprcrash.message == "ValueError: 0" @@ -746,8 +760,13 @@ raise ValueError() for style in ("short", "long", "no"): for showlocals in (True, False): repr = excinfo.getrepr(style=style, showlocals=showlocals) - assert isinstance(repr, ReprExceptionInfo) + if py.std.sys.version_info[0] < 3: + assert isinstance(repr, ReprExceptionInfo) assert repr.reprtraceback.style == style + if py.std.sys.version_info[0] >= 3: + assert isinstance(repr, ExceptionChainRepr) + for repr in repr.chain: + assert repr[0].style == style def test_reprexcinfo_unicode(self): from _pytest._code.code import TerminalRepr @@ -928,3 +947,70 @@ raise ValueError() assert tw.lines[14] == "E ValueError" assert tw.lines[15] == "" assert tw.lines[16].endswith("mod.py:9: ValueError") + + @pytest.mark.skipif("sys.version_info[0] < 3") + def test_exc_chain_repr(self, importasmod): + mod = importasmod(""" + class Err(Exception): + pass + def f(): + try: + g() + except Exception as e: + raise Err() from e + finally: + h() + def g(): + raise ValueError() + + def h(): + raise AttributeError() + """) + excinfo = pytest.raises(AttributeError, mod.f) + r = excinfo.getrepr(style="long") + tw = TWMock() + r.toterminal(tw) + for line in tw.lines: print (line) + assert tw.lines[0] == "" + assert tw.lines[1] == " def f():" + assert tw.lines[2] == " try:" + assert tw.lines[3] == "> g()" + assert tw.lines[4] == "" + assert tw.lines[5].endswith("mod.py:6: ") + assert tw.lines[6] == ("_ ", None) + assert tw.lines[7] == "" + assert tw.lines[8] == " def g():" + assert tw.lines[9] == "> raise ValueError()" + assert tw.lines[10] == "E ValueError" + assert tw.lines[11] == "" + assert tw.lines[12].endswith("mod.py:12: ValueError") + assert tw.lines[13] == "" + assert tw.lines[14] == "The above exception was the direct cause of the following exception:" + assert tw.lines[15] == "" + assert tw.lines[16] == " def f():" + assert tw.lines[17] == " try:" + assert tw.lines[18] == " g()" + assert tw.lines[19] == " except Exception as e:" + assert tw.lines[20] == "> raise Err() from e" + assert tw.lines[21] == "E test_exc_chain_repr0.mod.Err" + assert tw.lines[22] == "" + assert tw.lines[23].endswith("mod.py:8: Err") + assert tw.lines[24] == "" + assert tw.lines[25] == "During handling of the above exception, another exception occurred:" + assert tw.lines[26] == "" + assert tw.lines[27] == " def f():" + assert tw.lines[28] == " try:" + assert tw.lines[29] == " g()" + assert tw.lines[30] == " except Exception as e:" + assert tw.lines[31] == " raise Err() from e" + assert tw.lines[32] == " finally:" + assert tw.lines[33] == "> h()" + assert tw.lines[34] == "" + assert tw.lines[35].endswith("mod.py:10: ") + assert tw.lines[36] == ('_ ', None) + assert tw.lines[37] == "" + assert tw.lines[38] == " def h():" + assert tw.lines[39] == "> raise AttributeError()" + assert tw.lines[40] == "E AttributeError" + assert tw.lines[41] == "" + assert tw.lines[42].endswith("mod.py:15: AttributeError")