Files
gh-vanman2024-cli-builder-p…/skills/argparse-patterns/examples/validation-patterns.md
2025-11-30 09:04:14 +08:00

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