Splitting our code

Day 14 Project: Reading List

Welcome to the second end of week project in the 30 Days of Python series! This time we're going to create a program that can store and retrieve information related to a user's reading list.

We tackled something like this earlier on in Day 12, but today we'll be adding more functionality as well as permanently store their data in a file! That way we'll be able to load their data back up even after they close the program.

Down below you'll find a full project brief as well as a walkthrough for our solution. As always, there are many ways to approach a problem like this, so don't worry if your solution is a little different to ours.

The brief

For this project the application needs to have the following functionality:

  • Users should be able to add a book to their reading list by providing a book title, an author's name, and a year of publication.
  • The program should store information about all of these books in a file called books.csv, and this data should be stored in CSV format.
  • Users should be able to retrieve the books in their reading list, and these books should be printed out in a user-friendly format.
  • Users should be able to search for a specific book by providing a book title.
  • Users should be able to select these options from a text menu, and they should be able to perform multiple operations without restarting the program. You can see an example of a working menu in the post on while loops (day 8).

This project is much larger than the ones we've tackled before, so make sure you tackle it one piece at a time.

If you want to challenge yourself, we also have a slightly harder version of the project that you can try, that has additional functionality.

Good luck and I hope you have fun!

Our solution

You can find our walkthrough below. If you want a video walkthrough instead though, check it out!

The first step we need to take for this project is creating a books.csv file in the project work space.

I'm actually going to add a couple of test books to mine, and I suggest you do the same. This is going to make it easier to test aspects of our program, like searching for and printing the books, without having to add books manually each time.

My repl is therefore going to look like this: https://i.ibb.co/GncHdZK/Annotation-2020-03-13-114202.jpg As you can see, I have two books in my books.csv file, and there's an empty line at the end. This is going to make things easier for us later on.

If you want to use the same books as me, here is the data:

1Q84,Haruki Murakami,2009
The Picture of Dorian Gray,Oscar Wilde,1890

Now that we have everything we need, I think the first bit of functionality we should tackle is the menu.

Because our menu has a lot of options, I think we should write our prompt as a multi-line string and store this as a variable. We can then pass this variable to the input function when we want to ask the user to select a menu option. This is going to make our menu a lot cleaner.

menu_prompt = """Please enter one of the following options:

- 'a' to add a book
- 'l' to list the books
- 's' to search for a book
- 'q' to quit

What would you like to do? """

What you write for the prompt is up to you, but we should provide an exhaustive list of options for our users. For now we're not dealing with the harder version of the project, so we're just presenting options for adding, viewing, and searching for books.

Now that we have a prompt, we can create the skeleton of our menu:

menu_prompt = """Please enter one of the following options:

- 'a' to add a book
- 'l' to list the books
- 's' to search for a book
- 'q' to quit

What would you like to do? """

# Get a selection from the user
selected_option = input(menu_prompt)

# Run the loop until the user selected 'q'
while selected_option != "q":
    if selected_option == "a":
        print("You selected 'a'.")
    elif selected_option == "l":
        print("You selected 'l'.")
    elif selected_option == "s":
        print("You selected 's'.")

    # Allow the user to change their selection at the end of each iteration
    selected_option = input(menu_prompt)

For now we just print the selection, which allows us to test that everything is working.

There are a couple of things missing at the moment that I think we should add. For one, we currently don't inform the user if they selected an invalid option. I think this would improve the user experience, because it could be confusing if the user simply gets prompted for a new option without explanation.

The second issue is that we're not very forgiving of user mistakes at the moment. If they enter "A" rather than "a", we don't handle their request, even though the user intent is pretty clear. We also don't allow input with spaces, so "a " is invalid.

We can fix both of these issues by using our trusty strip and lower methods to process the string we get back from the user.

menu_prompt = """Please enter one of the following options:

- 'a' to add a book
- 'l' to list the books
- 's' to search for a book
- 'q' to quit

What would you like to do? """

# Get a selection from the user
selected_option = input(menu_prompt).strip().lower()

# Run the loop until the user selected 'q'
while selected_option != "q":
    if selected_option == "a":
        print("You selected 'a'.")
    elif selected_option == "l":
        print("You selected 'l'.")
    elif selected_option == "s":
        print("You selected 's'.")
    else:
        print(f"Sorry, '{selected_option}' isn't a valid option.")

    # Allow the user to change their selection at the end of each iteration
    selected_option = input(menu_prompt).strip().lower()

Make sure you test the menu, and if everything is working, we can define our function to add books to the reading list.

I'm going to call this function add_book, but feel free to choose another name, as long it adequately describes that the function does.

