Splitting our code

Day 14 Project: Reading List (Hard)

Python Guru planting a flag atop of a mountain made of abstract cubes, representing his progress in learning to code. With a light green background to match the day 14 image.

Welcome to this second post on the day 14 reading list project, where we're going to be tackling the harder version of the project.

I'd recommend having a go at the regular version of the project if you haven't done so already, as this version shares much of the functionality. You will, however, find a full brief below, along with a complete model solution.

The brief

For this harder version of the 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, a year of publication, and whether or not the book has been read.
  • 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 mark a book as read by entering a book title. If there are multiple books with the same title, you can just mark the first matching book as read.
  • Users should be able to delete books from their reading list by providing the book title for the book they want to delete. Once again, you can just delete the first matching book.
  • 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).# Day 14 Project: Reading List (Hard Version)

Welcome to this second post on the day 14 reading list project, where we're going to be tackling the harder version of the project.

I'd recommend having a go at the regular version of the project if you haven't done so already, as this version shares much of the functionality. You will, however, find a full brief below, along with a complete model solution.

Happy coding!

Our solution

Just like with the regular version of this project, the first step is creating a books.csv file in the project work space.

I've added a couple of example books to the books.csv file so we can more easily test our application, as you can see in the image below: https://i.ibb.co/hsnqdcf/Annotation-2020-03-13-172850.jpg Don't forget this empty line at the end, as we want any new additions to be placed on their own line.

Feel free to use different books to me, but here are my example books if you want to use the same ones:

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

Now that we have our books.csv file, and we've populated with some example books, let's get to work on the menu.

Because the menu is going to be pretty long, I'd strongly recommend you store the prompt in a variable. It's going to really clutter up the menu otherwise.

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

- 'a' to add a book
- 'd' to delete a book
- 'l' to list the books
- 'r' to mark a book as read
- 's' to search for a book
- 'q' to quit

