9.2 KiB
9.2 KiB
Validation Patterns with argparse
Custom validators, type checking, and error handling.
Template Reference
templates/choices-validation.py
Overview
Robust argument validation:
- Built-in choices validation
- Custom type validators
- Range validation
- Pattern matching (regex)
- File/path validation
Quick Start
# Valid inputs
python choices-validation.py --log-level debug --port 8080
python choices-validation.py --region us-east-1 --email user@example.com
# Invalid inputs (will error)
python choices-validation.py --log-level invalid # Not in choices
python choices-validation.py --port 99999 # Out of range
python choices-validation.py --email invalid # Invalid format
Validation Methods
1. Choices (Built-in)
parser.add_argument(
'--log-level',
choices=['debug', 'info', 'warning', 'error', 'critical'],
default='info',
help='Logging level'
)
Automatic validation - argparse rejects invalid values.
2. Custom Type Validator
def validate_port(value):
"""Validate port number is 1-65535."""
try:
ivalue = int(value)
except ValueError:
raise argparse.ArgumentTypeError(f"{value} is not a valid integer")
if ivalue < 1 or ivalue > 65535:
raise argparse.ArgumentTypeError(
f"{value} is not a valid port (must be 1-65535)"
)
return ivalue
parser.add_argument(
'--port',
type=validate_port,
default=8080,
help='Server port (1-65535)'
)
3. Regex Pattern Validation
import re
def validate_email(value):
"""Validate email address format."""
pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
if not re.match(pattern, value):
raise argparse.ArgumentTypeError(
f"{value} is not a valid email address"
)
return value
parser.add_argument('--email', type=validate_email)
4. IP Address Validation
def validate_ip(value):
"""Validate IPv4 address."""
pattern = r'^(\d{1,3}\.){3}\d{1,3}$'
if not re.match(pattern, value):
raise argparse.ArgumentTypeError(
f"{value} is not a valid IP address"
)
# Check each octet is 0-255
octets = [int(x) for x in value.split('.')]
if any(o < 0 or o > 255 for o in octets):
raise argparse.ArgumentTypeError(
f"{value} contains invalid octets"
)
return value
parser.add_argument('--host', type=validate_ip)
5. Path Validation
from pathlib import Path
def validate_path_exists(value):
"""Validate path exists."""
path = Path(value)
if not path.exists():
raise argparse.ArgumentTypeError(
f"Path does not exist: {value}"
)
return path
parser.add_argument('--config', type=validate_path_exists)
6. Range Validation Factory
def validate_range(min_val, max_val):
"""Factory function for range validators."""
def validator(value):
try:
ivalue = int(value)
except ValueError:
raise argparse.ArgumentTypeError(
f"{value} is not a valid integer"
)
if ivalue < min_val or ivalue > max_val:
raise argparse.ArgumentTypeError(
f"{value} must be between {min_val} and {max_val}"
)
return ivalue
return validator
# Usage
parser.add_argument(
'--workers',
type=validate_range(1, 32),
default=4,
help='Number of workers (1-32)'
)
Complete Validation Example
#!/usr/bin/env python3
import argparse
import re
import sys
from pathlib import Path
def validate_port(value):
ivalue = int(value)
if not (1 <= ivalue <= 65535):
raise argparse.ArgumentTypeError(
f"Port must be 1-65535, got {value}"
)
return ivalue
def validate_email(value):
if not re.match(r'^[\w\.-]+@[\w\.-]+\.\w+$', value):
raise argparse.ArgumentTypeError(
f"Invalid email: {value}"
)
return value
def main():
parser = argparse.ArgumentParser(
description='Validation examples'
)
# Choices
parser.add_argument(
'--env',
choices=['dev', 'staging', 'prod'],
required=True,
help='Environment (required)'
)
# Custom validators
parser.add_argument('--port', type=validate_port, default=8080)
parser.add_argument('--email', type=validate_email)
# Path validation
parser.add_argument(
'--config',
type=lambda x: Path(x) if Path(x).exists() else
parser.error(f"File not found: {x}"),
help='Config file (must exist)'
)
args = parser.parse_args()
print(f"Environment: {args.env}")
print(f"Port: {args.port}")
if args.email:
print(f"Email: {args.email}")
return 0
if __name__ == '__main__':
sys.exit(main())
Post-Parse Validation
Sometimes you need to validate relationships between arguments:
args = parser.parse_args()
# Validate argument combinations
if args.ssl and not (args.cert and args.key):
parser.error("--ssl requires both --cert and --key")
if args.output and args.output.exists() and not args.force:
parser.error(f"Output file exists: {args.output}. Use --force to overwrite")
# Validate argument ranges
if args.start_date > args.end_date:
parser.error("Start date must be before end date")
Error Messages
Built-in Error Format
$ python mycli.py --env invalid
usage: mycli.py [-h] --env {dev,staging,prod}
mycli.py: error: argument --env: invalid choice: 'invalid'
(choose from 'dev', 'staging', 'prod')
Custom Error Format
def validate_port(value):
try:
ivalue = int(value)
except ValueError:
raise argparse.ArgumentTypeError(
f"Port must be an integer (got '{value}')"
)
if ivalue < 1 or ivalue > 65535:
raise argparse.ArgumentTypeError(
f"Port {ivalue} is out of range (valid: 1-65535)"
)
return ivalue
$ python mycli.py --port 99999
usage: mycli.py [-h] [--port PORT]
mycli.py: error: argument --port: Port 99999 is out of range (valid: 1-65535)
Common Validation Patterns
URL Validation
import re
def validate_url(value):
pattern = r'^https?://[\w\.-]+\.\w+(:\d+)?(/.*)?$'
if not re.match(pattern, value):
raise argparse.ArgumentTypeError(f"Invalid URL: {value}")
return value
Date Validation
from datetime import datetime
def validate_date(value):
try:
return datetime.strptime(value, '%Y-%m-%d').date()
except ValueError:
raise argparse.ArgumentTypeError(
f"Invalid date: {value} (expected YYYY-MM-DD)"
)
File Extension Validation
def validate_json_file(value):
path = Path(value)
if path.suffix != '.json':
raise argparse.ArgumentTypeError(
f"File must have .json extension: {value}"
)
return path
Percentage Validation
def validate_percentage(value):
try:
pct = float(value.rstrip('%'))
except ValueError:
raise argparse.ArgumentTypeError(f"Invalid percentage: {value}")
if not (0 <= pct <= 100):
raise argparse.ArgumentTypeError(
f"Percentage must be 0-100: {value}"
)
return pct
Best Practices
✓ Do: Fail Early
# Validate during parsing
parser.add_argument('--port', type=validate_port)
# Not after parsing
args = parser.parse_args()
if not valid_port(args.port): # ✗ Too late
sys.exit(1)
✓ Do: Provide Clear Messages
# ✓ Clear, actionable error
raise argparse.ArgumentTypeError(
f"Port {value} is out of range (valid: 1-65535)"
)
# ✗ Vague error
raise argparse.ArgumentTypeError("Invalid port")
✓ Do: Use Choices When Possible
# ✓ Let argparse handle it
parser.add_argument('--env', choices=['dev', 'staging', 'prod'])
# ✗ Manual validation
parser.add_argument('--env')
if args.env not in ['dev', 'staging', 'prod']:
parser.error("Invalid environment")
✓ Do: Validate Type Before Range
def validate_port(value):
# First ensure it's an integer
try:
ivalue = int(value)
except ValueError:
raise argparse.ArgumentTypeError(f"Not an integer: {value}")
# Then check range
if not (1 <= ivalue <= 65535):
raise argparse.ArgumentTypeError(f"Out of range: {ivalue}")
return ivalue
Testing Validation
import pytest
from io import StringIO
import sys
def test_valid_port():
"""Test valid port number."""
parser = create_parser()
args = parser.parse_args(['--port', '8080'])
assert args.port == 8080
def test_invalid_port():
"""Test invalid port number."""
parser = create_parser()
with pytest.raises(SystemExit):
parser.parse_args(['--port', '99999'])
def test_invalid_choice():
"""Test invalid choice."""
parser = create_parser()
with pytest.raises(SystemExit):
parser.parse_args(['--env', 'invalid'])
Next Steps
- Advanced Patterns: See
advanced-parsing.md - Type Coercion: See
templates/type-coercion.py - Custom Actions: See
templates/custom-actions.py