Command-Line Interfaces (CLI)

Build professional CLI tools and scripts with argparse and click.

Why Learn CLI Development?

Command-line interfaces are essential for automation, system administration, data processing, and building developer tools. Professional CLI tools provide clear help text, handle errors gracefully, and follow Unix conventions.

What Are Command-Line Interfaces?

A Command-Line Interface (CLI) allows users to interact with programs through text commands in a terminal or shell. CLIs are powerful, scriptable, and efficient for automation tasks.

CLI Advantages
  • Fast and efficient for repetitive tasks
  • Easy to automate with scripts
  • Works over SSH and remote connections
  • Lower resource usage than GUIs
  • Perfect for server environments
Common CLI Examples
  • git commit -m "message"
  • pip install package
  • grep -r "pattern" .
  • docker run -p 8080:80 nginx
  • python script.py --input file.txt

Basic Command-Line Arguments (sys.argv)

The simplest way to access command-line arguments is through sys.argv, a list containing all arguments passed to the script.

# simple_cli.py
import sys

# sys.argv[0] is the script name
# sys.argv[1:] are the arguments

print(f"Script name: {sys.argv[0]}")
print(f"Arguments: {sys.argv[1:]}")

if len(sys.argv) > 1:
    name = sys.argv[1]
    print(f"Hello, {name}!")
else:
    print("Hello, World!")
Expected Output:
$ python simple_cli.py Alice
Script name: simple_cli.py
Arguments: ['Alice']
Hello, Alice!

Limitations of sys.argv: No type checking, no help text, manual parsing, no validation. For professional CLIs, use argparse or click instead.

Introduction to argparse

The argparse module is Python's standard library for building professional command-line interfaces. It handles argument parsing, generates help text, and validates input.

Basic argparse Usage

# greet.py
import argparse

# Create parser
parser = argparse.ArgumentParser(
    description='A simple greeting program'
)

# Add argument
parser.add_argument('name', help='Name of person to greet')

# Parse arguments
args = parser.parse_args()

# Use arguments
print(f"Hello, {args.name}!")
Expected Output:
$ python greet.py Alice
Hello, Alice!

$ python greet.py --help
usage: greet.py [-h] name

A simple greeting program

positional arguments:
  name        Name of person to greet

options:
  -h, --help  show this help message and exit

Positional vs Optional Arguments

Positional arguments are required and must be provided in order.
Optional arguments start with - or -- and can appear in any order.

# calculator.py
import argparse

parser = argparse.ArgumentParser(description='Simple calculator')

# Positional arguments (required)
parser.add_argument('num1', type=int, help='First number')
parser.add_argument('num2', type=int, help='Second number')

# Optional arguments (flags)
parser.add_argument('-o', '--operation',
                   default='add',
                   choices=['add', 'sub', 'mul', 'div'],
                   help='Operation to perform (default: add)')

parser.add_argument('-v', '--verbose',
                   action='store_true',
                   help='Print verbose output')

args = parser.parse_args()
# Perform calculation
result = 0
if args.operation == 'add':
    result = args.num1 + args.num2
elif args.operation == 'sub':
    result = args.num1 - args.num2
elif args.operation == 'mul':
    result = args.num1 * args.num2
elif args.operation == 'div':
    result = args.num1 / args.num2

if args.verbose:
    print(f"Calculating: {args.num1} {args.operation} {args.num2}")
    print(f"Result: {result}")
else:
    print(result)
Expected Output:
$ python calculator.py 10 5
15

$ python calculator.py 10 5 --operation mul
50

$ python calculator.py 10 5 -o div -v
Calculating: 10 div 5
Result: 2.0

Argument Types and Validation

Use the type parameter to automatically convert and validate arguments.

# validate.py
import argparse

parser = argparse.ArgumentParser()

# Integer argument
parser.add_argument('--age', type=int, help='Your age')

# Float argument
parser.add_argument('--price', type=float, help='Price in dollars')

# File argument (must exist)
parser.add_argument('--input',
                   type=argparse.FileType('r'),
                   help='Input file')

# Custom validation function
def positive_int(value):
    ivalue = int(value)
    if ivalue <= 0:
        raise argparse.ArgumentTypeError(f"{value} is not a positive integer")
    return ivalue

parser.add_argument('--count',
                   type=positive_int,
                   help='A positive integer')

