Phonebook Exercise

Let's make a command line tool for managing phonebook entries!

Introduction

This exercise is organized in a few sections:

Depending on your learning style and experience level, you might get through all of these sections. Or you might just get through the first one. That's okay! The main priority for this exercise is to write a lot of code and understand what the code you're writing does. Don't worry about "finishing", whatever that means.

If you don't understand a phrase or a line of code that I use, ask me! I can show you what I'd do to figure out what the line of code means. Learning how to figure out the things you don't know is an important skill in programming.

The more challenging sections are marked with an asterisk (*). If you find the sections without askerisks challenging, I recommend skipping the more challenging ones for now. You can come back to them later if you're curious.

On the other hand, if you have a good idea for how to do the things outlined in a section, try doing it without reading through the section first, and just use the tutorial for reference if you get stuck.

At the end of some sections I have links to additional resources if you'd like to implement a more advanced command line tool.

If you'd like to see what my code looks like when I worked through this exercise, I have snapshots of my phonebook.py file for each section on GitHub.

1. Specifications

We'll write a Python program that will support creating, retrieving, updating, and deleting phonebook entries consisting of a name and a phone number.

The program will have a command line interface supporting the following commands and behaviors:

$ python phonebook.py create ex_phonebook
Created phonebook 'ex_phonebook.pb' in the current directory.

$ python phonebook.py add 'Jane Doe' '432 123 4321' ex_phonebook
Added an entry to ex_phonebook.pb:
Jane Doe    432 123 4321
$ python phonebook.py add 'Jane Lin' '509 123 4567' ex_phonebook
Added an entry to ex_phonebook.pb:
Jane Lin    509 123 4567
$ python phonebook.py add 'Jane Lin' '643 357 9876' ex_phonebook
Error: Jane Lin already exists in ex_phonebook. Use the 'update' command to change this entry.

$ python phonebook.py update 'Jane Lin' '643 357 9876' ex_phonebook
Updated an entry in ex_phonebook.pb.
Previous entry:
Jane Lin    509 123 4567
New entry:
Jane Lin    643 357 9876

$ python phonebook.py lookup 'Jane' ex_phonebook
Jane Doe    432 123 4321
Jane Lin    643 357 9876

$ python phonebook.py reverse-lookup '643 357 9876' ex_phonebook
Jane Lin    643 357 9876

$ python phonebook.py remove 'Jane Doe' ex_phonebook
Removed an entry from ex_phonebook.pb:
Jane Doe    432 123 4321
$ python phonebook.py remove 'John Doe' ex_phonebook
Error: 'John Doe' does not exist in ex_phonebook.pb

Back to top

2. Code Skeleton

What are the functions that you'll need for your program? What arguments will each of those functions need? Write a skeleton program that has those functions definitions, but where the functions just pass, rather than actually doing anything.

Here is an example skeleton program with function definitions for createing a phonebook and adding an entry to the phonebook. You'll need more functions, too, but this is a start:

def create_phonebook(phonebook_name):
    # create a new phonebook
    pass


def add_entry(name, number, phonebook_name):
    # add a new name and number to the given phonebook
    pass


if __name__ == '__main__':
    pass

Similarly, create functions for update, lookup, reverse-lookup, and delete.

Wondering what the if __name__ == '__main__' code block is for? It's where you put code that you want to execute when you run this Python script directly, but not when you import this script into another one. More information on what that means can be found in the Further Reading section.

Back to top

3. Argument parsing

3.1 Accessing Arguments

Arguments passed to Python scripts from the command line are accessible using the sys module. Here's a simple example script that prints out the arguments passed to the Python interpreter:

# ex.py
import sys

print "The arguments given to the interpreter are:"
print sys.argv

What happens when you execute the script giving it some arguments?

$ python ex.py cats dragons frogs
The arguments given to the interpreter are:
['ex.py', 'cats', 'dragons', 'frogs']

So sys.argv is a list, containing the name of the script ('ex.py'), and then the three arguments we passed, all represented as strings.

Back to top

3.2 Parsing Arguments

For your phonebook application, you might choose to manually parse your arguments. This code should go in an if __name__ == '__main__' code block because parsing arguments doesn't make sense when you're importing a module.

