Skip to content

Objects in the to-do app

Goal: rebuild the to-do app's task as a class, see what gets simpler and what does not, and learn when a class is worth writing and when a dictionary is fine.

Why this matters

Last lesson built Task on its own, in files that did nothing but demonstrate it. This lesson puts it into the real app from Module 9, and that is the honest test of any idea in programming: not whether it looks neat in a demo, but what it does to a program you actually use. Some things will get shorter, some will only get different, and the whole thing will get a little longer. By the end you will be able to look at any small program and say whether a class would earn its place in it.

The plan

The app has three files. It will have four:

File What happens to it
task.py New. The Task class, plus the two functions that turn a task into a line and back.
todo_storage.py Shrinks. Loading and saving stay, but the line conversion moves out.
todo.py Changes throughout, one kind of change: task["title"] becomes task.title.
helpers.py Untouched.

Start by copying your project folder to a new one, so the Module 9 version stays as it was. Then put tasks_broken.txt in the new folder too, because the storage module's self-test uses it.

The task, in its own file

Here is task.py, complete:

task.py
"""The task, as a class, and the function that reads one back from a line of the file.

The file has one task per line: title|YYYY-MM-DD|priority|yes or no
"""

from datetime import date

class Task:
    def __init__(self, title, due, priority):
        self.title = title
        self.due = due
        self.priority = priority
        self.done = False

    def __str__(self):
        if self.done:
            mark = "[x]"
        else:
            mark = "[ ]"
        return f"{mark} {self.title} (due {self.due}, priority {self.priority})"

    def finish(self):
        """Mark the task as done."""
        self.done = True

    def is_overdue(self, today):
        """Return True if the task is not done and its due date has passed."""
        return not self.done and self.due < today

    def describe_due(self, today):
        """Return a short phrase saying when the task is due, or an empty string if it is done."""
        if self.done:
            return ""
        days = (self.due - today).days
        if days == 0:
            return "today"
        elif days == 1:
            return "tomorrow"
        elif days > 1:
            return f"in {days} days"
        elif days == -1:
            return "OVERDUE by 1 day"
        else:
            return f"OVERDUE by {-days} days"

    def to_line(self):
        """Return the task as one line of text: title|YYYY-MM-DD|priority|yes or no."""
        if self.done:
            done = "yes"
        else:
            done = "no"
        return f"{self.title}|{self.due}|{self.priority}|{done}"

def task_from_line(line):
    """Return the Task described by one line of the file. A malformed line causes a ValueError."""
    title, due_text, priority_text, done_text = line.split("|")
    task = Task(title, date.fromisoformat(due_text), int(priority_text))
    if done_text == "yes":
        task.finish()
    return task

if __name__ == "__main__":
    print("Testing task.py")
    original = Task("Book flights, hotel and car", date(2026, 11, 1), 1)
    original.finish()

    line = original.to_line()
    print(f"As a line:  {line}")

    back = task_from_line(line)
    print(f"Back again: {back}")
    print(f"Same as the original: {back.to_line() == line}")

Run it and the self-test at the bottom prints:

Testing task.py
As a line:  Book flights, hotel and car|2026-11-01|1|yes
Back again: [x] Book flights, hotel and car (due 2026-11-01, priority 1)
Same as the original: True

The class is the one from last lesson, with two additions.

The first is one more branch in describe_due(), so that it says OVERDUE by 1 day rather than 1 days. That branch was in the Module 9 version of the function all along, and it came across with it.

The second is to_line(). This is task_to_line() from the last exercise, turned into a method: it gained self, lost its task parameter, and every task. inside it became self.. It belongs in the class for the same reason describe_due() does. It is a thing a task can do, it uses nothing but the task's own attributes, and the call reads naturally: task.to_line().

task_from_line() did not become a method, and the reason is worth a moment. A method is called on an object, and when task_from_line() starts there is no task. Making one is its job. So it stays a plain function, next to the class, in the same file. Python does have a way to attach a function like this to a class, called a class method, and once you are comfortable with classes it is worth looking up @classmethod. For now, a plain function in the same file is clear and correct.

