AdvancedLesson 12 of 16

Building CLI Tools with argparse

Transform Python scripts into professional command-line tools with subcommands, help text, validation, and user-friendly argument parsing—tools that feel like kubectl, docker, and aws cli.

🧒 Simple Explanation (ELI5)

When you run `docker run --name myapp -e VAR=value myimage`, docker parses all those flags and options. argparse does that for your Python script. Instead of `my_script.py arg1 arg2`, you get `my_script.py deploy --app nginx --replicas 5 --force`. argparse handles parsing, validation, and auto-generates --help text so users know what is available.

🔧 Why Do We Need argparse?

⚙️ Technical Explanation

ArgumentParser: main class responsible for parsing. add_argument(): define each argument/flag. parse_args(): parse sys.argv and return Namespace object with values. Subparsers: support commands like `git commit` or `kubectl apply`—different subcommands with different args.

💡
Pre-populate with Environment Variables

Use default=os.getenv("VAR_NAME") to read from environment. This lets CI/CD pass values via env vars without explicit flags. Users can override with flags. Best of both worlds: defaults from env, explicit control via flags.

⌨️ argparse Patterns

python
import argparse
import os

# ===== SIMPLE ARGUMENT PARSER =====
parser = argparse.ArgumentParser(
    description="Deploy application to Kubernetes cluster",
    epilog="Examples:\n  python deploy.py --app nginx --replicas 3\n  python deploy.py --app web --env prod"
)

# Positional argument (required)
parser.add_argument("action", help="Action to perform: deploy, scale, restart")

# Optional argument
parser.add_argument("--app", required=True, help="Application name")
parser.add_argument("--replicas", type=int, default=1, help="Number of replicas (default: 1)")
parser.add_argument("--env", choices=["dev", "staging", "prod"], default="dev", help="Environment")

# Boolean flag
parser.add_argument("--force", action="store_true", help="Force deployment even if running")
parser.add_argument("--verbose", "-v", action="store_true", help="Verbose output")

# Parse arguments
args = parser.parse_args()

print(f"Action: {args.action}")
print(f"App: {args.app}")
print(f"Replicas: {args.replicas}")
print(f"Force: {args.force}")

# ===== ADVANCED: SUBCOMMANDS =====
# Like: deploy.py deploy --app nginx vs deploy.py scale --app nginx --replicas 5
main_parser = argparse.ArgumentParser(description="Deployment tool")
subparsers = main_parser.add_subparsers(dest="command", help="Command to execute")

# deploy command
deploy_parser = subparsers.add_parser("deploy", help="Deploy application")
deploy_parser.add_argument("--app", required=True)
deploy_parser.add_argument("--version", required=True)
deploy_parser.add_argument("--env", choices=["dev", "prod"], default="dev")

# scale command
scale_parser = subparsers.add_parser("scale", help="Scale deployment")
scale_parser.add_argument("--app", required=True)
scale_parser.add_argument("--replicas", type=int, required=True)

# status command
status_parser = subparsers.add_parser("status", help="Get deployment status")
status_parser.add_argument("--app", required=True)

# Parse
args = main_parser.parse_args()

if args.command == "deploy":
    print(f"Deploying {args.app} version {args.version} to {args.env}")
elif args.command == "scale":
    print(f"Scaling {args.app} to {args.replicas} replicas")
elif args.command == "status":
    print(f"Checking status of {args.app}")
else:
    main_parser.print_help()

# ===== ENVIRONMENT VARIABLE DEFAULTS =====
# Let environment variables provide defaults, but allow flag override
parser = argparse.ArgumentParser()
parser.add_argument(
    "--kubeconfig",
    default=os.getenv("KUBECONFIG", "~/.kube/config"),
    help="Path to kubeconfig file"
)
parser.add_argument(
    "--namespace",
    default=os.getenv("NAMESPACE", "default"),
    help="Kubernetes namespace"
)

