Handling errors with exceptions

Input errors

One of the most tedious jobs in writing code designed to be used by others is checking a user’s input to make sure it’s valid. As an example, we return to our program that calculates the area of a polygon. We have a Polygon class in Polygon.py:

from math import *

class Polygon:
    def __init__(self, numSides, sideLength) :
        self.__numSides = numSides
        self.__sideLength = sideLength
        if numSides == 3:
            self.__area = sqrt(3) * sideLength * sideLength / 4
        elif numSides == 4:
            self.__area = sideLength * sideLength
        elif numSides == 5:
            self.__area = sqrt(5 * (5 + 2*sqrt(5))) * sideLength * sideLength / 4

    def getArea(self):
        return self.__area

This class expects two inputs to the constructor: numSides, which should be an int with the value 3, 4 or 5, and sideLength, which should be a float. If these two conditions are satisfied, the constructor calculates the area of the polygon.

Now suppose we write a program (in main.py) that asks the user to input these two values and then creates a Polygon object to calculate the area. We might try this:

from Polygon import *

while True:
    polyData = input('Number of sides & side length: ')
    if polyData == 'quit': break

    polyData = polyData.split()
    numSides = int(polyData[0])
    sideLength = float(polyData[1])
    poly = Polygon(numSides, sideLength)
    print(f'Area = {poly.getArea()}')

This will work fine provided the user enters the two bits of data in the required format. However, there are several errors the user might make when entering the data.

  1. The first entry is not a valid int.
  2. The first entry is a valid int, but is not 3, 4 or 5.
  3. The second entry is not a valid float.
  4. Only one value is entered.

We could write code using various type-checking functions and if statements to check all these things, but a cleaner way of handling input errors is by using exceptions.

Exceptions: the try statement

To begin, it’s a good idea to see what happens when each of the above errors is made by a user. We find:

  1. Suppose we enter d 4.5, so that the first entry is not a valid int. The program gives the error: “ValueError: invalid literal for int() with base 10: ‘d'”.
  2. Next, we try entering 1 4.5, so that the first entry is a valid int, but is not 3, 4 or 5. This time, we get the error: “AttributeError: ‘Polygon’ object has no attribute ‘_Polygon__area'”. This might be a bit mystifying, but if we refer back to the Polygon class definition above, we see that if numSides isn’t 3, 4 or 5, the __area attribute is never calculated, so it doesn’t exist.
  3. Next, we try 3 d, so that the first entry is a valid int in the correct range, but the second entry is not a valid float. Now we get the error: “ValueError: could not convert string to float: ‘d'”.
  4. Finally, we try entering only a single value, such as 3. This time we get the error: “IndexError: list index out of range”. This is because we expect polyData to have 2 elements with indexes [0] and [1], and we have only the [0] element.

Note that in all cases, the first thing Python prints out is the name of an error, such as ValueError, AttributeError and so on. These are all names of built-in classes that are examples of exceptions. An exception is generated (the technical term is raise; users of other languages might be used to the keyword throw, which is the same thing) whenever something goes wrong during the execution of a program. If the coder doesn’t take any action when an exception is raised, the default behaviour is to print an error message and stop the program.

Handling built-in exceptions

Python provides a large number of built-in exceptions. A list of these is provided here. Keep in mind that this list may change as newer versions of Python are released. The built-in exception classes all ultimately inherit the BaseException class.

If you want to catch an exception in your code and provide some user-friendly feedback (and also prevent the program from crashing) you can use the try statement. The idea is that you identify a block of code which could potentially raise an exception and enclose this block inside a try statement. Following the try, you write one or more except blocks, each of which can deal with one (or more) class of exception.

As a first attempt, we deal with a ValueError and an IndexError. Here’s a rewrite of the above main.py program:

from Polygon import *

while True:
    polyData = input('Number of sides & side length: ')
    if polyData == 'quit': break

    polyData = polyData.split()
    try:
        numSides = int(polyData[0])
    except ValueError as err:
        print('Invalid quantity for number of sides:', err)
    else:
        try:
            sideLength = float(polyData[1])
            poly = Polygon(numSides, sideLength)
            print(f'Area = {poly.getArea()}')
        except ValueError as err:
            print('Invalid quantity for side length:', err)
        except IndexError:
            print('Enter number of sides and side length (2 items)')

