Python Arena
Step 1 of 4

Start with the application logic

Up to now, your programs have been a class or two, or a handful of functions. Real applications grow to hundreds or thousands of lines, and nobody can work on that much code at once. A big program stays manageable when you can work on one part of it and trust the rest to do its own job.

Think of a restaurant. The cook doesn't take orders and the waiter doesn't cook, and when the soup is too salty, everyone knows whose job it is to fix it. A program can be organized the same way. Dividing it into parts that each look after one area of the work is called separation of concerns. Functions are one tool for it and classes are another. For classes, the idea has a name of its own, the single responsibility principle: give each class one kind of work, so that there's only one kind of reason to ever change it.

Most applications have at least three kinds of work to divide:

  • the application logic: the data the program is about, and the rules for handling it
  • the user interface: everything the user sees and types
  • storage: keeping the data in a file between runs of the program

Each of them gets a class of its own. This module builds one application this way, a step at a time: a reading log that keeps track of the books you're reading and how far you've got in each one.

Start with the logic

You can start anywhere, but the logic is a good first part. It's what the whole program is for, and you can try it out before there's any user interface at all. The reading log needs to know each book's length and how many pages of it have been read. Two dictionaries, both with the title as the key, can hold that:

class ReadingLog:
    def __init__(self):
        self.__lengths = {}
        self.__pages_read = {}

    def add_book(self, title, length):
        self.__lengths[title] = length
        self.__pages_read[title] = 0

    def read(self, title, pages):
        self.__pages_read[title] += pages

    def pages_left(self, title):
        return self.__lengths[title] - self.__pages_read[title]


log = ReadingLog()
log.add_book("Dune", 412)
log.add_book("Emma", 474)
log.read("Dune", 50)
log.read("Dune", 38)
print(log.pages_left("Dune"))
print(log.pages_left("Emma"))
Output
324
474

The lines after the class are there only to try it out.

Try each method right away

Run a few lines like these as soon as you've written a method. It takes a minute, and it often shows you a case you didn't think of while planning. Here, trying read with a book that was never added shows that the log has no answer for it:

class ReadingLog:
    def __init__(self):
        self.__lengths = {}
        self.__pages_read = {}

    def add_book(self, title, length):
        self.__lengths[title] = length
        self.__pages_read[title] = 0

    def read(self, title, pages):
        self.__pages_read[title] += pages


log = ReadingLog()
log.add_book("Dune", 412)
log.read("Ulysses", 20)
Python stops with
Traceback (most recent call last):
  File "main.py", line 16, in <module>
    log.read("Ulysses", 20)
    ~~~~~~~~^^^^^^^^^^^^^^^
  File "main.py", line 11, in read
    self.__pages_read[title] += pages
    ~~~~~~~~~~~~~~~~~^^^^^^^
KeyError: 'Ulysses'

self.__pages_read[title] looks for a key that isn't there. Reading a book that isn't in the log is a mistake, but sooner or later a user will make it, so the code that calls read needs a way to check first. That's one more small method for ReadingLog:

def has_book(self, title):
    return title in self.__lengths

When the class works, delete the test lines, or move them under if __name__ == "__main__":.

The logic returns its answers

ReadingLog never prints and never calls input. It gets what it needs as parameters and returns its answers, and how they're shown is up to the code that called it. That's why the same class could serve a text interface today and a web page or a test program later, without a change.

Your task

Challenge 1 of 3

Add the method percent(title) to ReadingLog. It returns how much of the book has been read, in percent, as a whole number rounded down. For example,

log = ReadingLog()
log.add_book("Dune", 412)
log.add_book("Emma", 474)
log.read("Dune", 103)
print(log.percent("Dune"))
print(log.percent("Emma"))
log.read("Emma", 474)
print(log.percent("Emma"))

should print:

25
0
100

Challenge 2

Write the logic class of a scoreboard for a quiz night. A Scoreboard keeps each team's total points, and has these methods:

  • add_points(team, points) adds points to the team's total. A team that isn't on the board yet starts from 0.
  • points(team) returns the team's total, and 0 for a team that isn't on the board.
  • leader() returns the name of the team with the most points. If two teams have the most, it's the one that came on the board first. When the board is empty, it returns None.

For example,

board = Scoreboard()
print(board.leader())
board.add_points("Owls", 5)
board.add_points("Foxes", 7)
board.add_points("Owls", 4)
print(board.points("Owls"))
print(board.points("Bears"))
print(board.leader())

should print:

None
9
0
Owls

Challenge 3

The method undone of TodoList prints the tasks that aren't done yet. But showing things to the user is the user interface's job, and a logic class that prints can't be used anywhere else. Change undone so that it returns the tasks that aren't done, as a list in the order they were added, and prints nothing. For example,

todo = TodoList()
todo.add("wash the car")
todo.add("call grandma")
todo.add("water the plants")
todo.mark_done("call grandma")
print(todo.undone())

should print:

['wash the car', 'water the plants']