Skip to content
Commitpedia
Log in Join & edit

Build a Python command-line to-do app: from empty folder to installable command

Create a small to-do tool in Python with argparse and JSON storage, test it with pytest and package it with pyproject.toml so it installs as a real command.

Not checked by a trusted editor yet Last edited Outdated?

Edit history of Build a Python command-line to-do app: from empty folder to installable command: 1 recorded edit
Edit history · 1 edit
Type
Article
Edits
1
Editors
1
First issued
15 Oct 2026
Last edit
15 Oct 2026
Reading
12 min

In this tutorial you build tasklog, a tiny to-do list that lives in your terminal. It is a small program, but it goes through every step a real tool needs: a project folder, a virtual environment, a command-line interface, saving data to a file, automated tests and packaging so that tasklog works as a normal command.

It is written for people who know basic Python (functions, lists, dictionaries) and want to see how the pieces of a real project fit together. Everything in this tutorial was run end to end on Python 3.9 with pip and pytest; the code avoids newer syntax so it works on any currently supported Python 3 version.

What you will build

  • A tasklog command with four subcommands: add, list, done and remove, plus --help and --version.
  • Tasks stored as readable JSON in ~/.tasklog.json, or in a file you choose with --file or the TASKLOG_FILE environment variable.
  • Five pytest tests that run against a temporary file, so they never touch your real tasks.
  • A pyproject.toml that turns the project into an installable package with a console command.

Before you start

  • Python 3.9 or newer, with pip and the built-in venv module. Check with python3 --version (on Windows use py --version).
  • A terminal and a text editor. Commands are shown for macOS and Linux; Windows differences are noted where they matter.
  • An internet connection once, to install pytest and setuptools from PyPI.
  • Time: about 45-60 minutes.
  • Knowledge: basic Python, and the idea of a virtual environment (an isolated folder of packages for one project). Knowing what JSON looks like helps.

Step 1: Create the project folder and layout

Why: a predictable layout saves trouble later. This tutorial uses the src layout: your package lives in src/tasklog/ and tests live in tests/. The pytest documentation recommends this layout because tests then run against the installed package instead of whatever happens to be in the current folder.

mkdir tasklog
cd tasklog
mkdir -p src/tasklog tests
touch README.md

When you finish, the project will look like this:

tasklog/
├── pyproject.toml
├── README.md
├── src/
│   └── tasklog/
│       ├── __init__.py
│       ├── cli.py
│       └── storage.py
└── tests/
    └── test_cli.py

Step 2: Create and activate a virtual environment

Why: a virtual environment keeps this project’s packages (pytest, and later your own tool) away from the system Python and from other projects.

python3 -m venv .venv
source .venv/bin/activate
python -m pip install --upgrade pip

On Windows, activate with .venv\Scripts\activate.bat in cmd.exe or .venv\Scripts\Activate.ps1 in PowerShell. Check that it worked: your prompt usually shows (.venv), and which python (or where python on Windows) points into the .venv folder. Leave the environment later with deactivate.

Add .venv/ to a .gitignore file if you use Git; the environment is rebuilt from pyproject.toml, never committed.

Step 3: Write the storage layer

Why: keeping file handling in its own module makes the CLI code shorter and easier to test. The module has two jobs: read the task list (an empty list if the file does not exist yet) and write it back.

Create src/tasklog/__init__.py with the version number:

__version__ = "0.1.0"

Then create src/tasklog/storage.py:

import json
from pathlib import Path


def load_tasks(path):
    path = Path(path)
    if not path.exists():
        return []
    with path.open(encoding="utf-8") as f:
        return json.load(f)


def save_tasks(path, tasks):
    path = Path(path)
    path.parent.mkdir(parents=True, exist_ok=True)
    tmp = path.with_suffix(".tmp")
    with tmp.open("w", encoding="utf-8") as f:
        json.dump(tasks, f, indent=2, ensure_ascii=False)
        f.write("\n")
    tmp.replace(path)

Two details are worth noticing. save_tasks writes to a temporary file first and then renames it over the real file, so a crash halfway through writing never leaves you with a half-written, broken JSON file. And ensure_ascii=False keeps accented letters readable in the file instead of turning them into \u escapes.

Step 4: Build the command-line interface with argparse