Several features of a try statement are illustrated here. On line 8, we start a try block. We see from above that there are two cases that can generate a ValueError: either numSides is not a valid int, or sideLength is not a valid float. If we just enclosed both lines 9 and 14 inside the same try block, we would have no way of telling which error raised the ValueError exception (although the error message does tell us something, it’s not in a very user-friendly form). So we isolate line 9 in a try block on its own, and if the user enters numSides in an incorrect format, a ValueError will be raised at this point.

If a ValueError occurs, control skips from line 9 to the first except statement following the try. The except statement specifies the name of the exception that it is looking for, and if the exception just raised matches this type, the code inside the except block is run. On line 10, we are looking for a ValueError, and if such an exception has occurred, we name the instance of ValueError as err. Remember that ValueError is the class of exception, and err is the particular instance (object) of this class that has been raised. On line 11, we print out a message saying that the number of sides is incorrect, followed by err. Printing an exception object prints out the default error message that the exception generated. Thus if we entered d 4.5 into this program, we would get the message “Invalid quantity for number of sides: invalid literal for int() with base 10: ‘d'” printed out.

Crucially, the program does not crash after this error message. Control passes to the first statement following the try, which in this case reverts back to the while statement on line 3. To see why this is the next valid statement to be executed, we need to look at the rest of the program.

Line 12 illustrates the else block of a try statement. The else block is run only if no exceptions were raised by the parent try block. In our example, the try block contains only one statement (line 9), so if this doesn’t raise any exceptions (that is, if the value for numSides is valid) then the code in the else block is run, starting on line 13.

If we reach line 13, then numSides is valid, so we can continue by checking that sideLength is valid. As we mentioned above, two things might go wrong at this stage: the user might have entered an invalid quantity for sideLength, or they might have entered only one item, so that the polyData list contains only one item. The first of these would raise a ValueError, while the second would raise an IndexError.

We provide a separate except block for each of these two cases. As the IndexError will occur only if the user has entered too few data items, we don’t need to refer to its default message, so we don’t bother giving the IndexError object a name. We just print our own error message.

If the user has entered a correct set of two data values, then no exceptions are raised, and only lines 9 and 14 through 16 are executed.

Custom exception classes

The one case we haven’t dealt with is number 2 above: the user has entered a valid int, but the int is not in the correct range of 3, 4 or 5. As we saw above, this gives rise to an AttributeError, due to the __area attribute of the Polygon not being created. This error message isn’t very helpful to a user who has merely forgotten to enter a second number, so we’d like a better error message. Python allows us to create our own exception classes by inheriting one of the built-in ones.

For most purposes, it’s easiest to inherit the Exception class (rather than BaseException). The reason is that there are some system-generated exceptions that deal with program operation that we usually don’t want to override. The Exception class is a built-in class that excludes these system exceptions and deals only with exceptions generated by errors in the code. Since this is the kind of custom exception we want, we will write our own class called PolygonError by inheriting Exception.

The only feature of our new class that we want to customize is the error message that is printed. The following class definition (in PolygonError.py) does what we need:

class PolygonError(Exception):

    def __init__(self, message):
        super().__init__(message)

We override the constructor so that we can pass a message into the class, and then user super() to pass this message into the base class Exception.

In order to use our PolygonError, we need to explicitly raise it in our code. We can do this by modifying the Polygon class in Polygon.py:

from math import *
from PolygonError import *

class Polygon:
    def __init__(self, numSides, sideLength) :
        self.__numSides = numSides
        self.__sideLength = sideLength
        if numSides == 3:
            self.__area = sqrt(3) * sideLength * sideLength / 4
        elif numSides == 4:
            self.__area = sideLength * sideLength
        elif numSides == 5:
            self.__area = sqrt(5 * (5 + 2*sqrt(5))) * sideLength * sideLength / 4
        else:
            raise PolygonError('Number of sides must be 3, 4 or 5')

    def getArea(self):
        return self.__area