For the first two commands, create and add, your code would look something like this:

def create_phonebook(phonebook_name):
    # create a new phonebook
    pass


def add_entry(name, number, phonebook_name):
    # add a new name and number to the given phonebook
    pass


# include functions for other commands


if __name__ == '__main__':
    args = sys.argv[:]      # make a copy
    script = args.pop(0)    # name of script is first arg
    command = args.pop(0)   # the next arg will be the main command

    if command == 'create':
        phonebook_name = args.pop(0)
        create_phonebook(phonebook_name)

    elif command == 'add':
        name = args.pop(0)
        number = args.pop(0)
        phonebook_name = args.pop(0)
        add_entry(name, number, phonebook_name)

Similarly, define elif statements for update, lookup, reverse-lookup, and delete.

If you're not sure how the pop method works, google it, and play around with it in your REPL! Some questions you can try to figure out:

  1. What happens if you don't give pop an argument?
  2. What does the comment "make a copy" mean? What happens if you don't make a copy?

Back to top

3.3 Unpacking Arguments

List unpacking can make our code naming the variables in each of the if/elif blocks much cleaner.

For example, we can turn this:

name = args.pop(0)
number = args.pop(0)

into this:

name, number = args

This takes the first element in args and assigns it to the variable name, and the second element in args and assigns it to the variable number.

Edit the code for each of your commands to use list unpacking everywhere you have multiple lines with pop in a row.

An example for the add and create commands would look like this:

def create_phonebook(phonebook_name):
    # create a new phonebook
    pass


def add_entry(name, number, phonebook_name):
    # add a new name and number to the given phonebook
    pass


# include functions for other arguments


if __name__ == '__main__':
    args = sys.argv[:]      # make a copy
    script = args.pop(0)    # name of script is first arg
    command = args.pop(0)   # the next arg will be the main command

    if command == 'create':
        phonebook_name = args.pop(0)
        create_phonebook(phonebook_name)

    elif command == 'add':
        name, number, phonebook_name = args
        add_entry(name, number, phonebook_name)

We left the pop line for the create command since there should be only one argument left. What happens if you try to unpack a list with only one element? Try figuring it out in your REPL.

Similarly, define elif statements for update, lookup, reverse-lookup, and delete.

Back to top

3.4 Handling Bad Arguments

What happens if your program isn't given any arguments? Try:

$ python phonebook.py
Traceback (most recent call last):
  File "phonebook.py", line 69, in <module>
    command = args.pop(0)   # the next arg will be the main command
IndexError: pop from empty list

What if it's given an argument that isn't supported?

$ python phonebook.py cats

What about if you try adding a phone number, but you only give it a name, and not a number?

$ python phonebook.py add 'Jane Doe'
Traceback (most recent call last):
  File "phonebook.py", line 79, in <module>
    name, number, phonebook_name = args
ValueError: need more than 1 value to unpack

What about if you give it a name, number, and an extra nonsensical argument?

$ python phonebook.py add 'Jane Doe' '765-344-3421' 'cats'

It would be helpful to print out a more descriptive error message for these cases, rather than having Python print out a confusing message (or nothing at all).

For a start, we can add print statements and then quit when our program is given bad arguments:

if __name__ == '__main__':
    args = sys.argv[:]
    script = args.pop(0)    # name of script is first arg
    if not args:
        print "Not enough arguments"
        quit()
    command = args.pop(0)   # the next arg will be the main command

Here, we take advantage of an empty list being Falsey. If there are no arguments left in args after we pop off the script name, not args will evaluate to True, and we'll print an error message and quit. Otherwise we can move on with our program.

Similarly, we can add a check that we're given the correct number of arguments for the create command:

if command == 'create':
    if len(args) != 1:
        print "Phonebook name required"
        quit()
    phonebook_name = args.pop(0)
    create_phonebook(phonebook_name)

Add similar checks for each of the commands. Make your program print error messages and quit any time:

Doing checks based on the len of args is considered bad Python style. It's usually better to put code in try/except clauses. Similarly, when we write tests, we're going to want to raise exceptions rather than just printing and quiting. But for now, this will do.

