Lecture 15

Review

  1. Who is the "user" of our code now?
  2. What is the difference between a TypeError and a ValueError?
  3. What does isinstance do?
  4. What is the output of the following program? def print_value(val):
        print('val:', val)
        raise Exception
        print('Exception raised')

    print('a')
    print_value('b')
    print('c')

Functions (continued)

Raising Exceptions and Exception Propagation

Sometimes, the programmer calling our function passes in the wrong information. It could be wrong because:

In these cases, you need to alert the user of your function that bad information was given to the function. Since the user of your function may be another programmer and not the person running the program, you cannot just print an error to the screen. For example, this is the wrong way to alert the "user" that the wrong value was given:

def factorial(num):
    if num < 0:
        print('Bad value given')
        return
    
    product = 1
    for i in range(1, num+1):
        product *= i
    return product

It's primarily wrong because you're alerting the wrong person that num was wrong. You're alerting the person running the program, but in most cases the person running the program isn't capable of fixing the problem. A secondary reason this is wrong is because the function returns None on bad input and a number on good input. While this might not be a bad thing, it's possible for a sloppy programmer to not check whether factorial returned None.

The prefered way to deal with bad input to a function is with exceptions. You can raise an exception on bad input to a function by using the raise statement. The raise statement raises an exception. We've already seen how to catch these exceptions, now we're seeing how to raise (also called "throw") them.

def factorial(num):
    if num < 0:
        raise ValueError('The number must be positive')
    
    product = 1
    for i in range(1, num+1):
        product *= i
    return product

Now, if a negative value is passed into factorial, an exception is raised instead of None being returned. When an exception is raised, the function stops where it is and Python starts looking for the exception handling environment to catch this exception. The exception will propagate back up to the calling function and if there is no exception handling environment to catch it, it'll go to that function's calling function. This will continue until either an exception handling environment is found, or it reaches the top and the program crashes. Here's an example:

Code Output
def factorial(num):
    if num < 0:
        raise ValueError('The number must be positive')
    
    product = 1
    for i in range(1, num+1):
        product *= i
    return product

def user_input_factorial():
    val = int(input("Enter a positive integer: "))
    try:
        print("That number's factorial is:", factorial(val))
    except ValueError as ve:
        print(ve)

user_input_factorial()
user_input_factorial()
Enter a positive integer: 5
That number's factorial is: 120
Enter a positive integer: -1
The number must be positive
Code Output
def factorial(num):
    if num < 0:
        raise ValueError('The number must be positive')
    
    product = 1
    for i in range(1, num+1):
        product *= i
    return product

def get_user_factorial():
    val = int(input("Enter a positive integer: "))
    fact = factorial(val)
    print('Got the factorial')
    return fact

def print_user_factorial():
    user_fact = get_user_factorial()

    try
        print("The factorial of that number is:", user_fact)
    except ValueError:
        print('Error with computing factorial')

print_user_factorial()
print_user_factorial()
Enter a positive integer: 5
Got the factorial
The factorial of that number is: 120
Enter a positive integer: -1
Error with computing factorial

What's wrong with returning None on bad input? Why bother with exception handling environments? Exceptions allow us to do more than just indicate whether the function worked correctly or not. Our factorial function actually has two different kinds of bad arguments. The first is negative numbers, which are handled already. The second is that it only works on integers. If the programmer using our function tries to pass in a string or a float, the function will fail. To fix our problem, we need to do another kind of check at the start of the function. We need to check whether the function was given an integer. To do that, we can use the isinstance function. It takes a value and a data type and returns True if the value is an instance of that data type. For example:

>>> isinstance(15, int)
True
>>> isinstance(15, str)
False
>>> x = 15
>>> isinstance(x, float)
False

You can pass in a list/tuple of data types if you'd like. Maybe you're just curious if it's a number or not. You can easily do that with:

>>> isinstance(15, (int, float))
True
>>> isinstance('cat', (int, float))
False