Why: argparse is in the standard library and gives you help text, type checking and error messages for free. Each action (add, list, …) is a subcommand with its own arguments.

Create src/tasklog/cli.py:

import argparse
import os
import sys
from datetime import date
from pathlib import Path

from . import __version__
from .storage import load_tasks, save_tasks

DEFAULT_FILE = Path.home() / ".tasklog.json"


def build_parser():
    parser = argparse.ArgumentParser(prog="tasklog", description="A tiny to-do list for the terminal.")
    parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
    parser.add_argument(
        "--file",
        type=Path,
        default=Path(os.environ.get("TASKLOG_FILE", DEFAULT_FILE)),
        help="where tasks are stored (default: $TASKLOG_FILE or ~/.tasklog.json)",
    )
    sub = parser.add_subparsers(dest="command", required=True)

    add = sub.add_parser("add", help="add a task")
    add.add_argument("text", nargs="+", help="what you need to do")

    ls = sub.add_parser("list", help="show tasks")
    ls.add_argument("--all", action="store_true", help="include finished tasks")

    done = sub.add_parser("done", help="mark a task as finished")
    done.add_argument("id", type=int)

    rm = sub.add_parser("remove", help="delete a task")
    rm.add_argument("id", type=int)
    return parser


def find(tasks, task_id):
    for task in tasks:
        if task["id"] == task_id:
            return task
    return None


def main(argv=None):
    args = build_parser().parse_args(argv)
    tasks = load_tasks(args.file)

    if args.command == "add":
        next_id = max((t["id"] for t in tasks), default=0) + 1
        task = {"id": next_id, "text": " ".join(args.text), "done": False, "created": date.today().isoformat()}
        tasks.append(task)
        save_tasks(args.file, tasks)
        print(f"Added task {next_id}: {task['text']}")
        return 0

    if args.command == "list":
        shown = [t for t in tasks if args.all or not t["done"]]
        if not shown:
            print("Nothing to do.")
        for t in shown:
            mark = "x" if t["done"] else " "
            print(f"[{mark}] {t['id']:>3}  {t['text']}")
        return 0

    task = find(tasks, args.id)
    if task is None:
        print(f"tasklog: no task with id {args.id}", file=sys.stderr)
        return 1

    if args.command == "done":
        task["done"] = True
        print(f"Finished task {task['id']}: {task['text']}")
    elif args.command == "remove":
        tasks.remove(task)
        print(f"Removed task {task['id']}: {task['text']}")
    save_tasks(args.file, tasks)
    return 0


if __name__ == "__main__":
    sys.exit(main())

How it fits together:

  • add_subparsers(dest="command", required=True) stores the chosen subcommand in args.command and makes argparse print an error if none is given.
  • nargs="+" lets you type tasklog add buy milk without quotes; the words arrive as a list and are joined back together.
  • type=int on the id means tasklog done abc is rejected by argparse with a clear message before your code runs.
  • main() takes an optional argv list and returns an exit code instead of calling sys.exit itself. That is what makes it easy to test, and it is exactly what a console script entry point expects.
  • Errors go to sys.stderr and return exit code 1, so scripts that call tasklog can detect failure.

Try it before packaging anything. PYTHONPATH=src tells Python where to find the package for now, and TASKLOG_FILE keeps your experiments in a local file:

export TASKLOG_FILE="$PWD/demo.json"
PYTHONPATH=src python -m tasklog.cli add buy milk
PYTHONPATH=src python -m tasklog.cli list

Expected output:

Added task 1: buy milk
[ ]   1  buy milk

Step 5: Describe the package in pyproject.toml

Why: pyproject.toml is the standard place to declare a package’s name, version, dependencies and commands. The [project.scripts] table is what creates the tasklog command: after installation it imports main from tasklog.cli and passes its return value to sys.exit().

[build-system]
requires = ["setuptools>=77.0.3"]
build-backend = "setuptools.build_meta"

[project]
name = "tasklog"
version = "0.1.0"
description = "A tiny to-do list for the terminal"
readme = "README.md"
requires-python = ">=3.9"
dependencies = []

[project.optional-dependencies]
dev = ["pytest"]

[project.scripts]
tasklog = "tasklog.cli:main"