Your program should now support the following:

$ python phonebook.py
Command required
$ python phonebook.py cats
Invalid command
$ python phonebook.py add
Name, number, and phonebook name required
$ python phonebook.py add 'Jane Doe'
Name, number, and phonebook name required
$ python phonebook.py add 'Jane Doe' '234-234-2334'
Name, number, and phonebook name required
$ python phonebook.py add 'Jane Doe' '234-234-2334' 'ex_phonebook'
$ python phonebook.py add 'Jane Doe' '234-234-2334' 'ex_phonebook' 'cats'
Name, number, and phonebook name required

Similarly, try giving the other commands (update, remove, lookup, and reverse-lookup) too few and too many arguments. You want to make sure that the behavior that you expect is what actually happens.

Back to top

*3.5 More Ideas

If this section hasn't challenged you enough, here are some things you could work on that might be more interesting for you:

Back to top

4. Data Storage

Now that we have a skeleton of functions for each of our commands, and we call the appropriate functions for each command with the appropriate arguments, we need to figure out how to store and access our phonebook entries. First we'll start with saving these entries to a file, and reading them into a dictionary data structure. A more advanced option would be to save the data in a database.

4.1 Creating a Phonebook

Let's work on the create_phonebook function. This function should create a new text file in the working directory.

We can create a file by opening a file in write mode:

f = open('filename.txt', 'w')

The 'w' parameter designates write mode. There is also read mode and append mode, both of which we'll use later.

Any time you open a file you must remember to close it, or your changes won't be saved!

f = open('filename.txt', 'w')
f.close()

Since it's easy to forget to close a file, it's best practice to instead open files using the with statement:

with open('filename.txt', 'w') as f:
    # do things with f
    pass

with automatically closes the file for you, so you don't have to remember.

Let's use this to fill out our create_phonebook function, which should execute with the create command:

def create_phonebook(phonebook_name):
    with open('%s.txt' % phonebook_name, 'w') as f:
        pass

The syntax '%s.txt' % phonebook_name is called string interpolation. The variable on the right side of the % gets substituted for the %s inside the string. This will create files with names like phonebook.txt or ex_phonebook.txt.

We just pass because we don't need to write anything to the file at this point.

Now let's see what happens when we use our program to create a phonebook:

$ python phonebook.py create 'ex_phonebook'
$ ls
ex_phonebook.txt phonebook.py

We should see that a file ex_phonebook.txt was created!

Let's say we manually added some entries into our ex_phonebook.txt file, and tried creating another phonebook with the same name? Would our data get overwritten? Or would Python throw an error? Let's see!

I'm manually typing some text into ex_phonebook.txt using my text editor:

Jane Doe    123-123-1234

Now let's try createing ex_phonebook again:

$ python phonebook.py create 'ex_phonebook'

And... oh no! The contents of ex_phonebook.txt have been deleted! We don't want our users to accidentally delete the entire contents of their phonebooks this way. Let's rewrite the create_phonebook function so that it won't overwrite any existing files.

To check if the file exists, we'll need to use the os module. Add this import statement to the top of your phonebook.py script:

import os

And then edit the create_phonebook function:

def create_phonebook(phonebook_name):
    filename = '%s.txt' % phonebook_name
    if os.path.exists(filename):
        print "That phonebook already exists!"
        quit()
    with open(filename, 'w') as f:
        pass

Now let's check that this works:

$ python phonebook.py create 'ex_phonebook'
That phonebook already exists!

Sweet!

4.2 Adding an Entry

Now that we can create phonebooks, let's work on the add_entry function.

Recall that we should support adding an entry with the following command:

$ python phonebook.py add 'Jane Doe' '234-234-2334' 'ex_phonebook'

For now, adding an entry will mean adding a line to the phonebook.

To add a line to a file, we'll need to open the file in append mode:

def add_entry(name, number, phonebook_name):
    with open(filename, 'a') as f:
        f.write('%s\t%s' % (name, number))
        f.write('\n')   # add newline

'%s\t%s' % (name, number) is another example of string interpolation. This will substitute the first item in the tuple after the % for the first %s and the second item in the tuple for the second %s. The \t in the middle is the symbol for a tab. So our entries will be stored in a tab-delimited format.

