Lesson 25 of 25

Final Project: Build a Task Manager

What You Are Building, and Why in This Shape

The project is a command-line task manager: add tasks, list them, mark them done, delete them, and have all of it still there next time you run the program. It is small enough to finish in an evening and complete enough to put on GitHub, and it uses almost everything in this course — classes, files, JSON, exceptions, comprehensions and a menu loop.

The interesting decision is not what it does but how it is arranged. The program is split into three layers, each with one job. The Task is a single record and knows how to convert itself to and from a dictionary. The store knows only how to read and write the file. The manager holds the rules — what a valid task is, how ids are assigned, what happens on delete — and knows nothing about files or about the user.

The command loop sits on top and is the only part that talks to a human. It reads input, calls the manager, catches errors and prints messages. Nothing below it ever calls print() or input().

That split is worth the small extra effort for three reasons. The manager can be tested without touching a file or a keyboard, which the last section demonstrates. Swapping JSON for a database means rewriting one class. And when something breaks, the layer boundaries tell you where to look — a formatting problem is in the loop, a lost-data problem is in the store, a wrong-answer problem is in the manager.

Build it in the order below, running it after each step. Do not type the whole thing and then hunt for the reason it will not start.

  • Add a task with a title and an optional description
  • List all tasks, or only the pending ones
  • Mark a task done by its id, and delete one by its id
  • Everything persists in a JSON file between runs
  • Bad input produces a clear message, never a traceback
Example
# task_manager/
# |- README.md
# |- requirements.txt        (pytest, for the tests)
# |- .gitignore             (__pycache__/, .venv/, tasks.json)
# |- tasks/
# |  |- __init__.py
# |  |- model.py            Task            — one record
# |  |- store.py            TaskStore       — reading and writing the file
# |  |- manager.py          TaskManager     — the rules
# |  |- cli.py              main()          — the only layer that talks to a human
# |- tests/
#    |- test_manager.py

# Who is allowed to call whom:
#
#   cli.py      -> manager.py -> store.py -> the JSON file
#                       |
#                       +-----> model.py
#
# Nothing below cli.py ever calls print() or input().

# Run it:
#   python -m venv .venv
#   source .venv/bin/activate        (.venv\Scripts\activate on Windows)
#   python -m tasks.cli
Notes
  • If you would rather keep it to one file while learning, that is fine — put the four classes in task_manager.py in the order they appear here. The layering still matters; it is about which code knows about what, not about how many files there are.

The Task: One Record With Its Own Behaviour

A task could be a plain dictionary, and for a throwaway script it would do. A class earns its place here for two reasons: every task is guaranteed to have the same fields, and the conversion to and from JSON lives with the data rather than being repeated wherever it is needed.

@dataclass writes the boilerplate. Declaring the five fields gives you an __init__ that takes them in order, a __repr__ that shows them, and an __eq__ that compares them — which is what makes the tests at the end short.

Note the created_at default. Writing created_at: str = datetime.now().isoformat() would stamp every task with the moment the module was imported, which is the frozen-default trap from Lesson 13. field(default_factory=...) calls the function once per task, which is what you actually want.

to_dict and from_dict are the boundary with the file. JSON has no idea what a Task is, so something has to translate, and keeping that translation in one place means a change to the fields is a change to one file. from_dict is a classmethod — an alternative constructor, exactly as in Lesson 18 — and it uses .get() with defaults so that a file written by an older version of the program still loads.

__str__ decides how a task looks to a user. Putting it here rather than in the command loop means every part of the program displays a task the same way, and changing the format is one edit.

Example
# tasks/model.py
from dataclasses import dataclass, field, asdict
from datetime import datetime


def _now():
    return datetime.now().isoformat(timespec="seconds")


@dataclass
class Task:
    id: int
    title: str
    description: str = ""
    done: bool = False
    created_at: str = field(default_factory=_now)   # per task, not per import

    def to_dict(self):
        """Return a plain dictionary suitable for json.dump()."""
        return asdict(self)

    @classmethod
    def from_dict(cls, data):
        """Build a Task from a dictionary read out of the JSON file."""
        return cls(
            id=int(data["id"]),
            title=data["title"],
            description=data.get("description", ""),   # tolerate older files
            done=bool(data.get("done", False)),
            created_at=data.get("created_at", _now()),
        )

    def __str__(self):
        mark = "x" if self.done else " "
        return f"[{mark}] {self.id:>3}  {self.title}"