args = parser.parse_args()
print(f"Kubeconfig: {args.kubeconfig}")
print(f"Namespace: {args.namespace}")

# ===== TYPE VALIDATION =====
parser = argparse.ArgumentParser()

# Integer type
parser.add_argument("--port", type=int, help="Port number")

# Custom type validation
def positive_int(value):
    """Validate that value is a positive integer."""
    try:
        ivalue = int(value)
        if ivalue <= 0:
            raise ValueError(f"{ivalue} is not positive")
        return ivalue
    except ValueError as e:
        raise argparse.ArgumentTypeError(f"Invalid positive integer: {e}")

parser.add_argument("--replicas", type=positive_int, help="Replicas (must be > 0)")

# Parse with validation
try:
    args = parser.parse_args(["--port", "8080", "--replicas", "3"])
    print(f"Port: {args.port}, Replicas: {args.replicas}")
except argparse.ArgumentTypeError as e:
    print(f"Validation error: {e}")

# ===== MUTUALLY EXCLUSIVE ARGUMENTS =====
parser = argparse.ArgumentParser()
group = parser.add_mutually_exclusive_group(required=True)
group.add_argument("--file", help="Read config from file")
group.add_argument("--inline", help="Provide config as JSON string")
# User must provide either --file or --inline, but not both

# ===== ARGUMENT GROUPS =====
parser = argparse.ArgumentParser()

basic_args = parser.add_argument_group("basic options")
basic_args.add_argument("--app", required=True, help="App name")
basic_args.add_argument("--version", required=True, help="Version")

advanced_args = parser.add_argument_group("advanced options")
advanced_args.add_argument("--timeout", type=int, default=300, help="Timeout in seconds")
advanced_args.add_argument("--retries", type=int, default=3, help="Number of retries")

args = parser.parse_args()

