Reading and saving the data
A reading log that forgets every book when the program ends isn't much use. The books should be saved in a file, and read back the next time the program starts. Reading and writing the file is a job of its own, so it gets a class of its own: the third part of the application, next to the logic and the user interface.
Each line of the file holds one book: the title, the number of pages in the book, and then the pages of each reading session, all separated by | characters:
Dune|412|50|38
Emma|474
The title and the length are always there, but the number of sessions varies. Emma hasn't been opened yet, so its line has none.
Taking the rest of a list
When a line is split at the | characters, the first two parts are the title and the length, and whatever comes after them is the sessions, however many there are. A starred assignment takes a list apart in just this way: each variable without a star gets one item, and the one with a star gets a list of all the items that are left over.
>>> title, length, *sessions = "Dune|412|50|38".split("|")
>>> title
'Dune'
>>> length
'412'
>>> sessions
['50', '38']
>>> title, length, *sessions = "Emma|474".split("|")
>>> sessions
[]
>>> *rest, last = [7, 8, 9]
>>> rest
[7, 8]
>>> last
9The parts of a split line are strings, so the numbers still need int. When nothing is left over, the starred variable gets an empty list. And the star doesn't have to be on the last variable, as the last example shows: whichever variable has it gets what the others leave.
A class for the file
BookFile gets the file's name in its constructor. load reads the file and returns a list of Book objects, and save writes a list of books back, one line for each. The first time the program runs there's no file yet, so load catches the FileNotFoundError and returns an empty list: an empty log is the right start. Before the class goes into the app, it's tried out on its own:
Dune|412|50|38
Emma|474class Book:
def __init__(self, title, length):
self.__title = title
self.__length = length
self.__sessions = []
def title(self):
return self.__title
def length(self):
return self.__length
def sessions(self):
return self.__sessions
def read(self, pages):
self.__sessions.append(pages)
def pages_read(self):
return sum(self.__sessions)
def pages_left(self):
return self.__length - self.pages_read()
def __str__(self):
return f"{self.__title}: {self.pages_read()} of {self.__length} pages read"
class BookFile:
def __init__(self, filename):
self.__filename = filename
def load(self):
books = []
try:
with open(self.__filename) as file:
for line in file:
title, length, *sessions = line.strip().split("|")
book = Book(title, int(length))
for pages in sessions:
book.read(int(pages))
books.append(book)
except FileNotFoundError:
pass
return books
def save(self, books):
with open(self.__filename, "w") as file:
for book in books:
line = f"{book.title()}|{book.length()}"
for pages in book.sessions():
line += f"|{pages}"
file.write(line + "\n")
book_file = BookFile("books.txt")
books = book_file.load()
for book in books:
print(book)
books[1].read(25)
book_file.save(books)
print(BookFile("nothing-here.txt").load())
with open("books.txt") as file:
for line in file:
print(line.strip())
Dune: 88 of 412 pages read
Emma: 0 of 474 pages read
[]
Dune|412|50|38
Emma|474|25load turns each line back into a Book: it creates the book with its title and length, and then lets it record each session. save does the opposite. It builds each line from the book's own methods, the title and the length first and then a | and the pages for each session.
Into the application
The app reads the file once, when it starts. From then on, the books live in its objects, and every command works with them, not with the file. When the user quits, save writes all the books back, and the new contents replace the old. ReadingLog gets two small methods for this: add(book) puts a ready-made Book in the log, and books() returns a list of all of them.
def add(self, book):
self.__books[book.title()] = book
def books(self):
return list(self.__books.values())
In ReadingApp, the constructor loads the books and the command quit saves them:
class ReadingApp:
def __init__(self, storage):
self.__storage = storage
self.__log = ReadingLog()
for book in self.__storage.load():
self.__log.add(book)
def save(self):
self.__storage.save(self.__log.books())
# show_commands, add_book, read and left as before. In run,
# the command quit calls self.save() before the break.
if __name__ == "__main__":
app = ReadingApp(BookFile("books.txt"))
app.run()
Giving the app its storage
Notice that ReadingApp doesn't create its BookFile: it gets one as the parameter storage. The app only ever calls storage.load() and storage.save(...), so any object with those two methods will do. That makes it easy, for example, to try out the app without touching the real file at all:
class TestStorage:
def load(self):
return [Book("Test book", 100)]
def save(self, books):
print("would save", len(books), "books")
app = ReadingApp(TestStorage())
Giving an object the things it depends on from outside, instead of letting it create them itself, is called dependency injection. Which file the books are kept in, or whether there's a file at all, is decided where the app is created, and ReadingApp stays the same.