Beyond basic argument parsing, argparse offers powerful features for building professional CLI tools.

Subcommands

import argparse
 
parser = argparse.ArgumentParser(prog='git')
subparsers = parser.add_subparsers(dest='command', help='commands')
 
# git clone
clone_parser = subparsers.add_parser('clone', help='Clone a repository')
clone_parser.add_argument('url')
clone_parser.add_argument('--depth', type=int)
 
# git commit
commit_parser = subparsers.add_parser('commit', help='Commit changes')
commit_parser.add_argument('-m', '--message', required=True)
commit_parser.add_argument('-a', '--all', action='store_true')
 
# git push
push_parser = subparsers.add_parser('push', help='Push changes')
push_parser.add_argument('remote', nargs='?', default='origin')
push_parser.add_argument('branch', nargs='?')
 
args = parser.parse_args()
 
if args.command == 'clone':
    print(f"Cloning {args.url}")
elif args.command == 'commit':
    print(f"Committing: {args.message}")

Custom Types

import argparse
from pathlib import Path
from datetime import datetime
 
def valid_date(s):
    try:
        return datetime.strptime(s, "%Y-%m-%d")
    except ValueError:
        raise argparse.ArgumentTypeError(f"Invalid date: {s}")
 
def existing_file(s):
    path = Path(s)
    if not path.exists():
        raise argparse.ArgumentTypeError(f"File not found: {s}")
    return path
 
def port_number(s):
    port = int(s)
    if not 1 <= port <= 65535:
        raise argparse.ArgumentTypeError(f"Invalid port: {s}")
    return port
 
parser = argparse.ArgumentParser()
parser.add_argument('--date', type=valid_date)
parser.add_argument('--file', type=existing_file)
parser.add_argument('--port', type=port_number, default=8080)

Choices with Custom Display

import argparse
 
class LogLevel:
    DEBUG = 10
    INFO = 20
    WARNING = 30
    ERROR = 40
    
    @classmethod
    def from_string(cls, s):
        levels = {'debug': cls.DEBUG, 'info': cls.INFO, 
                  'warning': cls.WARNING, 'error': cls.ERROR}
        if s.lower() not in levels:
            raise argparse.ArgumentTypeError(
                f"Invalid level. Choose from: {', '.join(levels)}"
            )
        return levels[s.lower()]
 
parser = argparse.ArgumentParser()
parser.add_argument(
    '--log-level',
    type=LogLevel.from_string,
    default=LogLevel.INFO,
    metavar='LEVEL',
    help='Logging level (debug, info, warning, error)'
)

Argument Groups

import argparse
 
parser = argparse.ArgumentParser()
 
# Group related arguments
auth_group = parser.add_argument_group('authentication')
auth_group.add_argument('--user', '-u')
auth_group.add_argument('--password', '-p')
auth_group.add_argument('--token')
 
output_group = parser.add_argument_group('output options')
output_group.add_argument('--format', choices=['json', 'csv', 'table'])
output_group.add_argument('--output', '-o', type=argparse.FileType('w'))

Mutually Exclusive Arguments

import argparse
 
parser = argparse.ArgumentParser()
 
# Only one of these can be used
group = parser.add_mutually_exclusive_group()
group.add_argument('--verbose', '-v', action='store_true')
group.add_argument('--quiet', '-q', action='store_true')
 
# Required mutual exclusion
auth = parser.add_mutually_exclusive_group(required=True)
auth.add_argument('--user', help='Username for auth')
auth.add_argument('--token', help='API token')

Parent Parsers

Share common arguments between subcommands:

import argparse
 
# Common arguments
parent = argparse.ArgumentParser(add_help=False)
parent.add_argument('--verbose', '-v', action='store_true')
parent.add_argument('--config', '-c', default='config.yml')
 
# Main parser
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers()
 
# Inherit from parent
sub1 = subparsers.add_parser('serve', parents=[parent])
sub1.add_argument('--port', type=int, default=8000)
 
sub2 = subparsers.add_parser('deploy', parents=[parent])
sub2.add_argument('--target', required=True)

FileType Arguments

import argparse
 
parser = argparse.ArgumentParser()
 
# Input file (or stdin with -)
parser.add_argument('input', type=argparse.FileType('r'), default='-')
 
# Output file (or stdout with -)
parser.add_argument('--output', '-o', type=argparse.FileType('w'), default='-')
 
