Spaces:
Sleeping
Sleeping
| """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 | |