Everything in the Beginner section — strings, dicts, loops, files, try/except — is meant to add up to something you built yourself. This is that something: a to-do list that runs in the terminal, remembers your tasks between runs, and comes in under sixty lines. I'll show the pieces, then the whole script.
Four commands: add, list, done <number>, quit. Each task is one dict — a title, a done flag, and a number for the done command to aim at:
{"id": 1, "title": "water the plants", "done": False}
The whole to-do list is just a list of those. No class needed at this size — and a bonus: a list of dicts is exactly what JSON stores natively, so saving is nearly free.
Pathlib handles the file, json does the conversion:
import json
from pathlib import Path
STORE = Path("todo.json")
def load_tasks():
if STORE.exists():
return json.loads(STORE.read_text(encoding="utf-8"))
return []
def save_tasks(tasks):
STORE.write_text(json.dumps(tasks, indent=2), encoding="utf-8")
First run there's no file, so load_tasks() returns an empty list — no exception handling needed when a plain check will do. And json.dumps(indent=2) writes the file so a human can read it, which has saved me twice.
This is the complete program — save it as todo.py and run it with python todo.py:
import json
from pathlib import Path
STORE = Path("todo.json")
def load_tasks():
if STORE.exists():
return json.loads(STORE.read_text(encoding="utf-8"))
return []
def save_tasks(tasks):
STORE.write_text(json.dumps(tasks, indent=2), encoding="utf-8")
def next_id(tasks):
if not tasks:
return 1
return max(t["id"] for t in tasks) + 1
def list_tasks(tasks):
if not tasks:
print("nothing yet - try 'add'")
return
for t in tasks:
mark = "x" if t["done"] else " "
print(f"{t['id']}. [{mark}] {t['title']}")
def main():
tasks = load_tasks()
print("commands: add, list, done <id>, quit")
while True:
line = input("> ").strip()
if line == "quit":
break
if line == "add":
title = input("what needs doing? ").strip()
if title:
tasks.append({"id": next_id(tasks), "title": title, "done": False})
save_tasks(tasks)
elif line == "list":
list_tasks(tasks)
elif line.startswith("done"):
number = line[4:].strip()
try:
task_id = int(number)
except ValueError:
print("usage: done <number>")
continue
for t in tasks:
if t["id"] == task_id:
t["done"] = True
save_tasks(tasks)
break
else:
print(f"no task number {task_id}")
elif line:
print("unknown command - try add, list, done <id>, quit")
save_tasks(tasks)
print("saved. bye")
main()
A few things worth pointing at. The while True loop is the whole app: read a line, dispatch, repeat. The try/except around int() is there because done fff is a thing people type. And the else hanging off the for loop runs when the loop finished without finding the id — for/else looks strange, but "loop through it, and run this if we never broke out" is exactly the meaning I want there.
commands: add, list, done <id>, quit
> add
what needs doing? water the plants
> add
what needs doing? renew library card
> list
1. [ ] water the plants
2. [ ] renew library card
> done 1
> list
1. [x] water the plants
2. [ ] renew library card
> done 7
no task number 7
> quit
saved. bye
On disk, todo.json looks like what you'd hope:
[
{
"id": 1,
"title": "water the plants",
"done": true
},
{
"id": 2,
"title": "renew library card",
"done": false
}
]
Quit, run it again, and the list comes back loaded — the script's whole memory is that one file.
What I'd add next: a delete command, due dates, showing pending items first. But the current version already clears the bar I actually care about — it does its job, it survives being closed, and every line in it is one I understand. For a personal tool, that's the whole specification.