diff --git a/CHANGELOG.rst b/CHANGELOG.rst index dfd5c2236ab..18e0e3c6457 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -23,6 +23,10 @@ Thanks `@omarkohl`_ for the complete PR (`#1502`_) and `@nicoddemus`_ for the implementation tips. +* ``__tracebackhide__`` can now also be set to a callable which then can decide + whether to filter the traceback based on the ``ExceptionInfo`` object passed + to it. + * **Changes** diff --git a/_pytest/_code/code.py b/_pytest/_code/code.py index 79fcf9f1c9d..feb69f9e65c 100644 --- a/_pytest/_code/code.py +++ b/_pytest/_code/code.py @@ -140,7 +140,8 @@ class TracebackEntry(object): _repr_style = None exprinfo = None - def __init__(self, rawentry): + def __init__(self, rawentry, excinfo=None): + self._excinfo = excinfo self._rawentry = rawentry self.lineno = rawentry.tb_lineno - 1 @@ -221,16 +222,24 @@ def ishidden(self): """ return True if the current frame has a var __tracebackhide__ resolving to True + If __tracebackhide__ is a callable, it gets called with the + ExceptionInfo instance and can decide whether to hide the traceback. + mostly for internal use """ try: - return self.frame.f_locals['__tracebackhide__'] + tbh = self.frame.f_locals['__tracebackhide__'] except KeyError: try: - return self.frame.f_globals['__tracebackhide__'] + tbh = self.frame.f_globals['__tracebackhide__'] except KeyError: return False + if py.builtin.callable(tbh): + return tbh(self._excinfo) + else: + return tbh + def __str__(self): try: fn = str(self.path) @@ -254,12 +263,13 @@ class Traceback(list): access to Traceback entries. """ Entry = TracebackEntry - def __init__(self, tb): - """ initialize from given python traceback object. """ + def __init__(self, tb, excinfo=None): + """ initialize from given python traceback object and ExceptionInfo """ + self._excinfo = excinfo if hasattr(tb, 'tb_next'): def f(cur): while cur is not None: - yield self.Entry(cur) + yield self.Entry(cur, excinfo=excinfo) cur = cur.tb_next list.__init__(self, f(tb)) else: @@ -283,7 +293,7 @@ def cut(self, path=None, lineno=None, firstlineno=None, excludepath=None): not codepath.relto(excludepath)) and (lineno is None or x.lineno == lineno) and (firstlineno is None or x.frame.code.firstlineno == firstlineno)): - return Traceback(x._rawentry) + return Traceback(x._rawentry, self._excinfo) return self def __getitem__(self, key): @@ -302,7 +312,7 @@ def filter(self, fn=lambda x: not x.ishidden()): by default this removes all the TracebackItems which are hidden (see ishidden() above) """ - return Traceback(filter(fn, self)) + return Traceback(filter(fn, self), self._excinfo) def getcrashentry(self): """ return last non-hidden traceback entry that lead @@ -366,7 +376,7 @@ def __init__(self, tup=None, exprinfo=None): #: the exception type name self.typename = self.type.__name__ #: the exception traceback (_pytest._code.Traceback instance) - self.traceback = _pytest._code.Traceback(self.tb) + self.traceback = _pytest._code.Traceback(self.tb, excinfo=self) def __repr__(self): return "" % (self.typename, len(self.traceback)) diff --git a/doc/en/example/simple.rst b/doc/en/example/simple.rst index be12d2afe41..0bf1d7bfa35 100644 --- a/doc/en/example/simple.rst +++ b/doc/en/example/simple.rst @@ -216,6 +216,28 @@ Let's run our little function:: test_checkconfig.py:8: Failed 1 failed in 0.12 seconds +If you only want to hide certain exceptions, you can set ``__tracebackhide__`` +to a callable which gets the ``ExceptionInfo`` object. You can for example use +this to make sure unexpected exception types aren't hidden:: + + import operator + import pytest + + class ConfigException(Exception): + pass + + def checkconfig(x): + __tracebackhide__ = operator.methodcaller('errisinstance', ConfigException) + if not hasattr(x, "config"): + raise ConfigException("not configured: %s" %(x,)) + + def test_something(): + checkconfig(42) + +This will avoid hiding the exception traceback on unrelated exceptions (i.e. +bugs in assertion helpers). + + Detect if running from within a pytest run -------------------------------------------------------------- diff --git a/testing/code/test_excinfo.py b/testing/code/test_excinfo.py index 0280d1aa386..1d15a852b00 100644 --- a/testing/code/test_excinfo.py +++ b/testing/code/test_excinfo.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- +import operator import _pytest import py import pytest @@ -144,6 +145,39 @@ def test_traceback_filter(self): ntraceback = traceback.filter() assert len(ntraceback) == len(traceback) - 1 + @pytest.mark.parametrize('tracebackhide, matching', [ + (lambda info: True, True), + (lambda info: False, False), + (operator.methodcaller('errisinstance', ValueError), True), + (operator.methodcaller('errisinstance', IndexError), False), + ]) + def test_traceback_filter_selective(self, tracebackhide, matching): + def f(): + # + raise ValueError + # + def g(): + # + __tracebackhide__ = tracebackhide + f() + # + def h(): + # + g() + # + + excinfo = pytest.raises(ValueError, h) + traceback = excinfo.traceback + ntraceback = traceback.filter() + print('old: {0!r}'.format(traceback)) + print('new: {0!r}'.format(ntraceback)) + + if matching: + assert len(ntraceback) == len(traceback) - 2 + else: + # -1 because of the __tracebackhide__ in pytest.raises + assert len(ntraceback) == len(traceback) - 1 + def test_traceback_recursion_index(self): def f(n): if n < 10: @@ -442,7 +476,7 @@ class FakeFrame(object): f_globals = {} class FakeTracebackEntry(_pytest._code.Traceback.Entry): - def __init__(self, tb): + def __init__(self, tb, excinfo=None): self.lineno = 5+3 @property