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.
Drafted with AI from official documentation. Scripts were run in a test environment and repository links verified. Awaiting a human check. Did it work for you? Last edited Outdated?
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
tasklogcommand with four subcommands:add,list,doneandremove, plus--helpand--version. - Tasks stored as readable JSON in
~/.tasklog.json, or in a file you choose with--fileor theTASKLOG_FILEenvironment variable. - Five pytest tests that run against a temporary file, so they never touch your real tasks.
- A
pyproject.tomlthat turns the project into an installable package with a console command.
Before you start
- Python 3.9 or newer, with
pipand the built-invenvmodule. Check withpython3 --version(on Windows usepy --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 inargs.commandand makes argparse print an error if none is given.nargs="+"lets you typetasklog add buy milkwithout quotes; the words arrive as a list and are joined back together.type=inton the id meanstasklog done abcis rejected by argparse with a clear message before your code runs.main()takes an optionalargvlist and returns an exit code instead of callingsys.exititself. That is what makes it easy to test, and it is exactly what a console script entry point expects.- Errors go to
sys.stderrand return exit code 1, so scripts that calltasklogcan 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:
- Open a new terminal,
cdinto the project and runsource .venv/bin/activate. - Run
python -m pytest -qand confirm5 passed. - Run
TASKLOG_FILE=/tmp/check.json tasklog add test task, thenTASKLOG_FILE=/tmp/check.json tasklog list, and confirm the task is listed. - Run
tasklog --helpand 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.venvand runpython -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. Checkwhich 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.JSONDecodeErrorwhen 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 ofoptions:. 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
--dueoption withtype=date.fromisoformatand sort the list by due date. - Replace the JSON file with SQLite using the standard
sqlite3module, 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 buildto create a wheel you can share, and read the packaging guide before publishing anything to PyPI. - Use pytest’s
parametrizeto 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.
- Dominik +16,735 chars