args = parser.parse_args()
Expected Output:
$ python validate.py --age twenty
usage: validate.py [-h] [--age AGE] [--price PRICE] [--input INPUT]
                   [--count COUNT]
validate.py: error: argument --age: invalid int value: 'twenty'

$ python validate.py --count -5
usage: validate.py [-h] [--age AGE] [--price PRICE] [--input INPUT]
                   [--count COUNT]
validate.py: error: argument --count: -5 is not a positive integer

Notice what argparse prints on a bad value: the usage line first, then the program name and the specific problem. It writes all of that to stderr and exits with status 2, so a shell script calling your tool can tell the difference between "it worked" and "the arguments were wrong".

Default Values and Choices

# settings.py
import argparse

parser = argparse.ArgumentParser()

# Default value
parser.add_argument('--output',
                   default='output.txt',
                   help='Output filename (default: output.txt)')

# Limited choices
parser.add_argument('--format',
                   choices=['json', 'xml', 'csv'],
                   default='json',
                   help='Output format')

# Boolean flag (store_true means default is False)
parser.add_argument('--debug',
                   action='store_true',
                   help='Enable debug mode')

# Count occurrences (for -vvv style verbosity)
parser.add_argument('-v', '--verbose',
                   action='count',
                   default=0,
                   help='Increase verbosity (can be repeated)')

args = parser.parse_args()

print(f"Output: {args.output}")
print(f"Format: {args.format}")
print(f"Debug: {args.debug}")
print(f"Verbosity level: {args.verbose}")
Expected Output:
$ python settings.py
Output: output.txt
Format: json
Debug: False
Verbosity level: 0

$ python settings.py --format csv --debug -vvv
Output: output.txt
Format: csv
Debug: True
Verbosity level: 3

Advanced argparse Features

Multiple Values (nargs)

Use nargs to accept multiple values for a single argument.

# points.py
import argparse

parser = argparse.ArgumentParser()

# Exactly 2 values
parser.add_argument('--point', nargs=2, type=float,
                   help='X and Y coordinates')

# One or more values
parser.add_argument('--files', nargs='+',
                   help='One or more files to process')

# Zero or more values
parser.add_argument('--tags', nargs='*',
                   help='Optional tags')

# Optional single value
parser.add_argument('--config', nargs='?',
                   const='default.conf',
                   help='Config file')

args = parser.parse_args()

if args.point:
    x, y = args.point
    print(f"Point: ({x}, {y})")

if args.files:
    print(f"Processing {len(args.files)} files")
    for file in args.files:
        print(f"  - {file}")
Expected Output:
$ python points.py --point 10.5 20.3 --files a.txt b.txt c.txt
Point: (10.5, 20.3)
Processing 3 files
  - a.txt
  - b.txt
  - c.txt

Mutually Exclusive Groups

Use mutually exclusive groups when only one of several options should be allowed.

# export.py
import argparse

parser = argparse.ArgumentParser()

# Create mutually exclusive group
group = parser.add_mutually_exclusive_group()
group.add_argument('--json', action='store_true',
                  help='Output as JSON')
group.add_argument('--xml', action='store_true',
                  help='Output as XML')
group.add_argument('--csv', action='store_true',
                  help='Output as CSV')

args = parser.parse_args()

if args.json:
    print("Using JSON format")
elif args.xml:
    print("Using XML format")
elif args.csv:
    print("Using CSV format")
else:
    print("Using default format")
Expected Output:
$ python export.py --json
Using JSON format

$ python export.py --json --xml
usage: export.py [-h] [--json | --xml | --csv]
export.py: error: argument --xml: not allowed with argument --json

Subcommands

Subcommands allow you to create CLI tools with multiple commands, like git commit,git push, etc.

# tool.py - A CLI with subcommands
import argparse

parser = argparse.ArgumentParser(prog='tool')
subparsers = parser.add_subparsers(dest='command', help='Available commands')

# 'create' subcommand
create_parser = subparsers.add_parser('create', help='Create a new item')
create_parser.add_argument('name', help='Name of item')
create_parser.add_argument('--type', default='standard', help='Type of item')

# 'delete' subcommand
delete_parser = subparsers.add_parser('delete', help='Delete an item')
delete_parser.add_argument('name', help='Name of item to delete')
delete_parser.add_argument('--force', action='store_true',
                          help='Force deletion')

# 'list' subcommand
list_parser = subparsers.add_parser('list', help='List all items')
list_parser.add_argument('--verbose', action='store_true',
                        help='Show detailed information')

