In this tutorial, you’ll learn various techniques to catch multiple exceptions with Python. To begin with, you’ll review Python’s exception handling mechanism before diving deeper and learning how to identify what you’ve caught, sometimes ignore what you’ve caught, and even catch lots of exceptions.
Python raises an exception when your code encounters an occasional but not unexpected error. For example, this will occur if you try to read a missing file. Because you’re aware that such exceptions may occur, you should write code to deal with, or handle, them. In contrast, a bug happens when your code does something illogical, like a miscalculation. Bugs should be fixed, not handled. This is why debugging your code is important.
When your Python program encounters an error and raises an exception, your code will probably crash, but not before providing a message within a traceback indicating what the problem is:
>>> 12 / "five"
Traceback (most recent call last):
...
TypeError: unsupported operand type(s) for /: 'int' and 'str'
Here, you’ve tried to divide a number by a string. Python can’t do this, so it raises a TypeError
exception. It then shows a traceback reminding you that the division operator doesn’t work with strings.
To allow you to take action when an error occurs, you implement exception handling by writing code to catch and deal with exceptions. Better this than your code crashing and scaring your user. To handle exceptions, you use the try
statement. This allows you to monitor code for exceptions and take action should they occur.
Most try
statements use try
… except
blocks as follows:
-
The
try
block contains the code that you wish to monitor for exceptions. Any exceptions raised withintry
will be eligible for handling. -
One or more
except
blocks then followtry
. These are where you define the code that will run when exceptions occur. In your code, any raised exceptions trigger the associatedexcept
clause. Note that where you have multipleexcept
clauses, your program will run only the first one that triggers and then ignore the rest.
To learn how this works, you write a try
block to monitor three lines of code. You include two except
blocks, one each for ValueError
and ZeroDivisionError
exceptions, to handle them should they occur:
# handler_statement.py
try:
first = float(input("What is your first number? "))
second = float(input("What is your second number? "))
print(f"{first} divided by {second} is {first / second}")
except ValueError:
print("You must enter a number")
except ZeroDivisionError:
print("You can't divide by zero")
The code that you’re monitoring asks the user to enter both numbers and then prints the division. You’ll cause a ValueError
if you don’t enter a number in the first two lines of code. When the float()
function tries to convert your input into a float
, a ValueError
occurs if this isn’t possible. A ZeroDivisionError
occurs if you enter 0
as the second number. When the print()
function attempts to divide by zero, you get a ZeroDivisionError
.
Having written the above code, you then test each of the control flows. To do this, you first provide perfectly valid data, then provide a string
for the second number, and finally provide a 0
for the second number:
$ python handler_statement.py
What is your first number? 10
What is your second number? 5
10.0 divided by 5.0 is 2.0
$ python handler_statement.py
What is your first number? 10
What is your second number? "five"
You must enter a number
$ python handler_statement.py
What is your first number? 10
What is your second number? 0
You can't divide by zero
The good news is that your code never crashes. This is because your code has successfully handled the exceptions.
First, you provided acceptable data. When you look at the output, you can see that the program flows only through try
. You haven’t invoked any of the except
clauses because Python hasn’t raised any exceptions.
You then cause a ValueError
by entering a string. This happens because the float()
function can’t convert your "five"
into a float
. Your program flow now becomes try
then except ValueError
. Despite raising a ValueError
, your code has handled it gracefully. Your users will no longer experience a worrying crash.
In your final test run, you try to divide by 0
. This time, you cause a ZeroDivisionError
because Python doesn’t like your enthusiasm for Riemann spheres and infinity. This time, the program flow is try
then except ZeroDivisionError
. Again, your code has handled your exception gracefully. Most of your users will be happy with this, though the mathematicians may be disappointed.
After the error handling is complete, your program’s flow would usually continue with any code beyond the try
statement. In this case, there’s none, so the program simply ends.
As an exercise, you might like to try entering a string as the first input and a number as the second. Can you predict what will happen before you try it out?
Note: Your code catches only ZeroDivisionError
or ValueError
exceptions. Should any others be raised, it’ll crash as before. You could get around this by creating a final except Exception
clause to catch all other exceptions. However, this is bad practice because you might catch exceptions that you didn’t anticipate. It’s better to catch exceptions explicitly and customize your handling of them.
Up to this point, you’ve reviewed how to catch exceptions individually using the try
statement. In the remainder of this tutorial, you’ll learn about ways that you can catch multiple exceptions. Time to dive a bit deeper.
Get Your Code: Click here to download the sample code that shows you how to catch multiple exceptions in Python.
How to Catch One of Several Possible Python Exceptions
Catching individual exceptions in separate except
clauses is the way to go if you need to perform different handling actions on the different exceptions being caught. If you find that you’re performing the same actions in response to different exceptions, then you can produce simpler, more readable code by handling multiple exceptions in a single except
clause. To do this, you specify the exceptions as a tuple within the except
statement.
Suppose you now like the idea of your earlier code being able to handle both exceptions in a single line. To do this, you decide to rewrite your code as follows:
# multiple_exceptions.py
try:
first = float(input("What is your first number? "))
second = float(input("What is your second number? "))
print(f"{first} divided by {second} is {first / second}")
except (ValueError, ZeroDivisionError):
print("There was an error")
This time, if you catch either a ValueError
or ZeroDivisionError
exception, you handle it with the same except
clause. You also could’ve included additional except
clauses for other exceptions if you wanted to. These would’ve worked for you in the same way as before.
You can then test this version of your code using the same test cases as before:
$ python multiple_exceptions.py
What is your first number? 10
What is your second number? 5
10.0 divided by 5.0 is 2.0
$ python multiple_exceptions.py
What is your first number? 10
What is your second number? "five"
There was an error
$ python multiple_exceptions.py
What is your first number? 10
What is your second number? 0
There was an error
In your first test, only your try
block gets executed because you don’t raise any exceptions. Your second and third test cases raise a ValueError
and ZeroDivisionError
, respectively. You’ve caught each of them in the same except
clause. In both cases, the program flow is try
then except (ValueError, ZeroDivisionError)
. Again, your code survives the exceptions without crashing.
Although you’re happy that the handler safely handles both exceptions in the same way, you’d like to know exactly which exception is being raised. You’ll learn how to do this next.
Identify Which Python Exception Was Caught
If you’re familiar with object-oriented programming concepts, then you know that classes are templates that define the content and abilities of the objects that you create, or instantiate, from them. When your code raises a Python exception, it actually instantiates an object
from the class that defines the exception. For example, when you raise a ValueError
exception, you’re instantiating an instance of the ValueError
class.
While you don’t need a thorough knowledge of object-oriented programming to work with exceptions, you should be aware that different exception objects only exist because they’re instantiated from different classes.
You decide to try and identify the individual exceptions caught in your previous code:
# exception_identification.py
try:
first = float(input("What is your first number? "))
second = float(input("What is your second number? "))
print(f"{first} divided by {second} is {first / second}")
except (ValueError, ZeroDivisionError) as error:
print(f"A {type(error).__name__} has occurred.")
Here, you’ve made some adjustments to the handler. It still catches both ValueError
and ZeroDivisionError
exceptions, but this time, you assign the exception object to a variable named error
. Doing this allows you to analyze it.
To find the class of an Exception
, you use type()
. If you print the .__name__
attribute of the class, then you see exactly which exception your code has handled. Now you can find out which exceptions have occurred:
$ python exception_identification.py
What is your first number? 10
What is your second number? 5
10.0 divided by 4.0 is 2.0
$ python exception_identification.py
What is your first number? 10
What is your second number? "five"
A ValueError has occurred.
$ python exception_identification.py
What is your first number? 10
What is your second number? 0
A ZeroDivisionError has occurred.
The first test is a relief because it proves that your update still works. The second and third tests raise exceptions. As you can see, your code has correctly identified both the ValueError
and ZeroDivisionError
exceptions.
Suppose you decide to improve your code by allowing multiplication. You also decide to manually raise a RuntimeError
if your user requests an unsupported operation or does something unexpected, like entering a carriage return:
# exception_identification_with_structural_pattern_matching.py
from operator import mul, truediv
def calculate(operator, operand1, operand2):
return operator(operand1, operand2)
try:
first = float(input("What is your first number? "))
second = float(input("What is your second number? "))
operation = input("Enter either * or /: ")
if operation == "*":
answer = calculate(mul, first, second)
elif operation == "/":
answer = calculate(truediv, first, second)
else:
raise RuntimeError(f"'{operation}' is an unsupported operation")
except (RuntimeError, ValueError, ZeroDivisionError) as error:
print(f"A {type(error).__name__} has occurred")
match error:
case RuntimeError():
print(f"You have entered an invalid symbol: {error}")
case ValueError():
print(f"You have not entered a number: {error}")
case ZeroDivisionError():
print(f"You can't divide by zero: {error}")
else:
print(f"{first} {operation} {second} = {answer}")
This time, you use the operator
module’s mul()
and truediv()
functions to perform multiplication and division. You pass these to your calculate()
function, along with two numbers. Your calculate()
function then calls the operator
module function that you’ve passed to it, which performs the calculation. This only works if you enter in two numbers and either /
or *
for the operation.
Although you could’ve coded calculate()
to use the *
or /
operator directly, using an operator
module function simplifies the function’s code and provides extensibility.
If your user enters an invalid operator, then your code explicitly raises a RuntimeError
. Like any other exception, this will cause the code to crash if you don’t handle it.
The except
clause of your handler again contains a tuple of exceptions that you want to catch. You reference the exception, as before, by a variable named error
. First, your handler prints the name of the exception class, regardless of the exception raised. Then, you use the match
block to print a message based on the specific exception being handled.
You opt for structural pattern matching instead of many elif
clauses for neatness. The exception that your error
variable references is compared to the various case
clauses. Only the first matching case
block executes, and the rest are ignored. The chosen case
block then prints a message followed by error
. The inclusion of error
in your f-string prints the exception’s default traceback message for more context.
If the try
clause raises no more exceptions, then your program ignores the except
clause, and control passes to the else
clause. You use it here to print the answer of the calculation.
Now you retest calculate()
with normal inputs:
$ python exception_identification_with_structural_pattern_matching.py
What is your first number? 10
What is your second number? 4
Enter either * or /: /
10.0 / 4.0 = 2.5
$ python exception_identification_with_structural_pattern_matching.py
What is your first number? 10
What is your second number? 5
Enter either * or /: *
10.0 * 5.0 = 50.0
As you see, the division still works, and so does the new multiplication capability. Expand the section below to see what happens when you create exceptions.
In these tests, your exceptions don’t crash the code:
$ python exception_identification_with_structural_pattern_matching.py
What is your first number? 10
What is your second number? 5
Enter either * or /: +
A RuntimeError has occurred
You have entered an invalid symbol: '+' is an unsupported operation
$ python exception_identification_with_structural_pattern_matching.py
What is your first number? 10
What is your second number? 5
Enter either * or /:
A RuntimeError has occurred
You have entered an invalid symbol: '' is an unsupported operation
$ python exception_identification_with_structural_pattern_matching.py
What is your first number? 10
What is your second number? "five"
A ValueError has occurred
You have not entered a number: could not convert string to float: '"five"'
$ python exception_identification_with_structural_pattern_matching.py
What is your first number? 10
What is your second number? 0
Enter either * or /: /
A ZeroDivisionError has occurred
You can't divide by zero: float division by zero
Take a look at the last two lines of each test. Your exception handlers have successfully done their job.
This time, you perform a wider range of tests. First of all, you attempt an addition, and then you fail to specify an operation. Both result in a RuntimeError
. Your final tests are identical to those that you performed previously. As you can see, your code doesn’t crash, and it correctly identifies the exception raised.
Why not test this code more fully to consolidate your understanding? You may also like to delve deeper into the operator
module and use it to expand your previous example with support for addition, subtraction, or any other operations that you wish.
Now that you know how to identify different Exception
objects using their classes, next you’ll learn how to use Python’s exception hierarchy to simplify the handling of exceptions by handling their parent exception instead.
Catch One of Multiple Possible Python Exceptions Using Its Superclass
You’ve already learned how different exceptions are objects instantiated from different classes. These classes all belong to the Python exception class hierarchy. All Python exceptions inherit from a class named BaseException
, and one of these subclasses is the Exception
class. This is the superclass of all of the exceptions that you’ll learn about in this tutorial.
Python contains over sixty different exceptions. The diagram below illustrates only a few, but it does contain all the Exception
subclasses that you’ll cover in this tutorial. Indeed, these are some of the common exceptions that you’re likely to encounter:
The chart shows that Exception
is the superclass of all other exceptions. Subclasses of Exception
inherit everything that Exception
contains. Usually, subclasses extend their inherited members to differentiate themselves. In the case of exceptions, inheritance is mostly about creating an exception hierarchy. As you can see, ArithmeticError
is a subclass of Exception
. Internally, the differences between them are negligible.
You can also see two of the subclasses of the OSError
class, namely PermissionError
and FileNotFoundError
. Again, this means that both PermissionError
and FileNotFoundError
are actually subclasses of OSError
. By implication, both are also Exception
subclasses because OSError
inherits from Exception
.
You can use the fact that a subclass is a variant of its superclass to your advantage to capture different exceptions. Suppose you write the following:
# file_fault.py
from os import strerror
try:
with open("datafile.txt", mode="rt") as f:
print(f.readlines())
except OSError as error:
print(strerror(error.errno))
To begin with, you import the os
module to allow your code to interact with the operating system. More specifically, you import the os.strerror()
method to allow you to see the error message associated with the OSError
error code.
Your code will print the contents of a file named datafile.txt
, provided it exists. If datafile.txt
doesn’t exist, then your code raises a FileNotFoundError
. Although your inclusion of only one except
clause makes it look like you can only catch an OSError
, your handler can also deal with a FileNotFoundError
because it’s actually an OSError
.
To identify which subclass of OSError
you’ve caught, you can use type(error).__name__
to print its class name. However, this is meaningless to most users. Instead, you can identify the underlying error through the .errno
attribute. This is a number produced by the operating system that provides information on the problem that raised the OSError
. The number itself is meaningless, but its associated error message tells you more about the problem.
You’ve written your handler to use the variable error
, which references the OSError
subclass raised. To see its associated error message, you pass the error code into the os.strerror()
function. When you print the output of the function, you’ll learn exactly what went wrong:
$ python file_fault.py
No such file or directory
As you can see, the file doesn’t exist. To fully test the code, you need to cause other errors, like a PermissionError
. You might like to try this by creating a datafile.txt
file, but make sure you don’t have permission to access it. Then try rerunning your code once more and verify that your code recognizes and handles the PermissionError
exception.
As an exercise, you might like to try rewriting this program to use type(error).__name__
to identify specific errors by class name. Also, can you think of any other exceptions that this handler is also capable of catching? Perhaps the diagram above will help you figure out the answer.
If you’re interested in learning about the range of specific errors that OSErrror
could encounter, then take a look at the error code list below:
If you want a list of the error codes for your platform, you can get one by running this code:
# error_codes.py
import os
import errno
from pprint import pprint
pprint({e: os.strerror(e) for e in sorted(errno.errorcode)})
When you run it, you’ll see the full set of error code values assigned to OSError.errno
:
$ python error_codes.py
{1: 'Operation not permitted',
2: 'No such file or directory',
3: 'No such process',
4: 'Interrupted function call',
5: 'Input/output error',
6: 'No such device or address',
7: 'Arg list too long',
8: 'Exec format error',
9: 'Bad file descriptor',
10: 'No child processes',
11: 'Resource temporarily unavailable',
12: 'Not enough space',
13: 'Permission denied',
14: 'Bad address',
16: 'Resource device',
...
}
Your code produces a complete list of the error codes from the operating system that ran it, as well as their associated messages. As you can see, these messages provide you with great insight as to what went wrong.
Having spent some time learning how to handle exceptions, you’ll now move on, go against design principle ten of the Zen of Python, and learn how to ignore Exceptions
. Madness, you might think! Well, not always, as you’ll now see.
Ignore Multiple Python Exceptions Using contextlib.suppress()
Usually, when your code encounters an exception, you want to handle it. But sometimes, you might need to ignore exceptions to make your code work. Examples may include checking a network card for data you need that hasn’t yet arrived, or reading data from a file that may be currently locked by another user.
The traditional way of ignoring exceptions in Python is to catch them but do nothing with them. You’ll sometimes see code like this:
# exception_pass.py
try:
with open("file.txt", mode="rt") as f:
print(f.readlines())
except (FileNotFoundError, PermissionError):
pass
To ignore the FileNotFoundError
that occurs if this file doesn’t exist, you catch the exception in the usual way, then use the pass
keyword in the handler to do nothing with it. When you run your code this time, there’s no output, but your program hasn’t crashed either:
$ python exception_pass.py
$
If you use this technique, your code may confuse your readers. You’re instructing the program to catch an exception, and then you’re telling it not to bother with it. Fortunately, there’s a neater way.
To write code that explicitly suppresses exceptions, Python provides a context manager. To use it, you use the suppress()
function from the contextlib
module. You decide to rewrite your code using suppress()
:
# exception_suppress.py
from contextlib import suppress
with suppress(FileNotFoundError, PermissionError):
with open("file.txt", mode="rt") as f:
print(f.readlines())
Here, you use suppress()
to create a context manager to suppress both FileNotFoundError
and PermissionError
exceptions, and the with
statement applies the context manager to its indented code. Had you raised another exception type, the code would crash. Also, had you written any code outside of with
, the suppressions wouldn’t apply.
Why not try running this code against a file that doesn’t exist, then against one to which you don’t have read access? You’ll find that it doesn’t crash.
As another example, suppose that several files, each named transactions.txt
, get created at various times during the day. These contain data that you must merge into the all_transactions.txt
file. Your code will then archive the original file to transactions.arch_ts
, where ts
is a timestamp for uniqueness. Sometimes, when your program looks for a file, it won’t be there. You must handle this:
# file_archiver.py
import pathlib
import time
from contextlib import suppress
temporary_file = pathlib.Path("transactions.txt")
main_file = pathlib.Path("all_transactions.txt")
while True:
archive_path = temporary_file.with_suffix(f".arch_{time.time()}")
with suppress(FileNotFoundError):
with temporary_file.open(mode="rt") as transactions:
with main_file.open(mode="at") as main:
print("Found new transactions, updating log, & archiving")
main.writelines(transactions.readlines())
temporary_file.replace(archive_path)
time.sleep(3)
You decide to use pathlib
to take advantage of its file handling capabilities. First, you create two Path
objects that represent the paths to two of the files your program uses. The main body of your code runs in an infinite loop, but you use time.sleep(3)
to simulate periodic checking for the presence of a transactions.txt
file.
At the start of each iteration of the loop, you use the .with_suffix()
method of the Path
object to create a new Path
with an .arch_ts
extension. You then suppress the FileNotFoundError
exception. Should this occur within the loop, you ignore it to enable the loop to continue. This is how your code continually checks for the presence of transactions.txt
even when its absence raises an exception.
Next, your code processes the file. You open transactions.txt
for reading in text mode, and all_transactions.txt
for appending to. This is where you might cause the FileNotFoundError
exception. If transactions.txt
doesn’t exist, then the FileNotFoundError
exception will return control of your code back to the with suppress()
clause, which will suppress the exception.
If transactions.txt
is present, then your code opens or creates the all_transactions.txt
file. You then copy the content of transactions.txt
to the end of all_transactions.txt
before displaying a message to your users. At this point, both context managers end, closing both files automatically. Your code then renames transactions.txt
with its new timestamped name.
Regardless of whether or not transactions.txt
exists, your code will then sleep for three seconds before the start of the next iteration of the loop before rechecking to see if the file is now present.
To try this out, download the code and three sample files named transactions1.txt
, transactions2.txt
, and transactions3.txt
into the same folder. You can get them at the link below:
Get Your Code: Click here to download the sample code that shows you how to catch multiple exceptions in Python.
After you start the program, rename transactions1.txt
to transactions.txt
. Your program will soon find it and merge its content into all_transactions.txt
file before renaming it. Do the same for the other transaction
files. To close the program, use Ctrl+C or the stop button on your Python IDLE.
You’ll know everything has worked when you see output similar to the following:
$ python file_archiver.py
Found new transactions, updating log, & archiving
Found new transactions, updating log, & archiving
Found new transactions, updating log, & archiving
...
Once it completes, your program will have created three archived files with unique names. In addition, your all_transactions.txt
file should now contain the content of the three archived files.
You may also notice that an unhandled KeyBoardInterrupt
exception has caused the program to crash. This happens if you use the Ctrl+C keys to terminate the code. Because you didn’t catch the KeyBoardInterrupt
exception, your code had no option other than to crash out. Perhaps you could try handling this exception as well.
Up to this point, you’ve learned several ways of dealing with multiple possible exceptions. However, you’ve only ever caught one out of the bunch. In the final section, you’ll learn how to catch each member in a group of exceptions.
Catch Multiple Python Exceptions Using Exception Groups
When you use try
… except
in your code, it’s actually only capable of catching the first exception that occurs within the try
block. If you try to raise multiple exceptions, the program will finish after handling the first exception. The rest will never be raised. You can show this using code similar to the following:
# catch_first_only.py
exceptions = [ZeroDivisionError(), FileNotFoundError(), NameError()]
num_zd_errors = num_fnf_errors = num_name_errors = 0
try:
for e in exceptions:
raise e
except ZeroDivisionError:
num_zd_errors += 1
except FileNotFoundError:
num_fnf_errors += 1
except NameError:
num_name_errors += 1
finally:
print(f"ZeroDivisionError was raised {num_zd_errors} times.")
print(f"FileNotFoundError was raised {num_fnf_errors} times.")
print(f"NameError was raised {num_name_errors} times.")
Your program starts by defining a list of three Exception
objects. You then initialize three counter variables to zero. You write a try
statement that loops through your list and attempts to raise each exception in turn. Next, you run the code to find out exactly what gets handled:
$ python catch_first_only.py
ZeroDivisionError was raised 1 times.
FileNotFoundError was raised 0 times.
NameError was raised 0 times.
As you can see from the output, your code falls short of the requirements. While the ZeroDivisionError
exception gets raised and handled, none of your other handlers have any effect. Only the num_zd_errors
variable gets incremented.
You also used a finally
clause in your code. This clause runs regardless of whether or not any exceptions have occurred. You’ve used it here to print the count of each exception raised.
Sometimes, you may need to handle all the exceptions that occur. For example, this is necessary in concurrent programming, where your program performs multiple tasks simultaneously. In cases where you have concurrent tasks running, each task can raise its own exceptions. Beginning with Python 3.11, the language supports ExceptionGroup
objects and a special except*
clause to allow you to handle all exceptions.
To investigate exception groups, you decide to adapt your earlier code to learn how you can deal with multiple exceptions. For this, you use the special except*
syntax:
# catch_all.py
exceptions = [ZeroDivisionError(), FileNotFoundError(), NameError()]
num_zd_errors = num_fnf_errors = num_name_errors = 0
try:
raise ExceptionGroup("Errors Occurred", exceptions)
except* ZeroDivisionError:
num_zd_errors += 1
except* FileNotFoundError:
num_fnf_errors += 1
except* NameError:
num_name_errors += 1
finally:
print(f"ZeroDivisionError was raised {num_zd_errors} times.")
print(f"FileNotFoundError was raised {num_fnf_errors} times.")
print(f"NameError was raised {num_name_errors} times.")
In this version of your code, your try
clause raises a new ExceptionGroup
object that’s instantiated to contain the content of your exceptions
list. When your program raises this, it passes all of the exceptions to the handlers.
To accommodate your group and make sure that all handlers can process all exceptions, as opposed to just the first one, the handlers use except*
instead of the plain except
. This means that when the ExceptionGroup
is raised, the except* ZeroDivisionError
clause handles any ZeroDivisionError
exceptions. Your program then passes the outstanding exceptions to the except* FileNotFoundError
clause and so on, down through each handler.
Next you run it to find out exactly what gets handled, hoping it’s more than the last time:
$ python python catch_all.py
ZeroDivisionError was raised 1 times.
FileNotFoundError was raised 1 times.
NameError was raised 1 times.
Success! As you can see from the output, your program successfully handled all of your exceptions and correctly incremented all three variables. To investigate further, why not adjust your list to contain multiple instances of each exception and see what happens? Then try adding some exceptions to the list that you haven’t included in the handler. What happens now?
Conclusion
You now know how to use some of the more subtle features of Python’s exception handling mechanism. You can handle exceptions in more powerful ways than you may have thought possible. You also understand that sometimes ignoring exceptions is necessary to make sure your code does what you want it to.
After reading this tutorial, you can now:
- Catch one of several possible exceptions
- Identify the exception that you’ve caught
- Catch exceptions using their superclass
- Ignore exceptions and understand why you’d do this
- Catch multiple exceptions and handle them all
You’re well on your way to becoming an exceptional exception handler! If you have any questions, feel free to reach out using the comments section below.
Get Your Code: Click here to download the sample code that shows you how to catch multiple exceptions in Python.