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:
"""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:
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:
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:
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:
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:
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.pywould only change where the category is shown or asked for. - A task's behaviour sits with its data.
finish(),is_overdue()anddescribe_due()are inside the class, where anyone readingtask.pyfinds them. In Module 9,describe_due()lived intodo.pyfor 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
TypeErrorthat 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¶
- Contact. The planning lesson in Module 9 sketched a contacts app with a name, phone, email and birthday per
contact. Write the
Contactclass for it:__init__, a__str__, ato_line()method using the plan'sname|phone|email|YYYY-MM-DDformat, acontact_from_line()function, and a methodhas_birthday_soon(today)that isTruewhen the birthday falls in the next 30 days. Make a few contacts, print them, and check a round trip through a line. - Priority names. Add a
priority_name()method toTaskthat returnsurgent,normalorwheneverfor 1, 2 and 3. Use it in__str__, and change the table inshow_tasks()to show the word instead of the number. - Inventory. Write an
Itemclass with a name, a quantity and a price, a__str__, atotal_value()method, and atake(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 functionstock_value(items). - Events, and a verdict. Rewrite the Module 8 countdown with an
Eventclass: a name, a date, adescribe(today)method, and anevent_from_line()function, readingevents.txtand 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)becametask.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.