args = parser.parse_args()
# Handle subcommands
if args.command == 'create':
    print(f"Creating {args.type} item: {args.name}")
elif args.command == 'delete':
    if args.force:
        print(f"Force deleting: {args.name}")
    else:
        print(f"Deleting: {args.name}")
elif args.command == 'list':
    if args.verbose:
        print("Listing items (verbose mode)")
    else:
        print("Listing items")
else:
    parser.print_help()
Expected Output:
$ python tool.py create myitem --type premium
Creating premium item: myitem

$ python tool.py delete myitem --force
Force deleting: myitem

$ python tool.py list --verbose
Listing items (verbose mode)

Introduction to click

click is a third-party library that makes creating CLIs even easier with decorators and a more intuitive API. It's especially popular for complex CLI applications.

Install click: pip install click

Basic click Usage

# hello.py
import click

@click.command()
@click.argument('name')
@click.option('--greeting', default='Hello', help='Greeting to use')
@click.option('--count', default=1, help='Number of times to greet')
def hello(name, greeting, count):
    """Simple program that greets NAME."""
    for _ in range(count):
        click.echo(f"{greeting}, {name}!")

if __name__ == '__main__':
    hello()
Expected Output:
$ python hello.py Alice
Hello, Alice!

$ python hello.py Alice --greeting "Hi" --count 3
Hi, Alice!
Hi, Alice!
Hi, Alice!

click Options and Types

# profile_cli.py
import click

@click.command()
@click.option('--verbose', '-v', is_flag=True, help='Verbose output')
@click.option('--name', prompt='Your name', help='Your name')
@click.option('--age', type=int, help='Your age')
@click.option('--height', type=float, help='Height in meters')
@click.option('--color',
             type=click.Choice(['red', 'green', 'blue']),
             help='Favorite color')
@click.option('--input-file',
             type=click.File('r'),
             help='Input file')
def process(verbose, name, age, height, color, input_file):
    """Process user information."""
    if verbose:
        click.echo("Running in verbose mode")

    click.echo(f"Name: {name}")
    if age:
        click.echo(f"Age: {age}")
    if height:
        click.echo(f"Height: {height}m")
    if color:
        click.echo(f"Favorite color: {color}")

    if input_file:
        content = input_file.read()
        click.echo(f"File content: {content}")

if __name__ == '__main__':
    process()
Expected Output:
$ python profile_cli.py --age 30 --height 1.75 --color blue -v
Your name: Alice
Running in verbose mode
Name: Alice
Age: 30
Height: 1.75m
Favorite color: blue

The --name option was never passed on the command line, so prompt='Your name' asked for it and the answer was typed in. That is convenient interactively and a hazard in a script: a prompt with nothing to read will block. Give options a default, or accept them as arguments, for anything meant to run unattended.

click Command Groups

Create multi-command CLIs (like git, docker) with command groups.

# manager.py
import click

@click.group()
def cli():
    """Project management tool."""
    pass

@cli.command()
@click.argument('name')
# The flag stays --type for the user; the second argument renames the Python
# parameter so it does not shadow the builtin type() inside this function
@click.option('--type', 'project_type', default='basic', help='Project type')
def create(name, project_type):
    """Create a new project."""
    click.echo(f"Creating {project_type} project: {name}")

@cli.command()
@click.argument('name')
@click.option('--force', is_flag=True, help='Force deletion')
def delete(name, force):
    """Delete a project."""
    if force:
        click.echo(f"Force deleting project: {name}")
    else:
        if click.confirm(f'Delete project {name}?'):
            click.echo(f"Deleting project: {name}")

# Same idea for the command name: the CLI still exposes "list", but the
# function is list_projects so the builtin list() stays available
@cli.command('list')
@click.option('--detailed', is_flag=True, help='Show details')
def list_projects(detailed):
    """List all projects."""
    if detailed:
        click.echo("Detailed project list:")
    else:
        click.echo("Project list:")

if __name__ == '__main__':
    cli()
Expected Output:
$ python manager.py create myproject --type advanced
Creating advanced project: myproject

$ python manager.py delete myproject
Delete project myproject? [y/N]: y
Deleting project: myproject

$ python manager.py list --detailed
Detailed project list:

Building Real CLI Tools

Example 1: File Processing Tool

# fileproc.py - Process text files
import argparse
import sys

