5.3.1. Exceptions and error handling

5.3.1.1. Initial setup for the Lab

  1. In the VSCode terminal enter:

    cd /workspaces/`ls /workspaces`/lab-h
    uv init .
    

    Remember to enter these one at a time, not both together.

    This will make a new Python project in the current folder. It will be named automatically after the folder name.

    To analyze these lines:

    • cd /workspaces/`ls /workspaces`/lab-h makes sure we are working in the lab-h folder.

    • uv init . is the interesting command. This actually sets up our virtual environment.

    The above will make the a pyproject.toml file, a main.py file, and a number of others.

  2. Add some structure to your folder by entering the commands:

    mkdir tests docs
    mv main.py src
    uv run src/main.py
    touch tests/__init__.py
    
    • Here we’ve moved main.py into the src folder. You’ll see that the src folder contains some code that we’ve written for you.

    • We’ve made a tests folder for any tests that we might want to write later, and a docs folder for any documentation. We won’t ask you to put anything into these as part of the lab instructions, but you might want to write some tests for your code to check that it’s working!

    • In the files that were downloaded from Git automatically, you’ll also see there’s a folder called data. This contains some files that we’ll analyze during the lab.

  3. Run

    uv run src/main.py
    

    to make sure the virtual environment is built.

  4. When you open a Python file, make sure that the correct Python virtual environment is activated. See the instructions in Lab D if you’re unsure.

5.3.1.2. Raising exceptions

  1. In your Lab H src folder, edit the file main.py to contain:

    class MyError(Exception):
        """An example custom exception"""
    
        pass
    
    
    def main():
        print("Hello from lab-h!")
        x = 101
        raise MyError("Message to display to the user")
    
    
    if __name__ == "__main__":
        main()
    

    Run this code.

  2. You should see that the code runs, Hello from lab-h! is displayed, and then the programs ends with an error. This error has a custom name and message, the ones we asked for in the code. This is shown in the screenshot below.

    VSCode showing a Python exception being raised

    Screenshot of VSCode, software from Microsoft. See course copyright statement.

    To look at this code:

    • Remember that Python runs from the top to the bottom of the file. We raise() the error on Line 9, so the program stops running at that point. Everything before this runs as usual.

    • The terminology is that we are raising an exception. This means that something has gone wrong, and normal program execution cannot continue. There is a command called raise to do this.

    • Each exception has a type. This helps with debugging, we can have different types of exceptions for different problems. Here the code in class MyError(Exception): makes a custom exception type called MyError. We could give this any name we like, something informative for whoever is using the code to help them figure out what went wrong.

    • When the error is displayed to the user, a traceback is also shown. This shows where in the code the exception occurred, to help with debugging.

  3. To a first approximation, that’s it! You can make as many custom exceptions as you like. You can put the raise command wherever you like in your code. It might be common to put it inside an if statement, to check if something has gone wrong, and raise an exception if it has. There are a wide range of further behaviors that you can add to your custom exceptions, but we won’t cover these here. For many cases, just giving a meaningful name and message is enough to help with debugging.

  4. Add an if statement to the code so that the exception is only raised if x is greater than 100. Display the value of x in the message to the user. Try running your code with different values of x to check that it works.

  5. In addition to using custom names for exceptions, there are a wide range of built in named exceptions that you can use without having to make your own. A list is available online, so we won’t go through them all here. Probably the some of most common ones you might like to use early on are:

    • ValueError when a value is not in the expected range.

    • TypeError when the data types is wrong, such as you were expecting a string (say to represent a name) but got a number instead.

    • FileNotFoundError when you try to open a file that can’t be found in the location you specified.

    In your code, change raise MyError(...) to raise raise ValueError(...) instead. See how the output differs between the two types of exception.

5.3.1.3. Raising warnings

  1. Not every exception has to represent an error. You can also represent warnings. Add the code below somewhere in your main.py file, and run it.

    raise DeprecationWarning("This code is deprecated and will be removed in future versions.")
    

    This type of warning is intended for other developers using your code. Sometimes we have methods and functions in, say version 1 of the code, that for whatever reason will be removed in version 2. You can add this warning to let other developers know that they should avoid using this code as it will be removed in future versions.

  2. If you want a custom warning, or more control over built in warnings, there is a dedicated module in the standard library called warnings. This module provides more control over how warnings are displayed to the user. Replace the code in your file with that given below.

    import warnings
    
    
    class MyError(Exception):
        """An example custom exception"""
    
        pass
    
    
    class MyWarning(UserWarning):
        """An example custom warning"""
    
        pass
    
    
    def main():
        print("Hello from lab-h!")
        x = 99
        if x > 100:
            raise ValueError(f"x was {x}. It should be 100 or less.")
    
        warnings.warn("This is a custom warning from lab-h.", MyWarning)
    
    
    if __name__ == "__main__":
        main()
    

    Here we’ve made a custom warning type with class MyWarning(UserWarning). We then called the warning with warnings.warn(...). Run this code, and see how the warning is displayed to the user.

  3. Refer back to Lab D where we covered the logging module. If you add the line:

    logging.captureWarnings(True)
    

    (in addition to the other logging setup steps in Lab D) then your warnings will be automatically in the log file.

    Add logging to your code and check that you can log your custom warning.