Notice the module's docstring: the file format is now described here, next to the class, instead of in the storage module. Everything that knows what a task looks like, in memory or on disk, is in one file.

The storage module shrinks

With the two converters gone, todo_storage.py is load_tasks(), save_tasks() and a self-test. Here are the two functions:

todo_storage.py, the two functions
def load_tasks(filename=FILENAME):
    """Return the saved tasks as a list, skipping bad lines. Empty list if there is no file."""
    tasks = []
    try:
        lines = read_lines(filename)
    except FileNotFoundError:
        return tasks
    line_number = 0
    for line in lines:
        line_number += 1
        try:
            tasks.append(task_from_line(line))
        except ValueError as error:
            print(f"Skipping line {line_number} of {filename}: {error}")
    return tasks

def save_tasks(tasks, filename=FILENAME):
    """Write every task to the file, replacing what was there."""
    with open(filename, "w") as file:
        for task in tasks:
            file.write(task.to_line() + "\n")

Compare them with the Module 9 versions and you will find one word changed in each: line_to_task(line) became task_from_line(line), and task_to_line(task) became task.to_line(). The import line at the top brings both names in from task.py. Everything else, the missing-file handling, the skipped bad lines, the numbered messages, is exactly as it was. That is the sign of a good split: changing how a task is stored in memory did not disturb the code that reads and writes the file.

The self-test needed a real change, though, and it teaches something:

todo_storage.py, the self-test
if __name__ == "__main__":
    print("Testing todo_storage.py")

    # 1. A round trip: what we save is what we load. Objects are compared by their lines.
    original = [
        Task("Buy milk", date(2026, 9, 21), 2),
        Task("Book flights, hotel and car", date(2026, 11, 1), 1),
    ]
    original[1].finish()
    save_tasks(original, "test_tasks.txt")
    loaded = load_tasks("test_tasks.txt")

    lines_before = []
    for task in original:
        lines_before.append(task.to_line())
    lines_after = []
    for task in loaded:
        lines_after.append(task.to_line())
    print(f"Round trip gives the same tasks back: {lines_before == lines_after}")
    os.remove("test_tasks.txt")

    # 2. A missing file gives an empty list, not a crash.
    print(f"Missing file gives: {load_tasks('no_such_file.txt')}")

    # 3. Bad lines are skipped with a message, good lines survive.
    tasks = load_tasks("tasks_broken.txt")
    print(f"Loaded {len(tasks)} tasks from the broken file.")

The Module 9 test finished with loaded == original, comparing two lists of dictionaries, and Python compared them key by key. Do the same with two lists of Task objects and the answer is False, even when every attribute matches. Python does not know what makes two tasks the same, so unless you tell it, two objects are equal only when they are literally the same object. The test works around this by turning each list into a list of lines with to_line() and comparing those, because two tasks that produce the same line are the same for our purposes. There is a special method for telling Python how to compare your objects, __eq__, and it works the way __str__ does. You can add it later if you want == to work on tasks directly.

Run the module and the test passes as before:

Testing todo_storage.py
Round trip gives the same tasks back: True
Missing file gives: []
Skipping line 2 of tasks_broken.txt: Invalid isoformat string: 'next week'
Skipping line 3 of tasks_broken.txt: invalid literal for int() with base 10: 'three'
Skipping line 4 of tasks_broken.txt: not enough values to unpack (expected 4, got 1)
Loaded 2 tasks from the broken file.

The menu, with dots instead of brackets

todo.py changes in many places but in only one way. Here are the three functions where something interesting happens. First, the table:

