Exceptions Lesson

Site: Saylor Academy
Course: CS105: Introduction to Python
Book: Exceptions Lesson
Printed by: Guest user
Date: Tuesday, April 23, 2024, 12:57 PM

Description

Now that the motivation and some syntax for exceptions has been presented, we can delve a bit deeper into the structure of writing programs containing exceptions. The following lesson is designed to help put into context the rudiments just presented. In addition, one more important component of exception handling is the 'raise' statement which is useful for raising an exception if, for example, it occurs inside an exception handler.

Exceptions

In this chapter, you'll learn all about Exceptions in Python. We'll start by reviewing the exception hierarchy, raising exceptions, examining try and catch, and finally how to create our own Exception classes.


Source: Nina Zakharenko, https://www.learnpython.dev/03-intermediate-python/40-exceptions/
Creative Commons License This work is licensed under a Creative Commons Attribution-NonCommercial-NoDerivatives 4.0 License.

1. All About Exceptions

ALL ABOUT EXCEPTIONS

 

Built-in exceptions and easy exception handling is one of the shining features of Python. Technically, errors that happen during parsing are called SyntaxErrors - these will probably be the most common errors you see, and usually happen because of a mistake in whitespace, a syntax misunderstanding, or a simple typo.

Even if the syntax is correct, errors can still occur when your program is run. We call these Exceptions, and there are many different types (this is a good thing, because the more specifically we know what went wrong, the better we can handle it).

An un-handled exception is fatal: it will print debugging information (called a traceback), stop the interpreter, and exit your program. However, once you learn to handle Exceptions, you can cover your bases and write programs that are robust in the face of issues.

 

Types of Exceptions

Python has many useful built-in exceptions that you'll probably encounter in your travels. Some of the more common ones that you'll run into are:


Exception Cause of Error
AttributeError Raised when attribute assignment or reference fails.
ImportError Raised when the imported module is not found.
IndexError Raised when index of a sequence is out of range.
KeyError Raised when a key is not found in a dictionary.
KeyboardInterrupt Raised when the user hits interrupt key (Ctrl+c or delete).
NameError Raised when a variable is not found in local or global scope.
SyntaxError Raised by parser when syntax error is encountered.
IndentationError Raised when there is incorrect indentation.
ValueError Raised when a function gets argument of correct type but improper value.

You can find a more detailed list of built-in exceptions in the Python documentation .

 

Exception Hierarchy

An important thing to know is that exceptions, like everything else in Python, are just objects. They follow an inheritance hierarchy, just like classes do. For example, the ZeroDivisionError is a subclass of  ArithmeticError, which is a subclass of Exception, itself a subclass of  BaseException.

