Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Check if something is raisable in any version

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:

  1. Is there an easier way to do this and support all listed versions?
  2. If not, is there a better or safer way to handle the "special exceptions", e.g. KeyboardInterrupt.
  3. To be most Pythonic I'd like to ask forgiveness rather than permission, but given that we could get two types of 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).
like image 307
Dan Oberlam Avatar asked Dec 14 '25 10:12

Dan Oberlam


2 Answers

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:

  • The not_raisable test raises a string--but those are raisable in 2.6.
  • The 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.
  • The 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?).

like image 53
abarnert Avatar answered Dec 15 '25 23:12

abarnert


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
like image 29
Olivier Melançon Avatar answered Dec 15 '25 23:12

Olivier Melançon



Donate For Us

If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!