Modules

In the last chapter we saw how we can take sections of code and make them reusable in different contexts without having to copy and paste the code every time we want to use it in your script. By using functions we give the code block a name and define what it needs in order to do its job.

Functions are a step in the right direction but they still have the problem that to use functions from one script in another you will have to copy and paste them over. The answer to this is to move the functions to a common location which both scripts can access. The Python solution for this is modules. Just like we used modules from The Python Standard Library earlier, we can create our own modules too.

Continuing with the example of the ounces_to_grams function from the last section, let’s see how we can use the function in other code, outside of that one script.

convert.py
def ounces_to_grams(weight):
    new_weight = weight * 28.3495
    return new_weight

weight_in_grams = ounces_to_grams(12)

print(f"{weight_in_grams} g")

There are two main categories of code in this file:

  1. The function, ounces_to_grams is the sort of code which is useful in many different situations.
  2. The code below which uses the function. This is specific code which has been written to solve today’s problem of converting a specific value.

We will need to create a new file to hold the function. Let’s call it convert.py. Make sure this is in the same directory as your notebook. A .py file is a file containing Python code. Let’s put the function we wrote in the last section into that file:

convert.py
def ounces_to_grams(weight):
    new_weight = weight * 28.3495
    return new_weight

Now, we want to try and run our function. Go back to your notebook and make a new code cell.

weight_in_grams = ounces_to_grams(12)

print(f"{weight_in_grams} g")

You will notice, that you get an error that it doesn’t know what ounces_to_grams is:

---------------------------------------------------------------------------
NameError                                 Traceback (most recent call last)
Cell In[3], line 1
----> 1 weight_in_grams = ounces_to_grams(12)
      3 print(f"{weight_in_grams} g")

NameError: name 'ounces_to_grams' is not defined

This is because that function is indeed not defined in this script. We need to tell it to load the file which contains the function we want to use, using the import statement:

import convert

weight_in_grams = convert.ounces_to_grams(12)

print(f"{weight_in_grams} g")
340.19399999999996 g

Here we have done a few things. First, on the first line we have imported our module. This is Python’s way of getting access to code which is inside other modules. You’ll notice that the way we do this is identical to when we were importing modules from the Standard Library (e.g. math) earlier.

The name of a module is the same as the name of the file but without the .py extension. So, since we saved our file above as convert.py, the name of the module is convert. Then, when calling the function, we need to explicily state that we’re using the ounces_to_grams function from the convert module using convert.ounces_to_grams.

Importing your own modules in notebooks

When you import a module that you have written (for example, import morse) in a notebook, Python loads the module into memory. If you edit and save the module file, the notebook will not automatically reload the updated code—it will keep using the old version until you restart the notebook’s kernel.

Solutions:

  • Restart the kernel: You can do this by clicking the Restart Kernel button in your notebook interface (it looks like a refresh button). This will clear everything from memory and reload your module the next time you import it. This will delete all your variables and imports though, so you will need to re-run any previous cells to get your environment back to the state it was in.

  • Use notebook magics: You can use the %load magic to load code from a file directly into a cell, or %run to run a Python file and update the environment:

%load_ext autoreload
%autoreload 2

import convert

weight_in_grams = convert.ounces_to_grams(12)
print(f"{weight_in_grams} g")
Exercise

Do the following:

  • Make a module, morse (so the file name must be morse.py), and move the encode function from encode.py from the previous chapter into it. Delete it from your notebook
  • Move the definition of the dictionary letter_to_morse into morse.py.
  • Restart the notebook kernel to ensure it picks up the new module.
  • Edit your notebook so that it imports the new module and calls the function from the new module.

The code for encoding Morse code is:

letter_to_morse = {'a':'.-', 'b':'-...', 'c':'-.-.', 'd':'-..', 'e':'.', 'f':'..-.', 
                   'g':'--.', 'h':'....', 'i':'..', 'j':'.---', 'k':'-.-', 'l':'.-..', 'm':'--', 
                   'n':'-.', 'o':'---', 'p':'.--.', 'q':'--.-', 'r':'.-.', 's':'...', 't':'-',
                   'u':'..-', 'v':'...-', 'w':'.--', 'x':'-..-', 'y':'-.--', 'z':'--..',
                   '0':'-----', '1':'.----', '2':'..---', '3':'...--', '4':'....-',
                   '5':'.....', '6':'-....', '7':'--...', '8':'---..', '9':'----.',
                   ' ':'/'}

message = "SOS We have hit an iceberg and need help quickly"


def encode(message):
    morse = []

    for letter in message:
        letter = letter.lower()
        morse_letter = letter_to_morse[letter]
        morse.append(morse_letter)

    morse_message = " ".join(morse)
    
    return morse_message

Your morse.py file should look like this:

morse.py
letter_to_morse = {
    'a':'.-', 'b':'-...', 'c':'-.-.', 'd':'-..', 'e':'.', 'f':'..-.', 
    'g':'--.', 'h':'....', 'i':'..', 'j':'.---', 'k':'-.-', 'l':'.-..', 'm':'--', 
    'n':'-.', 'o':'---', 'p':'.--.', 'q':'--.-', 'r':'.-.', 's':'...', 't':'-',
    'u':'..-', 'v':'...-', 'w':'.--', 'x':'-..-', 'y':'-.--', 'z':'--..',
    '0':'-----', '1':'.----', '2':'..---', '3':'...--', '4':'....-',
    '5':'.....', '6':'-....', '7':'--...', '8':'---..', '9':'----.', ' ':'/'
}


def encode(message):
    morse = []

    for letter in message:
        letter = letter.lower()
        morse_letter = letter_to_morse[letter]
        morse.append(morse_letter)

    morse_message = " ".join(morse)
    
    return morse_message

Your notebook code cell should look like this:

import morse

message = "SOS We have hit an iceberg and need help quickly"

encoded_message = morse.encode(message)

print(f"Incoming message: {message}")
print(f"   Morse encoded: {encoded_message}")
Incoming message: SOS We have hit an iceberg and need help quickly
   Morse encoded: ... --- ... / .-- . / .... .- ...- . / .... .. - / .- -. / .. -.-. . -... . .-. --. / .- -. -.. / -. . . -.. / .... . .-.. .--. / --.- ..- .. -.-. -.- .-.. -.--
Exercise

Add into morse.py a function to convert Morse code to English. The code to do so is written below:

# We need to invert the dictionary. This will create a dictionary
# that can go from the morse back to the letter
morse_to_letter = {}
for letter in letter_to_morse:
    morse = letter_to_morse[letter]
    morse_to_letter[morse] = letter


def decode(message):
    english = []

    # Now we cannot read by letter. We know that morse letters are
    # separated by a space, so we split the morse string by spaces
    morse_letters = message.split(" ")

    for letter in morse_letters:
        english_letter = morse_to_letter[letter]
        english.append(english_letter)

    # Rejoin, but now we don't need to add any spaces
    english_message = "".join(english)
    
    return english_message

Then write a write some code in a notebook cell which imports morse, calls the decode function and decodes the message "... . -.-. .-. . - / -- . ... ... .- --. .".

Your morse.py file should look like this:

letter_to_morse = {
    'a':'.-', 'b':'-...', 'c':'-.-.', 'd':'-..', 'e':'.', 'f':'..-.', 
    'g':'--.', 'h':'....', 'i':'..', 'j':'.---', 'k':'-.-', 'l':'.-..', 'm':'--', 
    'n':'-.', 'o':'---', 'p':'.--.', 'q':'--.-', 'r':'.-.', 's':'...', 't':'-',
    'u':'..-', 'v':'...-', 'w':'.--', 'x':'-..-', 'y':'-.--', 'z':'--..',
    '0':'-----', '1':'.----', '2':'..---', '3':'...--', '4':'....-',
    '5':'.....', '6':'-....', '7':'--...', '8':'---..', '9':'----.', ' ':'/'
}


def encode(message):
    morse = []

    for letter in message:
        letter = letter.lower()
        morse_letter = letter_to_morse[letter]
        morse.append(morse_letter)

    morse_message = " ".join(morse)
    
    return morse_message


# We need to invert the dictionary. This will create a dictionary
# that can go from the morse back to the letter
morse_to_letter = {}
for letter in letter_to_morse:
    morse = letter_to_morse[letter]
    morse_to_letter[morse] = letter


def decode(message):
    english = []

    # Now we cannot read by letter. We know that morse letters are
    # separated by a space, so we split the morse string by spaces
    morse_letters = message.split(" ")

    for letter in morse_letters:
        english_letter = morse_to_letter[letter]
        english.append(english_letter)

    # Rejoin, but now we don't need to add any spaces
    english_message = "".join(english)
    
    return english_message
import morse

message = "... . -.-. .-. . - / -- . ... ... .- --. ."

decoded_message = morse.decode(message)

print(decoded_message)
secret message
Defining aliases

You can assign an alias to a module, so you don’t need to type the full name every time. In the example above, we could do

import convert as c

weight_in_grams = c.ounces_to_grams(12)

print(f"{weight_in_grams} g")
340.19399999999996 g

There are a few conventions that are good to know, for example the module pandas is by convention imported as pd, and seaborn as sns. You will see these these modules on Introduction to Data Analysis in Python.

Testing

So far we’ve been writing code and running it, but have you actually checked that the code is doing the right thing? Testing code is a very important part of the software development process because if you want other people to trust your work, you need to show that you’ve checked that your code does what you claim.

There are more formal methods for software testing but we’ll start with a common technique used with encoders/decoders and that is the round-trip. If we take a message, convert it to morse code and then convert it back, we should end up with the message we started with:

test_morse.py
import morse

message = "sos we have hit an iceberg"

code = morse.encode(message)
decode = morse.decode(code)

print(message == decode)

When you run it, you should see True printed to the console terminal tells us that the round-trip was successful.

True

We’ll cover move on how to write tests for your Python modules in the Best Practices in Software Engineering course but for now, this round-trip will suffice.