Build Command-Line Applications in Python

Learn how to build professional command-line applications in Python with argparse, logging, signal handling, and structured project layout.

7 min read

Building a command-line application in Python means combining several standard library modules into a cohesive tool that users can run from a terminal, install alongside other system commands, and script around in shell pipelines without surprises. A well-built CLI handles arguments with argparse, reports diagnostics with logging, exits with meaningful status codes, and responds to Ctrl+C gracefully instead of dumping a raw traceback.

pythonpython
import argparse
import logging
import sys
 
def main():
    parser = argparse.ArgumentParser(description="A well-behaved CLI tool.")
    parser.add_argument("input", help="input file")
    parser.add_argument("--verbose", action="store_true", help="verbose output")
    args = parser.parse_args()
    level = logging.DEBUG if args.verbose else logging.INFO
    logging.basicConfig(level=level, format="%(levelname)s: %(message)s", stream=sys.stderr)
    logging.info("Processing %s", args.input)
    logging.debug("Verbose mode enabled")
    print(f"Result for {args.input}")

Calling main() only happens when the file runs directly, not when it gets imported elsewhere as a module, which matters once your CLI code needs to be reused or tested from other scripts without accidentally triggering a real run:

pythonpython
if __name__ == "__main__":
    main()

This small script demonstrates the key patterns that the rest of this article covers in more depth: argument parsing, logging to stderr, and output to stdout. The user can redirect the result to a file while still seeing diagnostic messages in the terminal, because the two streams never get mixed together.

Structuring a CLI project

Cramming everything into a single script works for a quick throwaway tool, but it gets harder to test and maintain as the tool grows, especially once other people start depending on it. A clean CLI project separates the entry point from business logic from the very start.

texttext
mycli/
    __init__.py
    __main__.py
    cli.py
    core.py
pyproject.toml

cli.py handles argument parsing and wiring. core.py contains the actual logic, which can be imported and tested independently of the command-line interface, since tests can call those functions directly without going through argparse at all. main.py lets users run python -m mycli even before the package is installed as a console script, which is convenient during development.

In pyproject.toml, define the console script:

tomltoml
[project.scripts]
mycli = "mycli.cli:main"

After pip install -e ., users run mycli from anywhere on their system, without needing to know it is a Python script, where its source files live, or how to invoke the Python interpreter directly.

Exit codes

CLI tools communicate success or failure through exit codes rather than through their printed output, since anything printed can be redirected, filtered, or lost, but the exit code always reaches whatever invoked the process. 0 means success; non-zero means failure.

pythonpython
import argparse
import sys
 
def read_or_exit(filename):
    try:
        with open(filename) as file:
            return file.read()
    except FileNotFoundError:
        print(f"Error: '{filename}' not found", file=sys.stderr)
        sys.exit(1)
    except PermissionError:
        print(f"Error: permission denied for '{filename}'", file=sys.stderr)
        sys.exit(2)

The main function stays short and easy to scan because the error handling and exit codes live in the helper function above, instead of being tangled up with argument parsing and the top-level flow of the script:

pythonpython
def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("filename")
    args = parser.parse_args()
    print(read_or_exit(args.filename))
    sys.exit(0)
 
if __name__ == "__main__":
    main()

Use distinct exit codes for different failure modes instead of always exiting with a generic 1. This lets shell scripts and CI pipelines branch on the specific error, retrying a permission problem differently than a missing file, for example.

CodeMeaning
0Success
1General error
2Misuse (bad arguments)

You can define additional codes for your tool's specific error categories, as long as you document them somewhere so users and scripts calling your tool know what each one means.

Logging in CLI tools

Use the logging module for diagnostics, progress, and debugging instead of scattering print() calls throughout your code, since logging gives you levels, timestamps, and a single place to change formatting later. Send logs to stderr so they stay separate from stdout data, and let the --verbose flag control how much detail actually gets shown at runtime.

pythonpython
import argparse
import logging
import sys
 
def setup_logging(verbose):
    level = logging.DEBUG if verbose else logging.INFO
    logging.basicConfig(
        level=level,
        format="%(asctime)s %(levelname)s %(message)s",
        datefmt="%H:%M:%S",
        stream=sys.stderr,
    )

