Step 09 of 10
Your first project
Plan and build a complete command-line to-do app step by step, combining everything so far, and learn how to keep going on your own.
You now know variables, conditions, loops, functions, collections, files, errors and modules. That's genuinely enough to build useful programs. What's missing is the skill of going from "I want a program that does X" to working code, without a lesson telling you each line. This lesson builds a complete command-line to-do app, and the point isn't the app: it's the process, which is the same for every project you'll ever build.
Step 1: describe what it should do
Before writing code, write down what the program does, from the user's point of view, as concrete examples. Vague ideas ("a to-do app") hide decisions; examples force you to make them.
$ python3 todo.py add "Buy milk"
Added task 1: Buy milk
$ python3 todo.py add "Call the bank"
Added task 2: Call the bank
$ python3 todo.py list
1. [ ] Buy milk
2. [ ] Call the bank
$ python3 todo.py done 1
Completed task 1: Buy milk
$ python3 todo.py list
1. [x] Buy milk
2. [ ] Call the bank
$ python3 todo.py remove 2
Removed task 2: Call the bankAlso write down what's not included in the first version: no due dates, no categories, no undo. Keeping the first version small is how projects get finished. You can add those later.
Step 2: decide how to store the data
Tasks must survive between runs, so they go in a file. Pick a shape for the data in memory first; the file format follows from it.
Each task needs an ID (the number users type), a title, and whether it's done. A list of dictionaries fits, and JSON stores it directly:
{
"next_id": 3,
"tasks": [
{"id": 1, "title": "Buy milk", "done": true},
{"id": 2, "title": "Call the bank", "done": false}
]
}Why store next_id instead of using len(tasks) + 1? Because after removing task 1, len + 1 would hand out ID 2 again, and two tasks would share a number. Thinking through cases like this before coding is far cheaper than discovering them after.
Step 3: sketch the program as functions
Turn the description into a list of functions, each doing one job:
load_data(): read the JSON file, or return an empty structure if it doesn't exist yet.save_data(data): write it back.add_task(data, title),complete_task(data, task_id),remove_task(data, task_id): change the data.print_tasks(data): display it.main(): read the command the user typed and call the right function.
How does the program get the words the user typed after python3 todo.py? From sys.argv, a list of the command-line arguments:
import sys
print(sys.argv)$ python3 args.py add "Buy milk"
['args.py', 'add', 'Buy milk']sys.argv[0] is the script's name, sys.argv[1] is the command, and anything after that is the command's arguments. Quotes in the shell keep "Buy milk" as one argument.
Step 4: build it in small, working steps
Don't write the whole program and then run it for the first time. Build the smallest thing that runs, run it, then add one piece at a time. Start with loading, saving and listing:
# todo.py
import json
import sys
from pathlib import Path
DATA_FILE = Path("todo.json")
def load_data():
if not DATA_FILE.exists():
return {"next_id": 1, "tasks": []}
with open(DATA_FILE, encoding="utf-8") as f:
return json.load(f)
def save_data(data):
with open(DATA_FILE, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2)
def print_tasks(data):
if not data["tasks"]:
print("No tasks yet. Add one with: python3 todo.py add \"Task title\"")
return
for task in data["tasks"]:
mark = "x" if task["done"] else " "
print(f"{task['id']:>3}. [{mark}] {task['title']}")
def main():
data = load_data()
print_tasks(data)
if __name__ == "__main__":
main()Run it: you should see the "No tasks yet" message. Working? Now add the add command:
def add_task(data, title):
task = {"id": data["next_id"], "title": title, "done": False}
data["tasks"].append(task)
data["next_id"] += 1
return task
def main():
data = load_data()
if len(sys.argv) < 2 or sys.argv[1] == "list":
print_tasks(data)
elif sys.argv[1] == "add":
title = " ".join(sys.argv[2:]).strip()
task = add_task(data, title)
save_data(data)
print(f"Added task {task['id']}: {task['title']}")Run add twice, then list, and open todo.json to see what was saved. Every step ends with a program that works, so when something breaks, it's in the few lines you just wrote.
Step 5: finish the features and handle bad input
done and remove both need to find a task by ID, so write that once:
def find_task(data, task_id):
for task in data["tasks"]:
if task["id"] == task_id:
return task
return None
def complete_task(data, task_id):
task = find_task(data, task_id)
if task is not None:
task["done"] = True
return task
def remove_task(data, task_id):
task = find_task(data, task_id)
if task is not None:
data["tasks"].remove(task)
return taskReturning None for "not found" lets the caller decide what to tell the user. Now think about what users will actually type: done with no number, done abc, done 99, add with no title, a command that doesn't exist. Each should get a helpful message, not a traceback:
USAGE = """Usage:
python3 todo.py list
python3 todo.py add "Task title"
python3 todo.py done <id>
python3 todo.py remove <id>"""
def parse_id(args):
"""Return the task ID from the command's arguments, or None if missing or invalid."""
if len(args) != 1 or not args[0].isdigit():
return None
return int(args[0])
def main():
data = load_data()
command = sys.argv[1] if len(sys.argv) > 1 else "list"
args = sys.argv[2:]
if command == "list":
print_tasks(data)
elif command == "add":
title = " ".join(args).strip()
if not title:
print("Please give the task a title.")
return
task = add_task(data, title)
save_data(data)
print(f"Added task {task['id']}: {task['title']}")
elif command in ("done", "remove"):
task_id = parse_id(args)
if task_id is None:
print(f"Please give one task number, for example: python3 todo.py {command} 3")
return
action = complete_task if command == "done" else remove_task
task = action(data, task_id)
if task is None:
print(f"There's no task {task_id}.")
return
save_data(data)
verb = "Completed" if command == "done" else "Removed"
print(f"{verb} task {task['id']}: {task['title']}")
else:
print(f"Unknown command: {command}")
print(USAGE)action = complete_task if ... else remove_task stores a function in a variable and calls it later. Functions are values in Python, like numbers and strings, and this avoids writing two nearly identical branches.
Step 6: test it like a user
Walk through your examples from step 1, then try to break it: invalid IDs, empty titles, removing a task and adding a new one (does the new one get a fresh ID?), deleting todo.json and running list. Every surprise is either a bug to fix or a decision to make. Write down what you tested; in the Python roadmap you'll learn to turn checks like these into automated tests that run in a second.
How to keep going on your own
From here, the way to get better is to build things, slightly beyond what you're comfortable with:
- Pick projects you'd use: a budget tracker for your own bank export, a script that renames your photos by date, a quiz to practise a language.
- Start smaller than you think. Write the examples, cut the features in half, build that.
- Read error messages and documentation first, search second, ask for help third, and when you ask, include the code, the full error and what you expected.
- Read other people's code. The standard library's source is installed on your computer, and small open-source projects are full of patterns worth borrowing.
- Be patient with being stuck. Being stuck is where the learning happens. Everyone, at every level, is stuck often.
Practice
Extend the to-do app with:
- Priorities:
addaccepts an optional--highflag (python3 todo.py add --high "Pay rent"). High-priority tasks are listed first and marked with!. - A clear command:
python3 todo.py clearremoves all completed tasks and reports how many were removed. - A summary line at the end of
list:2 of 5 done.
Keep old todo.json files working: tasks saved before you added priorities don't have a priority key.
Show solution
def add_task(data, title, high=False):
task = {"id": data["next_id"], "title": title, "done": False, "high": high}
data["tasks"].append(task)
data["next_id"] += 1
return task
def print_tasks(data):
tasks = data["tasks"]
if not tasks:
print("No tasks yet. Add one with: python3 todo.py add \"Task title\"")
return
# High priority first; sorted() keeps the original order within each group.
ordered = sorted(tasks, key=lambda t: not t.get("high", False))
for task in ordered:
mark = "x" if task["done"] else " "
flag = "!" if task.get("high", False) else " "
print(f"{task['id']:>3}. [{mark}] {flag} {task['title']}")
done = sum(1 for t in tasks if t["done"])
print(f"\n{done} of {len(tasks)} done")
def clear_completed(data):
before = len(data["tasks"])
data["tasks"] = [t for t in data["tasks"] if not t["done"]]
return before - len(data["tasks"])In main, the add branch becomes:
elif command == "add":
high = "--high" in args
title = " ".join(a for a in args if a != "--high").strip()
if not title:
print("Please give the task a title.")
return
task = add_task(data, title, high=high)
save_data(data)
print(f"Added task {task['id']}: {task['title']}{' (high priority)' if high else ''}")and a new branch handles clear:
elif command == "clear":
removed = clear_completed(data)
save_data(data)
print(f"Removed {removed} completed task{'s' if removed != 1 else ''}.")task.get("high", False) is what keeps old files working: tasks saved before this change have no high key, and .get treats them as normal priority instead of crashing with KeyError. Sorting by not t.get("high") works because False sorts before True, so high-priority tasks (where the key is False) come first.
Key takeaways
- Start every project by writing concrete examples of using it, and cut the first version down to the essentials.
- Choose the data shape before writing code, and think through edge cases such as reused IDs early.
- Sketch the program as small functions, then build it in steps that each end with a working program.
- Treat every user input as possibly wrong, and answer with helpful messages instead of tracebacks.
- Keep improving by building projects you care about, slightly beyond your comfort zone.
Check yourself
Quiz
0/4 correct · 4 left
1.Why does the to-do app store next_id instead of using len(tasks) + 1 for new IDs?
2.A user runs python3 todo.py add "Buy milk". What is sys.argv?
3.What's the main benefit of building the program in small steps that each run?
4.Old todo.json files don't have a "high" key. Which expression reads the priority safely for both old and new tasks?
Finished reading?
Mark it complete to fill in your roadmap.