This function needs to do a few things:

  1. It needs to get some information from the user.
  2. It needs to put that information in the correct format for our file.
  3. It needs to write this information to the end of the file.

Let's start by getting the user input, and replacing the print("You selected 'a'.") line in the menu with a call to add_book.

def add_book():
    title = input("Title: ")
    author = input("Author: ")
    year = input("Year of publication: ")

menu_prompt = """Please enter one of the following options:

- 'a' to add a book
- 'l' to list the books
- 's' to search for a book
- 'q' to quit

What would you like to do? """

# Get a selection from the user
selected_option = input(menu_prompt).strip().lower()

# Run the loop until the user selected 'q'
while selected_option != "q":
    if selected_option == "a":
        add_book()
    elif selected_option == "l":
        print("You selected 'l'.")
    elif selected_option == "s":
        print("You selected 's'.")
    else:
        print(f"Sorry, '{selected_option}' isn't a valid option.")

    # Allow the user to change their selection at the end of each iteration
    selected_option = input(menu_prompt).strip().lower()

This is another good point to test everything is working as intended. Assuming we don't have any problems, we can format the data to match the rest of the books in books.csv, and we can process the user input a little bit.

def add_book():
    title = input("Title: ").strip().title()
    author = input("Author: ").strip().title()
    year = input("Year of publication: ").strip()

    book = f"{title},{author},{year}\n"

Note that I've added a newline character to the end of the line. This ensures that the next book we add is one its own line.

Now that we have our correctly formatted string, we can write it to books.csv. I'm going to use a context manager here, but feel free to open and close the file manually if you're not yet comfortable with that approach.

When opening the file, we're going to do so in append mode ("a"), which means our data will be written at the end of the existing file contents.

def add_book():
    title = input("Title: ").strip().title()
    author = input("Author: ").strip().title()
    year = input("Year of publication: ").strip()

    book = f"{title},{author},{year}\n"

    with open("books.csv", "a") as reading_list:
        reading_list.write(book)

We can shorten this down a little bit by writing the f-string directly inside the write call:

def add_book():
    title = input("Title: ").strip().title()
    author = input("Author: ").strip().title()
    year = input("Year of publication: ").strip()

    with open("books.csv", "a") as reading_list:
        reading_list.write(f"{title},{author},{year}\n")

With that, we should be able to add new books to books.csv. Try it out!

Next, we're going to write a little helper function to get our book data out of the books.csv file. The goal is to convert each line of the file to a dictionary so that we can more easily work with the data in our application. I'm going to call this function get_all_books.

To convert each line to a dictionary, I'm going to use the split method to separate the data based on commas. We can then take the resulting values and create a dictionary with where these values map to appropriate keys.

We can then gather these dictionaries by appending them to a list, which we can then return from the function.

# Helper function for retrieving data from the csv file
def get_all_books():
    books = []

    with open("books.csv", "r") as reading_list:
        for book in reading_list:
            # Extracts the values from the CSV data
            title, author, year = book.strip().split(",")

            # Creates a dictionary from the csv data and adds it to the books list
            books.append({
                "title": title,
                "author": author,
                "year": year
            })

    return books

Next, I think we should tackle printing out our books. I'm going to call this function show_books.

This function is actually going to take an argument, because I want to be able to use it with not only the whole reading list, but also a subset of the books that we fight find through searching.

When the user selects "l" from the menu, we're first going to gather the whole reading list using our new get_all_books function, and we're going to pass the result to show_books to get some nice output.

def add_book():
    title = input("Title: ").strip().title()
    author = input("Author: ").strip().title()
    year = input("Year of publication: ").strip()

    with open("books.csv", "a") as reading_list:
        reading_list.write(f"{title},{author},{year}\n")

# Helper function for retrieving data from the csv file
def get_all_books():
    books = []

    with open("books.csv", "r") as reading_list:
        for book in reading_list:
            # Extracts the values from the CSV data
            title, author, year = book.strip().split(",")

            # Creates a dictionary from the csv data and adds it to the books list
            books.append({
                "title": title,
                "author": author,
                "year": year
            })

    return books

def show_books(books):
    # Adds an empty line before the output
    print()

    for book in books:
        print(f"{book['title']}, by {book['author']} ({book['year']})")

    print()

menu_prompt = """Please enter one of the following options:

- 'a' to add a book
- 'l' to list the books
- 's' to search for a book
- 'q' to quit

What would you like to do? """

# Get a selection from the user
selected_option = input(menu_prompt).strip().lower()