Notes: the project has no runtime dependencies, so dependencies is empty. pytest is listed as an optional extra called dev, so users of the tool do not install it but developers can. Put one line of text in README.md so the readme entry has something to point at.

Step 6: Install the package in editable mode

Why: an editable install (-e) links the installed package to your source folder. You get a real tasklog command, and changes to the code take effect immediately without reinstalling.

python -m pip install -e ".[dev]"
which tasklog
tasklog --version

The quotes around .[dev] stop zsh from treating the square brackets as a file pattern. You should see a path inside .venv/bin/ and then tasklog 0.1.0. Now use it like any other command:

tasklog add "call the dentist"
tasklog done 1
tasklog list
tasklog list --all
tasklog --help
Added task 2: call the dentist
Finished task 1: buy milk
[ ]   2  call the dentist
[x]   1  buy milk
[ ]   2  call the dentist

Look at the data file too. It is plain, indented JSON you could edit by hand:

cat "$TASKLOG_FILE"
[
  {
    "id": 1,
    "text": "buy milk",
    "done": true,
    "created": "2026-10-15"
  },
  {
    "id": 2,
    "text": "call the dentist",
    "done": false,
    "created": "2026-10-15"
  }
]

Step 7: Write tests with pytest

Why: tests let you change the code later without fear. Because main() accepts an argument list and returns an exit code, the tests can call it directly; no subprocesses needed. pytest’s built-in tmp_path fixture gives every test its own empty folder, and capsys captures what the program prints.

Create tests/test_cli.py:

import json

import pytest

from tasklog.cli import main


@pytest.fixture
def store(tmp_path):
    return tmp_path / "tasks.json"


def run(store, *args):
    return main(["--file", str(store), *args])


def test_add_creates_file(store, capsys):
    assert run(store, "add", "buy", "milk") == 0
    assert "Added task 1: buy milk" in capsys.readouterr().out
    data = json.loads(store.read_text())
    assert data[0]["text"] == "buy milk"
    assert data[0]["done"] is False


def test_ids_keep_counting(store):
    run(store, "add", "one")
    run(store, "add", "two")
    run(store, "remove", "1")
    run(store, "add", "three")
    ids = [t["id"] for t in json.loads(store.read_text())]
    assert ids == [2, 3]


def test_done_hides_task_from_list(store, capsys):
    run(store, "add", "write tests")
    run(store, "done", "1")
    capsys.readouterr()
    run(store, "list")
    assert capsys.readouterr().out.strip() == "Nothing to do."
    run(store, "list", "--all")
    assert "[x]" in capsys.readouterr().out


def test_unknown_id_fails(store, capsys):
    assert run(store, "done", "42") == 1
    assert "no task with id 42" in capsys.readouterr().err


def test_missing_command_exits_with_usage_error(store):
    with pytest.raises(SystemExit) as exc:
        run(store)
    assert exc.value.code == 2

The last test checks the behavior argparse promises: a usage error raises SystemExit with exit status 2. Run the suite:

python -m pytest -q
.....                                                                    [100%]
5 passed in 0.03s

Make a test fail on purpose (for example change "buy milk" to "buy bread" in the first assertion) and run pytest again to see how it reports the difference. Then change it back.

Step 8: Check the error paths

Why: a good command-line tool fails loudly and with the right exit code, so it can be used safely from scripts and cron jobs.

tasklog done 9; echo "exit=$?"
tasklog; echo "exit=$?"
tasklog done abc; echo "exit=$?"

Expected: the first prints tasklog: no task with id 9 and exit=1; the second prints the usage line and tasklog: error: the following arguments are required: command with exit=2; the third prints an invalid int value: 'abc' error, also with exit status 2.

Check that it works

Run the whole flow from a fresh terminal to be sure nothing depends on leftovers from earlier steps:

  1. Open a new terminal, cd into the project and run source .venv/bin/activate.
  2. Run python -m pytest -q and confirm 5 passed.
  3. Run TASKLOG_FILE=/tmp/check.json tasklog add test task, then TASKLOG_FILE=/tmp/check.json tasklog list, and confirm the task is listed.
  4. Run tasklog --help and confirm all four subcommands appear.

If you want to install the tool for everyday use outside this environment, build and install it with pipx install . from the project folder (pipx installs each tool into its own environment), or keep using it from the activated venv.

