I was working on a project where we wanted to validate that a parameter could actually be raised as an exception if necessary. We went with the following:
def is_raisable(exception):
funcs = (isinstance, issubclass)
return any(f(exception, BaseException) for f in funcs)
This handles the following use cases, which meet our needs (for now):
is_raisable(KeyError) # the exception type, which can be raised
is_raisable(KeyError("key")) # an exception instance, which can be raised
It fails, however, for old-style classes, which are raisable in old versions (2.x). We tried to then solve it this way:
IGNORED_EXCEPTIONS = [
KeyboardInterrupt,
MemoryError,
StopIteration,
SystemError,
SystemExit,
GeneratorExit
]
try:
IGNORED_EXCEPTIONS.append(StopAsyncIteration)
except NameError:
pass
IGNORED_EXCEPTIONS = tuple(IGNORED_EXCEPTIONS)
def is_raisable(exception, exceptions_to_exclude=IGNORED_EXCEPTIONS):
funcs_to_try = (isinstance, issubclass)
can_raise = False
try:
can_raise = issubclass(exception, BaseException)
except TypeError:
# issubclass doesn't like when the first parameter isn't a type
pass
if can_raise or isinstance(exception, BaseException):
return True
# Handle old-style classes
try:
raise exception
except TypeError as e:
# It either couldn't be raised, or was a TypeError that wasn't
# detected before this (impossible?)
return exception is e or isinstance(exception, TypeError)
except exceptions_to_exclude as e:
# These are errors that are unlikely to be explicitly tested here,
# and if they were we would have caught them before, so percolate up
raise
except:
# Must be bare, otherwise no way to reliably catch an instance of an
# old-style class
return True
This passes all of our tests, but it isn't very pretty, and still feels hacky if we're considering things that we wouldn't expect the user to pass in, but might be thrown in there anyway for some other reason.
def test_is_raisable_exception(self):
"""Test that an exception is raisable."""
self.assertTrue(is_raisable(Exception))
def test_is_raisable_instance(self):
"""Test that an instance of an exception is raisable."""
self.assertTrue(is_raisable(Exception()))
def test_is_raisable_old_style_class(self):
"""Test that an old style class is raisable."""
class A: pass
self.assertTrue(is_raisable(A))
def test_is_raisable_old_style_class_instance(self):
"""Test that an old style class instance is raisable."""
class A: pass
self.assertTrue(is_raisable(A()))
def test_is_raisable_excluded_type_background(self):
"""Test that an exception we want to ignore isn't caught."""
class BadCustomException:
def __init__(self):
raise KeyboardInterrupt
self.assertRaises(KeyboardInterrupt, is_raisable, BadCustomException)
def test_is_raisable_excluded_type_we_want(self):
"""Test that an exception we normally want to ignore can be not
ignored."""
class BadCustomException:
def __init__(self):
raise KeyboardInterrupt
self.assertTrue(is_raisable(BadCustomException, exceptions_to_exclude=()))
def test_is_raisable_not_raisable(self):
"""Test that something not raisable isn't considered rasiable."""
self.assertFalse(is_raisable("test"))
Unfortunately we need to continue to support both Python 2.6+ (soon Python 2.7 only, so if you have a solution that doesn't work in 2.6 that's fine but not ideal) and Python 3.x. Ideally I'd like to do that without an explicit test for the version, but if there isn't a way to do it otherwise then that's fine.
Ultimately, my questions are:
KeyboardInterrupt.TypeError (one because it worked, and one because it didn't) that feels odd as well (but I have to fall back on that anyway for 2.x support).The way you test most things in Python is to try then and see whether you get an exception.
That works fine for raise. If something isn't raisable, you will get a TypeError; otherwise, you will get what you raised (or an instance of what you raised). That will work for 2.6 (or even 2.3) just as well as 3.6. Strings as exceptions in 2.6 will be raisable; types that don't inherit from BaseException in 3.6 will be not raisable; etc.—you get the right result for everything. No need to check BaseException or handle old-style and new-style classes differently; just let raise do what it does.
Of course we do need to special-case TypeError, because it'll land in the wrong place. But since we don't care about pre-2.4, there's no need for anything more complicated than an isinstance and issubclass test; there are no weird objects that can do anything other than return False anymore. The one tricky bit (which I initially got wrong; thanks to user2357112 for catching it) is that you have to do the isinstance test first, because if the object is a TypeError instance, issubclass will raise TypeError, so we need to short-circuit and return True without trying that.
The other issue is handling any special exceptions that we don't want to accidentally capture, like KeyboardInterrupt and SystemError. But fortunately, these all go back to before 2.6. And both isinstance/issubclass and except clauses (as long as you don't care about capturing the exception value, which we don't) can take tuples with syntax that also works in 3.x. Since it's required that we return True for those cases, we need to test them before trying to raise them. But they're all BaseException subclasses, so we don't have to worry about classic classes or anything like that.
So:
def is_raisable(ex, exceptions_to_exclude=IGNORED_EXCEPTIONS):
try:
if isinstance(ex, TypeError) or issubclass(ex, TypeError):
return True
except TypeError:
pass
try:
if isinstance(ex, exceptions_to_exclude) or issubclass(ex, exceptions_to_exclude):
return True
except TypeError:
pass
try:
raise ex
except exceptions_to_exclude:
raise
except TypeError:
return False
except:
return True
This doesn't pass your test suite as written, but I think that's because some of your tests are incorrect. I'm assuming you want is_raisable to be true for objects that are raisable in the current Python version, not objects that are raisable in any supported version even if they aren't raisable in the current version. You wouldn't want is_raisable('spam') to return True in 3.6 and then attempting to raise 'spam' would fail, right? So, off the top of my head:
not_raisable test raises a string--but those are raisable in 2.6.excluded_type test raises a class, which Python 2.x may handle by instantiating the class, but it isn't required to, and CPython 2.6 has optimizations that will trigger in this case.old_style tests raise new-style classes in 3.6, and they're not subclasses of BaseException, so they're not raisable.I'm not sure how to write correct tests without writing separate tests for 2.6, 3.x, and maybe even 2.7, and maybe even for different implementations for the two 2.x versions (although probably you don't have any users on, say, Jython?).
You can raise the object, catch the exception and then use the is keyword to check that the raised exception is the object or an instance of the object. If anything else was raised, it was a TypeError meaning the object was not raisable.
Furthermore, to handle absolutely any raisable object, we can use sys.exc_info. This will also catch exceptions such as KeyboardInterrupt, but we can then reraise them if the comparison with the argument is inconclusive.
import sys
def is_raisable(obj):
try:
raise obj
except:
exc_type, exc = sys.exc_info()[:2]
if exc is obj or exc_type is obj:
return True
elif exc_type is TypeError:
return False
else:
# We reraise exceptions such as KeyboardInterrupt that originated from outside
raise
is_raisable(ValueError) # True
is_raisable(KeyboardInterrupt) # True
is_raisable(1) # False
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With