diff --git a/oslo_utils/excutils.py b/oslo_utils/excutils.py index a4eeace6..dbaf0f6b 100644 --- a/oslo_utils/excutils.py +++ b/oslo_utils/excutils.py @@ -18,6 +18,7 @@ Exception related utilities. """ import logging +import os import sys import time import traceback @@ -25,6 +26,80 @@ import traceback import six from oslo_utils._i18n import _LE +from oslo_utils import reflection + + +class CausedByException(Exception): + """Base class for exceptions which have associated causes. + + NOTE(harlowja): in later versions of python we can likely remove the need + to have a ``cause`` here as PY3+ have implemented :pep:`3134` which + handles chaining in a much more elegant manner. + + :param message: the exception message, typically some string that is + useful for consumers to view when debugging or analyzing + failures. + :param cause: the cause of the exception being raised, when provided this + should itself be an exception instance, this is useful for + creating a chain of exceptions for versions of python where + this is not yet implemented/supported natively. + """ + def __init__(self, message, cause=None): + super(CausedByException, self).__init__(message) + self.cause = cause + + def __bytes__(self): + return self.pformat().encode("utf8") + + def __str__(self): + return self.pformat() + + def _get_message(self): + # We must *not* call into the ``__str__`` method as that will + # reactivate the pformat method, which will end up badly (and doesn't + # look pretty at all); so be careful... + return self.args[0] + + def pformat(self, indent=2, indent_text=" ", show_root_class=False): + """Pretty formats a caused exception + any connected causes.""" + if indent < 0: + raise ValueError("Provided 'indent' must be greater than" + " or equal to zero instead of %s" % indent) + buf = six.StringIO() + if show_root_class: + buf.write(reflection.get_class_name(self, fully_qualified=False)) + buf.write(": ") + buf.write(self._get_message()) + active_indent = indent + next_up = self.cause + seen = [] + while next_up is not None and next_up not in seen: + seen.append(next_up) + buf.write(os.linesep) + if isinstance(next_up, CausedByException): + buf.write(indent_text * active_indent) + buf.write(reflection.get_class_name(next_up, + fully_qualified=False)) + buf.write(": ") + buf.write(next_up._get_message()) + else: + lines = traceback.format_exception_only(type(next_up), next_up) + for i, line in enumerate(lines): + buf.write(indent_text * active_indent) + if line.endswith("\n"): + # We'll add our own newlines on... + line = line[0:-1] + buf.write(line) + if i + 1 != len(lines): + buf.write(os.linesep) + if not isinstance(next_up, CausedByException): + # Don't go deeper into non-caused-by exceptions... as we + # don't know if there exception 'cause' attributes are even + # useable objects... + break + active_indent += indent + next_up = getattr(next_up, 'cause', None) + return buf.getvalue() def raise_with_cause(exc_cls, message, *args, **kwargs): @@ -40,7 +115,8 @@ def raise_with_cause(exc_cls, message, *args, **kwargs): inspected/retained on py2.x to get *similar* information as would be automatically included/obtainable in py3.x. - :param exc_cls: the exception class to raise. + :param exc_cls: the exception class to raise (typically one derived + from :py:class:`.CausedByException` or equivalent). :param message: the text/str message that will be passed to the exceptions constructor as its first positional argument. diff --git a/oslo_utils/tests/test_excutils.py b/oslo_utils/tests/test_excutils.py index c2626a45..60f752d9 100644 --- a/oslo_utils/tests/test_excutils.py +++ b/oslo_utils/tests/test_excutils.py @@ -25,6 +25,41 @@ from oslo_utils import excutils mox = moxstubout.mox +class Fail1(excutils.CausedByException): + pass + + +class Fail2(excutils.CausedByException): + pass + + +class CausedByTest(test_base.BaseTestCase): + + def test_caused_by_explicit(self): + e = self.assertRaises(Fail1, + excutils.raise_with_cause, + Fail1, "I was broken", + cause=Fail2("I have been broken")) + self.assertIsInstance(e.cause, Fail2) + e_p = e.pformat() + self.assertIn("I have been broken", e_p) + self.assertIn("Fail2", e_p) + + def test_caused_by_implicit(self): + + def raises_chained(): + try: + raise Fail2("I have been broken") + except Fail2: + excutils.raise_with_cause(Fail1, "I was broken") + + e = self.assertRaises(Fail1, raises_chained) + self.assertIsInstance(e.cause, Fail2) + e_p = e.pformat() + self.assertIn("I have been broken", e_p) + self.assertIn("Fail2", e_p) + + class SaveAndReraiseTest(test_base.BaseTestCase): def test_save_and_reraise_exception(self):