grant-radar / src /analyzer /utils /validation.py
Riley Coleman
feat: add PDF & supporting materials integration to context
bfcc872
Raw
History Blame Contribute Delete
5.12 kB
"""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