5.3.1.4. Handling exceptions

Raising an exception is great, but you then need to decide what you want to do. This is known as handling the exception. In the examples above we just let the program terminate and display some debug information to the user. This might be what you want, particularly if the error is unrecoverable. However it might be that you want different behavior. For example, you might try and open a file with a particular name, and if it’s not present you try and open a different file with a default name instead. Alternatively, you might have a rule saying that if a number is over 100, you just automatically reset it to 100 as that’s the maximum it can be.

  1. You handle exceptions using a try and except block. Make a new code file and copy the code below into it. Run the code and see what happens with different values of x.

    class Over100(Exception):
        """An example custom exception"""
    
        pass
    
    
    def print_x(x):
        """Print x, but only if it is less than 100"""
        if x > 100:
            raise Over100(f"x was {x}. It should be 100 or less.")
        print(f"x is {x}")
    
    def main():
        print("Hello from lab-h!")
    
        try:
            x = 101
            print_x(x)
        except Over100 as e:
            print(f"Caught an exception: {e}")
        else:
            print("No exceptions were raised.")
        finally:
            print(f"x is {x}")
    
    
    if __name__ == "__main__":
        main()
    

    To analyse this code:

    • try is the code you want to run. It’s in a try block because, for whatever reason, you think it might cause an error that you need to detect and handle. Here it runs the print_x(x) function which will raise an exception if x is too large.

    • except gives the code for what you want to happen if there is an exception. Here we catch the Over100 exception, and puts the automatically generated traceback in to a variable e. We can then use e to write a nice message to the user, not the full traceback from the exception.

    • else gives the code you want to run if there were no exceptions. Here we just print a message to say that everything was OK.

    • finally gives code that will always run, whether there was an exception or not. Here we just print the value of x.

    You don’t have to have the else and finally blocks if you don’t want to, but they help give a lot of control over what happens in different situations.

  2. Change the code above so that if x is greater than 100, it is automatically set to 100 instead of displaying anything to the user.

  3. In the code above, we are specifically handling our custom Over100 exception. Try running the code with x = "hello".

    You’ll find that the program still crashes, and Python displays a TypeError because we tried to compare a string to a number in the print_x function.

    VSCode showing a different type of exception not being handled

    Screenshot of VSCode, software from Microsoft. See course copyright statement.

    To handle this, you can do one of two things.

    1. You can change the except block to be just except Exception as e:. This will catch essentially all exceptions.

    2. (The better approach) you can add another except block to handle the TypeError specifically.

    Change your try except block to be:

    try:
        x = 101
        print_x(x)
    except Over100 as e:
        x = 100
    except TypeError as e:
        print("I was expecting x to be a number!")
    else:
        print("No exceptions were raised.")
    finally:
        print(f"x is {x}")
    

    Then run your code with x = "hello", x = 99, x = 101 and to x = "101" to check it works as you expect.

    You can have as many except blocks as you like. It depends on how comprehensive you want to be in catching and handling different errors your code might have.

5.3.1.5. Extending the student class example

In your Lab H src folder we’ve included two files, student_example.py and my_classes.py. These are a copy of the example in Lab G where we made a class for representing student marks. Here we’ve split the code into two files for better organization.

  1. Run student_example.py and check it does what you expect.

    There is a commented out line

    # student1.set_assignment_mark("c", "apple")  # uncomment to test invalid input
    

    that you can uncomment to see what happens if invalid input is given.

  2. In student_example.py a number of the marks assignments are incorrect.

    • The mark for an exam must be between 0 and 100.

    • The exam is marked in whole numbers (integers) only.

    • The mark for an assignment must be between 0 and 5.

    • Assignments are marked in whole numbers (integers) only.

    Modify my_classes.py to raise appropriate exceptions if any of these rules are broken. You can use built in exceptions such as ValueError and TypeError, or make your own custom exceptions.

  3. Further modify my_classes.py to catch and handle any exceptions that are raised when setting marks. These should be corrected with the rules:

    • Any exam marks above 100 or below 0 should be set to 100 or 0 respectively.

    • Any assignment marks above 5 or below 0 should be set to 5 or 0 respectively.

    • Any non-integer marks should be rounded to the nearest integer.

    • Any non-numeric marks should cause the code to terminate and display a message to the user (not the full traceback).

  4. Optional task (no solution provided). Add logging statments so that any changes to marks are logged to a file so there is a record. (In practice we’d probably error out if we got any issues processing student marks rather than automatically correcting and logging as here, but there might be cases where an automatic correction, with a log of the correction, is appropriate.)