# ===== REAL-WORLD EXAMPLE: COMPREHENSIVE CLI TOOL =====
def create_deployment_cli():
    """Create a deployment management CLI."""
    parser = argparse.ArgumentParser(
        description="Manage Kubernetes deployments",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
Examples:
  python deploy_cli.py deploy --app web --version 2.0 --replicas 5
  python deploy_cli.py scale --app web --replicas 10
  python deploy_cli.py status --app web --namespace production
  python deploy_cli.py health --app web --check readiness
        """
    )
    
    # Global options
    parser.add_argument(
        "--kubeconfig",
        default=os.getenv("KUBECONFIG"),
        help="Path to kubeconfig"
    )
    parser.add_argument(
        "-v", "--verbose",
        action="store_true",
        help="Verbose output"
    )
    
    # Subcommands
    subparsers = parser.add_subparsers(dest="command", required=True)
    
    # deploy command
    deploy = subparsers.add_parser("deploy", help="Deploy or update deployment")
    deploy.add_argument("--app", required=True, help="Application name")
    deploy.add_argument("--version", required=True, help="Image version")
    deploy.add_argument("--replicas", type=int, default=3, help="Number of replicas")
    deploy.add_argument("--namespace", default="default", help="Kubernetes namespace")
    deploy.add_argument("--force", action="store_true", help="Force redeployment")
    
    # scale command
    scale = subparsers.add_parser("scale", help="Scale deployment")
    scale.add_argument("--app", required=True)
    scale.add_argument("--replicas", type=positive_int, required=True)
    scale.add_argument("--namespace", default="default")
    
    # status command
    status = subparsers.add_parser("status", help="Check deployment status")
    status.add_argument("--app", required=True)
    status.add_argument("--namespace", default="default")
    
    # health command
    health = subparsers.add_parser("health", help="Check pod health")
    health.add_argument("--app", required=True)
    health.add_argument("--check", choices=["readiness", "liveness"], default="readiness")
    
    return parser

# Usage
def positive_int(value):
    try:
        ivalue = int(value)
        if ivalue <= 0:
            raise ValueError(f"{ivalue} is not positive")
        return ivalue
    except ValueError as e:
        raise argparse.ArgumentTypeError(f"Invalid positive int: {e}")

parser = create_deployment_cli()
args = parser.parse_args()

print(f"Command: {args.command}")
if args.command == "deploy":
    print(f"Deploying {args.app}:{args.version} with {args.replicas} replicas")
elif args.command == "scale":
    print(f"Scaling {args.app} to {args.replicas} replicas")

# ===== PARSE FROM LIST (NOT sys.argv) =====
# Useful for testing
test_args = ["deploy", "--app", "nginx", "--version", "1.20", "--replicas", "5"]
args = parser.parse_args(test_args)

# ===== NAMESPACE OBJECT =====
# parse_args() returns a Namespace object
# Access values as attributes
print(args.app)        # 'nginx'
print(args.version)    # '1.20'
print(args.replicas)   # 5

# Convert to dict if needed
args_dict = vars(args)  # {'app': 'nginx', 'version': '1.20', ...}

💼 Example (Real-world Use Case)

A DevOps team builds a tool: `deploy-cli.py deploy --app api --version 2.1 --env prod --replicas 5`. The tool parses arguments, validates (version format, replicas > 0), reads kubeconfig from KUBECONFIG env var (or use --kubeconfig to override), then deploys. Users run `deploy-cli.py --help` to discover options. CI/CD runs `deploy-cli.py deploy --app ... --env prod --force`.

🧪 Hands-on

  1. Create a simple CLI with positional args and optional flags.
  2. Add subcommands: create, delete, status (like git or kubectl).
  3. Implement custom type validation for an argument.
  4. Use environment variables as defaults for arguments.
  5. Add mutually exclusive argument groups (--file vs --inline).
🎮
Try It Yourself

Build a CLI tool that: (1) accepts command (deploy, rollback, status), (2) for deploy: app name, version, replicas, (3) validates replicas > 0, (4) shows --help explaining usage, (5) parses from environment variables when available, (6) accepts flag overrides. Run: tool.py deploy --app web --version 2.0 --help

🐛 Debugging Scenario

Problem: argparse shows "unrecognized arguments" error but you are sure you passed the right flag.

🎯 Interview Questions

Beginner

What is the difference between positional arguments and optional arguments?

Positional arguments are required and specified by position (first arg, second arg). Optional arguments use flags (--flag value) and can have defaults/be omitted. Example: `script.py action --option value` has "action" as positional, "--option value" as optional.

How do subparsers work?

Subparsers let you create commands like `git commit` vs `git push`. Each subcommand has its own arguments. The main program routes to the correct subparser based on the command name. Example: deploy deploy ... vs deploy scale ... use different subparsers.

How do you make argparse auto-generate useful --help?

Provide help text to each add_argument() call, and description/epilog to the parser. Use RawDescriptionHelpFormatter to preserve formatting in epilog (examples). Good --help is better documentation than a README—users discover features by reading help.

Scenario-based

Create a CLI that accepts either a config file (--config) or inline config (--json), but not both. How?

Use add_mutually_exclusive_group(required=True). Add both --config and --json to the group. argparse will enforce exactly one is provided and show error if both/neither given.

🌐 Real-world Usage

Every DevOps tool—kubectl, docker, aws cli, terraform—uses CLI argument parsing internally. Helm, Ansible, CI/CD platforms all use similar patterns. DevOps teams build custom tools on top of argparse. Professional tools have professional CLIs built with argparse.

📝 Summary

argparse parses command-line arguments into a Namespace object. Define positional args (required by position) and optional args (--flags). add_argument() accepts type validation (int, custom functions), choices, default values. Use subparsers for multi-command tools (like kubectl). Environment variables provide defaults; flags override. Provide help text for auto-generated --help. Validate arguments early (custom type functions) before business logic. These patterns create professional CLI tools that teams love to use and that integrate cleanly into CI/CD pipelines.