What would you like to do? """

The menu itself is very similar to the ones we've seen before. For now we're just going to check that things work by printing out the option the user selected. If they enter an invalid option, we're going to catch this in an else clause, and print a message informing them of the issue.

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

- 'a' to add a book
- 'd' to delete a book
- 'l' to list the books
- 'r' to mark a book as read
- '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 == "d":
        print("You selected 'd'.")
    elif selected_option == "l":
        print("You selected 'l'.")
    elif selected_option == "r":
        print("You selected 'r'.")
    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)

One thing we can do to improve this menu is to process the user selection to ensure it's stripped of whitespace, and that it's in the correct case. This additional flexibility is really going to improve the user experience.

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

- 'a' to add a book
- 'd' to delete a book
- 'l' to list the books
- 'r' to mark a book as read
- '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 == "d":
        print("You selected 'd'.")
    elif selected_option == "l":
        print("You selected 'l'.")
    elif selected_option == "r":
        print("You selected 'r'.")
    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()

It's a good idea at this point to test the menu to make sure everything is working. Once you're satisfied, we can start adding functionality, starting with adding books to the reading list.

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.

I'm going to assume that books the user is adding to the reading list have not been read, so I'm only going to grab the title, author, and year of publication from the user.

I'm then going to open the file in append mode ("a") using a context manager, and I'm going to format the string we want to write with an f-string.

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},Not Read\n")

As you can see, I've also done a bit of processing for the user input, just to ensure it's in the right format when we retrieve it later.

Now that we have our function written, we can call it in the menu and try it out.

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},Not Read\n")

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

- 'a' to add a book
- 'd' to delete a book
- 'l' to list the books
- 'r' to mark a book as read
- '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 == "d":
        print("You selected 'd'.")
    elif selected_option == "l":
        print("You selected 'l'.")
    elif selected_option == "r":
        print("You selected 'r'.")
    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()

We should now be able to add books to the books.csv file. Try it out a few times and take a look at the file to see if everything was added correctly. If you don't see any problems, we can move onto the next function.

The next function 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},Not Read\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, read_status = 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,
                "read": read_status
            })

    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']}) - {book['read']}")

    print()

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

- 'a' to add a book
- 'd' to delete a book
- 'l' to list the books
- 'r' to mark a book as read
- '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 == "d":
        print("You selected 'd'.")
    elif selected_option == "l":
        # Retrieves the whole reading list for printing
        reading_list = get_all_books()
        show_books(reading_list)
    elif selected_option == "r":
        print("You selected 'r'.")
    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 search functionality.

For my implementation of the search functionality, 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. If a search term matches several books, I'm going to provide all of these matching books.

One thing this function won't do is print out the matching books: we're just going to return the books and have our show_books function take care of the output. This is going to allow us to reuse this search functionality when deleting books and marking them as read.

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: ").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: ").strip().lower()

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

Now that we have our list matching books, we can return it.

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

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

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

    return matching_books

Inside the menu, we're going to take a similar approach to what we did in the "l" branch of the conditional statement. First we're going to call find_books to get the matching books. If we have matching books, we're going to pass the list to show_books, otherwise we're going to print a message saying we didn't find anything.

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},Not Read\n")

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

    search_term = input("Please enter a book title: ").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, read_status = 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,
                "read": read_status
            })

    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']}) - {book['read']}")

    print()

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

- 'a' to add a book
- 'd' to delete a book
- 'l' to list the books
- 'r' to mark a book as read
- '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 == "d":
        print("You selected 'd'.")
    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 == "r":
        print("You selected 'r'.")
    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()

Next up, let's tackle deleting books. The way I'm going to handle this is as follows:

  1. I'm going to grab the whole reading list using get_all_books.

  2. I'm going to use the find_books function to get a list of books matching the title of the book the user wants to delete.

  3. I'm going to check that this search returned at least one value, and if it did, I'm going to remove the first value in this collection from the reading list. It it didn't, I'm going to let the user know we didn't find any matching books.

  4. I'm going to open the books.csv file in write mode, and I'm going to add all of the remaining books back to the file.

def delete_book():
    books = get_all_books()
    matching_books = find_books()

    if matching_books:
        books.remove(matching_books[0])

        with open("books.csv", "w") as reading_list:
            for book in books:
                reading_list.write(f"{book['title']},{book['author']},{book['year']},{book['read']}\n")
    else:
        print("Sorry, we didn't find any books matching that title.")

Now we just need to call delete_book in our menu in the "d" branch of the conditional statement.

We're going to use a very similar approach for updating the read status of the books. The main difference is that we're going to find out the index of the matching book, and then we're going to update its value.

def mark_book_as_read():
    books = get_all_books()
    matching_books = find_books()

    if matching_books:
        index = books.index(matching_books[0])
        books[index]['read'] = "Read"

        with open("books.csv", "w") as reading_list:
            for book in books:
                reading_list.write(f"{book['title']},{book['author']},{book['year']},{book['read']}\\n")
    else:
        print("Sorry, we didn't find any books matching that title.")

An alternative approach would be to check for the matching book while iterating over the books list. When we find the book we want to update, we could write a different string to the file.

The benefit of the approach we've taken above, however, is that we now have two basically identical functions. This allows us to do a little refactoring to cut down on duplication.

Instead of these two functions, let's create a function called update_reading_list. This function is going to be a little special, because we're going to pass in another function as an argument.

We're then going to trim down delete_book and mark_book_as_read to take care of one very small operation.

def delete_book(books, book_to_delete):
    books.remove(book_to_delete)

def mark_book_as_read(books, book_to_update):
    index = books.index(book_to_update)
    books[index]['read'] = "Read"

def update_reading_list(operation):
    books = get_all_books()
    matching_books = find_books()

    if matching_books:
        operation(books, matching_books[0])

        with open("books.csv", "w") as reading_list:
            for book in books:
                reading_list.write(f"{book['title']},{book['author']},{book['year']},{book['read']}\\n")
    else:
        print("Sorry, we didn't find any books matching that title.")

Don't worry if you feel a little lost after this step. This is getting into some more complicated techniques, and it's totally fine if you want to stick with the version that has some duplicate code.

With that we've implemented all of the required functionality, and our finished program looks like this:

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},Not Read\n")

def delete_book(books, book_to_delete):
    books.remove(book_to_delete)

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

    search_term = input("Please enter a book title: ").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, read_status = 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,
                "read": read_status
            })

    return books

def mark_book_as_read(books, book_to_update):
    index = books.index(book_to_update)
    books[index]['read'] = "Read"

def update_reading_list(operation):
    books = get_all_books()
    matching_books = find_books()

    if matching_books:
        operation(books, matching_books[0])

        with open("books.csv", "w") as reading_list:
            for book in books:
                reading_list.write(f"{book['title']},{book['author']},{book['year']},{book['read']}\n")
    else:
        print("Sorry, we didn't find any books matching that title.")

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']}) - {book['read']}")

    print()

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

- 'a' to add a book
- 'd' to delete a book
- 'l' to list the books
- 'r' to mark a book as read
- '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 == "d":
        update_reading_list(delete_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 == "r":
        update_reading_list(mark_book_as_read)
    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()

If you want to keep working on this, there's plenty more than we can do to improve it, so knock yourself out!

Additional resources

You can find additional information about the index method used in this solution in the official documentation.

There's also a new piece of syntax in Python 3.8 called an assignment expression. You can read about this on our blog. There are number of places in this solution where we can use an assignment expression, so you may want to have a go at that yourself.