Here's our solution for the day 28 exercise in the 30 Days of Python series. Make sure you try the exercise yourself before checking out the solution!
The exercise for today was to add type annotations to our day 14 project solution. I'm going to be using my solution from the harder version of the project, which you can find below.
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(",".join(book.values()) + "\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()
There's a lot of code here, so let's tackle it one piece at a time, starting from the top with the add_book
function.
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")
There's not a lot to annotate for this function. It doesn't take any arguments, and it implicitly returns, so all we have to add are some annotations for our variables.
In this case, they're all strings.
def add_book():
title: str = input("Title: ").strip().title()
author: str = input("Author: ").strip().title()
year: str = input("Year of publication: ").strip()
with open("books.csv", "a") as reading_list:
reading_list.write(f"{title},{author},{year},Not Read\n")
Next up we have delete_book
.
def delete_book(books, book_to_delete):
books.remove(book_to_delete)
This time we don't have any variables inside the function, but we do have some parameters to annotate.
If we look at our the get_all_books
function, we can see that our books are stored in a list whenever we retrieve them from the file. Each book in that list is represented using a dictionary, where the keys and values are all strings.
Since this would be a fairly complex annotation, it's probably a good idea for us to define some aliases to make our annotations easier to understand. We also have to remember to import the typing
module, since we need access to both the List
and Dict
annotations.
Note
There's a more general type for anything with a dictionary like structure which is called Mapping
. We don't need to worry about it here, but it's something you should be aware of.
from typing import List, Dict
Book = Dict[str, str]
def add_book():
title: str = input("Title: ").strip().title()
author: str = input("Author: ").strip().title()
year: str = 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: List[Book], book_to_delete: Book):
books.remove(book_to_delete)
Now that delete_book
has been properly annotated, we can work on find_books
.
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
find_books
has no parameters, but it does have a return value. Note that we can still use our List[Book]
annotation here, despite the fact that the list can sometimes be empty. An empty list technically doesn't violate this annotation, since it's just a list of zero Book
elements.
def find_books() -> List[Book]:
reading_list: List[Book] = get_all_books()
matching_books: List[Book] = []
search_term: str = 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
Next up is get_all_books
, our helper function for reading the file and formatting the data.
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
The annotations here are very similar to find_books
. We don't have any arguments here, but we do have a variable we need to annotate, as well as a return value. These should be exactly the same, since we're returning that variable.
def get_all_books() -> List[Book]:
books: List[Book] = []
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
mark_book_as_read
is our next function, and it's going to be almost exactly the same as delete_book
.
def mark_book_as_read(books: List[Book], book_to_update: Book):
index: int = books.index(book_to_update)
books[index]['read'] = "Read"
Now we come to something a little more complicated: update_reading_list
.
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(",".join(book.values()) + "\n")
else:
print("Sorry, we didn't find any books matching that title.")
This function is complicated because it's a higher order function. It expects a function as an argument. How do we annotate a function?
For this, we need to use the Callable
type, and I'd advise making a new annotation alias for this, since it will get quite long.
When using Callable
, we should provide a signature for the function we want to pass in. For example, the following would indicate a function that takes in two integers and returns a float.
Callable[[int, int], float]
The argument types are provided in a set of square brackets, and the return value is provided after the arguments without square brackets.
In our case, the function signature for our operations, looks like this:
Callable[[List[Book], Book], Any]
We need to provide the Any
because our functions do not have a return type annotation. Don't forget to import it!
I'm going to assign this new annotation to an alias, Operation
.
Now we can annotate update_reading_list
as follows:
def update_reading_list(operation: Operation):
books: List[Book] = get_all_books()
matching_books: List[Book] = 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(",".join(book.values()) + "\n")
else:
print("Sorry, we didn't find any books matching that title.")
The last function we need to annotate is show_books
, which is very simple. It just accepts a single argument.
def show_books(books: List[Book]):
# Adds an empty line before the output
print()
for book in books:
print(f"{book['title']}, by {book['author']} ({book['year']}) - {book['read']}")
print()
We could also go on to annotate the variables used in the menu, etc. In which case our completed application would look like this:
from typing import Any, Callable, Dict, List
Book = Dict[str, str]
Operation = Callable[[List[Book], Book], Any]
def add_book():
title: str = input("Title: ").strip().title()
author: str = input("Author: ").strip().title()
year: str = 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: List[Book], book_to_delete: Book):
books.remove(book_to_delete)
def find_books() -> List[Book]:
reading_list: List[Book] = get_all_books()
matching_books: List[Book] = []
search_term: str = 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() -> List[Book]:
books: List[Book] = []
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: List[Book], book_to_update: Book):
index: int = books.index(book_to_update)
books[index]['read'] = "Read"
def update_reading_list(operation: Operation):
books: List[Book] = get_all_books()
matching_books: List[Book] = 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(",".join(book.values()) + "\n")
else:
print("Sorry, we didn't find any books matching that title.")
def show_books(books: List[Book]):
# 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: str = """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: List[Book] = 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: List[Book] = 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()
Note that I've omitted an annotation for selected
option, because mypy
will complain about us redefining a variable within a given scope.
If you used a while True
style loop, this isn't an issue you need to worry about.