After the branches of the if statement on lines 8 through 13, we add an else clause that raises a PolygonError with an explicit error message. The advantage of putting the raise statement here is that if we change the Polygon class later (for example, by allowing polygons with more than 5 sides to be handled), we can change the raise message to correspond to these changes.

We can now modify the main program so that it handles a PolygonError:

from Polygon import *

while True:
    polyData = input('Number of sides & side length: ')
    if polyData == 'quit': break

    polyData = polyData.split()
    try:
        numSides = int(polyData[0])
    except ValueError as err:
        print('Invalid quantity for number of sides:', err)
    else:
        try:
            sideLength = float(polyData[1])
            poly = Polygon(numSides, sideLength)
            print(f'Area = {poly.getArea()}')
        except ValueError as err:
            print('Invalid quantity for side length:', err)
        except IndexError:
            print('Enter number of sides and side length (2 items)')
        except PolygonError as err:
            print(err)

As the only place a PolygonError can be raised is when creating a Polygon (line 15) we add an except clause on line 21 to handle it. As the error message from PolygonError is explicit, we can just print out the exception object.

Exercise

Write a program that implements a simple 4-function calculator. The user should be able to type in an arithmetic operation like 4.3 * -12.9 and the program should print out the answer. The input should always be in the form of three items separated by blanks, with the first and third items being floats and the middle item an operator which is one of +, -, * or /.

Write an Arithmetic class whose constructor takes three arguments: left, operator, right, where left and right are assumed to be floats and operator is one of the four acceptable operators.

Use exception handling to deal with the following possible errors in the user’s input:

  1. The left operand is not a valid float.
  2. The middle operand is not one of the four accepted operators.
  3. The left operand is not a valid float.
  4. The user has entered fewer than 3 items.
  5. The user has attempted to divide by zero.

All the errors except #2 can be handled by catching a built-in exception. To handle #2, write your own custom exception class (note that ArithmeticError is already a built-in exception class, so you’ll need to call your class something different) and raise it if error #2 has occurred.

See answer

A suitable custom exception class is ArithOpError, in ArithOpError.py:

class ArithOpError(Exception):

    def __init__(self, message):
        super().__init__(message)

We can use this in the Arithmetic class in Arithmetic.py:

from ArithOpError import *

class Arithmetic(object):
    """does an arithmetic calculation"""

    def __init__(self, left, operator, right):
        if operator == '+':
            self.__result = left + right
        elif operator == '-':
            self.__result = left - right
        elif operator == '*':
            self.__result = left * right
        elif operator == '/':
            self.__result = left / right
        else:
            raise ArithOpError('Operator must be one of +, -, *, /')

    def getResult(self):
        return self.__result

This class performs the calculation corresponding to operator if that operator is one of the four acceptable ones. If it isn’t, we raise an ArithOpError to give some feedback to the user.

The main program is in main.py:

from Arithmetic import *

while True:
    arithOp = input('Enter an arithmetic expression: ')
    if arithOp == 'quit': break

    try:
        arithOp = arithOp.split()
        left = float(arithOp[0])
    except ValueError:
        print('Left operand must be a float')
    else:
        try:
            right = float(arithOp[2])
            calc = Arithmetic(left, arithOp[1], right)
            print(f'Result = {calc.getResult()}')
        except IndexError:
            print('Enter (float) (operator) (float)')
        except ZeroDivisionError:
            print('Cannot divide by zero')
        except ValueError:
            print('Right operand must be a float')
        except ArithOpError as err:
            print(err)

We begin by checking that the left operand is a valid float by enclosing statement 9 in the outer try block. If it isn’t, we’ll get a ValueError so we catch that.

If the left operand is valid, we enter the else block on line 12. Here we calculate the right operand and pass the three items into the Arithmetic constructor. There are four possible errors that can occur here.

  1. There are fewer than 3 items in the arithOp list. This is handled by catching an IndexError.
  2. The user has attempted to divide by zero.
  3. The right operand isn’t a valid float.
  4. The operator isn’t one of the four acceptable operators. This is caught in the Arithmetic constructor where an ArithOpError is raised, and then caught in the main program.

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.