if __name__ == "__main__":
    t = Task(id=1, title="Revise DSA", description="trees and graphs")
    print(t)                      # [ ]   1  Revise DSA
    print(t.to_dict())
    print(Task.from_dict(t.to_dict()) == t)   # True — __eq__ came free
Notes
  • datetime.now().isoformat(timespec="seconds") produces text such as 2026-08-02T14:30:00. It is readable, it sorts correctly as a plain string, and JSON can store it — which a datetime object cannot.

Storage That Does Not Lose Your Data

The store has one job: turn a list of tasks into a file and back. Keeping it separate is what makes the rest of the program testable, and it is also where the failures nobody thinks about are handled.

There are three of them. The file may not exist yet, on the very first run — that is normal, and the right answer is an empty list, not a crash. The file may exist and be unreadable, because a previous run was interrupted halfway through writing. And the write itself may fail partway, which is the one that quietly destroys everything.

That last case deserves explaining. Opening a file with "w" empties it immediately. If the program is stopped between that moment and the end of the write — a crash, a full disk, Ctrl+C — you are left with a truncated or empty file and the old data is gone. Writing to a temporary file first and then replacing the real one avoids it: the rename is a single operation, so at every instant the file on disk is either the complete old version or the complete new one.

For the unreadable file, do not delete it. Rename it out of the way and start fresh, so the user can still recover what was in it. Losing someone's data silently is the worst thing a small program can do, and avoiding it costs two lines.

Note the details carried over from Lesson 16: encoding="utf-8" everywhere so a task written in Hindi or Odia survives, ensure_ascii=False so it stays readable in the file, and indent=2 so a human can open it and see what happened.

Example
# tasks/store.py
import json
from pathlib import Path

from tasks.model import Task


class TaskStore:
    """Reads and writes the list of tasks. Knows nothing about the rules."""

    def __init__(self, path="tasks.json"):
        self.path = Path(path)

    def load(self):
        """Return the saved tasks, or an empty list if there are none."""
        if not self.path.exists():
            return []                       # first run — not an error

        try:
            raw = json.loads(self.path.read_text(encoding="utf-8"))
            return [Task.from_dict(d) for d in raw]
        except (ValueError, KeyError, TypeError):
            # Unreadable file: keep it, do not delete it
            backup = self.path.with_name(self.path.name + ".corrupt")
            self.path.replace(backup)
            raise IOError(
                f"{self.path} could not be read; a copy is at {backup}"
            )

    def save(self, tasks):
        """Write all tasks, without risking the existing file."""
        text = json.dumps(
            [t.to_dict() for t in tasks],
            indent=2,
            ensure_ascii=False,
        )
        tmp = self.path.with_name(self.path.name + ".tmp")
        tmp.write_text(text, encoding="utf-8")
        tmp.replace(self.path)              # one step: old file or new file


if __name__ == "__main__":
    store = TaskStore("demo.json")
    store.save([Task(1, "Revise DSA"), Task(2, "Submit lab record", done=True)])
    for t in store.load():
        print(t)
  • Missing file on first run is normal — return an empty list
  • Unreadable file: rename it aside and report, never delete it
  • Write to a temporary file and replace() the real one — no half-written state
  • encoding="utf-8" on every read and write
  • ensure_ascii=False keeps non-English text readable in the file
  • indent=2 so a person can open the file and understand it
Notes
  • Path.replace() is atomic when the temporary file is on the same filesystem, which it is here because it sits beside the original. That is why the temporary file goes next to the real one rather than in the system temp folder.

The Manager: Rules, Not Messages

The manager is where the program's logic lives. It holds the tasks in memory, applies the rules, and asks the store to save after every change. It is deliberately silent — no print(), no input() — because that is what makes it usable from a command loop, from a test, or later from a web handler without changing a line.

It reports problems by raising, following Lesson 17. Two small exception classes under one base give the caller a choice: catch TaskNotFound to handle a missing id specifically, or catch TaskError to handle anything this program raises. Returning None instead would push the checking onto every caller, and the one that forgets fails somewhere unrelated.