# Run the loop until the user selected 'q'
while selected_option != "q":
    if selected_option == "a":
        add_book()
    elif selected_option == "l":
        # Retrieves the whole reading list for printing
        reading_list = get_all_books()
        show_books(reading_list)
    elif selected_option == "s":
        print("You selected 's'.")
    else:
        print(f"Sorry, '{selected_option}' isn't a valid option.")

    # Allow the user to change their selection at the end of each iteration
    selected_option = input(menu_prompt).strip().lower()

One small issue we have to take care of here is when the user tries to see their reading list when it's empty.

I think a good approach here would be to check the truth value of the reading_list variable, and only call show_books if reading_list has content.

elif selected_option == "l":
    # Retrieves the whole reading list for printing
    reading_list = get_all_books()

    # Check that reading_list contains at least one book
    if reading_list:
        show_books(reading_list)
    else:
        print("Your reading list is empty.")

Once again, we should check that all of this works, and then we can tackle the final problem: the search functionality.

The approach I'm going to tackle for the search functionality is that I'm going to consider a book as matching if the user's search term is a substring of the of the book's title. In other words, I'd going to return 1Q84 if the user enters a partial match, like 1Q. I'm also going to provide multiple results if we get several matches.

The first step is to call our get_all_books function, so that we have a collection to work from when filtering. I'm also going to create an empty list called matching_books. This is where we're going to put the books which match our search term.

def find_books():
    reading_list = get_all_books()
    matching_books = []

Now we need to get our search term from the user. As usual we're going to strip any whitespace from the resulting string, but we're also going to convert the string to lowercase.

When comparing terms, we're also going to convert the book title to lowercase, which is going to allow us to match books where the casing is different to what the user specified.

def find_books():
    reading_list = get_all_books()
    matching_books = []

    search_term = input("Please enter a book title to search for: ").strip().lower()

Now for the actual filtering. Here we're going to use a for loop to iterate over the books. We're then going to append each book to matching_books if they meet a certain condition.

For the condition, we're going to use the in keyword, which will tell us if the search term is contained within the book title.

def find_books():
    reading_list = get_all_books()
    matching_books = []

    search_term = input("Please enter a book title to search for: ").strip().lower()

    for book in reading_list:
        if search_term in book["title"].lower():
            matching_books.append(book)

Now we have a choice to make. Should we call show_books inside the find_books function? Or should we return the values and call the show_books function elsewhere?

In my opinion, we should do the latter. This function we've created is really useful, but there are cases where we might use this function without wanting to print the results. I think it's best we therefore keep the function focused purely on filtering the books.

We can now take a similar approach to the one we did for the "l" branch of our menu.

def add_book():
    title = input("Title: ").strip().title()
    author = input("Author: ").strip().title()
    year = input("Year of publication: ").strip()

    with open("books.csv", "a") as reading_list:
        reading_list.write(f"{title},{author},{year}\n")

def find_books():
    reading_list = get_all_books()
    matching_books = []

    search_term = input("Please enter a book title to search for: ").strip().lower()

    for book in reading_list:
        if search_term in book["title"].lower():
            matching_books.append(book)

    return matching_books

# Helper function for retrieving data from the csv file
def get_all_books():
    books = []

    with open("books.csv", "r") as reading_list:
        for book in reading_list:
            # Extracts the values from the CSV data
            title, author, year = book.strip().split(",")

            # Creates a dictionary from the csv data and adds it to the books list
            books.append({
                "title": title,
                "author": author,
                "year": year
            })

    return books

def show_books(books):
    # Adds an empty line before the output
    print()

    for book in books:
        print(f"{book['title']}, by {book['author']} ({book['year']})")

    print()

menu_prompt = """Please enter one of the following options:

- 'a' to add a book
- 'l' to list the books
- 's' to search for a book
- 'q' to quit

What would you like to do? """

# Get a selection from the user
selected_option = input(menu_prompt).strip().lower()

# Run the loop until the user selected 'q'
while selected_option != "q":
    if selected_option == "a":
        add_book()
    elif selected_option == "l":
        # Retrieves the whole reading list for printing
        reading_list = get_all_books()

        # Check that reading_list contains at least one book
        if reading_list:
            show_books(reading_list)
        else:
            print("Your reading list is empty.")
    elif selected_option == "s":
        matching_books = find_books()

        # Checks that the seach returned at least one book
        if matching_books:
            show_books(matching_books)
        else:
            print("Sorry, we didn't find any books for that search term.")
    else:
        print(f"Sorry, '{selected_option}' isn't a valid option.")

    # Allow the user to change their selection at the end of each iteration
    selected_option = input(menu_prompt).strip().lower()

With this final addition, we're done!

Bonus material

If you want to challenge yourself, you should have a go at the harder version of this project. We have a second, dedicated walkthrough for that version if you get stuck.