CHAPTER 37 · INTERMEDIATE4 min read

Project: A Persistent Task Tracker

Build a complete command-line task tracker with validated models, JSON persistence, predictable errors, and acceptance checks.

Python 3.12+Free to readModule 8 of 13
YOUR NEXT SMALL WINS

By the end, you’ll be able to…

  • Combine models, CLI parsing, and persistence behind clear boundaries.
  • Preserve existing data when validation fails.
  • Verify a real add-list-complete-reload workflow in a temporary directory.

01Define requirements before implementation

Build a single-user local task tracker with add, list, and done commands. Every task has a positive integer identifier, a nonblank title, and a boolean completion flag. Identifiers increase from the largest existing identifier. The user supplies a data-file path explicitly so the storage location is visible. A missing file starts a new collection; malformed or unsupported data must produce an error rather than being silently replaced.

Acceptance means more than printing one task: adding two titles must persist them across separate calls, completing one must survive reload, an unknown identifier must fail, and rejected input must leave the prior file intact. The project uses only the standard library and is intended for one writer at a time. Concurrent editing and crash-durable storage require additional design.

02Implement the full application

Save this complete program as tracker.py. Task owns field validation, load_tasks validates the saved representation, save_tasks replaces a complete prepared document, and main connects those pieces to argparse. The representation includes a schema version so future migrations can be deliberate. All records are validated before the application accepts the file.

The save operation writes to a temporary file in the destination directory and then replaces the destination. This avoids truncating the original before serialization and writing succeed. It does not provide locking or guarantee persistence through every power-loss scenario. The CLI catches expected input and storage failures at its boundary and returns a nonzero status; programmer errors remain visible.

tracker.pyPython
import argparsefrom dataclasses import asdict, dataclassimport jsonfrom pathlib import Pathimport sysimport tempfileclass StoreError(ValueError):    pass@dataclassclass Task:    id: int    title: str    done: bool = False    def __post_init__(self):        if type(self.id) is not int or self.id <= 0:            raise ValueError("task id must be a positive integer")        if not isinstance(self.title, str) or not self.title.strip():            raise ValueError("task title must be nonblank text")        if type(self.done) is not bool:            raise ValueError("task done must be a boolean")        self.title = self.title.strip()def load_tasks(path):    try:        text = path.read_text(encoding="utf-8")    except FileNotFoundError:        return []    except (OSError, UnicodeError) as exc:        raise StoreError(f"cannot read data: {exc}") from exc    try:        payload = json.loads(text)    except json.JSONDecodeError as exc:        raise StoreError("data file is not valid JSON") from exc    if not isinstance(payload, dict):        raise StoreError("data root must be an object")    if type(payload.get("schema_version")) is not int or payload["schema_version"] != 1:        raise StoreError("unsupported schema version")    rows = payload.get("tasks")    if not isinstance(rows, list):        raise StoreError("tasks must be a list")    tasks = []    seen = set()    for number, row in enumerate(rows, start=1):        if not isinstance(row, dict) or set(row) != {"id", "title", "done"}:            raise StoreError(f"task row {number} has unexpected fields")        try:            task = Task(row["id"], row["title"], row["done"])        except ValueError as exc:            raise StoreError(f"task row {number}: {exc}") from exc        if task.id in seen:            raise StoreError(f"duplicate task id: {task.id}")        seen.add(task.id)        tasks.append(task)    return tasksdef save_tasks(path, tasks):    payload = {"schema_version": 1, "tasks": [asdict(task) for task in tasks]}    text = json.dumps(payload, ensure_ascii=False, indent=2) + "\n"    temporary = None    try:        with tempfile.NamedTemporaryFile(            mode="w", encoding="utf-8", dir=path.parent,            prefix=path.name + ".", suffix=".tmp", delete=False        ) as handle:            temporary = Path(handle.name)            handle.write(text)        temporary.replace(path)    except OSError as exc:        raise StoreError(f"cannot save data: {exc}") from exc    finally:        if temporary is not None:            temporary.unlink(missing_ok=True)def make_parser():    parser = argparse.ArgumentParser(description="Track local study tasks")    parser.add_argument("--file", type=Path, required=True, help="JSON data path")    commands = parser.add_subparsers(dest="command", required=True)    add = commands.add_parser("add", help="add a task")    add.add_argument("title")    commands.add_parser("list", help="list all tasks")    done = commands.add_parser("done", help="complete a task")    done.add_argument("id", type=int)    return parserdef main(argv=None):    args = make_parser().parse_args(argv)    try:        tasks = load_tasks(args.file)        if args.command == "add":            next_id = max((task.id for task in tasks), default=0) + 1            task = Task(next_id, args.title)            tasks.append(task)            save_tasks(args.file, tasks)            print(f"Added {task.id}: {task.title}")        elif args.command == "list":            if not tasks:                print("No tasks")            for task in tasks:                marker = "x" if task.done else " "                print(f"{task.id} [{marker}] {task.title}")        elif args.command == "done":            task = next((task for task in tasks if task.id == args.id), None)            if task is None:                raise ValueError(f"unknown task id: {args.id}")            task.done = True            save_tasks(args.file, tasks)            print(f"Completed {task.id}")    except (StoreError, ValueError) as exc:        print(f"Error: {exc}", file=sys.stderr)        return 2    return 0if __name__ == "__main__":    raise SystemExit(main())

03Verify a real workflow safely

Save the following acceptance script beside tracker.py and run it with Python. Every invocation of main reloads the document, so the assertions verify actual persistence rather than only in-memory mutation. TemporaryDirectory contains all writes and removes the data afterward. Captured output also checks that the list command exposes the completed state.