todo.py, show_tasks()
def show_tasks(tasks, today):
    """Print the tasks as a table, with a column saying when each one is due."""
    if len(tasks) == 0:
        print("Nothing to do!")
        return
    print(f"{'#':>2}  {'':<4}{'Title':<28}{'Due':<12}{'Pri':>3}  {'When'}")
    number = 1
    for task in tasks:
        if task.done:
            mark = "[x]"
        else:
            mark = "[ ]"
        print(f"{number:>2}  {mark:<4}{task.title:<28}{str(task.due):<12}{task.priority:>3}  {task.describe_due(today)}")
        number += 1

Every task['title'] became task.title, and the When column calls task.describe_due(today). The old describe_due() function is gone from this file, because it lives in the class now. Adding a task:

todo.py, add_task()
def add_task(tasks):
    """Ask for a title, due date and priority, and add the new task to the list."""
    title = ask_for_title("Title: ")
    due = ask_for_date("Due (YYYY-MM-DD): ")
    priority = ask_for_priority("Priority, 1 (urgent) to 3 (whenever): ")
    tasks.append(Task(title, due, priority))
    tasks.sort(key=due_date)
    print(f"Added: {title}")

The dictionary with four keys became Task(title, due, priority). This is the change that matters most. In Module 9, the shape of a task was spelled out in three places: here, in line_to_task(), and in the storage test. If you added a fifth key, you had to find all three. Now the shape is written once, in __init__, and every task everywhere is made by calling it. Finishing a task:

todo.py, finish_task()
def finish_task(tasks, today):
    """Ask which task is done and mark it done."""
    if len(tasks) == 0:
        print("Nothing to finish!")
        return
    show_tasks(tasks, today)
    index = choose_task(tasks, "Which number is done? ")
    if index is None:
        return
    tasks[index].finish()
    print(f"Finished: {tasks[index].title}")

tasks[index]["done"] = True became tasks[index].finish(). The line is no shorter, but it says what it means, and if finishing a task ever comes to involve more than one attribute, there is exactly one place to put it.

edit_task() follows the same pattern, and one point from Module 9 still holds: task = tasks[index] gives the object in the list a second name, so task.title = new_title changes the task the list holds, not a copy. Objects behave like dictionaries in that respect, and unlike numbers and strings.

Here is a session with the finished app, run on Wednesday 23 September 2026:

3 tasks to do, 1 overdue.

1. Show tasks
2. Add a task
3. Edit a task
4. Finish a task
5. Delete a task
6. Quit
Choose: 1
 #      Title                       Due         Pri  When
 1  [x] Call the dentist            2026-09-18    3
 2  [ ] Water the plants            2026-09-22    3  OVERDUE by 1 day
 3  [ ] Buy milk                    2026-09-24    2  tomorrow
 4  [ ] Finish Module 10            2026-10-03    1  in 10 days

1. Show tasks
2. Add a task
3. Edit a task
4. Finish a task
5. Delete a task
6. Quit
Choose: 4
 #      Title                       Due         Pri  When
 1  [x] Call the dentist            2026-09-18    3
 2  [ ] Water the plants            2026-09-22    3  OVERDUE by 1 day
 3  [ ] Buy milk                    2026-09-24    2  tomorrow
 4  [ ] Finish Module 10            2026-10-03    1  in 10 days
Which number is done? 2
Finished: Water the plants

1. Show tasks
2. Add a task
3. Edit a task
4. Finish a task
5. Delete a task
6. Quit
Choose: 6
Goodbye.

From the user's side, nothing changed. That is exactly right. The user never saw the dictionaries, and they never see the objects. The file on disk is the same format too, so a tasks.txt from the Module 9 app loads in this one.

What got simpler, and what did not

Count the lines and the result is not what you might expect:

File Module 9 Now
todo.py 180 169
todo_storage.py 71 66
task.py 72
Total 251 307