If something goes wrong

  • ModuleNotFoundError: No module named 'tasklog' when running pytest. The package is not installed in the active environment. Activate .venv and run python -m pip install -e ".[dev]". With a src layout, pytest cannot import the package from the source folder on its own.
  • tasklog: command not found. The venv is not active, or the install step failed. Check which python, then reinstall. After changing [project.scripts] you must reinstall for the new command name to appear.
  • zsh: no matches found: .[dev]. zsh expands square brackets. Put the argument in quotes: ".[dev]".
  • json.decoder.JSONDecodeError when listing tasks. The tasks file was edited by hand and is no longer valid JSON. Open it, fix the syntax (often a missing comma or a trailing comma), or move it aside to start fresh.
  • Help shows optional arguments: instead of options:. That is only the heading text argparse uses in older Python versions; nothing is wrong.

Clean up

Everything lives inside the project folder plus one data file in your home folder. Check before deleting: ls -la ~/.tasklog.json shows whether the default data file exists and pip show tasklog shows where the package is installed.

python -m pip uninstall tasklog
deactivate
rm -i ~/.tasklog.json demo.json

Warning: removing the virtual environment folder deletes every package installed in it. It is safe here because pyproject.toml can rebuild it, but make sure you are in the right folder first:

pwd
rm -r .venv

Next steps

  • Add a --due option with type=date.fromisoformat and sort the list by due date.
  • Replace the JSON file with SQLite using the standard sqlite3 module, and keep the same tests passing.
  • Add a linter such as Ruff and run it together with pytest in a GitHub Actions workflow.
  • Use python -m build to create a wheel you can share, and read the packaging guide before publishing anything to PyPI.
  • Use pytest’s parametrize to test many inputs (empty text, very long text, non-ASCII text) with one test function.

Official documentation

Did this work for you?

No votes yet. Be the first.

If it did not work, the report form below tells the editors what went wrong.

See also

  • 0001 Virtual environments with venv A virtual environment is a private folder of Python packages for one project, so projects never fight over versions. Create one with… Beginner · Explains an idea · Any system Article Environments & packages 15 Oct 2026
  • 0003 pathlib cheat sheet: files and folders in Python pathlib.Path is the modern way to build paths, read and write files, and list or search folders in Python. This page lists… Beginner · Cheat sheet · Any system Article Files & automation 15 Oct 2026
  • 0004 Logging instead of print The logging module gives every message a level, a timestamp and a destination you can change without editing code. It is the… Intermediate · Explains an idea · Any system Article Files & automation 15 Oct 2026
  • 0006 Testing with pytest: first tests, fixtures and parametrize pytest runs plain functions whose names start with test_ and reports every failing assert in detail. This guide covers setup, assertions, exceptions,… Intermediate · Step by step · Any system Article Testing 15 Oct 2026
  • 0007 Fix: ModuleNotFoundError: No module named ‘…’ Python cannot find a module you import. Usually the package was installed into a different interpreter or virtual environment than the one… Beginner · Fixes an error · Any system Article Common errors · Environments & packages 15 Oct 2026
  • 0009 CSV to JSON converter A small command-line tool that turns a CSV file into a JSON array of objects, with optional number detection, custom delimiters and… Beginner · Step by step · Any system Script CSV & JSON · Files & automation 15 Oct 2026

Repos in the wild

  • Requests: HTTP for humans psf/requests Requests is the classic Python library for calling web APIs and downloading pages, with a simple API for GET and POST requests, headers, JSON and sessions. GitHub
  • pytest pytest-dev/pytest pytest is the de facto standard test runner for Python: plain functions with assert, detailed failure reports, fixtures, parametrization and a large plugin ecosystem. GitHub
  • uv: Python package and project manager astral-sh/uv uv is a very fast Python package and project manager written in Rust. It replaces pip, venv and related tools, manages lock files and can install Python itself. GitHub
  • Ruff: Python linter and formatter astral-sh/ruff Ruff is a fast Python linter and code formatter written in Rust. It catches bugs such as undefined names and unused imports before you run your code. GitHub

History

Every saved edit is kept. The plot on the sleeve draws one line per edit. Open any edit to see exactly what changed.

  1. Commitpedia +16,735 chars

Report a problem

Something wrong, unsafe or copied? Tell the editors.
What is wrong?