def count_words(filename):
    """Count words in a file."""
    try:
        with open(filename, 'r') as f:
            content = f.read()
            words = content.split()
            return len(words)
    except FileNotFoundError:
        print(f"Error: File '{filename}' not found", file=sys.stderr)
        sys.exit(1)
    except Exception as e:
        print(f"Error: {e}", file=sys.stderr)
        sys.exit(1)

def count_lines(filename):
    """Count lines in a file."""
    with open(filename, 'r') as f:
        return sum(1 for _ in f)

def main():
    parser = argparse.ArgumentParser(
        description='File processing utility',
        epilog='Example: fileproc.py --words myfile.txt'
    )

    parser.add_argument('files', nargs='+', help='Files to process')
    parser.add_argument('-w', '--words', action='store_true',
                       help='Count words')
    parser.add_argument('-l', '--lines', action='store_true',
                       help='Count lines')
    parser.add_argument('-v', '--verbose', action='store_true',
                       help='Show detailed output')

    args = parser.parse_args()

    # Default to word count if no option specified
    if not args.words and not args.lines:
        args.words = True

    for filename in args.files:
        if args.verbose:
            print(f"\nProcessing: {filename}")

        if args.words:
            word_count = count_words(filename)
            print(f"{word_count} words")

        if args.lines:
            line_count = count_lines(filename)
            print(f"{line_count} lines")

if __name__ == '__main__':
    main()
Expected Output:
$ printf 'the quick brown fox\njumps over the lazy dog\nand keeps on running\n' > document.txt

$ python fileproc.py document.txt
13 words

$ python fileproc.py --lines document.txt
3 lines

$ python fileproc.py -wl -v document.txt

Processing: document.txt
13 words
3 lines

$ python fileproc.py missing.txt
Error: File 'missing.txt' not found
$ echo $?
1

Example 2: Data Conversion Tool with click

# convert.py - Convert between data formats
import click
import json
import csv
import sys

@click.command()
@click.argument('input_file', type=click.File('r'))
@click.option('--output', '-o', type=click.File('w'), default='-',
             help='Output file (default: stdout)')
@click.option('--from-format', type=click.Choice(['json', 'csv']),
             required=True, help='Input format')
@click.option('--to-format', type=click.Choice(['json', 'csv']),
             required=True, help='Output format')
def convert(input_file, output, from_format, to_format):
    """Convert data between JSON and CSV formats."""

    try:
        # Read input
        if from_format == 'json':
            data = json.load(input_file)
            if not isinstance(data, list):
                raise ValueError("JSON must be a list of objects")
        else:  # CSV
            reader = csv.DictReader(input_file)
            data = list(reader)

        # Write output
        if to_format == 'json':
            json.dump(data, output, indent=2)
            output.write('\n')
        else:  # CSV
            if not data:
                click.echo("Warning: No data to convert", err=True)
                return

            writer = csv.DictWriter(output, fieldnames=data[0].keys())
            writer.writeheader()
            writer.writerows(data)

        click.echo(f"✓ Converted {len(data)} records", err=True)

    except json.JSONDecodeError as e:
        click.echo(f"Error: Invalid JSON - {e}", err=True)
        sys.exit(1)
    except Exception as e:
        click.echo(f"Error: {e}", err=True)
        sys.exit(1)

if __name__ == '__main__':
    convert()
Expected Output:
$ printf 'name,age\nAlice,30\nBob,25\n' > data.csv

$ python convert.py data.csv --from-format csv --to-format json
[
  {
    "name": "Alice",
    "age": "30"
  },
  {
    "name": "Bob",
    "age": "25"
  }
]
✓ Converted 2 records

$ python convert.py data.csv --from-format csv --to-format json -o data.json
✓ Converted 2 records

$ python convert.py data.json --from-format json --to-format csv -o out.csv
✓ Converted 2 records

The record count goes to stderr with err=True while the converted data goes to stdout, which is what makes the middle command above safe to redirect: pipe the output into another tool and you get only the JSON, with the progress note still visible on your terminal.

Best Practices

DO
  • Provide clear, helpful help text
  • Validate inputs and provide meaningful error messages
  • Follow Unix conventions (e.g., exit code 0 for success)
  • Use --verbose for debugging output
  • Support --version flag
  • Write to stderr for errors, stdout for output
DON'T
  • Show stack traces to end users
  • Require interactive input in scripts
  • Use unclear or cryptic argument names
  • Exit without proper error messages
  • Ignore the user's locale/encoding
  • Print sensitive information (passwords, keys)