The app got longer, by about fifty lines. A class is not a way to write less code. What it changes is where the code lives, and that shows up in what got easier:

  • The shape of a task is in one place. Adding a category, the last exercise of Module 9, meant changing four functions and knowing which four. Now it is one parameter in __init__, one attribute, and the two line functions next to it. todo.py would only change where the category is shown or asked for.
  • A task's behaviour sits with its data. finish(), is_overdue() and describe_due() are inside the class, where anyone reading task.py finds them. In Module 9, describe_due() lived in todo.py for no better reason than that it was written there.
  • Mistakes are caught earlier. A task with a missing value fails the moment it is made, with a TypeError that names what is missing. A dictionary with a missing key failed later, wherever the key happened to be used first.
  • print(task) means something. The app barely uses this yet, and the Try it below fixes that.

And what did not change: the menu loop, the ask_for_ helpers, the sorting, the file handling. They were already fine, because they were already functions with one job each. A class does not replace good functions. It gives a home to the ones that belong together.

When a class is worth it, and when a dictionary is fine

The task earned its class. Here is how to tell, for the next thing you build. A class is worth writing when:

  • Several functions all take the same dictionary. If you have describe_due(task, ...), task_to_line(task), is_overdue(task, ...), that first parameter is telling you they belong together.
  • The thing has behaviour. A task can be finished. An account can be withdrawn from. A die can be rolled. When you find yourself changing a dictionary's values in a particular way from several places, that is a method waiting to be written.
  • You want print() to be readable, or you want two of them to compare sensibly, or you want the object to know its own text form. Those are the double-underscore methods, and only a class can have them.

A dictionary is fine, and a class would be clutter, when:

  • It is a bag of settings that gets read and never operated on, like {"width": 80, "colour": "green"}.
  • The keys are data, not fields, like the word counts in Module 4, where the keys were whatever words the text contained. A class has a fixed set of attributes, and that is the wrong shape for this.
  • It is one-off, built in one function and used in the next line, or read straight from a file and printed.

The countdown events from Module 8 sit right on the line: a name and a date, one function that describes them. The last exercise asks you to rewrite them as a class and then decide, honestly, whether it helped.

Try it

Add a seventh menu option, Show one task, that asks for a number with choose_task() and then simply does print(tasks[index]). It is four lines, because choose_task() already exists and __str__ already knows what a task looks like. Then change __str__ in task.py to show the priority as a word, and watch the new option and the self-test in task.py both change with it, while the table does not, because show_tasks() builds its own row.

Common mistakes

TypeError: 'Task' object is not subscriptable

