Error handling and testing solutions#

Introduction#

In this notebook we will try to understand what our program should do when it encounters unforeseen situations, and how to test the code we write.

For some strange reason, many people believe that computer programs do not need much error handling nor testing. Just to make a simple comparison, would you ever drive a car that did not undergo scrupolous checks? We wouldn’t.

Unforeseen situations#

There is a party for a birthday and these lazy friends of yours asked you to make a pie. You need the following steps:

  1. take milk

  2. take sugar

  3. take flour

  4. mix

  5. heat in the oven

You take the milk, the sugar, but then you discover there is no flour. It’s late, and there isn’t any open supermarket. Obviously, it makes no sense to proceed to point 4 with the mixture, and you have to give up on the pie, telling the guest of honor the problem. You can only hope she/he decides for some alternative.

Translating everything in Python terms, we can ask ourselves if during the function execution, when we find an unforeseen situation, is it possible to:

  1. interrupt the execution flow of the program

  2. signal to whoever called the function that a problem has occurred

  3. allow to manage the problem to whoever called the function

The answer is yes, you can do it with the mechanism of exceptions (Exception)

make_problematic_pie#

Let’s see how we can represent the above problem in Python. A basic version might be the following:

def make_problematic_pie(milk, sugar, flour):
    """Suppose you need 1.3 kg for the milk, 0.2kg for the sugar and 1.0kg for the flour

    - takes as parameters the quantities we have in the sideboard
    """

    if milk > 1.3:
        print("take milk")
    else:
        print("Don't have enough milk !")

    if sugar > 0.2:
        print("take sugar")
    else:
        print("Don't have enough sugar!")

    if flour > 1.0:
        print("take flour")
    else:
        print("Don't have enough flour !")

    print("Mix")
    print("Heat")
    print("I made the pie!")


make_problematic_pie(5, 1, 0.3)  # not enough flour ...

print("Party")
take milk
take sugar
Don't have enough flour !
Mix
Heat
I made the pie!
Party

Question

This above version has a serious problem. Can you spot it?

Check with the return#

EXERCISE: We could correct the problems of the above pie by adding return commands. Improve make_pie above by returning True if the pie is doable, and False otherwise, so that outside of the function, you may use the value returned to announce the party or not.

Warning

Do not move the print("Party") inside the function.

The goal of the exercise is to keep it outside, so to use the value returned by make_pie in order to decide whether to announce the party or not.

Hide code cell content
def make_pie(milk, sugar, flour):
    """  - suppose we need 1.3 kg for milk, 0.2kg for sugar and 1.0kg for flour
        
         - takes as parameters the quantities we have in the sideboard

    """    
    if milk > 1.3:
        print("take milk")
        # return True  # NO, it would finish right here
    else:
        print("Don't have enough milk !")
        return False
      
    if sugar > 0.2:
        print("take sugar")
    else:
        print("Don't have enough sugar !")
        return False
      
    if flour > 1.0:
        print("take flour")
    else:
        print("Don't have enough flour !")
        return False
  
    print("Mix")
    print("Heat")
    print("I made the pie !")
    return True
made_pie = make_pie(5,1,0.3)

if made_pie:
    print("Party")
else:
    print("No party !")
take milk
take sugar
Don't have enough flour !
No party !

Exceptions#

Using return we improved the previous function, but there remains a problem: the responsibility to understand whether or not the pie is properly made is given to the caller of the function, who has to take the returned value and decide upon that whether to announce the party or not. A careless programmer might forget to do the check and announce it even with an ill-formed pie.

So would it be possible to stop the execution not just of the function, but of the whole program when we find an unforeseen situation?

To improve on our previous attempt, we can use exceptions. To tell Python to interrupt the program execution in a given point, we can insert the instruction raise like this:

raise Exception()

If we want, we can also write a message to help programmers (who could be ourselves …) to understand the origin of the problem. In our case it could be a message like this:

raise Exception("Don't have enough flour !")

