Retain nested causes where/when we can

On py3.x we should attempt to retain an exceptions
causes (if any) so that they can be examined at a later
time. This adds that change into gather and retain this
information via a new failure causes property (that is
lazily populated).

This will aid in the change to traceback2 and the
adjustments made in http://bugs.python.org/issue17911 so
that we can in the future use its exception class instead.

Change-Id: I8cadd7b70c2f638719155d96df4236fc40e18ccf
This commit is contained in:
Joshua Harlow 2015-03-09 18:27:04 -07:00
parent 722d41b377
commit 1478f52c9a
2 changed files with 172 additions and 3 deletions

View File

@ -17,6 +17,8 @@
import sys
import six
from six.moves import cPickle as pickle
import testtools
from taskflow import exceptions
from taskflow import test
@ -311,6 +313,101 @@ class NonAsciiExceptionsTestCase(test.TestCase):
self.assertEqual(fail, copied)
@testtools.skipIf(not six.PY3, 'this test only works on python 3.x')
class FailureCausesTest(test.TestCase):
@classmethod
def _raise_many(cls, messages):
if not messages:
return
msg = messages.pop(0)
e = RuntimeError(msg)
try:
cls._raise_many(messages)
raise e
except RuntimeError as e1:
six.raise_from(e, e1)
def test_causes(self):
f = None
try:
self._raise_many(["Still still not working",
"Still not working", "Not working"])
except RuntimeError:
f = failure.Failure()
self.assertIsNotNone(f)
self.assertEqual(2, len(f.causes))
self.assertEqual("Still not working", f.causes[0].exception_str)
self.assertEqual("Not working", f.causes[1].exception_str)
f = f.causes[0]
self.assertEqual(1, len(f.causes))
self.assertEqual("Not working", f.causes[0].exception_str)
f = f.causes[0]
self.assertEqual(0, len(f.causes))
def test_causes_to_from_dict(self):
f = None
try:
self._raise_many(["Still still not working",
"Still not working", "Not working"])
except RuntimeError:
f = failure.Failure()
self.assertIsNotNone(f)
d_f = f.to_dict()
f = failure.Failure.from_dict(d_f)
self.assertEqual(2, len(f.causes))
self.assertEqual("Still not working", f.causes[0].exception_str)
self.assertEqual("Not working", f.causes[1].exception_str)
f = f.causes[0]
self.assertEqual(1, len(f.causes))
self.assertEqual("Not working", f.causes[0].exception_str)
f = f.causes[0]
self.assertEqual(0, len(f.causes))
def test_causes_pickle(self):
f = None
try:
self._raise_many(["Still still not working",
"Still not working", "Not working"])
except RuntimeError:
f = failure.Failure()
self.assertIsNotNone(f)
p_f = pickle.dumps(f)
f = pickle.loads(p_f)
self.assertEqual(2, len(f.causes))
self.assertEqual("Still not working", f.causes[0].exception_str)
self.assertEqual("Not working", f.causes[1].exception_str)
f = f.causes[0]
self.assertEqual(1, len(f.causes))
self.assertEqual("Not working", f.causes[0].exception_str)
f = f.causes[0]
self.assertEqual(0, len(f.causes))
def test_causes_supress_context(self):
f = None
try:
try:
self._raise_many(["Still still not working",
"Still not working", "Not working"])
except RuntimeError as e:
six.raise_from(e, None)
except RuntimeError:
f = failure.Failure()
self.assertIsNotNone(f)
self.assertEqual([], list(f.causes))
class ExcInfoUtilsTest(test.TestCase):
def test_copy_none(self):
result = failure._copy_exc_info(None)

View File

@ -152,8 +152,10 @@ class Failure(object):
self._exception_str = exc.exception_message(self._exc_info[1])
self._traceback_str = ''.join(
traceback.format_tb(self._exc_info[2]))
self._causes = kwargs.pop('causes', None)
else:
self._exc_info = exc_info # may be None
self._causes = kwargs.pop('causes', None)
self._exc_info = exc_info
self._exception_str = kwargs.pop('exception_str')
self._exc_type_names = tuple(kwargs.pop('exc_type_names', []))
self._traceback_str = kwargs.pop('traceback_str', None)
@ -172,7 +174,8 @@ class Failure(object):
return True
return (self._exc_type_names == other._exc_type_names
and self.exception_str == other.exception_str
and self.traceback_str == other.traceback_str)
and self.traceback_str == other.traceback_str
and self.causes == other.causes)
def matches(self, other):
"""Checks if another object is equivalent to this object.
@ -269,6 +272,66 @@ class Failure(object):
return cls
return None
@classmethod
def _extract_causes_iter(cls, exc_val):
seen = [exc_val]
causes = [exc_val]
while causes:
exc_val = causes.pop()
if exc_val is None:
continue
# See: https://www.python.org/dev/peps/pep-3134/ for why/what
# these are...
#
# '__cause__' attribute for explicitly chained exceptions
# '__context__' attribute for implicitly chained exceptions
# '__traceback__' attribute for the traceback
#
# See: https://www.python.org/dev/peps/pep-0415/ for why/what
# the '__suppress_context__' is/means/implies...
supress_context = getattr(exc_val,
'__suppress_context__', False)
if supress_context:
attr_lookups = ['__cause__']
else:
attr_lookups = ['__cause__', '__context__']
nested_exc_val = None
for attr_name in attr_lookups:
attr_val = getattr(exc_val, attr_name, None)
if attr_val is None:
continue
if attr_val not in seen:
nested_exc_val = attr_val
break
if nested_exc_val is not None:
exc_info = (
type(nested_exc_val),
nested_exc_val,
getattr(nested_exc_val, '__traceback__', None),
)
seen.append(nested_exc_val)
causes.append(nested_exc_val)
yield cls(exc_info=exc_info)
@property
def causes(self):
"""Tuple of all *inner* failure *causes* of this failure.
NOTE(harlowja): Does **not** include the current failure (only
returns connected causes of this failure, if any). This property
is really only useful on 3.x or newer versions of python as older
versions do **not** have associated causes (the tuple will **always**
be empty on 2.x versions of python).
Refer to :pep:`3134` and :pep:`409` and :pep:`415` for what
this is examining to find failure causes.
"""
if self._causes is not None:
return self._causes
else:
self._causes = tuple(self._extract_causes_iter(self.exception))
return self._causes
def __str__(self):
return self.pformat()
@ -323,6 +386,10 @@ class Failure(object):
self._exc_info = tuple(_fill_iter(dct['exc_info'], 3))
else:
self._exc_info = None
causes = dct.get('causes')
if causes is not None:
causes = tuple(self.from_dict(d) for d in causes)
self._causes = causes
@classmethod
def from_dict(cls, data):
@ -332,6 +399,9 @@ class Failure(object):
if version != cls.DICT_VERSION:
raise ValueError('Invalid dict version of failure object: %r'
% version)
causes = data.get('causes')
if causes is not None:
data['causes'] = tuple(cls.from_dict(d) for d in causes)
return cls(**data)
def to_dict(self):
@ -341,6 +411,7 @@ class Failure(object):
'traceback_str': self.traceback_str,
'exc_type_names': list(self),
'version': self.DICT_VERSION,
'causes': [f.to_dict() for f in self.causes],
}
def copy(self):
@ -348,4 +419,5 @@ class Failure(object):
return Failure(exc_info=_copy_exc_info(self.exc_info),
exception_str=self.exception_str,
traceback_str=self.traceback_str,
exc_type_names=self._exc_type_names[:])
exc_type_names=self._exc_type_names[:],
causes=self._causes)