In a function, we can use it to check whether the correct data type was given. For our factorial function, it only works on positive integers, so we must check whether an integer is given (we're already checking whether it's positive:

def factorial(num):
    if not isinstance(num, int):
        raise TypeError('The factorial is only defined for positive integers')
    if num < 0:
        raise ValueError('The number must be positive')
    
    product = 1
    for i in range(1, num+1):
        product *= i
    return product

Does it matter which check comes first? Can we check whether it's positive before we check whether it's an integer?

How do you know which kind of error to raise? Python provides a lot (and we can even create our own). For now, we'll primarily be using these errors:

The complete list of built-in exceptions can be found in Python's documentation.

Documenting Functions

Since you may not be the only programmer using your functions, you should document your function. Function documentation is a multi-line comment just after the function header. This comment should:

You should leave a blank line between "paragraphs".

Here's an example:

def factorial(num):
    '''
        Calculates the factorial of num.
        
        The parameter (num) must be a positive integer. The function returns an int representing num factorial (i.e. num!).
        
        It will raise TypeError if num is not an int and ValueError if num is not a positive int.
    '''
    if not isinstance(num, int):
        raise TypeError('The factorial is only defined for positive integers')
    if num < 0:
        raise ValueError('The number must be positive')
    
    product = 1
    for i in range(1, num+1):
        product *= i
    return product

This "docstring" (or "doc string") belongs to the function. You can access it using __doc__, as in:

>>> print('documentation for factorial is:\n', factorial.__doc__)
documentation for factorial is:

        Calculates the factorial of num.
        
        The parameter (num) must be a positive integer. The function returns an int representing num factorial (i.e. num!).
        
        It will raise TypeError if num is not an int and ValueError if num is not a positive int.

However, the easier way is to just use the help function:

>>> help(factorial)
Help on function factorial in module __main__:

factorial(num)
    Calculates the factorial of num.
    
    The parameter (num) must be a positive integer. The function returns an int representing num factorial (i.e. num!).
    
    It will raise TypeError if num is not an int and ValueError if num is not a positive int.

These docstrings are also helpful for generating documentation, similar to what you can find in Python's Standard Library. Python's pydoc module provides some basic tools for this.

Modules

Modules are Python files containing pre-written code. We gain access to them in our programs through the import statements. So far, we've been using modules provided by Python (e.g. math, string, and random). However, we can write our own modules. You've actually been doing this for the labs and assignments. The .py files you submit could be treated as modules. However, modules often contain just function and class definitions (so, lab 6, part 1 would be a good example of a module that you're writing).

To import one of your modules, you must make sure it's in a location that Python knows about. Python has a list of paths that it will search whenever you try to import something. To view it, import the sys module and access its path list. For example:

>>> import sys
>>> print(sys.path)
['', '/usr/lib/python34.zip', '/usr/lib/python3.4', '/usr/lib/python3.4/plat-linux', '/usr/lib/python3.4/lib-dynload', '/usr/lib/python3.4/site-packages']

Your path will likely look different from the one above. The first elements in the list is an empty string. That means to search the current working directory (which we last talked about with files). The rest of the paths are absolute paths to locations that Python has modules installed. If you want to import your own modules, you can:

We've already talked about changing the current working directory, so we won't re-cover it here (use os.chdir). One down side to this option is that you might not want to change your current working directory just to import a file. Maybe the rest of your program assumes you'll be working in a certain location.

Putting your module where Python already knows to look could work if you have access to do that. Unfortunately, those locations are often restricted to administrator access. Additionally, you might not want to share your modules with everyone else on the computer (maybe you're storing passwords in the file.

The third option is very easy to do. We've already talked about appending/inserting into a list. sys.path is just a list. You just append/insert the path you want to use into sys.path. Once you've updated the path, import as you normally would. In the example below,

>>> import sys
>>> print(sys.path)
['', '/usr/lib/python34.zip', '/usr/lib/python3.4', '/usr/lib/python3.4/plat-linux', '/usr/lib/python3.4/lib-dynload', '/usr/lib/python3.4/site-packages']
>>>
>>> sys.path.append('/home/michael/lib/python')
>>> print(sys.path)
['', '/usr/lib/python34.zip', '/usr/lib/python3.4', '/usr/lib/python3.4/plat-linux', '/usr/lib/python3.4/lib-dynload', '/usr/lib/python3.4/site-packages', '/home/michael/lib/python']
>>>
>>> import greetings
>>> greetings.greet_user2('Hi', 'Michael')
Hi, Michael!
<< Previous Notes Daily Schedule Next Notes >>