argparse and CLI Design
What You'll Learn
How to build professional command-line tools using Python's built-in argparse module.
Why argparse?
Instead of reading sys.argv manually:
# ❌ Fragile and no help text
import sys
filename = sys.argv[1]
verbose = "--verbose" in sys.argv
Use argparse:
# ✅ Clean, with automatic --help
import argparse
parser = argparse.ArgumentParser(description="Process a file")
parser.add_argument("filename")
parser.add_argument("--verbose", action="store_true")
args = parser.parse_args()
Running python3 script.py --help now prints:
usage: script.py [-h] [--verbose] filename
Process a file
positional arguments:
filename
options:
-h, --help show this help message and exit
--verbose
Positional Arguments
Positional arguments are required and matched by position:
import argparse
parser = argparse.ArgumentParser(description="Rename a file")
parser.add_argument("source", help="Source file path")
parser.add_argument("destination", help="Destination file path")
args = parser.parse_args()
print(f"Moving {args.source} → {args.destination}")
python3 rename.py old.txt new.txt
Optional Arguments (Flags)
parser = argparse.ArgumentParser()
# Boolean flag (store_true/store_false)
parser.add_argument("--verbose", "-v", action="store_true",
help="Enable verbose output")
parser.add_argument("--dry-run", action="store_true",
help="Show what would be done without doing it")
# Argument with a value
parser.add_argument("--output", "-o", default="output.txt",
help="Output file (default: output.txt)")
parser.add_argument("--count", "-n", type=int, default=10,
help="Number of results (default: 10)")
# Required option (rare — prefer positional for required args)
parser.add_argument("--config", required=True,
help="Path to config file")
args = parser.parse_args()
print(args.verbose) # True or False
print(args.output) # "output.txt" or user's value
print(args.count) # 10 or user's value
Argument Types and Choices
from pathlib import Path
parser.add_argument("--level", type=int, choices=[1, 2, 3],
default=1, help="Verbosity level (1-3)")
parser.add_argument("--format", choices=["json", "csv", "yaml"],
default="json", help="Output format")
parser.add_argument("input", type=Path,
help="Input file path") # auto-converts to Path
args = parser.parse_args()
# args.input is already a Path object
Accepting Multiple Values (nargs)
# One or more values
parser.add_argument("files", nargs="+", help="Files to process")
# Zero or more values
parser.add_argument("--tags", nargs="*", default=[])
# Exactly N values
parser.add_argument("--range", nargs=2, type=int, metavar=("START", "END"))
args = parser.parse_args()
# args.files = ["a.txt", "b.txt"]
# args.range = [1, 100]
Subcommands (like git or docker)
import argparse
parser = argparse.ArgumentParser(description="User management tool")
subparsers = parser.add_subparsers(dest="command", required=True)
# 'create' subcommand
create = subparsers.add_parser("create", help="Create a new user")
create.add_argument("username", help="Username to create")
create.add_argument("--admin", action="store_true")
# 'delete' subcommand
delete = subparsers.add_parser("delete", help="Delete a user")
delete.add_argument("username")
delete.add_argument("--force", action="store_true")
# 'list' subcommand
list_cmd = subparsers.add_parser("list", help="List all users")
list_cmd.add_argument("--active-only", action="store_true")
args = parser.parse_args()
if args.command == "create":
print(f"Creating user: {args.username}, admin={args.admin}")
elif args.command == "delete":
print(f"Deleting user: {args.username}, force={args.force}")
elif args.command == "list":
print(f"Listing users, active_only={args.active_only}")
Usage:
python3 users.py create alice --admin
python3 users.py delete bob --force
python3 users.py list --active-only
python3 users.py --help
python3 users.py create --help
Full Example: A Real CLI Tool
#!/usr/bin/env python3
"""Count lines, words, and characters in files."""
import argparse
import sys
from pathlib import Path
def count_file(path: Path) -> dict:
try:
text = path.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError) as e:
print(f"Error reading {path}: {e}", file=sys.stderr)
return {}
return {
"lines": text.count("\n"),
"words": len(text.split()),
"chars": len(text),
"path": str(path),
}
def main() -> int:
parser = argparse.ArgumentParser(
description="Count lines, words, and chars in files"
)
parser.add_argument("files", nargs="+", type=Path, help="Files to count")
parser.add_argument("--lines", "-l", action="store_true",
help="Show line count only")
parser.add_argument("--words", "-w", action="store_true",
help="Show word count only")
args = parser.parse_args()
total = {"lines": 0, "words": 0, "chars": 0}
errors = 0
for path in args.files:
if not path.exists():
print(f"File not found: {path}", file=sys.stderr)
errors += 1
continue
counts = count_file(path)
if not counts:
errors += 1
continue
if args.lines:
print(f"{counts['lines']:>8} {path}")
elif args.words:
print(f"{counts['words']:>8} {path}")
else:
print(f"{counts['lines']:>8} {counts['words']:>8} {counts['chars']:>8} {path}")
for key in ("lines", "words", "chars"):
total[key] += counts.get(key, 0)
if len(args.files) > 1:
print(f"{total['lines']:>8} {total['words']:>8} {total['chars']:>8} total")
return 1 if errors else 0
if __name__ == "__main__":
sys.exit(main())
Common Mistakes
| Mistake | Fix |
|---|---|
| Using positional for optional args | Optional args use -- prefix |
No help= strings | Always add help text to every argument |
Forgetting type=int | String by default — cast with type= |
No if __name__ == "__main__": | Always guard the entry point |
| Not validating args after parse | Validate paths, ranges, combinations |
Quick Reference
import argparse
import sys
parser = argparse.ArgumentParser(description="What this does")
# Positional (required)
parser.add_argument("name", help="Description")
# Optional flag
parser.add_argument("--verbose", "-v", action="store_true")
parser.add_argument("--dry-run", action="store_true")
# Optional with value
parser.add_argument("--output", "-o", default="out.txt")
parser.add_argument("--count", type=int, default=10)
# Choices
parser.add_argument("--format", choices=["json", "csv"])
# Multiple values
parser.add_argument("files", nargs="+", type=Path)
# Subcommands
sub = parser.add_subparsers(dest="command", required=True)
sub.add_parser("run")
sub.add_parser("stop")
args = parser.parse_args()