So, if you wanted to catch a divide-by-zero error, you could use except ZeroDivisionError. But you could also use  except ArithmeticError, which would catch not only  ZeroDivisionEror, but also OverflowError and FloatingPointError. You could use except Exception, but this is not a good idea, as it will catch almost every type of error, even ones you weren't expecting. We'll discuss this a bit later. Again, a full chart of the hierarchy for built-in exceptions can be found at the bottom of the (Python documentation)[ https://docs.python.org/3/library/exceptions.html#exception-hierarchy].

 

Exiting your Program

As we mentioned, exceptions that are allowed to bubble up to the top level (called unhandled exceptions) will cause your program to exit. This is generally unwanted - even if an error is unrecoverable, we still want to provide more detailed information about the error for later inspection, or a pretty error for the user if our program is user-facing, and in most cases, we want the program to go back to doing what it was doing.

What if we want our program to stop, though? You may already be familiar with ctrl-c , the age-old posix method of sending SIGINT (an interrupt signal) to a program. You may be surprised to know asking your operating system to send SIGINT to Python causes, yes, an exception - KeyboardInterrupt . And yes, you can catch KeyboardInterrupt, but this will make your program a little harder to kill. 

You can also use sys.exit() from the built-in sys library. It's generally not a good idea to pepper sys.exit() around your code, as it makes it harder to control when your program exits, but this can be a handy function for controlling how and when your program exits. By default, sys.exit() with no parameters will exit with a  0 return code, which, by posix convention, signals success. You can pass an integer to sys.exit() if you'd like to exit with a non-zero return code (usually signaling some sort of failure condition). You can also pass a string to sys.exit(), which will get printed to the command line, along with a return code of 1.

sys.exit() generates a SystemExit exception, which inherits from the master BaseException class, which makes it possible for clean-up handlers (such as finally statements) to run.

2. Try Except

TRY EXCEPT


Many languages have the concept of the "Try-Catch" block. Python uses four keywords: tryexceptelse, and finally. Code that can possibly throw an exception goes in the  try block.  except gets the code that runs if an exception is raised. else is an optional block that runs if no exception was raised in the try block, and finally is an optional block of code that will run last, regardless of if an exception was raised. We'll focus on try and except for this chapter.

A basic example looks like this:

>>> try:
... x = int(input("Enter a number: "))
... except ValueError:
... print("That number was invalid")

First, the try clause is executed. If no exception occurs, the except  clause is skipped and execution of the try statement is finished. If an exception occurs in the try  clause, the rest of the clause is skipped. If the exception's type matches the exception named after the except keyword, then the  except clause is executed. If the exception doesn't match, then the exception is unhandled and execution stops.


The except Clause

An except clause may have multiple exceptions, given as a parenthesized tuple:

try:
 # Code to try
except (RuntimeError, TypeError, NameError):
 # Code to run if one of these exceptions is hit

try statement can also have more than one except  clause:

try:
 # Code to try
except RuntimeError:
 # Code to run if there's a RuntimeError
except TypeError:
 # Code to run if there's a TypeError
except NameError:
 # Code to run if there's a NameError


Finally

Finally, we have finallyfinally is an optional block that runs after  tryexcept, and else, regardless of if an exception is thrown or not. This is good for doing any cleanup that you want to happen, whether or not an exception is thrown.

>>> try:
... raise KeyboardInterrupt
... finally:
... print("Goodbye!")
...
Goodbye!
Traceback (most recent call last):
 File "<stdin>", line 2, in <module>
KeyboardInterrupt

As you can see, our Goodbye! gets printed just before the unhandled KeyboardInterrupt gets propagated up and triggers the traceback.

3. Best Practices

BEST PRACTICES


Catch More Specific Exceptions First

Remember, your except handlers are evaluated in order, so be sure to put more specific exceptions first. For example:

>>> try:
... my_value = 3.14 / 0
... except ArithmeticError:
... print("We had a general math error")
... except ZeroDivisionEror:
... print("We had a divide-by-zero error")
...
We had a general math error

When we tried to divide by zero, we inadvertently raised a ZeroDivisionError. However, because ZeroDivisionError is a subclass of ArithmeticError, and except ArithemticError  came first, the information about our specific error was swallowed by the except ArithemticError handler, and we lost more detailed information about our error.


Don't Catch Exception

It's bad form to catch the general Exception class. This will catch every type of exception that subclasses the Exception class, which is almost all of them. You may have errors that you don't care about, and don't affect the operation of your program, or maybe you're dealing with a flaky API and want to swallow errors and retry. By catching Exception, you run the risk of hitting an unexpected exception that your program actually can't recover from, or worse, swallowing an important exception without properly logging it - a huge headache when trying to debug programs that are failing in weird ways.


Definitely don't catch BaseException

Catching BaseException is a really bad idea, because you'll swallow every type of Exception, including KeyboardInterrupt, the exception that causes your program to exit when you send a SIGINT (Ctrl-C). Don't do it.

4. Custom Exceptions

CUSTOM EXCEPTIONS


As we mentioned, exceptions are just regular classes that inherit from the Exception class. This makes it super easy to create our own custom exceptions, which can make our programs easier to follow and more readable. An exception need not be complicated, just inherit from Exception: 

>>> class MyCustomException(Exception):
... pass
...
>>> raise MyCustomException()
Traceback (most recent call last):
 File "<stdin>", line 1, in <module>
__main__.MyCustomException

 It's OK to have a custom Exception subclass that only pass-es - your exception doesn't need to do anything fancy to be useful. Having custom exceptions - tailored to your specific use cases and that you can raise and catch in specific circumstances - can make your code much more readable and robust, and reduce the amount of code you write later to try and figure out what exactly went wrong.

Of course, you can get as fancy as you want. You can send additional information, like messages, to your exceptions. Just add an __init__() method to your exception class, with whatever arguments you want.

class IncorrectValueError(Exception):
... def __init__(self, value):
... message = f"Got an incorrect value of {value}"
... super().__init__(message)
...
>>> my_value = 9999
>>> if my_value > 100:
... raise IncorrectValueError(my_value)
...
Traceback (most recent call last):
 File "<stdin>", line 2, in <module>
__main__.IncorrectValueError: Got an incorrect value of 9999

Exception takes an optional string argument message that gets printed with your exception. We pass our erroneous value to our IncorrectValueError object, which constructs a special message and passes it its parent class, Exception, via super().__init__(). The custom message string, along with the value for context, gets printed along with our error traceback.


A Custom Exception for our GitHub API app

If we wanted to write a custom Exception for our GitHub API app, it might look something like this.

class GitHubApiException(Exception):
 def __init__(self, status_code):
 if status_code == 403:
 message = "Rate limit reached. Please wait a minute and try again."
 else:
 message = f"HTTP Status Code was: {status_code}."
 super().__init__(message)

Notice how it takes the HTTP status code into account, and displays a custom error message for the 403, rate limited reached status code.

5. Practice

PRACTICE


Syntax Errors

Let's get more comfortable with exceptions. First, you've probably seen this one already: 

The IndentationError.

>>> def my_function():
... print("Hello!")
 File "<stdin>", line 2
 print("Hello!")
 ^
IndentationError: expected an indented block

Notice that we started a new function scope with the def keyword, but didn't indent the next line of the function, the  print() argument.

You've probably also seen the more general SyntaxError. This one's probably obvious - something is misspelled, or the syntax is otherwise wrong. Python gives us a helpful little caret  ^ under the earliest point where the error was detected, however you'll have to learn to read this with a critical eye as sometimes the actual mistake precedes the invalid syntax. For example:

>>> a = [4,
... x = 5
 File "<stdin>", line 2
 x = 5
 ^
SyntaxError: invalid syntax

Here, the invalid syntax is x = 5, because assignment statements aren't valid list elements, however the actual error is the missing right bracket  ] on the line above.


Common Exceptions

You'll get plenty of practice triggering syntax errors on your own. Let's practice triggering some exceptions. Type this perfectly valid code into your REPL and see what happens:

>>> a = 1 / 0
 Here's what you should have seen in your REPL:
>>> a = 1 / 0
Traceback (most recent call last):
 File "<stdin>", line 1, in <module>
ZeroDivisionError: division by zero

Of course, you'll get a divide-by-zero error, or as Python calls it, ZeroDivisionError. Some other common errors are  TypeError when trying to perform an action on two unrelated types, KeyError when trying to access a dictionary key that doesn't exist, and AttributeError when trying to access a variable or call a function that doesn't exist on an object.

>>> 2 + "3"
>>> my_dict = {"hello": "world"}
>>> my_dict["foo"]
>>> my_dict.append("foo")
 Here's what you should have seen in your REPL:
>>> 2 + "3"
Traceback (most recent call last):
 File "<stdin>", line 1, in <module>
TypeError: unsupported operand type(s) for +: 'int' and 'str'
>>> my_dict = {"hello": "world"}
>>> my_dict["foo"]
Traceback (most recent call last):
 File "<stdin>", line 1, in <module>
KeyError: 'foo'
>>> my_dict.append("foo")
Traceback (most recent call last):
 File "<stdin>", line 1, in <module>
AttributeError: 'dict' object has no attribute 'append'


Raising our own Exceptions

Making our own Exceptions is cheap and easy, and useful for keeping track of various error states that are specific to your application. Simply inherit from the general Exception class:

>>> class MyException(Exception):
... pass
>>> raise MyException()
 Here's what you should have seen in your REPL:
>>> class MyException(Exception):
... pass
>>> raise MyException()
Traceback (most recent call last):
 File "<stdin>", line 1, in <module>
__main__.MyException

It's also sometimes helpful to change the default behavior for your custom Exceptions. In this case, you can simply provide your own __init__()  method inside your Exception subclass:

class MyException(Exception):
... def __init__(self, message):
... new_message = f"!!!ERROR!!! {message}"
... super().__init__(new_message)
...
>>> raise MyException("Something went wrong!")
 Here's what you should have seen in your REPL:
class MyException(Exception):
... def __init__(self, message):
... new_message = f"!!!ERROR!!! {message}"
... super().__init__(new_message)
...
>>> raise MyException("Something went wrong!")
Traceback (most recent call last):
 File "<stdin>", line 1, in <module>
__main__.MyException: !!!ERROR!!! Something went wrong!

tryexcept

In Python, the "try-catch" statements use try and except. As we discussed,  try is the code that could possibly throw an Exception, and except is the code that runs if the error is raised. Practice catching a KeyError by trying to access a fake dictionary key:

>>> try:
... my_dict = {"hello": "world"}
... print(my_dict["foo"])
... except KeyError:
... print("Oh no! That key doesn't exist")
...
 Here's what you should have seen in your REPL:
>>> try:
... my_dict = {"hello": "world"}
... print(my_dict["foo"])
... except KeyError:
... print("Oh no! That key doesn't exist")
...
Oh no! That key doesn't exist

Let's add in catching the specific KeyError object so that we can access it during the  except block:

>>> try:
... my_dict = {"hello": "world"}
... print(my_dict["foo"])
... except KeyError as key_error:
... print(f"Oh no! The key {key_error} doesn't exist!")
...
 Here's what you should have seen in your REPL:
>>> try:
... my_dict = {"hello": "world"}
... print(my_dict["foo"])
... except KeyError as key_error:
... print(f"Oh no! The key {key_error} doesn't exist!")
...
Oh no! The key 'foo' doesn't exist!


Re-Raising

Sometimes it's helpful to catch an error, perform an action, and then pass the error on rather than swallowing it. This is useful when, for example, something goes wrong deep inside your code and you need to perform a special action, but also let code further up the chain know that something is wrong and the program can't continue. Let's divide one number by other, decrementing until we hit zero. Catch that error and immediately raise a RuntimeError:

>>> while True:
... for divisor in range(5, -1, -1):
... try:
... quotient = 10 / divisor
... print(f"10 / {divisor} = {quotient}")
... except ZeroDivisionError:
... print("Oops! We tried to divide by zero!")
... raise RuntimeError
...
Here's what you should have seen in your REPL:
>>> while True:
... for divisor in range(5, -1, -1):
... try:
... quotient = 10 / divisor
... print(f"10 / {divisor} = {quotient}")
... except ZeroDivisionError:
... print("Oops! We tried to divide by zero!")
... raise RuntimeError
...
10 / 5 = 2.0
10 / 4 = 2.5
10 / 3 = 3.3333333333333335
10 / 2 = 5.0
10 / 1 = 10.0
Oops! We tried to divide by zero!
Traceback (most recent call last):
 File "<stdin>", line 4, in <module>
ZeroDivisionError: division by zero
During handling of the above exception, another exception occurred:
Traceback (most recent call last):
 File "<stdin>", line 8, in <module>
RuntimeError

What happened here? We got two exceptions! First, our code hit the ZeroDivisionError, which we caught, and printed our "Oops!" message. Then, the interpreter saw that we raised a  RuntimeError, which we didn't catch, so it broke us out of our while True loop and ended the program with a Traceback.