Comprehensive Help Text

# mytool.py
import argparse

parser = argparse.ArgumentParser(
    prog='mytool',
    description='A comprehensive tool for data processing',
    epilog='For more information, visit: https://example.com/docs',
    formatter_class=argparse.RawDescriptionHelpFormatter
)

parser.add_argument('--version', action='version', version='%(prog)s 1.0.0')

parser.add_argument(
    'input',
    help='Input file path (supports .txt, .csv, .json)'
)

parser.add_argument(
    '--output', '-o',
    metavar='FILE',
    help='Output file path (default: stdout)'
)

# Good help text is descriptive and includes examples
parser.add_argument(
    '--format',
    choices=['json', 'csv', 'xml'],
    help='Output format: json (default), csv, or xml'
)

args = parser.parse_args()
print(f"Reading {args.input}, writing {args.format or 'json'} to {args.output or 'stdout'}")
Expected Output:
$ python mytool.py --help
usage: mytool [-h] [--version] [--output FILE] [--format {json,csv,xml}] input

A comprehensive tool for data processing

positional arguments:
  input                 Input file path (supports .txt, .csv, .json)

options:
  -h, --help            show this help message and exit
  --version             show program's version number and exit
  --output FILE, -o FILE
                        Output file path (default: stdout)
  --format {json,csv,xml}
                        Output format: json (default), csv, or xml

For more information, visit: https://example.com/docs

$ python mytool.py --version
mytool 1.0.0

$ python mytool.py data.csv --format csv
Reading data.csv, writing csv to stdout

Proper Error Handling

# safe_read.py
import sys
import click

@click.command()
@click.argument('filename')
def process_file(filename):
    """Process a file with proper error handling."""

    try:
        with open(filename, 'r') as f:
            content = f.read()
            # Process content...
            click.echo("✓ File processed successfully")

    except FileNotFoundError:
        click.echo(f"Error: File '{filename}' not found", err=True)
        sys.exit(1)

    except PermissionError:
        click.echo(f"Error: Permission denied reading '{filename}'", err=True)
        sys.exit(1)

    except Exception as e:
        click.echo(f"Error: {e}", err=True)
        sys.exit(1)

if __name__ == '__main__':
    process_file()
Expected Output:
$ python safe_read.py missing.txt
Error: File 'missing.txt' not found
$ echo $?
1

User Feedback

# feedback.py
import click

@click.command()
@click.argument('files', nargs=-1)
def process(files):
    """Process multiple files with progress feedback."""

    if not files:
        click.echo("No files specified", err=True)
        return

    # Progress bar for long operations
    with click.progressbar(files, label='Processing files') as bar:
        for filename in bar:
            # Process file...
            pass

    # Styled output
    click.secho("✓ All files processed!", fg='green', bold=True)

    # Confirmation prompts
    if click.confirm('Delete temporary files?'):
        click.echo("Deleting temporary files...")

    # Color-coded messages
    click.secho("Warning: Using default settings", fg='yellow')
    click.secho("Error: Connection failed", fg='red', err=True)

# The progress bar redraws itself in place, so only its final state is left on
# screen. click checks whether it is talking to a terminal: piped into another
# command it drops the bar and the color codes, which is what keeps output
# readable in a log file.
if __name__ == '__main__':
    process()
Expected Output:
$ python feedback.py a.txt b.txt
Processing files  [####################################]  100%
✓ All files processed!
Delete temporary files? [y/N]: y
Deleting temporary files...
Warning: Using default settings
Error: Connection failed

Key Takeaways

  • sys.argv is simple but limited - use argparse or click for professional CLIs
  • argparse is Python's standard library for building CLIs with type checking and validation
  • Positional arguments are required, optional arguments start with - or --
  • Use type parameter for automatic conversion and validation
  • Subcommands enable multi-command tools like git or docker
  • click offers a decorator-based API that's more intuitive for complex CLIs
  • Always provide clear help text and meaningful error messages
  • Follow Unix conventions: exit code 0 for success, non-zero for errors
  • Write errors to stderr, output to stdout
  • Add progress indicators and color for better user experience
What's Next?

You've mastered CLI development! Now let's learn how to manage application configuration across different environments and deployment scenarios.

  • Configuration Management - Handle config files, environment variables, and secrets
  • Environment-Specific Settings - Manage dev, staging, and production configurations
  • Best Practices - Learn the 12-factor app methodology and secure config handling