import re from typing import Any, Callable EMAIL_RE = re.compile(r'^[\w.+-]+@[\w-]+\.[\w.-]+$') PHONE_RE = re.compile(r'^\+?[\d\s\-().]{7,20}$') Rule = Callable[[str, Any], str | None] def required(field: str, value: Any) -> str | None: if value is None or (isinstance(value, str) and not value.strip()): return f"{field} is required" return None def min_len(n: int) -> Rule: def check(field: str, value: Any) -> str | None: if value is not None and len(str(value)) < n: return f"{field} must be at least {n} characters" return None return check def max_len(n: int) -> Rule: def check(field: str, value: Any) -> str | None: if value is not None and len(str(value)) > n: return f"{field} must be at most {n} characters" return None return check def is_email(field: str, value: Any) -> str | None: if value and not EMAIL_RE.match(str(value)): return f"{field} is not a valid email" return None def is_phone(field: str, value: Any) -> str | None: if value and not PHONE_RE.match(str(value)): return f"{field} is not a valid phone number" return None def is_positive(field: str, value: Any) -> str | None: try: if float(value) <= 0: return f"{field} must be positive" except (TypeError, ValueError): return f"{field} must be a number" return None def one_of(*choices: Any) -> Rule: def check(field: str, value: Any) -> str | None: if value not in choices: return f"{field} must be one of: {', '.join(map(str, choices))}" return None return check def validate(data: dict[str, Any], rules: dict[str, list[Rule]]) -> list[str]: errors: list[str] = [] for field, checks in rules.items(): for rule in checks: err = rule(field, data.get(field)) if err: errors.append(err) return errors