"""Input validation with proper error raising.""" import re from typing import Optional from .errors import ValidationError def validate_grant_id(gid: str) -> str: """ Validate and normalize grant ID. Args: gid: Grant ID in any format ("2315", "competition-2315", etc.) Returns: Normalized ID (just the numeric part without prefix) Raises: ValidationError: If ID format is invalid Example: >>> validate_grant_id("2315") '2315' >>> validate_grant_id("competition-2315") '2315' >>> validate_grant_id("invalid") ValidationError: Invalid grant ID format: 'invalid' """ if not gid: raise ValidationError("Grant ID cannot be empty") gid = str(gid).strip() # Extract numeric part match = re.match(r'^(?:comp(?:etition)?-|grant-)?(\d{3,7})$', gid, re.I) if not match: raise ValidationError( f"Invalid grant ID format: '{gid}'. " f"Expected: '2315' or 'competition-2315'" ) numeric_id = match.group(1) return numeric_id def validate_url(url: str, *, allowed_hosts: Optional[set] = None) -> str: """ Validate URL and check against allowlist. Args: url: URL to validate allowed_hosts: Optional set of allowed hostnames Returns: Validated URL (unchanged) Raises: ValidationError: If URL is invalid or not allowed """ from urllib.parse import urlparse if not url: raise ValidationError("URL cannot be empty") url = str(url).strip() if not url.startswith(('http://', 'https://')): raise ValidationError( f"URL must start with http:// or https://: {url}" ) try: parsed = urlparse(url) except Exception as e: raise ValidationError(f"Malformed URL: {url}") from e if not parsed.netloc: raise ValidationError(f"URL has no hostname: {url}") if allowed_hosts and parsed.netloc not in allowed_hosts: allowed_preview = ', '.join(list(allowed_hosts)[:3]) raise ValidationError( f"URL host '{parsed.netloc}' not in allowlist. " f"Allowed: {allowed_preview}..." ) return url def sanitize_filename(name: str, max_length: int = 200) -> str: """ Sanitize filename to prevent path traversal. Args: name: Original filename max_length: Maximum length Returns: Safe filename Example: >>> sanitize_filename("../../../etc/passwd") 'etc_passwd' """ if not name: raise ValidationError("Filename cannot be empty") # Remove path separators and dangerous chars safe = re.sub(r'[^\w\-.]', '_', str(name)) safe = safe.strip('._') if not safe: raise ValidationError(f"Filename '{name}' produces empty result after sanitization") return safe[:max_length] def validate_search_query(query: str, max_length: int = 500) -> str: """ Validate search query. Args: query: Search query string max_length: Maximum allowed length Returns: Validated query (stripped) Raises: ValidationError: If query is empty or too long """ if not query: raise ValidationError("Search query cannot be empty") query = str(query).strip() if not query: raise ValidationError("Search query cannot be whitespace only") if len(query) > max_length: raise ValidationError( f"Search query too long ({len(query)} chars, max {max_length})" ) return query def validate_positive_int(value: any, name: str = "value") -> int: """ Validate positive integer. Args: value: Value to validate name: Name of parameter (for error messages) Returns: Validated integer Raises: ValidationError: If not a positive integer """ try: val = int(value) except (TypeError, ValueError) as e: raise ValidationError(f"{name} must be an integer, got {type(value).__name__}") from e if val <= 0: raise ValidationError(f"{name} must be positive, got {val}") return val def validate_date_string(date_str: str, name: str = "date") -> str: """ Validate ISO date string (YYYY-MM-DD). Args: date_str: Date string to validate name: Name of parameter (for error messages) Returns: Validated date string Raises: ValidationError: If not valid ISO date format """ if not date_str: raise ValidationError(f"{name} cannot be empty") date_str = str(date_str).strip() # Check format if not re.match(r'^\d{4}-\d{2}-\d{2}$', date_str): raise ValidationError( f"{name} must be in YYYY-MM-DD format, got: {date_str}" ) # Validate actual date (catches invalid like 2025-13-45) try: from datetime import datetime datetime.strptime(date_str, '%Y-%m-%d') except ValueError as e: raise ValidationError(f"Invalid date: {date_str}") from e return date_str