While the software is highly predictable, the runtime context can provide unexpected inputs and situations. A possible solution to this problem is based on Exceptions, that are special error objects raised when a normal response is impossible.

Raising Exceptions

Python’s normal behaviour is to execute statements in the order they are found. A few statement, such as if, while, and for, alter the simple top-to-bottom sequence of statement execution. Additionally, an Exception can break the sequential flow of execution. Exceptions are raised, and this interrupts the sequential execution of statements. In Python, the exception that is raised is also an object. There are many different exception classes available, but they all extends a built-in class called BaseException. Here’s some example of exceptions potentially raised:

>>> print "hello world"
Cell In[61],   line 1
    print "hello world"
    ^
SyntaxError: Missing parentheses in call to 'print'. Did you mean print(...)?
 
 
>>> x = 5/0
ZeroDivisionError                         Traceback (most recent call last)
Cell In[62], line 1
----> 1 x = 5/0
 
ZeroDivisionError: division by zero
 
 
>>> lst = [1,2,3]
>>> print(lst[3])
IndexError                                Traceback (most recent call last)
Cell In[63], line 2
      1 lst = [1,2,3]
----> 2 print(lst[3])
 
IndexError: list index out of range
  • Some exceptions are indicators of something clearly wrong in the program, such as SyntaxError and NameError: in these cases we need to find the indicated line number and fix the problem.
  • Others are design-problem, such as ZeroDivisionError: once gone to the indicated line, we need to work backwards from there to find out what caused the problem that raised the exceptions.

Note

All of Python’s built-in exceptions end with the name Error. That’s because in Python, the words error and exceptions are used almost interchangeably.

Raising and Exception

Let’s write a function that inform the user that the inputs are invalid. In particular, we want to add items to a list only if they’re even-numbered integers and we’ll do that by extending the built-in List and overriding the append method:

from typing import List
 
class EvenOnly(List[int]):
    def append(self, value: int) -> None:
        if not isinstance(value, int):
            raise TypeError("Only integers can be added.")
        if value % 2 != 0:
            raise ValueError("Only even numbers can be added")
        super().append(value)
        
 
e = EvenOnly()

Let’s see if it works:

>>> e.append("hello")
TypeError                                 Traceback (most recent call last)
Cell In[78], line 13
      9         super().append(value)
     12 e = EvenOnly()
---> 13 e.append("hello")
 
Cell In[78], line 6
      4 def append(self, value: int) -> None:
      5     if not isinstance(value, int):
----> 6         raise TypeError("Only integers can be added.")
      7     if value % 2 != 0:
      8         raise ValueError("Only even numbers can be added")
 
TypeError: Only integers can be added.
 
 
>>> e.append(3)
ValueError                                Traceback (most recent call last)
Cell In[81], line 1
----> 1 e.append(3)
 
Cell In[80], line 8
      6     raise TypeError("Only integers can be added.")
      7 if value % 2 != 0:
----> 8     raise ValueError("Only even numbers can be added.")
      9 super().append(value)
 
ValueError: Only even numbers can be added.

The effect of an Exception

When an exception is raised, it stops the program execution immediately. Any lines that were supposed to run after the exception is raised are not executed, and unless the exception is handled by an except clause, the program will exit with an error message. An example:

def never_returns():
    print("I am about to raise an exception.")
    raise Exception("This is always raised.")
    print("This line will never execute.")
    return "I won't be returned."
 
never_returns()

Output:

I am about to raise an exception.
---------------------------------------------------------------------------
Exception                                 Traceback (most recent call last)
Cell In[82], line 7
      4     print("This line will never execute.")
      5     return "I won't be returned."
----> 7 never_returns()

Cell In[82], line 3
      1 def never_returns():
      2     print("I am about to raise an exception.")
----> 3     raise Exception("This is always raised.")
      4     print("This line will never execute.")
      5     return "I won't be returned."

Exception: This is always raised.

Or, a more complex one:

def never_returns():
    print("I am about to raise an exception.")
    raise Exception("This is always raised.")
    print("This line will never execute.")
    return "I won't be returned."
 
def call_exceptor():
    print("Call exceptor start here...")
    never_returns()
    print("An exception was raised...")
    print("... so these lines don't run")
 
call_exceptor()

Output:

Call exceptor start here...
I am about to raise an exception.
---------------------------------------------------------------------------
Exception                                 Traceback (most recent call last)
Cell In[83], line 13
     10     print("An exception was raised...")
     11     print("... so these lines don't run")
---> 13 call_exceptor()
 
Cell In[83], line 9
      7 def call_exceptor():
      8     print("Call exceptor start here...")