args = parser.parse_args()
 
# Read and write
data = args.input.read()
args.output.write(data.upper())

nargs Patterns

import argparse
 
parser = argparse.ArgumentParser()
 
# Exactly N arguments
parser.add_argument('--point', nargs=2, type=float, metavar=('X', 'Y'))
 
# Zero or more
parser.add_argument('--files', nargs='*')
 
# One or more
parser.add_argument('--tags', nargs='+')
 
# Optional (0 or 1)
parser.add_argument('--config', nargs='?', const='default.yml')
 
# Remaining arguments
parser.add_argument('command', nargs=argparse.REMAINDER)

Action Classes

import argparse
 
# Count occurrences
parser.add_argument('-v', '--verbose', action='count', default=0)
# -v = 1, -vv = 2, -vvv = 3
 
# Append to list
parser.add_argument('--include', action='append')
# --include a --include b -> ['a', 'b']
 
# Store constant
parser.add_argument('--debug', action='store_const', const=True)
 
# Custom action
class UpperAction(argparse.Action):
    def __call__(self, parser, namespace, values, option_string=None):
        setattr(namespace, self.dest, values.upper())
 
parser.add_argument('--name', action=UpperAction)

Environment Variable Defaults

import argparse
import os
 
parser = argparse.ArgumentParser()
parser.add_argument(
    '--api-key',
    default=os.environ.get('API_KEY'),
    help='API key (or set API_KEY env var)'
)
parser.add_argument(
    '--port',
    type=int,
    default=int(os.environ.get('PORT', 8000)),
    help='Port (default: $PORT or 8000)'
)

Config File Integration

import argparse
import json
from pathlib import Path
 
def load_config(path):
    if path and Path(path).exists():
        return json.loads(Path(path).read_text())
    return {}
 
parser = argparse.ArgumentParser()
parser.add_argument('--config', '-c', help='Config file')
parser.add_argument('--host', default='localhost')
parser.add_argument('--port', type=int, default=8000)
 
# Parse known args first to get config
args, remaining = parser.parse_known_args()
 
# Load config and set as defaults
if args.config:
    config = load_config(args.config)
    parser.set_defaults(**config)
 
# Re-parse with config defaults
args = parser.parse_args()

Professional Help Formatting

import argparse
 
parser = argparse.ArgumentParser(
    prog='mytool',
    description='A professional CLI tool',
    epilog='Examples:\n  mytool --verbose process file.txt',
    formatter_class=argparse.RawDescriptionHelpFormatter
)
 
# Argument with detailed help
parser.add_argument(
    '--output-format',
    choices=['json', 'csv', 'table'],
    default='table',
    metavar='FORMAT',
    help='Output format: json, csv, or table (default: %(default)s)'
)

Version Flag

import argparse
 
parser = argparse.ArgumentParser()
parser.add_argument(
    '--version', '-V',
    action='version',
    version='%(prog)s 1.0.0'
)

Complete Example

#!/usr/bin/env python3
import argparse
import sys
 
def create_parser():
    parser = argparse.ArgumentParser(
        prog='deploy',
        description='Deploy applications to servers'
    )
    
    parser.add_argument('--version', action='version', version='1.0.0')
    parser.add_argument('-v', '--verbose', action='count', default=0)
    
    subparsers = parser.add_subparsers(dest='command', required=True)
    
    # deploy push
    push = subparsers.add_parser('push', help='Push to server')
    push.add_argument('target', help='Server to deploy to')
    push.add_argument('--branch', '-b', default='main')
    push.add_argument('--dry-run', action='store_true')
    
    # deploy status
    status = subparsers.add_parser('status', help='Check status')
    status.add_argument('--all', '-a', action='store_true')
    
    return parser
 
def main():
    parser = create_parser()
    args = parser.parse_args()
    
    if args.command == 'push':
        print(f"Deploying {args.branch} to {args.target}")
    elif args.command == 'status':
        print("Checking status...")
 
if __name__ == '__main__':
    main()

Summary

Advanced argparse patterns:

  • Subcommands: add_subparsers() for git-style CLIs
  • Custom types: Validation with type= functions
  • Groups: add_argument_group() and add_mutually_exclusive_group()
  • Parent parsers: Share common arguments
  • FileType: Handle stdin/stdout elegantly
  • Actions: count, append, custom actions

Build professional CLIs that users actually enjoy.

React to this post: