Errors

Jon Reades

We all make mistakes

It’s how we deal with them that matters. Same in Python: understanding and handling errors is the key to good code.

Helpfully…

Python will always try to tell you what it thinks went wrong: “I didn’t understand what you meant by this…” or “I’m sorry, I can’t let you do that Dave…”

The challenges are:

  1. Python tends to give you a lot of information about the error: this can be very helpful for programmers dealing with complex problems and totally overwhelming for beginners.
  2. That what Python thinks the problem is doesn’t always line up with where the problem actually is. In cases of syntax, for instance, the problem could be an unclosed parenthesis three lines earlier!

Challenge 1

That the ‘error’ isn’t always the error…

total = 0
print("About to start loop"
for i in range(1,10):
  total += i
print(total)

This outputs:

print("About to start loop"
... for i in range(1,10):
  File "<stdin>", line 2
    for i in range(1,10):
                        ^
SyntaxError: invalid syntax

Dealing with Errors

Errors Have Types

In the same way that variables have types, so do errors:

  • ModuleNotFoundError
  • IndexError
  • KeyError
  • OSError

We can add our own messages:

raise Exception("Sorry, I can't let you do that, Dave.")

Custom Errors

We can create our own types (classes) of error:

class CustomError(Exception):
  pass # We do nothing except create a new type

This can then be triggered with:

raise CustomError("Our custom error")

And (very importantly) this can be caught with:

except CustomError: 
  #... do something with CustomError ...

This means that exceptions could accept custom arguments, perform tidying-up or rollback operations, etc.

So Errors can be Trapped

Python calls errors exceptions, so this leads to:

try:
  #... some code that might fail...
except <Named_Error_Type>:
  #... what do it if it fails for a specific reason...
except:
  #... what to do if it fails for any other reason...
finally:
  #... always do this, even if it fails...

You can use any or all of these together: you can have multiple named excepts to handle different types of errors from a single block of code; you do not have to have a catch-all except or a finally.

Trapping Errors

This code fails:

x,y = 10,0
print(x/y)

And it generates this error:

> Traceback (most recent call last):
>   File "<stdin>", line 1, in <module>
> ZeroDivisionError: division by zero

Trapping Errors (cont’d)

But if you ‘trap’ the error using except then:

x,y = 10,0
try:
  print(x/y)
except ZeroDivisionError:
  print("You can't divide by zero!")
except:
  print("Something has gone very wrong.")
finally: 
  print("Division is fun!")

This will print

> You can't divide by zero!
> Division is fun!

Raising Hell

You can trigger your own exceptions using raise.

x,y = 10,0
try:
  print(x/y)
except ZeroDivisionError:
  print("You can't divide by zero!")
  raise Exception("Please don't do that again!")
finally: 
  print("Division is fun!")

Understanding Multiple Errors

x,y = 10,0
try:
  print(x/y)
except ZeroDivisionError:
  print("You can't divide by zero!")
  raise Exception("Please don't do that again!")
finally: 
  print("Division is fun!")
  1. The code we try triggers the ZeroDivisionError block.
  2. This prints "You can't divide by zero!"
  3. We then raise a new exception that is not caught.
  4. The finally code executes because it always does before Python exits.
  5. Python exits with the message from our newly raised Exception.

Thus: ‘During handling of above (ZeroDivisionError) another exception (our Exception) occurred…’

A Debugging Manifesto!1

A debugging manifesto

Understanding exceptions is critical to fixing problems, instead of being overwhelmed by them!

Test-Based Development

We can actually think of exceptions as a way to develop our code.

Here’s some ‘pseudo-code’:

# Testing the 'addition' operator
test(1+1, 2)           # Should equal 2
test(1+'1', TypeError) # Should equal TypeError
test('1'+'1', '11')    # Should equal '11'
test(-1+1, 0)          # Should equal 0 

Our test(A,B) function takes an input (A) and the expected output (B) and then compares them. The test returns True if A==B and False otherwise.

Unit Tests

Each test is a Unit Test because it tests one thing and one thing only. So if you had three functions to ‘do stuff’ then you’d need at least three unit tests.

A Unit Test may be composed of one or more assertions. Our pseudo-code on the previous slide contained 4 assertions.

A Unit Test does not mean that your code is correct or will perform properly under all circumstances. It means that your code returns the expected value for a specified input.

Python considers this approach so important that it’s built in.

Approach 1

This is an explict assertion to test fun:

import unittest

def fun(x):
  return x + 1

class MyTest(unittest.TestCase):
  def test(self):
    self.assertEqual(fun(3), 4)
    print("Assertion 1 passed.")
    self.assertEqual(fun(3), 5)
    print("Assertion 2 passed.")

m = MyTest()
m.test()

The critical output is:

AssertionError: 4 != 5

Approach 2

This approach uses the ‘docstring’ (the bits between """) to test the results of the function. This is intended to encourage good documentation of functions using examples:

def square(x):
    """Return the square of x.

    >>> square(2)
    4
    >>> square(-2)
    4
    >>> square(-1)
    2
    """
    return x * x

if __name__ == '__main__':
    import doctest
    doctest.testmod()

Collaboration & Continuous Integration

The Unit Test approach is often used on collaborative projects, especially in the Open Source world. PySAL, for instance, asks for unit tests with every new feature or integration.

The running of all tests for multiple components is called ‘integration testing’.

A commit, merge, or pull on GitHub can trigger the unit testing process for the entire software ‘stack’. This is known as Continuous Integration because you are always checking that the code works as expected, rather than leaving testing to the end.

Resources