Notice that the store is passed in rather than created inside. That is composition, from Lesson 19, and it is the reason the tests at the end need no file at all: they hand in a fake store that keeps everything in memory. A manager that built its own TaskStore would drag the filesystem into every test.

Two implementation details are worth pausing on. _next_id uses max(..., default=0) + 1, so ids keep increasing even after deletions and an id is never reused — reusing one would make old references point at the wrong task. And save is called after every change, which is the simple, correct choice at this size; batching writes would be faster and would risk losing work if the program stopped unexpectedly.

The leading underscore on _next_id and _find marks them internal, as in Lesson 20. The public interface is five methods, and everything else is free to change.

Example
# tasks/manager.py
from tasks.model import Task


class TaskError(Exception):
    """Base class for every error this program raises."""

class TaskNotFound(TaskError):
    pass

class InvalidTask(TaskError):
    pass


class TaskManager:
    """The rules. Never prints, never reads input."""

    def __init__(self, store):
        self.store = store          # passed in, not created here
        self.tasks = store.load()

    # ---- internal ----
    def _next_id(self):
        return max((t.id for t in self.tasks), default=0) + 1

    def _find(self, task_id):
        for task in self.tasks:
            if task.id == task_id:
                return task
        raise TaskNotFound(f"no task with id {task_id}")

    # ---- public interface ----
    def add(self, title, description=""):
        title = title.strip()
        if not title:
            raise InvalidTask("a task needs a title")

        task = Task(id=self._next_id(), title=title,
                    description=description.strip())
        self.tasks.append(task)
        self.store.save(self.tasks)
        return task

    def complete(self, task_id):
        task = self._find(task_id)
        task.done = True
        self.store.save(self.tasks)
        return task

    def delete(self, task_id):
        task = self._find(task_id)
        self.tasks = [t for t in self.tasks if t.id != task_id]
        self.store.save(self.tasks)
        return task

    def list(self, pending_only=False):
        if pending_only:
            return [t for t in self.tasks if not t.done]
        return list(self.tasks)     # a copy, so callers cannot edit ours
Notes
  • max(..., default=0) is what stops the first add() from failing on an empty list. Without the default, max() of nothing raises ValueError — a small thing that only shows up on a brand-new install, which is exactly when you least want a crash.

The Command Loop

This is the only layer that meets a human, and its job is translation: read a line, work out what was meant, call the manager, and turn whatever comes back — a task or an exception — into a sentence.

The loop is while True with an explicit exit, the shape from Lesson 8. Each line is normalised on arrival with .strip(), then split once with partition(" ") so that done 3 becomes the command done and the argument 3. Using partition rather than split keeps the rest of the line intact, which matters for add revise DSA notes.

All the error handling sits in one try around the dispatch. TaskError covers everything the manager raises, and ValueError covers int() failing on something that is not a number. Both print a single readable line and the loop carries on — the user never sees a traceback, and no failure ends the session.

Two smaller courtesies. Catching EOFError and KeyboardInterrupt around input() means Ctrl+C and Ctrl+D exit politely instead of dumping a stack trace. And an empty line does nothing rather than complaining, because pressing Enter to see the prompt again is a reasonable thing to do.

Finally, main() is a function, called under if __name__ == "__main__":. The file can then be imported — by a test, or by a future graphical version — without launching the menu.

Example
# tasks/cli.py
from tasks.manager import TaskManager, TaskError
from tasks.store import TaskStore

HELP = """Commands:
  add [title]   add a task (you will be asked for a description)
  list          show every task
  pending       show only the tasks that are not done
  done ID       mark a task as done
  delete ID     remove a task
  help          show this text
  quit          leave"""


def show(tasks):
    if not tasks:
        print("  (nothing to show)")
        return
    for task in tasks:
        line = str(task)
        if task.description:
            line += f"  - {task.description}"
        print(" ", line)
    print(f"\n  {len(tasks)} task(s)")


