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 packagegrep -r "pattern" .docker run -p 8080:80 nginxpython 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 integerNotice 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 recordsThe 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
--verbosefor debugging output - Support
--versionflag - 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 stdoutProper 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