Build a Real-World Python CLI Tool

Learn how to build a complete, real-world command-line tool in Python using the standard library: argparse, logging, pathlib, sqlite3, and more.

8 min read

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:

bashbash
tasker add "Finish report" --priority high
tasker add "Review PR" --priority medium
tasker list
tasker done 1
tasker export --format json

Project 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.

texttext
tasker/
    __init__.py
    __main__.py
    cli.py
    database.py
    export.py
pyproject.toml

cli.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.

pythonpython
import sqlite3
from datetime import datetime
 
DATABASE = "tasks.db"
 
def get_connection():
    conn = sqlite3.connect(DATABASE)
    conn.execute("PRAGMA journal_mode=WAL;")
    return conn

With 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:

pythonpython
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:

pythonpython
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:

pythonpython
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.

pythonpython
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_size

The JSON version follows the same pattern, but first converts each row tuple into a dictionary keyed by column name:

pythonpython
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_size

Both 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.

pythonpython
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:

pythonpython
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.

pythonpython
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:

pythonpython
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:

pythonpython
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 parser

With 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:

pythonpython
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:

tomltoml
[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.

bashbash
tasker add "Finish quarterly report" --priority high
tasker add "Review pull request" --priority medium
tasker add "Update dependencies"
tasker list

Tasks print most recently added first, with their priority and completion status shown before the title, matching the id DESC ordering in list_tasks():

texttext
Added: Finish quarterly report
Added: Review pull request
Added: Update dependencies
  3 [ ] medium   Update dependencies
  2 [ ] medium   Review pull request
  1 [ ] high     Finish quarterly report

Marking a task done removes it from the default list view, since list_tasks() filters out completed tasks unless --all is passed:

bashbash
tasker done 2
tasker list

Task 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:

texttext
Marked task 2 as done.
  3 [ ] medium   Update dependencies
  1 [ ] high     Finish quarterly report

Finally, export everything, including the completed task, to a JSON file by passing --format json explicitly, since csv is the default:

bashbash
tasker export --format json

The 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:

texttext
Exported to tasks.json

What 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:

ModulePurpose
argparseCommand-line argument parsing with subcommands
sqlite3Persistent task storage
csvExport to CSV format
jsonExport to JSON format
pathlibFile size checking
loggingDiagnostic output to stderr
datetimeTimestamps in SQLite
sysstderr 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

Rune AI

Key Insights

  • Combine argparse, sqlite3, pathlib, csv, and json to build a working CLI tool.
  • Use subcommands for multi-action tools like add, list, done, and export.
  • Store data in sqlite3 for persistence; export to CSV/JSON for portability.
  • Use logging to stderr and print() to stdout for clean output separation.
  • Structure code with separate modules for CLI, database, and export logic.
  • Define console scripts in pyproject.toml for pip install support.
RunePowered by Rune AI

Frequently Asked Questions

Can a real CLI tool be built with only the standard library?

Yes. The standard library includes argparse for argument parsing, sqlite3 for data storage, csv and json for data formats, pathlib for file paths, logging for diagnostics, and more. Many production CLI tools use only the standard library.

How do I distribute my CLI tool to others?

Define a `[project.scripts]` entry point in `pyproject.toml`, then publish to PyPI with `python -m build` and `twine upload`. Users can also install directly from a Git repository with `pip install git+https://...`.

Should I use the standard library or third-party libraries for a real project?

Start with the standard library. When you hit a genuine limitation (complex HTTP sessions, YAML support, rich terminal output), add a focused third-party library. Avoid adding large dependency trees for problems the standard library can solve.

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.