Initial commit
This commit is contained in:
424
skills/argparse-patterns/examples/validation-patterns.md
Normal file
424
skills/argparse-patterns/examples/validation-patterns.md
Normal file
@@ -0,0 +1,424 @@
|
||||
# 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`
|
||||
Reference in New Issue
Block a user