With logging configured separately, main() stays focused on parsing arguments and processing each file, one at a time, without repeating any logging setup details inline every time the function runs:

pythonpython
def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--verbose", action="store_true")
    parser.add_argument("files", nargs="+")
    args = parser.parse_args()
    setup_logging(args.verbose)
    for path in args.files:
        logging.info("Processing %s", path)
        with open(path) as file:
            line_count = sum(1 for _ in file)
        logging.debug("%s: %d lines", path, line_count)
    print("Done")

The usual guard at the bottom of the file makes sure main() only runs when the script is executed directly, not when some other module imports it:

pythonpython
if __name__ == "__main__":
    main()

The user sees Done in stdout. Status messages appear in stderr. This pattern lets users pipe output to other commands:

bashbash
python count.py data.csv > output.txt

The log messages still appear in the terminal because stderr is not redirected, and only Done goes into output.txt since that is the only thing written to stdout.

Handling Ctrl+C

Users press Ctrl+C often, whether to cancel a long download, stop a script they started by mistake, or just get their terminal back, and Python raises KeyboardInterrupt whenever they do. Catch KeyboardInterrupt to exit cleanly instead of showing a traceback that makes a deliberate cancellation look like a crash.

pythonpython
import sys
import time
 
def main():
    try:
        for i in range(100):
            time.sleep(0.1)
            print(f"Progress: {i + 1}%", end="\r")
    except KeyboardInterrupt:
        print("\nCancelled by user.")
        sys.exit(130)
 
if __name__ == "__main__":
    main()

Exit code 130 is the Unix convention for a process terminated by Ctrl+C (128 + signal number 2), and following it lets other tools recognize the interruption correctly. On Windows, any non-zero exit code signals the interruption, since the same 128-plus-signal convention does not apply there.

Multi-command CLI with subparsers

A single tool often needs to do more than one thing, the way git has commit, push, and pull as distinct subcommands, each accepting its own combination of flags. For tools with multiple commands, use add_subparsers() to give each one its own set of arguments and help text.

pythonpython
import argparse
 
def cmd_list(args):
    print("Listing items...")
 
def cmd_add(args):
    print(f"Adding item: {args.name}")

With both handlers defined above, main() builds the subparsers and dispatches to whichever handler matches the subcommand the user actually chose on the command line, using a plain dictionary instead of a long if/elif chain:

pythonpython
def main():
    parser = argparse.ArgumentParser(description="Task manager")
    subparsers = parser.add_subparsers(dest="command", required=True)
    list_parser = subparsers.add_parser("list", help="list all tasks")
    add_parser = subparsers.add_parser("add", help="add a task")
    add_parser.add_argument("name", help="task name")
    add_parser.add_argument("--priority", type=int, default=1)
    args = parser.parse_args()
    commands = {"list": cmd_list, "add": cmd_add}
    commands[args.command](args)
 
if __name__ == "__main__":
    main()

Each subcommand maps to a handler function that receives the parsed arguments, so adding a new subcommand later just means writing another handler and registering it in the commands dictionary, without touching the ones that already exist. This keeps the parser definition clean and each command's logic self-contained and independently testable.

Practical example: a complete CLI tool

Combine all the patterns from this article, argument parsing, logging, exit codes, and Ctrl+C handling, into a single file search tool that behaves like a well-mannered CLI a user could actually rely on day to day.

pythonpython
import argparse
import logging
import sys
from pathlib import Path
 
def setup_logging(verbose):
    level = logging.DEBUG if verbose else logging.WARNING
    logging.basicConfig(level=level, format="%(levelname)s: %(message)s", stream=sys.stderr)

The search logic lives in its own function, separate from argument parsing, so it can be tested or reused without going through the command line at all, and a unit test can call it directly with plain arguments instead of simulating a real invocation:

pythonpython
def search_files(root, pattern, ignore_case):
    root = Path(root)
    if not root.is_dir():
        print(f"Error: '{root}' is not a directory", file=sys.stderr)
        sys.exit(1)
    matches = []
    for path in root.rglob("*"):
        if path.is_file():
            name = path.name.lower() if ignore_case else path.name
            search = pattern.lower() if ignore_case else pattern
            if search in name:
                matches.append(path)
                logging.debug("Match: %s", path)
    return matches