Let's try adding a couple entries:

$ python phonebook.py add 'Jane Doe' '234-234-2334' 'ex_phonebook'
$ python phonebook.py add 'John Doe' '789-234-4567' 'ex_phonebook'

Now the ex_phonebook.txt file should look like:

Jane Doe    234-234-2334
John Doe    789-234-4567

There are plenty of things we should do to make this add_entry function better, like

But for now let's move on. We can come back to these if we want.

4.3 Looking up an Entry by Name

Now, let's work on the lookup_name function. We want to read the contents of a phonebook file, and print out the matching entries, if there are any. There are so many ways to do this!

We'll need to open a file in read mode, and then iterate through each line of the file. We can do that with:

def lookup_name(name, phonebook_name):
    filename = '%s.txt' % phonebook_name
    with open(filename, 'r') as f:      # 'r' for read mode
        for line in f:
            print line

We'll first just try printing the line to see what it looks like:

$ python phonebook.py lookup 'Jane Doe' 'ex_phonebook'
Jane Doe    234-234-2334

John Doe    789-234-4567

Hmm. This doesn't give us much information. We can use the function repr to tell us the actual representation of the lines:

def lookup_name(name, phonebook_name):
    filename = '%s.txt' % phonebook_name
    with open(filename, 'r') as f:
        for line in f:
            print repr(line)            # use repr function

After replacing this in our phonebook.py file, let's see what the lookup_name function prints:

$ python phonebook.py lookup 'Jane Doe' 'ex_phonebook'
'Jane Doe\t234-234-2334\n'
'John Doe\t789-234-4567\n'

Okay! That is more illuminating. So for each line, we have a string containing the name, followed by a tab (\t), followed by the number, followed by a newline (\n).

We need to somehow extract the names from each of these lines so we can compare it to the name we're looking up.

Python has some helpful string methods that we can use here. rstrip removes trailing whitespace from a string (\n counts as whitespace), and split breaks up a string into a list based on the characters of your choosing.

So, for each of these lines, let's strip off the whitespace, and split the string into a list on the \t character:

def lookup_name(name, phonebook_name):
    filename = '%s.txt' % phonebook_name
    with open(filename, 'r') as f:
        for line in f:
            print line.strip().split('\t')      # use string methods

This will print the results:

$ python phonebook.py lookup 'Jane Doe' 'ex_phonebook'
['Jane Doe', '234-234-2334']
['John Doe', '789-234-4567']

Great! Now we need to see if the name of the entry is the same as the name we're looking up, and only print the names that match:

def lookup_name(name, phonebook_name):
    filename = '%s.txt' % phonebook_name
    with open(filename, 'r') as f:
        for line in f:
            entry_name, entry_number = line.strip().split('\t')
            if entry_name == name:
                print entry_name, entry_number

Now when we run our program:

$ python phonebook.py lookup 'Jane Doe' 'ex_phonebook'
Jane Doe 234-234-2334

Great! Now it only prints matches. When we write tests for this function, we'll want to return the matches, rather than just printing them, but for now this will do.

4.4 Other Functionality

Now we can create phonebooks, add entries, and look up existing entries by name. How could we remove a name? Update a name? Look up a name by phone number? Try writing these functions on your own.

I'll give you a hint: deleting a line from a file isn't very straightforward. Instead, you'll need to save the lines you want to keep in a list, remove the old contents of the file by opening it in write mode, and then write the lines you saved in the list to the file.

*4.5 More Ideas

If this section hasn't challenged you enough, here are some things you could work on that might be more interesting for you:

5. Writing Tests

This part isn't finished yet! If you get to this point, let me know, and I can do a demo!

5.1 A Basic Test

5.2 A tests.py Script

5.3 Testing Bad Input

Let's say we wanted to test that running our program with the command

$ python phonebook.py create

(which is missing an argument for the phonebook name) causes an error. How would we do that? Well, we could manually type in the command and observe the results. But would we want to do that with every command variation? And for too many arguments as well as too few? That quickly becomes too many things to remember to test manually.

Thankfully, testing frameworks like Python's unittest provide methods that test whether code raises a specific exception.

