Unravelling `from` for `raise` statements

As part of my series on Python's syntax, I want to tackle the from clause for raise statements. In case you're unfamiliar, raise A from B causes B to be assigned to A.__cause__ which lets chained tracebacks exist (as well as __context__, but that's not relevant to today's topic). There is a restriction that only instances of exceptions can be assigned to __cause__, and if you specify an exception class then it gets instantiated before assignment. Trying to assign anything else triggers a TypeError.

So the first question is how much checking do we need to do for the from clause's object to make sure it meets the requirement of being an (eventual) exception instance?

>>> exc1 = Exception()
>>> exc1.__cause__ = 42
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: exception cause must be None or derive from BaseException
>>> exc1.__cause__ = Exception
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: exception cause must be None or derive from BaseException
>>> exc1.__cause__ = Exception()
Exploring restrictions when assigning to BaseException.__cause__

It appears that only assigning an instance of an exception to __cause__ is allowed. This is convenient as we don't have to do the check for something not being an exception instance. But it's also inconvenient as we do have to instantiate any exception class ourselves. What this says to me is the following:

  1. Assigning an exception instance is fine.
  2. Assigning a non-exception-related object is "fine" because the exception object will raise the exception.
  3. Assigning an exception class is problematic, and so we will have to handle the instantiation.

So how do we detect if an object is an exception class but not an instance? To tell if an object is a class, it needs to be an instance of type (as inspect.issclass() shows us); isinstance(obj, type). To tell if something is an exception instance, it needs to be an instance of BaseException; isinstance(obj, BaseException). And to tell if a class is an exception class, we need to know if it subclasses BaseException; issubclass(obj, BaseException). Now one thing to note is issubclass() will raise a TypeError if you pass anything that isn't a class as its first argument; isinstance() does not have the inverse issue of passing in a class as its first argument.

Oh, and we also have to make sure the object we are raising is also appropriately instantiated before we attempt any assignment to __cause__. That adds an extra wrinkle to this problem as it means we will have to raise the TypeError when the to-be-raised exception isn't an exception-related object.

This can all be summarized by the following function:

def exception_instance(obj):
    if isinstance(obj, BaseException):
        return obj
    elif isinstance(obj, type) and issubclass(obj, BaseException):
        return obj()
        raise TypeError("exceptions must derive from BaseException")
Utility function for getting an exception instance

When we unravel raise A from B, we can inline the logic and simplify it a bit:

  1. If we have an exception class, instantiate it.
  2. If we have a non-exception object for the raise clause, raise TypeError.
  3. Rely on the fact that assigning anything other than an exception instance to __cause__ raises the appropriate TypeError for us.
  4. Don't assign anything if the from clause evaluates to None.

This lets us unravel raise A from B to:

_raise = A
if isinstance(_raise, type) and issubclass(_raise, BaseException):
        _raise = _raise()
elif not isinstance(_raise, BaseException):
    raise TypeError("exceptions must derive from BaseException")

_from = B
if isinstance(_from, type) and issubclass(_from, BaseException):
        _from = _from()
if _from is not None:
	_raise.__cause__ = _from

raise _raise
Unravelling raise A from B

In the general case of raise A we don't have to do any of this as it's part of Python's semantics to handle the class-to-instance scenario.