----> 9     never_returns()
     10     print("An exception was raised...")
     11     print("... so these lines don't run")
 
Cell In[83], line 3
      1 def never_returns():
      2     print("I am about to raise an exception.")
----> 3     raise Exception("This is always raised.")
      4     print("This line will never execute.")
      5     return "I won't be returned."
 
Exception: This is always raised.

Handling Exceptions

Generic Exception

Now we need a way to handle exceptions. We’ll do that by wrapping any code that might throw one:

try:
    never_returns()
    print("Never Executed")
except Exception as ex:
    print(f"I caught an exception: {ex!r}")
print("Executed after the exception.")

Output:

I am about to raise an exception.
I caught an exception: Exception('This is always raised.')
Executed after the exception.

Explaination:

  • The never_returns() function raises an exception, specifically an Exception;
  • The except clause catches the Exception exception;
  • Once caught, we are able to handle it by printing the string at line 5, and continue on our way.

Attention

The problem with the preceding code is that it uses the Exception class to match any type of exception.

Specific Exceptions

For instance we want to catch ZeroDivisionError because it reflects a known object state, but let any other exceptions propagate to the console because they reflects bugs we need to catch and kill:

from typing import Union
 
def funny_division(divisor: float) -> Union[str, float]:
    try:
        return 100 / divisor
    except ZeroDivisionError:
        return "Division by 0 is not allowed."

Let’s apply this function:

>>> funny_division(200)
0.5
 
>>> funny_division(0)
'Division by 0 is not allowed.'
 
>>> funny_division("string")
TypeError                                 Traceback (most recent call last)
Cell In[14], line 1
----> 1 funny_division("string")
 
Cell In[12], line 5
      3 def funny_division(divisor: float) -> Union[str, float]:
      4     try:
----> 5         return 100 / divisor
      6     except ZeroDivisionError:
      7         return "Division by 0 is not allowed."
 
TypeError: unsupported operand type(s) for /: 'int' and 'str'
  • The first run operates correctly.
  • The second run would raise an exception, but it is correctly handled.
  • The third run raised an error because the TypeError exception is not handled.

It’s also possible to catch two or more different exceptions and handle them with the same code:

from typing import Union
 
def funny_division_2(divisor: int) -> Union[str, float]:
    try:
        if divisor == 13:
            raise ValueError("13 is an unlucky number.")
        return 100 / divisor
    except (ZeroDivisionError, TypeError):
        return "Enter a number other than 0."
 
 
for val in (0, "hello", 50.0, 13):
    print(f"Testing {val!r}:", end=" ")
    print(funny_division_2(val))

Output:

Testing 0: Enter a number other than 0.
Testing 'hello': Enter a number other than 0.
Testing 50.0: 2.0
Testing 13: 
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
Cell In[38], line 3
      1 for val in (0, "hello", 50.0, 13):
      2     print(f"Testing {val!r}:", end=" ")
----> 3     print(funny_division_2(val))

Cell In[35], line 6
      4 try:
      5     if divisor == 13:
----> 6         raise ValueError("13 is an unlucky number.")
      7     return 100 / divisor
      8 except (ZeroDivisionError, TypeError):

ValueError: 13 is an unlucky number.
  • Both ZeroDivisionError and TypeError are handled with the same exception handler.
  • The exceptions from the number 13 is not caught because it is a ValueError error, which is not handled.

Now we want:

  1. handle different exceptions in different ways;
  2. do something with an exception and then allow it to continue to bubble up to the parent function, as if it had never been caught.
def funny_division_3(divisor: int) -> Union[str, float]:
    try:
        if divisor == 13:
            raise ValueError("13 is an unlucky number.")
        return 100 / divisor
    except ZeroDivisionError:
        return "Enter a number other than 0."
    except TypeError:
        return "Enter a numberical value"
    except ValueError:
        print("No, No, not 13!")
        raise
  1. We stacked the except clauses to remedy the problem 1.
  2. The last line re-raises the ValueError error, after outputting No, No, not 13! to remedy the problem 2. Note that if we stack exceptions clauses, only the first matching clause will be run, even if more than one of them fits. For example, if we have an except clause to match Exception before we match TypeError, then only the Exception handler will be executed, because TypeError is an Exception by inheritance. That’s why we must be sure the order of the except clauses has classes that move from the most specific subclasses to most generic superclasses.

Often, when we catch an exception, we need a reference to the Exception object itself. The syntax for capturing an exception as aa variable uses the as keyword:

try:
    raise ValueError("This is an argument.")
except ValueError as e:
    print(f"The exception arguments were {e.args}")