The checks deliberately try a blank title and an unknown identifier after valid records exist. Comparing the saved text before and after proves these rejections did not rewrite the file. Add malformed JSON and duplicate identifiers to your test cases when extending the loader; bad existing data should never be interpreted as an empty workspace.

check_tracker.pyPython
from contextlib import redirect_stderr, redirect_stdoutimport iofrom pathlib import Pathfrom tempfile import TemporaryDirectoryfrom tracker import load_tasks, mainwith TemporaryDirectory() as folder:    path = Path(folder) / "tasks.json"    base = ["--file", str(path)]    output = io.StringIO()    errors = io.StringIO()    with redirect_stdout(output), redirect_stderr(errors):        assert main(base + ["list"]) == 0        assert main(base + ["add", "Learn files"]) == 0        assert main(base + ["add", "Write tests"]) == 0        assert main(base + ["done", "1"]) == 0        assert main(base + ["list"]) == 0        before = path.read_text(encoding="utf-8")        assert main(base + ["add", " "]) == 2        assert main(base + ["done", "999"]) == 2        assert path.read_text(encoding="utf-8") == before    tasks = load_tasks(path)    assert [(task.id, task.done) for task in tasks] == [(1, True), (2, False)]    assert "1 [x] Learn files" in output.getvalue()    assert "unknown task id" in errors.getvalue()print("All acceptance checks passed")
Outputstdout
All acceptance checks passed

04Extend one requirement at a time

Useful next tasks are a pending-only list filter, a rename command with the same nonblank-title rule, and an export command that preserves the saved schema. Add a test for each new behavior before changing the CLI. If deleting tasks is introduced, decide whether identifiers may ever be reused; storing a next_id counter is one way to preserve monotonic identity after deletion.

A larger application can separate models, storage, and CLI into modules without changing the command contract. Add timestamps only with an aware-UTC policy, and add collaborative use only after choosing locking or a transactional store. The next design step should follow a concrete requirement and acceptance case, not an urge to replace every simple function with an abstraction.

LET IT CLICK

The ideas worth keeping.

  • Give each layer one clear contract and failure policy.
  • Validate saved data before accepting it as application state.
  • Verify persistence and rejection behavior with temporary acceptance workflows.
TURN UNDERSTANDING INTO SOMETHING YOU CAN DO

Make it yours.

Type and test your code right here in the interactive workspace. Run your solution, check the output, and compare with the worked guide when ready.

01

Add a pending-only query

Implement pending_titles(tasks) without mutating its input. Use Task-like objects and verify that complete tasks are excluded while original order remains.

your_turn_1.py🐍Python
Ctrl + ↵
Output
Click Run or press Ctrl+Enter
Output will appear here after you run the code.
Explore a worked solution
one_possible_solution.pyPython
from dataclasses import dataclass@dataclassclass Task:    id: int    title: str    done: bool = Falsedef pending_titles(tasks):    return [task.title for task in tasks if not task.done]tasks = [Task(1, "Read", True), Task(2, "Practice"), Task(3, "Review")]assert pending_titles(tasks) == ["Practice", "Review"]assert [task.done for task in tasks] == [True, False, False]

The query returns a new list and does not change stored objects. A CLI --pending option can call this same selection rule while preserving identifier display.

02

Reject duplicate saved identifiers

After saving tracker.py, create a temporary schema-version-1 document containing duplicate IDs. Verify load_tasks raises StoreError and does not alter the file.

your_turn_2.py🐍Python
Ctrl + ↵
Output
Click Run or press Ctrl+Enter
Output will appear here after you run the code.
Explore a worked solution
one_possible_solution.pyPython
import jsonfrom pathlib import Pathfrom tempfile import TemporaryDirectoryfrom tracker import StoreError, load_taskswith TemporaryDirectory() as folder:    path = Path(folder) / "bad.json"    payload = {"schema_version": 1, "tasks": [        {"id": 1, "title": "First", "done": False},        {"id": 1, "title": "Second", "done": True},    ]}    original = json.dumps(payload)    path.write_text(original, encoding="utf-8")    try:        load_tasks(path)    except StoreError as exc:        assert "duplicate" in str(exc)    else:        raise AssertionError("duplicate IDs accepted")    assert path.read_text(encoding="utf-8") == original

Duplicate identity is an application-level corruption even though the JSON syntax and individual records are valid. Read validation rejects it without writing recovery data over the evidence.

A SMALL QUESTION. A USEFUL NEXT STEP.

What stayed with you?

Check the ideas while they’re fresh. Your answers stay in this page and feedback appears as you go.

A LITTLE ACTIVE RECALL0 / 3 answered
01Why does a malformed existing file raise an error instead of returning an empty task list?
02What does the temporary-file-and-replace strategy improve?
03Why reload the JSON document in the acceptance workflow?
⚡CODING CHALLENGE
🔒Required to Mark Lesson Complete

Add a pending-only query

Implement pending_titles(tasks) without mutating its input. Use Task-like objects and verify that complete tasks are excluded while original order remains.

EXPECTED OUTPUT
[Process exited with code 0 - No output]
challenge.pyPython 3
Ctrl + ↵
Execution Console
Click "Run & Verify" (or press Ctrl + Enter) to test your code against the challenge.

Keep exploring.

The official references behind this chapter, when you want a second angle or the full details.

MODULE 08 · PRACTICAL SELF-CHECK

Pause. Put the pieces together.

You’ve reached the end of Reliable Programs and an Intermediate Project. Before moving on, use this task to check what you can do independently.

Add, list, complete, and reload tasks; confirm rejected input preserves the saved file.

Try it in your own environment and write down the result. This is a self-check; reading completion does not evaluate your code.

Review this module

One chapter closer.

Reading is a start. Trying it yourself makes the connection.

Solve the hands-on coding challenge in the knowledge section to verify your code and unlock chapter completion.