# 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 ```bash # 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) ```python 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 ```python 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 ```python 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 ```python 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 ```python 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 ```python 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 ```python #!/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: ```python 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 ```bash $ 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 ```python 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 ``` ```bash $ 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 ```python 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 ```python 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 ```python 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 ```python 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 ```python # 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 ```python # ✓ 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 ```python # ✓ 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 ```python 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 ```python 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`