Output:

The exception arguments were ('This is an argument.',)

finally and else

We can add additional execution paths:

  • we can execute code regardless of whether or not an exception has occurred (with finally);
  • we can specify code that should be executed only if an exception does not occur (with else). Example:
some_exceptions = [ValueError, TypeError, IndexError, None]
 
for index, choice in enumerate(some_exceptions):
    try:
        print(f"\nRaising Exception {index}: {choice}")
        if choice:
            raise choice
        else:
            print("no exception raised")
    except ValueError:
        print("Caught a ValueError")
    except TypeError:
        print("Caught a TypeError")
    except Exception as e:
        print(f"Caught some other error: {e.__class__.__name__}")
    else:
        print("This code called if there is no exception")
    finally:
        print("This cleanup code is always called")

Output:

Raising Exception 0: <class 'ValueError'>
Caught a ValueError
This cleanup code is always called

Raising Exception 1: <class 'TypeError'>
Caught a TypeError
This cleanup code is always called

Raising Exception 2: <class 'IndexError'>
Caught some other error: IndexError
This cleanup code is always called

Raising Exception 3: None
no exception raised
This code called if there is no exception
This cleanup code is always called

Note that:

  • the else clause is executed only if there is no exception;
  • the finally clause is executed no matter what happens. Common cases:
    • cleaning up an open database connection;
    • closing an open file.
  • when no exception is raised, both the else and finally clauses are executed.

The Exception hierarchy

  • All exceptions must extend the BaseException class or one of its subclasses.
  • Most of the exceptions we have seen so far are subclasses of the Exception class. Besides the exceptions classes that extends the Exception class we have seen so far, there are other two key built-in exception classes:
  • SystemExit exception class: it’s raised whenever the program exits naturally (i.e. we called sys.exit(), or the user clicked the Close button on a window);
  • KeyboardInterrupt exception class: it’s raised when the user explicitly interrupts program execution with an OS-dependent key combination (normally , Ctrl+C). It’s common in command-line programs.

Note: when you use except: clause without specifying any type of exception, it will catch all subclasses of BaseException, which is strongly discouraged. If you want to catch all exceptions other than SystemExit and KeyboardInterrupt always explicitly catch Exception.

Defining our own exceptions

Occasionally, we find that none of the built-in exceptions are suitable. When we introduce a new exception it must be because there will be distinct processing in a handler; that’s because there’s no good reason to define an exception that’s handled exactly like ValueError (we can use ValueError in that case!).

To create a custom exception you inherit from the Exception class or one of the existing exceptions that’s semantically similar and we don’t even have to add any content to the class:

class InvalidWithdrawal(ValueError):
	pass

You can use it as any other exceptions:

>>> raise InvalidWithdrawal("You don't have $50 in your account.")
 
InvalidWithdrawal                         Traceback (most recent call last)
Cell In[20], line 4
      1 class InvalidWithdrawal(ValueError):
      2 	pass
----> 4 raise InvalidWithdrawal("You don't have $50 in your account.")
 
InvalidWithdrawal: You don't have $50 in your account.

Like in the example above, often a string is used, bu any object that might be useful in a later exception handler can be stored. Indeed the Exception.__init__() method is designed to accept any arguments and store them as a tuple in an attribute named args:

try:
    raise InvalidWithdrawal("Error occurred", 404, "Not Found")
except InvalidWithdrawal as e:
    print("Exception caught!")
    print("Arguments:", e.args)

Output:

Exception caught!
Arguments: ('Error occurred', 404, 'Not Found')

Exceptions aren’t exceptional

Consider the following two functions:

def divide_with_exception(dividend: int, divisor: int) -> None:
    try:
        print(f"{dividend / divisor=}")
    except ZeroDivisionError:
        print("You can't divide by zero")
        
def divide_with_if(dividend: int, divisor: int) -> None:
    if divisor == 0:
        print("You can't divide by zero")
    else:
        print(f"{dividend / divisor=}")

These two functions behave identically. In this example the if clause is simple, but there are some cases where the computation of the if condition is much complex. Python programmers tend to follow a model summarized by “It’s Easier to Ask Forgiveness than Permission” (aka EAFP), where the main principle is to execute code and then deal with anything that goes wrong. The alternative is described as “Look Before You Leap” (aka LBYL). The main reason why EAFP is preferred over LBYL is to avoids the overhead of checking for conditions that are unlikely to occur. That’s also because generally the code is written to assume that the most common path through the code will work correctly, so EAFP allows to avoid unnecessary checks.

Further details on: https://realpython.com/python-lbyl-vs-eafp/