To make writing tests easier, we can define a custom exception that we'll call ArgumentError. To do this we just need to subclass it from the built-in Exception class:

class ArgumentError(Exception): pass

Subclassing and class inheritance is out of scope for this exercise, but it's really interesting! I definitely recommend reading about it and playing around with it.

Now to raise this exception when an invalid number of arguments is passed:

if command == 'create':
    if args:
        phonebook_name = args.pop(0)
        create_phonebook(phonebook_name)
    else:
        raise ArgumentError("Not enough arguments!")

Now see what happens when you run:

$ python phonebook.py create

You should see an error message like this:

Traceback (most recent call last):
  File "phonebook.py", line 25, in <module>
    raise ArgumentError("Not enough arguments!")
__main__.ArgumentError: Not enough arguments!

Try raiseing our ArgumentError exception any time a user enters too few or too many arguments.

We should also raise an exception if the phone number passed as an argument isn't valid, but we'll do that later when we talk about partial string matching.

Back to top

*5.4 Testing with the unittest Module

Further Reading

Helpful Modules and Resources

The __name__ Variable

What does if __name__ == '__main__': do, and why do I have it in my program?

Suppose someone (maybe you) wanted to import this phonebook.py script into another program. Maybe you want to do this so you could write tests for the program (which we'll cover later), or because you wanted to make a web front-end to the program. You would probably want some code that executes only if the script is executed directly (e.g. by calling python phonebook.py on the command line) that is not executed when you import the script into another Python program.

This is exactly what the if __name__ == '__main__' code block is for. __name__ is a variable whose value will be __main__ if the script was executed directly (e.g. by python phonebook.py) and otherwise will be the name of the module, (e.g. phonebook).

To make this a bit more concrete, let's make some example scripts:

First a.py:

print "__name__: ", __name__

What happens when we execute a.py?

$ python a.py
__name__:  __main__

a.py is fairly straightforward -- we execute the print statement, and we see that the value of the variable __name__ is the string '__main__'. __name__ is a variable that is defined for us automatically in every Python program, and if the program was executed directly, its value is the string '__main__'. Great.

Let's define a second script, b.py:

import a

And execute b.py:

$ python b.py
__name__:  a

b.py is a bit less straightforward. Here we import a, which executes the code that is inside a.py. I'll give a basic overview of what import does in a later section.

So the value of __name__ inside a in this context is the string 'a', rather than '__main__'. This is because the code is being executed by result of an import statement, rather than being executed directly.

How could we execute different code when we import a script than when we execute the script directly? This is where the if __name__ == '__main__' part comes into play. Let's make two more example scripts to explore this.

c.py:

if __name__ == '__main__':
    print "c.py was executed directly"
else:
    print "c.py was imported"

d.py:

import c

And let's execute each of these scripts to see what happens:

$ python c.py
c.py was executed directly

$ python d.py
c.py was imported

Viola! So that's what the if __name__ == '__main__' statement does. We'll be adding some code that we don't want to execute when we import phonebook into this code block.

Back to top

Mapping commands to functions

Having an if/elif statement for each of our commands is kind of ugly. Instead, try creating a dictionary mapping the commands to their corresponding functions, like this:

command_funcs = {
    'create' : create,  # create is a function defined elsewhere
    'add' : add,
    'update' : update,
    'delete' : delete,
    'lookup' : lookup,
    'reverse-lookup' : reverse_lookup
}

Having functions as values in a dictionary might be pretty foreign at first, but it can be really useful, as we're about to see!

We can look up the appropriate function for the given command like this:

func = command_funcs[command]

Here the variable command is the main command you grabbed from the command line arguments. func is the function associated with command in the command_funcs dictionary we created.

To invoke func, we could try:

func()

But we need to figure out how to pass the appropriate arguments to func. func could be any of create, add, update, delete, etc. These functions take varying number of arguments. So how can we pass the correct number of arguments to func without a messy if statement?

This is where the super awesome *args comes in handy. Try reading up on *args. Then try figuring out how to use it to pass func the rest of the arguments that were given on the command line. Try to figure out how to handle when an incorrect number of arguments is passed.

Back to top