A command-line task manager that stores tasks in a local JSON file, supports adding, listing, completing, and deleting tasks, and uses only the Python standard library.
Prerequisites
- Python 3.10 or later
- uv 0.4 or later (
curl -LsSf https://astral.sh/uv/install.sh | sh) - A Unix-like terminal (macOS, Linux, or WSL on Windows)
makeinstalled (pre-installed on macOS and most Linux distributions)
Step 1: Set up the project structure
Create the file
uv init cli-task-manager
cd cli-task-manager
rm main.py README.md
uv add --dev pytest
Add the code: .gitignore
__pycache__/
*.pyc
.venv/
.pytest_cache/
*.egg-info/
dist/
build/
.DS_Store
*.log
tmp/
tasks.json
Detailed breakdown
uv init cli-task-managerscaffolds a new Python project with apyproject.toml, amain.pysample file, and a.python-versionfile.rm main.py README.mdremoves the placeholder files since the project will use its own package structure.uv add --dev pytestinstalls pytest and records it under[dependency-groups]inpyproject.toml.__pycache__/and*.pycexclude Python bytecode files generated at runtime..venv/excludes the virtual environment that uv manages locally.tasks.jsonis excluded because it is runtime data, not source code. Each user generates their own task file..DS_Storeandtmp/cover common OS and temporary artifacts.
Step 2: Create the task storage module
This module handles all persistence — reading and writing tasks to a JSON file on disk.
Create the file
mkdir -p taskm
touch taskm/__init__.py
touch taskm/store.py
Add the code: taskm/store.py
"""Persistent task storage backed by a JSON file."""
import json
from pathlib import Path
DEFAULT_PATH = Path("tasks.json")
def _resolve(path: Path | None) -> Path:
return path if path is not None else DEFAULT_PATH
def _load(path: Path | None = None) -> list[dict]:
p = _resolve(path)
if not p.exists():
return []
text = p.read_text(encoding="utf-8")
if not text.strip():
return []
return json.loads(text)
def _save(tasks: list[dict], path: Path | None = None) -> None:
p = _resolve(path)
p.write_text(json.dumps(tasks, indent=2) + "\n", encoding="utf-8")
def _next_id(tasks: list[dict]) -> int:
if not tasks:
return 1
return max(t["id"] for t in tasks) + 1
def add_task(description: str, path: Path | None = None) -> dict:
tasks = _load(path)
task = {"id": _next_id(tasks), "description": description, "done": False}
tasks.append(task)
_save(tasks, path)
return task
def list_tasks(path: Path | None = None) -> list[dict]:
return _load(path)
def complete_task(task_id: int, path: Path | None = None) -> dict:
tasks = _load(path)
for task in tasks:
if task["id"] == task_id:
task["done"] = True
_save(tasks, path)
return task
raise ValueError(f"Task {task_id} not found")
def delete_task(task_id: int, path: Path | None = None) -> dict:
tasks = _load(path)
for i, task in enumerate(tasks):
if task["id"] == task_id:
removed = tasks.pop(i)
_save(tasks, path)
return removed
raise ValueError(f"Task {task_id} not found")
Detailed breakdown
_resolve: Converts aNonedefault into the module-levelDEFAULT_PATHat call time. This is critical for testability — Python evaluates default arguments once at function definition time, so usingpath: Path = DEFAULT_PATHwould capture the original value and ignore any monkeypatching. TheNone-sentinel pattern ensuresmonkeypatch.setattr("taskm.store.DEFAULT_PATH", ...)works correctly in tests._load/_save: Private helpers that serialize tasks as a JSON array._loadreturns an empty list if the file is missing or empty, so the CLI works on first run without setup._next_id: Generates a monotonically increasing integer ID by taking the max existing ID plus one. This avoids ID collisions even after deletions.add_task: Appends a new task dict withid,description, anddone: False, then persists immediately.complete_task/delete_task: Locate a task by ID, mutate or remove it, and persist. Both raiseValueErrorwhen the ID does not exist, giving the CLI a clear error path.- Every public function accepts an optional
pathparameter. This makes the module testable — tests can pass a temporary file instead of writing to the realtasks.json.
Step 3: Build the CLI entry point
The CLI uses argparse with subcommands so each operation (add, list, done, delete) is its own verb.
Create the file
touch taskm/cli.py
Add the code: taskm/cli.py
"""Command-line interface for the task manager."""
import argparse
import sys
from taskm.store import add_task, complete_task, delete_task, list_tasks
def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="taskm",
description="A simple CLI task manager.",
)
sub = parser.add_subparsers(dest="command")
add_p = sub.add_parser("add", help="Add a new task")
add_p.add_argument("description", help="Task description")
sub.add_parser("list", help="List all tasks")
done_p = sub.add_parser("done", help="Mark a task as completed")
done_p.add_argument("id", type=int, help="Task ID to complete")
del_p = sub.add_parser("delete", help="Delete a task")
del_p.add_argument("id", type=int, help="Task ID to delete")
return parser
def main(argv: list[str] | None = None) -> None:
parser = _build_parser()
args = parser.parse_args(argv)
if args.command is None:
parser.print_help()
return
if args.command == "add":
task = add_task(args.description)
print(f"Added task {task['id']}: {task['description']}")
elif args.command == "list":
tasks = list_tasks()
if not tasks:
print("No tasks.")
return
for t in tasks:
status = "x" if t["done"] else " "
print(f" [{status}] {t['id']}: {t['description']}")
elif args.command == "done":
try:
task = complete_task(args.id)
print(f"Completed task {task['id']}: {task['description']}")
except ValueError as exc:
print(f"Error: {exc}", file=sys.stderr)
sys.exit(1)
elif args.command == "delete":
try:
task = delete_task(args.id)
print(f"Deleted task {task['id']}: {task['description']}")
except ValueError as exc:
print(f"Error: {exc}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()
Detailed breakdown
_build_parser: Constructs anArgumentParserwith four subcommands. Each subcommand declares only the arguments it needs —addtakes a description string,doneanddeletetake an integer ID, andlisttakes nothing.main: Parses arguments, dispatches to the appropriate store function, and formats output. When no subcommand is given, it prints help and returns. Usingreturninstead ofsys.exit(0)keeps the function testable —sys.exitraisesSystemExit, which would require tests to catch the exception.ValueErrorexceptions from the store layer are caught and printed to stderr with a non-zero exit code.argvparameter: Accepts an optional argument list so tests can callmain(["add", "Buy milk"])directly instead of mockingsys.argv.
Step 4: Add the package runner
A __main__.py file lets the package run with python -m taskm.
Create the file
touch taskm/__main__.py
Add the code: taskm/__main__.py
"""Allow running the package with: python -m taskm"""
from taskm.cli import main
main()
Detailed breakdown
- This file is the entry point Python invokes when you run
uv run python -m taskm. It simply delegates tomain(). - Keeping the actual logic in
cli.pyrather than here ensures the CLI is importable and testable without triggering execution.
Step 5: Add a Makefile
Create the file
touch Makefile
Add the code: Makefile
.DEFAULT_GOAL := help
.PHONY: help run test clean
help: ## Show this help screen
@grep -E '^[a-zA-Z_-]+:.*##' $(MAKEFILE_LIST) | \
awk 'BEGIN {FS = ":.*## "}; {printf " %-12s %s\n", $$1, $$2}'
run: ## Run the task manager (usage: make run ARGS="add Buy milk")
uv run python -m taskm $(ARGS)
test: ## Run the test suite
uv run pytest tests/ -v
clean: ## Remove generated files
rm -rf __pycache__ taskm/__pycache__ tests/__pycache__ .pytest_cache
rm -f tasks.json
Detailed breakdown
helpas default target: Runningmakewith no arguments prints a formatted list of targets by scanning for##comments. This satisfies the Makefile default target rule.run: Invokes the CLI viauv run python -m taskm, using the uv-managed virtual environment. Additional arguments are passed through theARGSvariable (e.g.,make run ARGS="add Buy milk").test: Runs pytest with verbose output against thetests/directory.clean: Removes Python cache directories and thetasks.jsondata file.
Step 6: Write tests
Create the file
mkdir -p tests
touch tests/__init__.py
touch tests/test_store.py
touch tests/test_cli.py
Add the code: tests/test_store.py
"""Tests for the task storage module."""
from pathlib import Path
from taskm.store import add_task, complete_task, delete_task, list_tasks
import pytest
def test_add_and_list(tmp_path: Path):
db = tmp_path / "tasks.json"
add_task("First task", path=db)
add_task("Second task", path=db)
tasks = list_tasks(path=db)
assert len(tasks) == 2
assert tasks[0]["description"] == "First task"
assert tasks[1]["description"] == "Second task"
assert tasks[0]["id"] == 1
assert tasks[1]["id"] == 2
def test_complete_task(tmp_path: Path):
db = tmp_path / "tasks.json"
add_task("Do laundry", path=db)
result = complete_task(1, path=db)
assert result["done"] is True
tasks = list_tasks(path=db)
assert tasks[0]["done"] is True
def test_complete_missing_task(tmp_path: Path):
db = tmp_path / "tasks.json"
with pytest.raises(ValueError, match="Task 99 not found"):
complete_task(99, path=db)
def test_delete_task(tmp_path: Path):
db = tmp_path / "tasks.json"
add_task("Temporary", path=db)
deleted = delete_task(1, path=db)
assert deleted["description"] == "Temporary"
assert list_tasks(path=db) == []
def test_delete_missing_task(tmp_path: Path):
db = tmp_path / "tasks.json"
with pytest.raises(ValueError, match="Task 42 not found"):
delete_task(42, path=db)
def test_ids_increment_after_delete(tmp_path: Path):
db = tmp_path / "tasks.json"
add_task("A", path=db)
add_task("B", path=db)
delete_task(1, path=db)
task = add_task("C", path=db)
assert task["id"] == 3
Detailed breakdown
tmp_pathfixture: Each test gets a unique temporary directory from pytest, so tests never share state or touch the real filesystem.test_add_and_list: Verifies that adding two tasks produces sequential IDs and both appear in the list.test_complete_task: Confirms thedoneflag is set toTrueand persisted.test_complete_missing_task/test_delete_missing_task: Verify that operations on non-existent IDs raiseValueErrorwith the expected message.test_ids_increment_after_delete: Ensures IDs are never reused — after deleting task 1, the next task gets ID 3 (max of remaining ID 2, plus 1).
Add the code: tests/test_cli.py
"""Tests for the CLI interface."""
import os
from pathlib import Path
from taskm.cli import main
def test_add_via_cli(tmp_path: Path, capsys, monkeypatch):
db = tmp_path / "tasks.json"
monkeypatch.setattr("taskm.store.DEFAULT_PATH", db)
main(["add", "Buy groceries"])
captured = capsys.readouterr()
assert "Added task 1: Buy groceries" in captured.out
def test_list_empty(tmp_path: Path, capsys, monkeypatch):
db = tmp_path / "tasks.json"
monkeypatch.setattr("taskm.store.DEFAULT_PATH", db)
main(["list"])
captured = capsys.readouterr()
assert "No tasks." in captured.out
def test_list_shows_tasks(tmp_path: Path, capsys, monkeypatch):
db = tmp_path / "tasks.json"
monkeypatch.setattr("taskm.store.DEFAULT_PATH", db)
main(["add", "Task one"])
main(["add", "Task two"])
capsys.readouterr() # discard add output
main(["list"])
captured = capsys.readouterr()
assert "[ ] 1: Task one" in captured.out
assert "[ ] 2: Task two" in captured.out
def test_done_via_cli(tmp_path: Path, capsys, monkeypatch):
db = tmp_path / "tasks.json"
monkeypatch.setattr("taskm.store.DEFAULT_PATH", db)
main(["add", "Finish report"])
capsys.readouterr()
main(["done", "1"])
captured = capsys.readouterr()
assert "Completed task 1" in captured.out
def test_delete_via_cli(tmp_path: Path, capsys, monkeypatch):
db = tmp_path / "tasks.json"
monkeypatch.setattr("taskm.store.DEFAULT_PATH", db)
main(["add", "Old task"])
capsys.readouterr()
main(["delete", "1"])
captured = capsys.readouterr()
assert "Deleted task 1" in captured.out
def test_no_command_prints_help(capsys):
main([])
captured = capsys.readouterr()
assert "usage:" in captured.out.lower() or "taskm" in captured.out.lower()
Detailed breakdown
monkeypatch.setattr: RedirectsDEFAULT_PATHin the store module to a temp file so the CLI tests use isolated storage without modifying any real files.capsys: Captures stdout/stderr so assertions can check CLI output strings.test_no_command_prints_help: Confirms that calling the CLI with no arguments prints help text instead of crashing.- The tests exercise the full stack —
main()calls the store, which writes to disk, and the output is verified. This catches integration issues between the CLI parsing and storage layers.
Step 7: Run and validate
Run the full test suite from the cli-task-manager/ directory:
cd cli-task-manager
make test
Expected output:
tests/test_store.py::test_add_and_list PASSED
tests/test_store.py::test_complete_task PASSED
tests/test_store.py::test_complete_missing_task PASSED
tests/test_store.py::test_delete_task PASSED
tests/test_store.py::test_delete_missing_task PASSED
tests/test_store.py::test_ids_increment_after_delete PASSED
tests/test_cli.py::test_add_via_cli PASSED
tests/test_cli.py::test_list_empty PASSED
tests/test_cli.py::test_list_shows_tasks PASSED
tests/test_cli.py::test_done_via_cli PASSED
tests/test_cli.py::test_delete_via_cli PASSED
tests/test_cli.py::test_no_command_prints_help PASSED
Verify the default Makefile target:
make
Expected output:
help Show this help screen
run Run the task manager (usage: make run ARGS="add Buy milk")
test Run the test suite
clean Remove generated files
Try the CLI manually:
uv run python -m taskm add "Write CLI task manager article"
uv run python -m taskm add "Review the article"
uv run python -m taskm list
uv run python -m taskm done 1
uv run python -m taskm list
uv run python -m taskm delete 2
uv run python -m taskm list
Troubleshooting
ModuleNotFoundError: No module named 'taskm': Make sure you run commands from thecli-task-manager/project root so Python can find thetaskmpackage.uv: command not found: Install uv withcurl -LsSf https://astral.sh/uv/install.sh | shand restart your shell.- Tasks persist unexpectedly between runs: The CLI writes to
tasks.jsonin the current directory. Runmake cleanto reset.
Recap
This article built a CLI task manager with four operations (add, list, done, delete) backed by a JSON file. The project uses uv for dependency management, the Python standard library for the application code, argparse subcommands for a clean verb-based interface, and pytest with tmp_path for isolated tests. The storage layer accepts an injectable file path, keeping the module testable without mocks.
Next improvements to consider:
- Add due dates and priority levels to tasks
- Add a
searchsubcommand with substring matching - Replace JSON with SQLite via the
sqlite3standard library module for concurrent access