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.