A task["title"] left over from the dictionary version. Objects do not take square brackets: it is task.title. Search todo.py for [" to find any you missed.

AttributeError: 'dict' object has no attribute 'title'

The opposite: a dictionary got into the list. Either add_task() still builds {...} instead of Task(...), or todo.py is importing the old todo_storage.py from the Module 9 folder. Every file must be the new version, in the same folder.

ModuleNotFoundError: No module named 'task'

task.py is not in the folder Python was started from. Run the app from inside the project folder, as Module 8 explained, and check the file is named exactly task.py.

Round trip gives the same tasks back: False, but the file is right

You compared the two lists of objects with ==. Objects are only equal to themselves unless the class says otherwise. Compare their lines, or their attributes, as the self-test does.

TypeError: Task.to_line() takes 1 positional argument but 2 were given

You called task.to_line(task). The object before the dot is already passed as self, so the parentheses are empty.

The task never becomes done, and there is no error

tasks[index].finish without parentheses. That names the method without calling it, and Python does nothing with the name. It is .finish().

Exercises

  1. Contact. The planning lesson in Module 9 sketched a contacts app with a name, phone, email and birthday per contact. Write the Contact class for it: __init__, a __str__, a to_line() method using the plan's name|phone|email|YYYY-MM-DD format, a contact_from_line() function, and a method has_birthday_soon(today) that is True when the birthday falls in the next 30 days. Make a few contacts, print them, and check a round trip through a line.
  2. Priority names. Add a priority_name() method to Task that returns urgent, normal or whenever for 1, 2 and 3. Use it in __str__, and change the table in show_tasks() to show the word instead of the number.
  3. Inventory. Write an Item class with a name, a quantity and a price, a __str__, a total_value() method, and a take(amount) method that reduces the quantity and refuses, with a message, to go below zero. Make a list of three items, print each with its value, and print the value of the whole stock with a function stock_value(items).
  4. Events, and a verdict. Rewrite the Module 8 countdown with an Event class: a name, a date, a describe(today) method, and an event_from_line() function, reading events.txt and skipping bad lines as before. Then write, in a comment at the top, whether the class made the program better, and why or why not. There is no wrong answer, but there is a lazy one.
Solution 1
from datetime import date

class Contact:
    def __init__(self, name, phone, email, birthday):
        self.name = name
        self.phone = phone
        self.email = email
        self.birthday = birthday

    def __str__(self):
        return f"{self.name} ({self.phone}, {self.email}), born {self.birthday}"

    def to_line(self):
        """Return the contact as one line of text: name|phone|email|YYYY-MM-DD."""
        return f"{self.name}|{self.phone}|{self.email}|{self.birthday}"

    def has_birthday_soon(self, today):
        """Return True if the birthday falls within the next 30 days, counting today."""
        this_year = date(today.year, self.birthday.month, self.birthday.day)
        days = (this_year - today).days
        return days >= 0 and days <= 30

def contact_from_line(line):
    """Return the Contact described by one line of the file. A malformed line causes a ValueError."""
    name, phone, email, birthday_text = line.split("|")
    return Contact(name, phone, email, date.fromisoformat(birthday_text))

today = date(2026, 9, 23)
contacts = [
    Contact("Ada Lovelace", "555-0101", "ada@example.com", date(1815, 12, 10)),
    Contact("Grace Hopper", "555-0102", "grace@example.com", date(1906, 12, 9)),
    Contact("Alan Turing", "555-0103", "alan@example.com", date(1912, 6, 23)),
    Contact("Mary Jackson", "555-0104", "mary@example.com", date(1921, 10, 2)),
]

for contact in contacts:
    print(contact)
    if contact.has_birthday_soon(today):
        print("    birthday soon!")

line = contacts[0].to_line()
print(line)
back = contact_from_line(line)
print(f"Round trip works: {back.to_line() == line}")

has_birthday_soon() builds this year's birthday from the stored one, and then it is the same subtraction as describe_due(). A birthday in early January is missed in late December, which is a fair stretch goal: try next year's date as well when this year's has passed.

Solution 2
"""The Task class from the app, with a priority_name() method added.

In the app, the method goes into task.py and the new table line into show_tasks() in todo.py.
"""

from datetime import date

class Task:
    def __init__(self, title, due, priority):
        self.title = title
        self.due = due
        self.priority = priority
        self.done = False

    def __str__(self):
        if self.done:
            mark = "[x]"
        else:
            mark = "[ ]"
        return f"{mark} {self.title} (due {self.due}, {self.priority_name()})"

    def finish(self):
        """Mark the task as done."""
        self.done = True

    def priority_name(self):
        """Return the priority as a word: urgent, normal or whenever."""
        if self.priority == 1:
            return "urgent"
        elif self.priority == 2:
            return "normal"
        else:
            return "whenever"

    def describe_due(self, today):
        """Return a short phrase saying when the task is due, or an empty string if it is done."""
        if self.done:
            return ""
        days = (self.due - today).days
        if days == 0:
            return "today"
        elif days == 1:
            return "tomorrow"
        elif days > 1:
            return f"in {days} days"
        elif days == -1:
            return "OVERDUE by 1 day"
        else:
            return f"OVERDUE by {-days} days"

def show_tasks(tasks, today):
    """Print the tasks as a table, with the priority as a word."""
    print(f"{'#':>2}  {'Title':<24}{'Due':<12}{'Priority':<10}When")
    number = 1
    for task in tasks:
        print(f"{number:>2}  {task.title:<24}{str(task.due):<12}{task.priority_name():<10}{task.describe_due(today)}")
        number += 1

today = date(2026, 9, 23)
tasks = [
    Task("Buy milk", date(2026, 9, 24), 2),
    Task("Finish Module 10", date(2026, 10, 3), 1),
    Task("Tidy the garage", date(2026, 11, 15), 3),
]
for task in tasks:
    print(task)
print()
show_tasks(tasks, today)

One method, and every place that shows a priority can use it. That is the behaviour sits with its data point from above: nobody needs to remember that 1 means urgent, because the task knows.

Solution 3
class Item:
    def __init__(self, name, quantity, price):
        self.name = name
        self.quantity = quantity
        self.price = price

    def __str__(self):
        return f"{self.name}: {self.quantity} at {self.price} each"

    def total_value(self):
        """Return what the stock of this item is worth."""
        return self.quantity * self.price

    def take(self, amount):
        """Remove some stock. Return True if there was enough, False if not."""
        if amount > self.quantity:
            print(f"Only {self.quantity} {self.name} in stock, cannot take {amount}.")
            return False
        self.quantity -= amount
        return True

def stock_value(items):
    """Return the value of every item added together."""
    total = 0
    for item in items:
        total += item.total_value()
    return total

items = [
    Item("nails", 4, 2.5),
    Item("screws", 6, 0.75),
    Item("hammers", 2, 12.0),
]

for item in items:
    print(f"{item} = {item.total_value()}")
print(f"Total stock value: {stock_value(items)}")

print()
items[2].take(1)
items[2].take(5)
print(items[2])
print(f"Total stock value: {stock_value(items)}")

take() is withdraw() from last lesson in a different costume. stock_value() is a plain function, not a method, because it works on the list of items, and the list is not an Item.

Solution 4
"""The Module 8 countdown, with the event as a class. Run it from the folder that has events.txt."""

from datetime import date

class Event:
    def __init__(self, name, when):
        self.name = name
        self.when = when

    def describe(self, today):
        """Return one line saying how far away the event is."""
        days = (self.when - today).days
        if days == 0:
            return f"{self.name} is today!"
        elif days > 0:
            return f"{self.name} is in {days} days."
        else:
            return f"{self.name} was {-days} days ago."

def event_from_line(line):
    """Return the Event described by one line of the file. A bad date causes a ValueError."""
    text, name = line.split(",")
    return Event(name, date.fromisoformat(text))

def load_events(filename):
    """Return the events in the file as a list, skipping lines with bad dates."""
    events = []
    with open(filename) as file:
        for line in file:
            line = line.strip()
            try:
                events.append(event_from_line(line))
            except ValueError:
                print(f"Skipping '{line}': the date is not in the form YYYY-MM-DD.")
    return events

today = date(2026, 9, 23)
for event in load_events("events.txt"):
    print(event.describe(today))

The verdict, for what it is worth: the class is tidy but it is not earning much. An event has two values and one thing to do, and the Module 8 version handled that with a three-parameter function. The class would start to pay for itself if events grew a second behaviour, such as repeats every year or remind me a week before. If you reached a different verdict with a real reason, that is fine. Being able to give the reason is the skill.

Summary

  • Turn a function into a method when its first parameter is the object and it uses the object's own attributes: task_to_line(task) became task.to_line().
  • A function that makes an object, like task_from_line(), stays a plain function next to the class.
  • Two objects are only == when they are the same object, unless the class defines __eq__. Compare attributes or lines instead.
  • A class does not make a program shorter. It puts the shape of a thing, and what the thing can do, in one place.
  • Write a class when several functions share the same dictionary or the thing has behaviour. Keep a dictionary for settings, for data-as-keys, and for one-off values.

Module 10 complete

You can now read most Python code you will meet, because classes were the last big piece of the language's shape. Everything from here is more of the same: more library modules, more patterns, more practice. The final module points you at what was left out, and at what to build next.

Next: Where to go next