Command-line tools are the building blocks of DevOps automation. Every day you use tools like `git`, `docker`, `kubectl`, and `terraform`. In this lesson, you'll learn to build your own professional CLI tools that developers will love to use. We'll cover both Python's built-in `argparse` and the more powerful `Click` library.
1. Learning Objectives
By the end of this lesson, you will be able to:
- Build CLI tools with argparse and Click
- Handle positional and optional arguments
- Create subcommands (like git commit, git push)
- Add colors, progress bars, and interactive prompts
- Package and distribute CLI tools
- Create a production-ready deployment CLI
2. Why This Matters
Real-world scenario: Your team needs a tool to deploy applications, manage servers, or run database migrations. A well-designed CLI tool is the perfect solutionβeasy to automate, easy to document, and easy for teammates to use.
3. Core Concepts
Argparse: Python's Built-in Solution
import argparse
# Basic parser
parser = argparse.ArgumentParser(description='My CLI Tool')
parser.add_argument('name', help='Your name')
parser.add_argument('--age', type=int, help='Your age')
parser.add_argument('-v', '--verbose', action='store_true', help='Enable verbose output')
args = parser.parse_args()
print(f"Hello, {args.name}!")
if args.age:
print(f"You are {args.age} years old")
if args.verbose:
print("Verbose mode enabled")
# Advanced argument types
parser = argparse.ArgumentParser()
# Choices
parser.add_argument('--log-level', choices=['DEBUG', 'INFO', 'WARNING', 'ERROR'], default='INFO')
# Multiple values
parser.add_argument('--files', nargs='+', help='List of files')
# Required flag
parser.add_argument('--api-key', required=True, help='API key is required')
# Mutually exclusive group
group = parser.add_mutually_exclusive_group()
group.add_argument('--json', action='store_true', help='Output as JSON')
group.add_argument('--yaml', action='store_true', help='Output as YAML')
# Subcommands
subparsers = parser.add_subparsers(dest='command', help='Subcommands')
# deploy subcommand
deploy_parser = subparsers.add_parser('deploy', help='Deploy application')
deploy_parser.add_argument('--environment', choices=['dev', 'staging', 'prod'], required=True)
deploy_parser.add_argument('--version', help='Version to deploy')
# status subcommand
status_parser = subparsers.add_parser('status', help='Get status')
status_parser.add_argument('--service', help='Service name')
# Parse and handle
args = parser.parse_args()
if args.command == 'deploy':
print(f"Deploying to {args.environment}")
elif args.command == 'status':
print(f"Checking status of {args.service or 'all services'}")
Argparse for CLI tools
Click: The Professional Choice
# Install Click
pip install click
Installation
import click
@click.command()
@click.argument('name')
@click.option('--age', type=int, help='Your age')
@click.option('--verbose', '-v', is_flag=True, help='Enable verbose output')
@click.option('--log-level', type=click.Choice(['DEBUG', 'INFO', 'WARNING', 'ERROR']), default='INFO')
@click.option('--count', default=1, help='Number of greetings')
def hello(name, age, verbose, log_level, count):
"""Simple CLI tool that says hello."""
for _ in range(count):
click.echo(f"Hello, {name}!")
if age:
click.echo(f"You are {age} years old")
if verbose:
click.echo(f"Log level: {log_level}")
if __name__ == '__main__':
hello()
Click basics
import click
# Colors and styling
@click.command()
def colors():
click.echo(click.style('Success!', fg='green', bold=True))
click.echo(click.style('Warning!', fg='yellow'))
click.echo(click.style('Error!', fg='red', blink=True))
click.echo(click.style('Info', fg='cyan', underline=True))
# Progress bar
@click.command()
@click.option('--count', default=100, help='Number of items')
def progress(count):
with click.progressbar(range(count)) as bar:
for _ in bar:
pass # Do work
# Interactive prompts
@click.command()
def prompts():
name = click.prompt('Enter your name', type=str)
password = click.prompt('Enter password', hide_input=True)
confirm = click.confirm('Do you want to continue?', default=True)
if confirm:
click.echo(f"Hello, {name}!")
# Password confirmation
def password_prompt():
while True:
pwd = click.prompt('Password', hide_input=True)
confirm = click.prompt('Confirm password', hide_input=True)
if pwd == confirm:
return pwd
click.echo('Passwords do not match!')
# Spinner for long operations
@click.command()
def spinner():
with click.progressbar(length=100, label='Processing') as bar:
for _ in range(100):
time.sleep(0.02)
bar.update(1)
Click advanced features
Click Subcommands
import click
@click.group()
def cli():
"""DevOps CLI Tool"""
pass
@cli.command()
@click.option('--environment', '-e', required=True, help='Environment to deploy')
@click.option('--version', '-v', help='Version to deploy')
def deploy(environment, version):
"""Deploy application to environment"""
click.echo(f"Deploying to {environment}")
if version:
click.echo(f"Version: {version}")
@cli.command()
@click.argument('service', required=False)
def status(service):
"""Get service status"""
if service:
click.echo(f"Checking status of {service}")
else:
click.echo("Checking status of all services")
@cli.command()
@click.option('--from', 'from_env', help='Source environment')
@click.option('--to', help='Destination environment')
def backup(from_env, to):
"""Backup data between environments"""
click.echo(f"Backing up from {from_env} to {to}")
@cli.command()
def logs():
"""Show application logs"""
click.echo("Fetching logs...")
if __name__ == '__main__':
cli()
# Usage:
# python cli.py deploy --environment production --version 1.2.3
# python cli.py status nginx
# python cli.py backup --from production --to staging
Click subcommands
4. Complete Project: DevOps CLI Tool
#!/usr/bin/env python3
"""
devops-cli.py - Production DevOps Command Line Tool
"""
import click
import subprocess
import json
import os
import sys
from pathlib import Path
from datetime import datetime
VERSION = "1.0.0"
# Color constants
SUCCESS = lambda x: click.style(x, fg='green', bold=True)
ERROR = lambda x: click.style(x, fg='red', bold=True)
WARNING = lambda x: click.style(x, fg='yellow')
INFO = lambda x: click.style(x, fg='cyan')
class Config:
"""Configuration management"""
def __init__(self):
self.config_file = Path.home() / '.devops-cli' / 'config.json'
self.config = self.load()
def load(self):
if self.config_file.exists():
with open(self.config_file) as f:
return json.load(f)
return {}
def save(self):
self.config_file.parent.mkdir(exist_ok=True)
with open(self.config_file, 'w') as f:
json.dump(self.config, f, indent=2)
def get(self, key, default=None):
return self.config.get(key, default)
def set(self, key, value):
self.config[key] = value
self.save()
pass_config = click.make_pass_decorator(Config, ensure=True)
@click.group()
@click.version_option(version=VERSION)
@click.pass_context
def cli(ctx):
"""DevOps CLI Tool - Automate deployment, server management, and monitoring"""
ctx.obj = Config()
@cli.group()
def deploy():
"""Deploy commands"""
pass
@deploy.command()
@click.option('--environment', '-e', required=True,
type=click.Choice(['dev', 'staging', 'production']),
help='Deployment environment')
@click.option('--version', '-v', help='Version to deploy')
@click.option('--skip-tests', is_flag=True, help='Skip pre-deployment tests')
@click.option('--dry-run', is_flag=True, help='Show what would happen')
@click.pass_context
def app(ctx, environment, version, skip_tests, dry_run):
"""Deploy application to environment"""
click.echo(f"\n{INFO('π Starting deployment...')}\n")
if dry_run:
click.echo(WARNING("DRY RUN - No changes will be made"))
click.echo(f" Environment: {environment}")
click.echo(f" Version: {version or 'latest'}")
return
# Run tests
if not skip_tests:
click.echo(INFO("Running pre-deployment tests..."))
result = subprocess.run(['pytest', 'tests/'], capture_output=True, text=True)
if result.returncode != 0:
click.echo(ERROR("β Tests failed!"))
click.echo(result.stdout[-500:])
sys.exit(1)
click.echo(SUCCESS("β
Tests passed"))
# Simulate deployment
with click.progressbar(length=100, label='Deploying') as bar:
for i in range(100):
bar.update(1)
click.echo(SUCCESS(f"β
Successfully deployed to {environment}"))
@deploy.command()
@click.argument('service')
@click.option('--config', '-c', help='Config file path')
@click.pass_context
def service(ctx, service, config):
"""Deploy a specific service"""
click.echo(f"Deploying service: {service}")
if config:
click.echo(f"Using config: {config}")
@cli.group()
def server():
"""Server management commands"""
pass
@server.command()
@click.argument('host')
@click.option('--user', '-u', default='root', help='SSH user')
@click.option('--port', '-p', default=22, help='SSH port')
def ssh(host, user, port):
"""SSH into a server"""
click.echo(f"Connecting to {user}@{host}:{port}")
os.system(f'ssh {user}@{host} -p {port}')
@server.command()
@click.argument('host')
def status(host):
"""Check server status"""
click.echo(f"Checking status of {host}...")
try:
# Check if server is reachable
result = subprocess.run(['ping', '-c', '1', host], capture_output=True)
if result.returncode == 0:
click.echo(SUCCESS(f" β
Server is reachable"))
else:
click.echo(ERROR(f" β Server unreachable"))
except Exception:
click.echo(ERROR(f" β Cannot reach {host}"))
@cli.group()
def logs():
"""Log management commands"""
pass
@logs.command()
@click.argument('service')
@click.option('--lines', '-n', default=50, help='Number of lines')
@click.option('--follow', '-f', is_flag=True, help='Follow logs')
@click.option('--since', help='Show logs since timestamp')
def show(service, lines, follow, since):
"""Show logs for a service"""
click.echo(f"Showing last {lines} lines from {service}")
# Simulate log output
for i in range(min(lines, 20)):
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
click.echo(f"[{timestamp}] {service}: Sample log line {i+1}")
@logs.command()
@click.argument('service')
@click.option('--pattern', '-p', help='Search pattern')
@click.option('--start', help='Start date')
@click.option('--end', help='End date')
def search(service, pattern, start, end):
"""Search logs for pattern"""
click.echo(f"Searching {service} logs for '{pattern}'")
if start:
click.echo(f" From: {start}")
if end:
click.echo(f" To: {end}")
@cli.command()
@click.option('--format', '-f', type=click.Choice(['json', 'table']), default='table')
def status(format):
"""Show overall system status"""
if format == 'json':
data = {
'services': ['web', 'api', 'db'],
'status': 'healthy',
'timestamp': datetime.now().isoformat()
}
click.echo(json.dumps(data, indent=2))
else:
click.echo("\nπ SYSTEM STATUS")
click.echo("="*40)
click.echo(f" {'Service':15} {'Status':12} {'Uptime':10}")
click.echo("-"*40)
click.echo(f" {'web':15} {SUCCESS('β running'):12} {'2d 3h':10}")
click.echo(f" {'api':15} {SUCCESS('β running'):12} {'2d 3h':10}")
click.echo(f" {'db':15} {SUCCESS('β running'):12} {'2d 3h':10}")
click.echo("="*40)
@cli.command()
@click.option('--environment', '-e', default='development', help='Environment')
@click.pass_context
def config(ctx, environment):
"""Show current configuration"""
click.echo(f"\nπ Configuration ({environment})")
click.echo("="*40)
click.echo(f" API URL: {ctx.obj.get('api_url', 'Not set')}")
click.echo(f" Log Level: {ctx.obj.get('log_level', 'INFO')}")
@cli.command()
@click.option('--key', help='Configuration key')
@click.option('--value', help='Configuration value')
@click.pass_context
def set_config(ctx, key, value):
"""Set configuration value"""
if key and value:
ctx.obj.set(key, value)
click.echo(SUCCESS(f"β Set {key} = {value}"))
else:
click.echo(ERROR("Please provide both --key and --value"))
@cli.command()
def health():
"""Check system health"""
click.echo("\nπ₯ HEALTH CHECK")
click.echo("="*40)
# Check disk space
disk = subprocess.run(['df', '-h', '/'], capture_output=True, text=True)
click.echo(f" Disk: {disk.stdout.split()[11] if len(disk.stdout.split()) > 11 else 'N/A'}")
# Check memory
mem = subprocess.run(['free', '-h'], capture_output=True, text=True)
mem_line = mem.stdout.split('\n')[1].split()
click.echo(f" Memory: {mem_line[2]} used / {mem_line[1]} total")
# Check load
load = subprocess.run(['uptime'], capture_output=True, text=True)
click.echo(f" Load: {load.stdout.split('load average:')[-1].strip()}")
@cli.command()
def init():
"""Initialize DevOps CLI configuration"""
click.echo("π§ Initializing DevOps CLI...\n")
api_url = click.prompt("Enter API URL", default="https://api.example.com")
log_level = click.prompt("Log level", type=click.Choice(['DEBUG', 'INFO', 'WARNING', 'ERROR']), default='INFO')
default_env = click.prompt("Default environment", type=click.Choice(['dev', 'staging', 'production']), default='dev')
config = Config()
config.set('api_url', api_url)
config.set('log_level', log_level)
config.set('default_env', default_env)
click.echo(SUCCESS("\nβ
Configuration saved!"))
click.echo(f" API URL: {api_url}")
click.echo(f" Log Level: {log_level}")
click.echo(f" Default Environment: {default_env}")
if __name__ == '__main__':
cli()
Complete DevOps CLI tool
Packaging CLI Tools
# setup.py
from setuptools import setup, find_packages
setup(
name='devops-cli',
version='1.0.0',
packages=find_packages(),
install_requires=[
'click>=8.0.0',
'PyYAML>=5.0',
],
entry_points={
'console_scripts': [
'devops=devops_cli.main:cli',
],
},
author='Your Name',
description='DevOps CLI Tool',
classifiers=[
'Programming Language :: Python :: 3',
'Operating System :: OS Independent',
],
)
# Install with pip
# pip install -e .
# After installation, use: devops --help
Packaging CLI tools for distribution
5. Summary Checklist
6. Next Steps
Next category: Docker - Containerization (Lessons 5.2-5.6)
You'll learn to:
- Write production Dockerfiles
- Use Docker Compose for multi-container apps
- Manage Docker networking and volumes
- Implement Docker security best practices
Comments (0)
This is exactly what I needed! The initContainer approach solved our migration issues completely. Thanks for the detailed guide!
ReplyLeave a Comment