EXERCISE: Try to rewrite the function above by substituting the rows containing return with raise Exception():

Hide code cell content
def make_exceptional_pie(milk, sugar, flour):
    """- suppose we need 1.3 kg for milk, 0.2kg for sugar and 1.0kg for flour

    - takes as parameters the quantities we have in the sideboard

    - if there are missing ingredients, raises Exception

    """
    if milk > 1.3:
        print("take milk")
    else:
        raise Exception("Don't have enough milk !")
    if sugar > 0.2:
        print("take sugar")
    else:
        raise Exception("Don't have enough sugar!")
    if flour > 1.0:
        print("take flour")
    else:
        raise Exception("Don't have enough flour!")
    print("Mix")
    print("Heat")
    print("I made the pie !")
make_exceptional_pie(5, 1, 0.3)
print("Party")
take milk
take sugar
---------------------------------------------------------------------------
Exception                                 Traceback (most recent call last)
Cell In[5], line 1
----> 1 make_exceptional_pie(5, 1, 0.3)
      2 print("Party")

Cell In[4], line 20, in make_exceptional_pie(milk, sugar, flour)
     18     print("take flour")
     19 else:
---> 20     raise Exception("Don't have enough flour!")
     21 print("Mix")
     22 print("Heat")

Exception: Don't have enough flour!

We see the program got interrupted before arriving to mix step (inside the function), and it didn’t even arrived to party (which is outside the function). Let’s try now to call the function with enough ingredients in the sideboard:

make_exceptional_pie(5, 1, 20)
print("Party")
take milk
take sugar
take flour
Mix
Heat
I made the pie !
Party

Manage exceptions#

Instead of brutally interrupting the program when problems are spotted, we might want to try some alternative (like go buying some ice cream). We could use some try except blocks like this:

try:
    make_exceptional_pie(5, 1, 0.3)
    print("Party")
except:
    print("Can't make the pie, what about going out for an ice cream?")
take milk
take sugar
Can't make the pie, what about going out for an ice cream?

If you note, the execution jumped the print("Party") but no exception has been printed, and the execution passed to the row right after the except

Particular exceptions#

Until know we used a generic Exception, but, if you will, you can use more specific exceptions to better signal the nature of the error. For example, when you implement a function, since checking the input values for correctness is very frequent, Python gives you an exception called ValueError. If you use it instead of Exception, you allow the function caller to intercept only that particular error type.

If the function raises an error which is not intercepted in the catch, the program will halt.

def make_exceptional_pie_2(milk, sugar, flour):
    """- suppose we need 1.3 kg for milk, 0.2kg for sugar and 1.0kg for flour

    - takes as parameters the quantities we have in the sideboard

    - if there are missing ingredients, raises Exception
    """

    if milk > 1.3:
        print("take milk")
    else:
        raise ValueError("Don't have enough milk !")
    if sugar > 0.2:
        print("take sugar")
    else:
        raise ValueError("Don't have enough sugar!")
    if flour > 1.0:
        print("take flour")
    else:
        raise ValueError("Don't have enough flour!")
    print("Mix")
    print("Heat")
    print("I made the pie !")


try:
    make_exceptional_pie_2(5, 1, 0.3)
    print("Party")
except ValueError:
    print()
    print("There must be a problem with the ingredients!")
    print("Let's try asking neighbors !")
    print("We're lucky, they gave us some flour, let's try again!")
    print("")
    make_exceptional_pie_2(5, 1, 4)
    print("Party")
except:  # manages all exceptions
    print(
        "Guys, something bad happened, don't know what to do. Better to go out and take an ice-cream !"
    )
take milk
take sugar

There must be a problem with the ingredients!
Let's try asking neighbors !
We're lucky, they gave us some flour, let's try again!

take milk
take sugar
take flour
Mix
Heat
I made the pie !
Party

For more explanations about try catch, you can see Real Python - Python Exceptions: an Introduction