def main():
    manager = TaskManager(TaskStore("tasks.json"))
    print("Task Manager - type 'help' for commands")

    while True:
        try:
            line = input("\n> ").strip()
        except (EOFError, KeyboardInterrupt):
            print("\nbye")
            break

        if not line:
            continue

        command, _, argument = line.partition(" ")
        command = command.lower()

        try:
            if command == "help":
                print(HELP)

            elif command == "add":
                title = argument or input("Title: ")
                description = input("Description (optional): ")
                task = manager.add(title, description)
                print(f"added #{task.id}")

            elif command == "list":
                show(manager.list())

            elif command == "pending":
                show(manager.list(pending_only=True))

            elif command == "done":
                task = manager.complete(int(argument))
                print(f"done: {task.title}")

            elif command == "delete":
                task = manager.delete(int(argument))
                print(f"deleted: {task.title}")

            elif command in ("quit", "exit", "q"):
                print("bye")
                break

            else:
                print(f"unknown command {command!r} - type 'help'")

        except TaskError as e:
            print(f"error: {e}")
        except ValueError:
            print(f"'{command}' needs a task id, for example: {command} 3")


if __name__ == "__main__":
    main()
Notes
  • Every branch of the dispatch is one or two lines because the work happens in the manager. If a branch starts growing logic of its own, that logic almost certainly belongs one layer down.

Testing It, and Where to Take It Next

Now the payoff for the layering. Because the manager takes its store as an argument, a test can hand it a fake one that keeps everything in a list. No file is touched, nothing has to be cleaned up afterwards, and the tests run instantly.

Write the four tests below and you have covered the rules that matter: ids increase, blank titles are refused, an unknown id raises, and the pending filter excludes completed tasks. Each is three lines. Run them with python -m pytest, and from then on you can rewrite the internals with some confidence that you have not broken the behaviour.

When it works, put it on GitHub properly. A README.md saying what it does and exactly how to run it, a requirements.txt, and a .gitignore containing .venv/, __pycache__/ and tasks.json — the last one because your own task list is not part of the project. That is the difference between a folder of files and something a recruiter can evaluate.

Then extend it, one feature at a time, running the tests after each. Due dates and overdue highlighting bring in datetime arithmetic. Priorities and sorting bring in sorted(key=...). Search brings in string methods. Replacing the hand-rolled parser with argparse turns it into a proper command-line tool. Swapping TaskStore for a SQLite version touches exactly one class — which is the point the layering was making all along.

That is the end of the course. You have covered the language, the standard library pieces you will actually use, the object-oriented model, and the habits that separate code that runs from code that survives. The remaining skill is not something a lesson can give you: build things, get them wrong, and read the error messages. They are more informative than they look.

Example
# tests/test_manager.py
import pytest

from tasks.manager import TaskManager, TaskNotFound, InvalidTask


class FakeStore:
    """A store that keeps everything in memory — no file, no cleanup."""

    def __init__(self, tasks=None):
        self._tasks = list(tasks or [])
        self.saves = 0

    def load(self):
        return list(self._tasks)

    def save(self, tasks):
        self._tasks = list(tasks)
        self.saves += 1


def test_ids_increase_and_are_not_reused():
    m = TaskManager(FakeStore())
    assert m.add("first").id == 1
    second = m.add("second")
    m.delete(second.id)
    assert m.add("third").id == 3          # 2 is gone for good


def test_blank_title_is_refused():
    m = TaskManager(FakeStore())
    with pytest.raises(InvalidTask):
        m.add("   ")


def test_unknown_id_raises():
    m = TaskManager(FakeStore())
    with pytest.raises(TaskNotFound):
        m.complete(99)


def test_pending_excludes_completed():
    m = TaskManager(FakeStore())
    first = m.add("revise DSA")
    m.add("submit lab record")
    m.complete(first.id)
    assert [t.title for t in m.list(pending_only=True)] == ["submit lab record"]


# Run:  python -m pip install pytest
#       python -m pytest -q
  • Extend it: due dates with datetime, and highlight what is overdue
  • Priorities, with sorted(key=...) to order the list
  • Search by keyword across titles and descriptions
  • Replace the parser with argparse for a real command-line tool
  • Swap TaskStore for SQLite — one class changes, nothing else
  • Wrap the manager in Flask or FastAPI and you have a web API
Notes
  • Keep tasks.json out of the repository. Committing your own data alongside the code confuses anyone who clones it and makes every run produce a diff — .gitignore exists for exactly this.
Ask AI