Documenting functions and testing their output#

You’ve now learned to write functions in programs. With this lesson I would like to illustrate three new concepts which you will find useful for the rest of the class.

Positional and keyword arguments#

Until now you coded functions with relatively simple signatures. Here I would like to illustrate another way to pass arguments to a function. Consider the following example:

def say_hi(firstname, lastname, language='en'):
    if language == 'en':
        output = f'Hello {firstname} {lastname}!'
    elif language == 'fr':
        output = f'Bonjour {firstname} {lastname}!'
    else:
        raise ValueError(f'Language not recognized: {language}')
    return output

The first two arguments (firstname and lastname) are called positional arguments of the function. They are called this way because their position matters (you can’t mix them up, the order matters), and they are required for the function to run successfully. For example:

# Position matters:
say_hi('Smith', 'Will')
'Hello Smith Will!'
# All arguments matter:
say_hi('Will')
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
/tmp/ipykernel_123684/1781319118.py in <cell line: 2>()
      1 # All arguments matter:
----> 2 say_hi('Will')

TypeError: say_hi() missing 1 required positional argument: 'lastname'

Now, what about the third argument in the function? They is called a keyword argument, and it is defined as a pair:

  • the key (here language), which is also the name of the variable used in the function scope

  • the value (here 'en'), which is assigned to the key if no other value is specified.

Therefore:

say_hi('Will', 'Smith')  # here language is set to `'en'` per default
'Hello Will Smith!'
say_hi('Will', 'Smith', language='fr')  # here language is set to `'fr'` by the caller
'Bonjour Will Smith!'

Keyword arguments are used to offer more optional behaviors to the function without enforcing the caller to set them. They are very useful, as you will see.

Function documentation with docstrings#

“Naked” functions like the one above are not recommended practice in code. Indeed, in order to understand what the function is doing people have to read the code. Imagine if you had to read all of the python codebase to understand how things work!

Let’s add some “clothes” to our function above:

def say_hi(firstname, lastname, language='en'):
    """Prepares a string saying hi in different languages.

    The currently supported languages are french and english.

    Parameters
    ----------
    firstname : str
        The first name of the person to greet
    lastname : str
        The last name of the person to greet
    language : {'en', 'fr'}, optional
        Which language to use.

    Returns
    -------
    out : str
        A string with the correct greeting

    Examples
    --------
    >>> say_hi('John', 'Lenon')
    Hello John Lenon!
    >>> say_hi('Jeanne', "d'Arc", language='fr')
    Bonjour Jeanne d'Arc!
    """
    if language == 'en':
        print(f'Hello {firstname} {lastname}!')
    elif language == 'fr':
        print(f'Bonjour {firstname} {lastname}!')
    else:
        raise ValueError(f'Language not recognized: {language}')

Function documentation allows users to learn about a function without having to read the code. For example, they are now able to do:

help(say_hi)
Help on function say_hi in module __main__:

say_hi(firstname, lastname, language='en')
    Prepares a string saying hi in different languages.
    
    The currently supported languages are french and english.
    
    Parameters
    ----------
    firstname : str
        The first name of the person to greet
    lastname : str
        The last name of the person to greet
    language : {'en', 'fr'}, optional
        Which language to use.
    
    Returns
    -------
    out : str
        A string with the correct greeting
    
    Examples
    --------
    >>> say_hi('John', 'Lenon')
    Hello John Lenon!
    >>> say_hi('Jeanne', "d'Arc", language='fr')
    Bonjour Jeanne d'Arc!

Or, in jupyter notebooks:

say_hi?

You can do this with other functions as well! See for example:

import random
random.uniform?

You can document functions the way you want. The only rules are that the documentation (called docstring) starts and ends with three """.

I recommend to follow some rules though, and the most used is the numpydoc convention. It’s also the one I used above.

Testing a function’s output with doctest#

You may have noticed the Examples section in the docstring of our function above.

This section uses a specific syntax (the >>>) to signify that we are documenting python code. This section can actually be run and understood by a tool called doctest.

Using it will actually discover a “bug” in our documentation:

import doctest
doctest.testmod()
TestResults(failed=0, attempted=2)

Everything seems fine here. But what if I made a mistake, either in the documentation or the function? Let’s try again:

def fahrenheit_to_celsius(tc):
    """Converts a temperature in °C to °F.

    Examples
    --------
    >>> fahrenheit_to_celsius(68)
    20.0
    """
    r = (tc - 32) * 4 / 9
    return r

doctest.testmod()
**********************************************************************
File "__main__", line 6, in __main__.fahrenheit_to_celsius
Failed example:
    fahrenheit_to_celsius(68)
Expected:
    20.0
Got:
    16.0
**********************************************************************
1 items had failures:
   1 of   1 in __main__.fahrenheit_to_celsius
***Test Failed*** 1 failures.
TestResults(failed=1, attempted=3)

Aha! This is actually quite useful! The doctest is pointing me to a problem in my code. Can you find it? Let’s try again:

def fahrenheit_to_celsius(tc):
    """Converts a temperature in °C to °F.

    Examples
    --------
    >>> fahrenheit_to_celsius(68)
    20.0
    >>> fahrenheit_to_celsius(100)
    37.7
    """
    r = (tc - 32) * 5 / 9
    return r

doctest.testmod()
**********************************************************************
File "__main__", line 8, in __main__.fahrenheit_to_celsius
Failed example:
    fahrenheit_to_celsius(100)
Expected:
    37.7
Got:
    37.77777777777778
**********************************************************************
1 items had failures:
   1 of   2 in __main__.fahrenheit_to_celsius
***Test Failed*** 1 failures.
TestResults(failed=1, attempted=4)

Aha! This time the previous test is now corrected, but the new one is not precise enough. Let’s try one more time:

def fahrenheit_to_celsius(tc):
    """Converts a temperature in °C to °F.

    Examples
    --------
    >>> fahrenheit_to_celsius(68)
    20.0
    >>> fahrenheit_to_celsius(100)
    37.77777777777778
    """
    r = (tc - 32) * 5 / 9
    return r

doctest.testmod()
TestResults(failed=0, attempted=4)

Perfect!

Doctests are very useful to:

  • document a function’s behavior with examples, which is often the best way to explain

  • test if the function is working as expected and discover future bugs if the internal code changes

  • test if the documentation is still correct after internal code changes

  • communicate exercises with you ;-) I will use this format as often as possible to let you know what I expect your code to do.

Learning recap#

You have learned to:

  • use keyword arguments in functions on top of the positional arguments

  • use doctrings to document a function’s signature and behavior

  • use doctests to test if a function works properly

You are now ready for the rest of the lecture!