assert#

They asked you to develop a program to control a nuclear reactor. The reactor produces a lot of energy, but requires at least 20 meters of water to cool down, and your program needs to regulate the water level. Without enough water, you risk a meltdown. You do not feel exactly up to the job, and start sweating.

Nervously, you write the code. You check and recheck the code - everything looks fine.

On inauguration day, the reactor is turned on. Unexpectedly, the water level goes down to 5 meters, and an uncontrolled chain reaction occurs. Plutoniom fireworks follow.

Could we have avoided all of this? We often believe everything is good but then for some reason we find variables with unexpected values. The wrong program described above might have been written like so:

# we need water to cool our reactor

water_level = 40  #  seems ok

print("water level: ", water_level)

# a lot of code

water_level = 5  # forgot somewhere this bad row !

print("WARNING: water level low! ", water_level)

# a lot of code

# after a lot of code we might not know if there are the proper conditions so that everything works alright

print("turn on nuclear reactor")
water level:  40
WARNING: water level low!  5
turn on nuclear reactor

How could we improve it? Let’s look at the assert command, which must be written by following it with a boolean condition.

assert True does absolutely nothing:

print("before")
assert True
print("after")
before
after

Instead, assert False completely blocks program execution, by launching an exception of type AssertionError (Note how "after" is not printed):

print("before")
assert False
print("after")
before
---------------------------------------------------------------------------
AssertionError                            Traceback (most recent call last)
Cell In[11], line 2
      1 print("before")
----> 2 assert False
      3 print("after")

AssertionError: 

To improve the previous program, we might use assert like this:

# we need water to cool our reactor

water_level = 40   # seems ok

print("water level: ", water_level)

# a lot of code

water_level = 5  # forgot somewhere this bad row !

print("WARNING: water level low! ", water_level)

# a lot of code

# after a lot of code we might not know if there are the proper conditions so that
# everything works alright so before doing critical things, it is always a good idea
# to perform a check ! if asserts fail (that is, the boolean expression is False),
# the execution suddenly stops

assert water_level >= 20

print("turn on nuclear reactor")
water level:  40
WARNING: water level low!  5
---------------------------------------------------------------------------
AssertionError                            Traceback (most recent call last)
Cell In[12], line 20
     11 print("WARNING: water level low! ", water_level)
     13 # a lot of code
     14 
     15 # after a lot of code we might not know if there are the proper conditions so that
     16 # everything works alright so before doing critical things, it is always a good idea
     17 # to perform a check ! if asserts fail (that is, the boolean expression is False),
     18 # the execution suddenly stops
---> 20 assert water_level >= 20
     22 print("turn on nuclear reactor")

AssertionError: 

When to use assert?#

The case above is willingly exaggerated, but shows how an additional check sometimes prevents disasters.

Asserts are a quick way to do checks, so much so that Python even allows to ignore them during execution to improve the performance (calling python with the -O parameter like in python -O my_file.py).

But if performance is not a problem (like in the reactor above), it’s more convenient to rewrite the program using an if and explicitly raising an Exception:

# we need water to cool our reactor

water_level = 40  # seems ok

print("water level: ", water_level)

# a lot of code

water_level = 5  # forgot somewhere this bad row !

print("WARNING: water level low! ", water_level)

# a lot of code

# after a lot of code we might not know if there are the proper conditions so
# that everything works alright. So before doing critical things, it is always
# a good idea to perform a check !

if water_level < 20:
    raise Exception("Water level too low !")  # execution stops here

print("turn on nuclear reactor")
water level:  40
WARNING: water level low!  5
---------------------------------------------------------------------------
Exception                                 Traceback (most recent call last)
Cell In[13], line 20
     13 # a lot of code
     14 
     15 # after a lot of code we might not know if there are the proper conditions so
     16 # that everything works alright. So before doing critical things, it is always
     17 # a good idea to perform a check !
     19 if water_level < 20:
---> 20     raise Exception("Water level too low !")  # execution stops here
     22 print("turn on nuclear reactor")

Exception: Water level too low !

Note how the reactor was not turned on.

Testing#

  • If it seems to work, then it actually works? Probably not.

  • The devil is in the details, especially for complex algorithms.

  • We will do a crash course on testing in Python

Warning

Bad software can cause losses of million $/€ or even harm people. Suggested reading: Software Horror Stories

Testing with asserts#

Note

In this book we test with assert, but there are much better frameworks for testing!

If you get serious about software development, please consider using something like PyTest (recent and clean) or Unittest (Python default testing suite, has more traditional approach)

During the remainder of this course, we will often use assert to perform tests, that is, to verify a function behaves as expected.

Look for example at this function:

def my_sum(x, y):
    s = x + y
    return s

We expect that my_sum(2, 3) gives 5. We can write in Python this expectation by using an assert:

assert my_sum(2, 3) == 5

If my_sum is correctly implemented:

  1. my_sum(2,3) will give 5

  2. the boolean expression my_sum(2,3) == 5 will give True

  3. assert True will be executed without producing any result, and the program execution will continue.

Otherwise, if my_sum is NOT correctly implemented like in this case:

def my_sum(x,y):
    return 666
  1. my_sum(2,3) will produce the number 666

  2. the boolean expression my_sum(2,3) == 5 will giveFalse

  3. assert False will interrupt the program execution, raising an exception of type AssertionError

Exercise - gre3#

✪✪ Write a function gre3 which takes three numbers and RETURN the greatest among them

Hide code cell content
def gre3(a, b, c):
    if a > b:
        if a > c:
            return a
        else:
            return c
    else:
        if b > c:
            return b
        else:
            return c
assert gre3(1, 2, 4) == 4
assert gre3(5, 7, 3) == 7
assert gre3(4, 4, 4) == 4

Exercise - final_price#

✪✪ The cover price of a book is € 24,95, but a library obtains 40% of discount. Shipping costs are € 3 for first copy and 75 cents for each additional copy. How much n copies cost ?

Write a function final_price(n) which RETURN the price.

ATTENTION 1: Decimal numbers in Python contain a dot, NOT a comma !

ATTENTION 2: If you ordered zero books, how much should you pay ?

HINT: the 40% of 24,95 can be calculated by multiplying the price by 0.40

Hide code cell content
def final_price(n):
    if n == 0:
        return 0
    else:
        return n * 24.95 * 0.6 + 3 + (n - 1) * 0.75
assert final_price(10) == 159.45
assert final_price(0) == 0

Exercise - arrival_time#

✪✪✪ By running slowly you take 8 minutes and 15 seconds per mile, and by running with moderate rhythm you take 7 minutes and 12 seconds per mile.

Write a function arrival_time(n,m) which, supposing you start at 6:52, given n miles run with slow rhythm and m with moderate rhythm, PRINTs arrival time.

  • HINT 1: to calculate an integer division, use //

  • HINT 2: to calculate the reminder of integer division, use the modulo operator %

Hide code cell content
def arrival_time(n, m):
    start_hour = 6
    start_minutes = 52

    # past time
    seconds = (
        start_hour * 60 * 60
        + start_minutes * 60
        + n * (8 * 60 + 15)
        + m * (7 * 60 + 12)
    )
    minutes = seconds // 60
    hours = minutes // 60

    hours_display = hours % 24
    minutes_display = minutes % 60

    return "%s:%s" % (hours_display, minutes_display)
assert arrival_time(0, 0) == "6:52"
assert arrival_time(2, 2) == "7:22"
assert arrival_time(2, 5) == "7:44"
assert arrival_time(8, 5) == "8:34"
assert arrival_time(40, 5) == "12:58"
assert arrival_time(100, 25) == "23:37"
assert arrival_time(100, 40) == "1:25"
assert arrival_time(700, 305) == "19:43"  # Forrest Gump