diff --git a/taskflow/tests/unit/test_failure.py b/taskflow/tests/unit/test_failure.py index 793274e12..c8f83b9b0 100644 --- a/taskflow/tests/unit/test_failure.py +++ b/taskflow/tests/unit/test_failure.py @@ -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) diff --git a/taskflow/types/failure.py b/taskflow/types/failure.py index 7df0f68ca..f251b00e9 100644 --- a/taskflow/types/failure.py +++ b/taskflow/types/failure.py @@ -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)