This article builds a complete, real-world Python CLI tool, a command-line task manager, using only the Python standard library. The tool stores tasks in sqlite3, parses commands with argparse, exports data to CSV files and JSON, and logs diagnostics with the logging module.
The finished tool supports these commands, each mapping to one subcommand handled by argparse's subparser feature:
tasker add "Finish report" --priority high
tasker add "Review PR" --priority medium
tasker list
tasker done 1
tasker export --format jsonProject structure
Splitting the tool into separate modules keeps each file focused on one responsibility, rather than piling argument parsing, database access, and export logic into a single script. This mirrors how larger real-world CLI tools are organized: a thin command-line layer on top of independently testable modules that do not know anything about argparse.
tasker/
__init__.py
__main__.py
cli.py
database.py
export.py
pyproject.tomlcli.py handles argument parsing and dispatches to the right command handler. database.py manages all SQLite operations, so no other module talks to the database directly. export.py handles CSV and JSON output independently of storage. main.py enables python -m tasker as an alternative to installing the console script.
Step 1: database layer
The database layer is built first because everything else in the tool depends on it: the CLI commands call into it, and the export functions read from whatever it returns. Building it in isolation, before wiring up argument parsing, also makes it easy to test each function directly from a Python shell before connecting it to a command, which catches bugs earlier and closer to their source.
database.py creates the table and provides functions for task operations: adding, listing, marking complete, and reading everything back for export. Every function opens its own connection and lets the with block handle commit and close automatically, so the rest of the codebase never has to manage a connection lifecycle directly or remember to call commit() itself.
import sqlite3
from datetime import datetime
DATABASE = "tasks.db"
def get_connection():
conn = sqlite3.connect(DATABASE)
conn.execute("PRAGMA journal_mode=WAL;")
return connWith a way to open connections in place, init_db() creates the tasks table the first time the tool runs, using IF NOT EXISTS so it is safe to call on every startup:
def init_db():
with get_connection() as conn:
conn.execute("""
CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
priority TEXT DEFAULT 'medium',
done INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
completed_at TEXT
)
""")With the connection helper and table setup in place, add the functions that insert a new task and list existing ones. list_tasks() builds its query conditionally, appending a WHERE clause only when the caller wants to hide completed tasks, which avoids maintaining two nearly identical SQL strings:
def add_task(title, priority="medium"):
with get_connection() as conn:
conn.execute(
"INSERT INTO tasks (title, priority) VALUES (?, ?)",
(title, priority),
)
def list_tasks(show_done=False):
with get_connection() as conn:
query = "SELECT id, title, priority, done, created_at FROM tasks"
if not show_done:
query += " WHERE done = 0"
query += " ORDER BY id DESC"
return conn.execute(query).fetchall()The remaining two functions round out the module by marking a task done and fetching every task, including completed ones, for export:
def mark_done(task_id):
with get_connection() as conn:
conn.execute(
"UPDATE tasks SET done = 1, completed_at = datetime('now') WHERE id = ?",
(task_id,),
)
def get_all_tasks():
with get_connection() as conn:
return conn.execute(
"SELECT id, title, priority, done, created_at, completed_at FROM tasks ORDER BY id"
).fetchall()Each function opens its own short-lived connection, runs its query, and lets the context manager handle commit and close automatically, even if an exception occurs partway through. The ? placeholders prevent SQL injection by keeping user-supplied values separate from the SQL text itself.
Step 2: export module
Portability matters for a task manager: users may want to open their tasks in a spreadsheet, feed them into another tool, or back them up outside the SQLite file. Rather than build a single export function with a format flag branching internally, this module exposes one function per format, which keeps each function simple and makes it obvious where to add a third format later, such as Markdown or plain text.
export.py converts task data to CSV and JSON, keeping the export format decision entirely separate from how tasks are stored or queried. Both functions return the resulting file size so the CLI can confirm how much data was written, without having to reopen the file to check.
import csv
import json
from pathlib import Path
def export_csv(tasks, output_path):
with open(output_path, "w", newline="") as file:
writer = csv.writer(file)
writer.writerow(["id", "title", "priority", "done", "created_at", "completed_at"])
writer.writerows(tasks)
return Path(output_path).stat().st_sizeThe JSON version follows the same pattern, but first converts each row tuple into a dictionary keyed by column name:
def export_json(tasks, output_path):
columns = ["id", "title", "priority", "done", "created_at", "completed_at"]
data = [dict(zip(columns, row)) for row in tasks]
with open(output_path, "w") as file:
json.dump(data, file, indent=2)
return Path(output_path).stat().st_sizeBoth functions write files and return the file size so the caller can print a confirmation message with a concrete number, rather than a vague "done." CSV uses newline="" for cross-platform compatibility, since the csv module handles line endings itself and an extra layer of translation would produce blank lines on Windows.
Step 3: CLI layer
With the database and export logic already working on their own, this final module only has to handle two things: turning command-line text into structured arguments, and routing those arguments to the right function. Keeping this layer thin is deliberate, since argparse code is awkward to unit test compared to plain functions, so pushing logic down into database.py and export.py keeps the parts that actually need testing easy to test.
cli.py defines the argument parser and connects commands to database functions. Each cmd_* function is a thin handler that unpacks the parsed arguments and delegates to the database or export module.
import argparse
import logging
import sys
from . import database, export
def setup_logging(verbose):
level = logging.DEBUG if verbose else logging.INFO
logging.basicConfig(
level=level,
format="%(levelname)s: %(message)s",
stream=sys.stderr,
)setup_logging() routes messages to stderr so they never mix with the plain-text output the commands below print to stdout, keeping normal output easy to redirect or pipe into another program:
def cmd_add(args):
database.add_task(args.title, args.priority)
print(f"Added: {args.title}")
def cmd_list(args):
tasks = database.list_tasks(args.all)
if not tasks:
print("No tasks found.")
return
for task in tasks:
status = "[x]" if task[3] else "[ ]"
print(f"{task[0]:>3} {status} {task[2]:8} {task[1]}")cmd_done marks a task complete by delegating straight to the database layer, then prints a short confirmation message back to the user. Notice that it does not check whether the id actually exists first; an UPDATE against a missing id simply affects zero rows instead of raising an error, which keeps the handler simple at the cost of silent no-ops on typos.
def cmd_done(args):
database.mark_done(args.id)
print(f"Marked task {args.id} as done.")cmd_export writes every task to CSV or JSON, choosing the export function based on the requested format, and logs a summary at INFO level so the detail is available in verbose mode without cluttering the normal output:
def cmd_export(args):
tasks = database.get_all_tasks()
if not tasks:
print("No tasks to export.")
return
fmt = args.format
filename = f"tasks.{fmt}"
if fmt == "csv":
size = export.export_csv(tasks, filename)
else:
size = export.export_json(tasks, filename)
logging.info("Exported %d tasks to %s (%d bytes)", len(tasks), filename, size)
print(f"Exported to {filename}")Pulling the argparse setup into its own build_parser() function keeps main() short and keeps each subcommand's arguments grouped together in one place, instead of scattered through a much longer function. It also makes the parser easy to construct in a test without invoking the rest of the program:
def build_parser():
parser = argparse.ArgumentParser(description="Task manager CLI")
parser.add_argument("--verbose", action="store_true")
subparsers = parser.add_subparsers(dest="command", required=True)
add_parser = subparsers.add_parser("add")
add_parser.add_argument("title")
add_parser.add_argument("--priority", default="medium", choices=["low", "medium", "high"])
list_parser = subparsers.add_parser("list")
list_parser.add_argument("--all", action="store_true", help="include completed tasks")
done_parser = subparsers.add_parser("done")
done_parser.add_argument("id", type=int)
export_parser = subparsers.add_parser("export")
export_parser.add_argument("--format", default="csv", choices=["csv", "json"])
return parserWith the parser factored out, main() just wires everything together: it initializes the database, parses arguments, sets up logging, and dispatches to the right handler. This is the only function that runs the moment the program starts, and every other function only runs because main() eventually calls it:
def main():
database.init_db()
parser = build_parser()
args = parser.parse_args()
setup_logging(args.verbose)
commands = {"add": cmd_add, "list": cmd_list, "done": cmd_done, "export": cmd_export}
commands[args.command](args)
if __name__ == "__main__":
main()The commands dictionary maps subcommand names to handler functions, avoiding a long if/elif chain that would otherwise grow with every new subcommand. Each handler receives the parsed arguments namespace and delegates immediately to the database or export module, keeping cli.py free of any actual business logic.
Step 4: entry point and packaging
The last piece is making tasker installable as a real command, rather than something you can only run with python -m tasker from inside the project directory. Python packaging handles this through the [project.scripts] table in pyproject.toml, which tells pip to generate a small executable wrapper that calls a specific function when the package is installed, so the wrapper script itself never needs to be written by hand.
pyproject.toml:
[project]
name = "tasker"
version = "0.1.0"
requires-python = ">=3.10"
[project.scripts]
tasker = "tasker.cli:main"After pip install -e ., the tasker command is available from any terminal, without needing to remember the full python -m tasker invocation or activate a specific virtual environment by path.
Using the tool
With the package installed, here is what a real session looks like end to end, starting by adding a few tasks and listing them. Each command below is exactly what you would type in a terminal after running pip install -e . once.
tasker add "Finish quarterly report" --priority high
tasker add "Review pull request" --priority medium
tasker add "Update dependencies"
tasker listTasks print most recently added first, with their priority and completion status shown before the title, matching the id DESC ordering in list_tasks():
Added: Finish quarterly report
Added: Review pull request
Added: Update dependencies
3 [ ] medium Update dependencies
2 [ ] medium Review pull request
1 [ ] high Finish quarterly reportMarking a task done removes it from the default list view, since list_tasks() filters out completed tasks unless --all is passed:
tasker done 2
tasker listTask 2 no longer appears in the default listing, leaving only the two still-open tasks, since cmd_list filters out completed tasks unless --all is passed:
Marked task 2 as done.
3 [ ] medium Update dependencies
1 [ ] high Finish quarterly reportFinally, export everything, including the completed task, to a JSON file by passing --format json explicitly, since csv is the default:
tasker export --format jsonThe export includes all three tasks regardless of completion status, since get_all_tasks() has no WHERE clause filtering on done, unlike the default list view:
Exported to tasks.jsonWhat this project demonstrates
This CLI tool uses eight standard library modules, each doing one clearly defined job rather than one module trying to do everything, which keeps the codebase easy to navigate:
| Module | Purpose |
|---|---|
| argparse | Command-line argument parsing with subcommands |
| sqlite3 | Persistent task storage |
| csv | Export to CSV format |
| json | Export to JSON format |
| pathlib | File size checking |
| logging | Diagnostic output to stderr |
| datetime | Timestamps in SQLite |
| sys | stderr for logging |
No third-party dependencies are needed. The entire tool installs and runs anywhere Python 3.10+ is available, with no compiled extensions or platform-specific wheels to worry about, and no dependency versions to keep patched over time.
From here, the natural next steps for a real project would be adding tests for the database and export functions, validating user input more thoroughly in the CLI layer, and perhaps adding an update or delete subcommand following the same pattern as the existing ones. None of that requires anything beyond what this project already demonstrates: pick the standard library module for the job, keep each module focused on one responsibility, and wire them together through a thin CLI layer.
Rune AI
Key Insights
- Combine
argparse,sqlite3,pathlib,csv, andjsonto build a working CLI tool. - Use subcommands for multi-action tools like
add,list,done, andexport. - Store data in
sqlite3for persistence; export to CSV/JSON for portability. - Use
loggingto stderr andprint()to stdout for clean output separation. - Structure code with separate modules for CLI, database, and export logic.
- Define console scripts in
pyproject.tomlforpip installsupport.
Frequently Asked Questions
Can a real CLI tool be built with only the standard library?
How do I distribute my CLI tool to others?
Should I use the standard library or third-party libraries for a real project?
Conclusion
You can build a complete, useful CLI tool using only the Python standard library. This project combined argparse, sqlite3, pathlib, csv, json, logging, and datetime into a working task manager. The same pattern scales to larger tools: organize your code into modules, use the standard library for data and I/O, and add third-party libraries only when you genuinely need them.
More in this topic
Format Dates and Times in Python
Learn how to use strftime and strptime to format dates as strings and parse date strings into Python datetime objects.
What Is the Python Standard Library?
Learn what the Python standard library is, why it matters, and how to use its built-in modules without installing anything extra.
Encode and Decode Base64 Data in Python
Learn how to use Python's base64 module to encode binary data as text and decode Base64 strings back to bytes.