A small helper builds the parser, keeping the list of arguments separate from the logic that uses them, which makes both pieces easier to read, test, and change independently as the tool grows over time:

pythonpython
def build_parser():
    parser = argparse.ArgumentParser(description="Search for files by name pattern.")
    parser.add_argument("directory", help="directory to search")
    parser.add_argument("pattern", help="pattern to match in filenames")
    parser.add_argument("-i", "--ignore-case", action="store_true", help="case-insensitive search")
    parser.add_argument("--verbose", action="store_true", help="verbose output")
    return parser

main() ties everything together: it parses arguments, configures logging, calls search_files, and reports the results with an appropriate exit code, while staying short enough to read in a single glance:

pythonpython
def main():
    args = build_parser().parse_args()
    setup_logging(args.verbose)
    try:
        results = search_files(args.directory, args.pattern, args.ignore_case)
    except KeyboardInterrupt:
        print("\nSearch cancelled.", file=sys.stderr)
        sys.exit(130)
    for path in results:
        print(path)
    logging.info("Found %d matching files", len(results))
    sys.exit(0)

The same guard as before keeps this script safely importable elsewhere without triggering a file search just from the import itself, which matters if you later want to reuse search_files from a different tool:

pythonpython
if __name__ == "__main__":
    main()

Run with python search.py . report -i to find files containing "report" (case-insensitive) in the current directory. Every pattern from this article shows up here: argument parsing defines the interface, logging reports progress without polluting the results, the try/except turns Ctrl+C into a clean message and exit code, and the final sys.exit(0) confirms success to anything scripting around this tool, from a shell script to a CI job that runs it on a schedule.

Common mistakes

These mistakes show up repeatedly in real CLI tools, often because they work fine during casual manual testing and only cause problems once a script is automated, piped, or run unattended in a cron job or CI pipeline.

Mixing diagnostic output with data output. Print data results to stdout and diagnostics to stderr. Do not use print() for log messages that the user should see even when redirecting output, since anything printed to stdout ends up mixed in with the data the user actually wanted to capture.

No exit codes. Every CLI should call sys.exit() with an appropriate code, even on the success path. Shell scripts and CI rely on exit codes to know whether your tool succeeded, and a script that always exits 0 makes failures invisible to anything automating it.

No KeyboardInterrupt handling. An uncaught KeyboardInterrupt prints a messy traceback that looks like a crash, even though the user simply pressed Ctrl+C on purpose. Always wrap long-running operations in a try/except for a clean exit instead.

Putting all logic in the argument parsing function. The function that calls parse_args() should be thin: parse, validate, dispatch. Put business logic in separate, testable functions that receive plain arguments, not a parsed argparse namespace, so those functions can be tested without simulating command-line input at all.

Rune AI

Rune AI

Key Insights

  • Use argparse with subcommands for multi-command CLI tools.
  • Configure logging to write to stderr so diagnostic output stays separate from data.
  • Return meaningful exit codes: 0 for success, non-zero for errors.
  • Catch KeyboardInterrupt to handle Ctrl+C gracefully.
  • Use if __name__ == '__main__' to separate the CLI entry point from importable code.
  • Define console scripts in pyproject.toml with [project.scripts].
RunePowered by Rune AI

Frequently Asked Questions

How do I structure a Python CLI project?

Use a standard layout: a package directory with an `__init__.py`, a `cli.py` (or `__main__.py`) for the entry point, and separate modules for logic. Define your entry point in `pyproject.toml` with `[project.scripts]` so users can run your tool by name after installing.

How do I handle Ctrl+C gracefully?

Register a signal handler with `signal.signal(signal.SIGINT, handler)` or catch `KeyboardInterrupt` around your main function. Print a message and exit cleanly instead of showing a traceback.

Should I use print() or logging for CLI output?

Use `print()` for the main output the user expects. Use `logging` (configured to write to stderr) for diagnostic messages, progress information, and debugging. This keeps status messages separate from data output, so users can redirect stdout to a file and still see errors.

Conclusion

A production-quality Python CLI combines argparse for argument parsing, logging for diagnostics, proper exit codes for scripting, and signal handling for graceful shutdown. Structure your project so the CLI entry point is separate from the core logic, and use pyproject.toml to define the console script entry point for easy installation.