""" Comprehensive date validation and parsing utilities Provides robust date format validation and constraint checking """ import logging import re from datetime import datetime, date, timedelta from typing import Optional, List, Tuple, Union, Dict, Any try: from .error_handling import ValidationError except ImportError: from error_handling import ValidationError class DateValidator: """Comprehensive date validation with multiple format support""" def __init__(self): self.logger = logging.getLogger(__name__) # Supported date formats with their regex patterns and strptime formats self.date_formats = { 'iso_date': { 'pattern': r'^\d{4}-\d{2}-\d{2}$', 'strptime': '%Y-%m-%d', 'description': 'ISO date format (YYYY-MM-DD)' }, 'iso_datetime': { 'pattern': r'^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$', 'strptime': '%Y-%m-%dT%H:%M:%S', 'description': 'ISO datetime format (YYYY-MM-DDTHH:MM:SS)' }, 'iso_datetime_ms': { 'pattern': r'^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}$', 'strptime': '%Y-%m-%dT%H:%M:%S.%f', 'description': 'ISO datetime with milliseconds' }, 'iso_datetime_tz': { 'pattern': r'^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}$', 'strptime': '%Y-%m-%dT%H:%M:%S%z', 'description': 'ISO datetime with timezone' }, 'us_date': { 'pattern': r'^\d{1,2}/\d{1,2}/\d{4}$', 'strptime': '%m/%d/%Y', 'description': 'US date format (MM/DD/YYYY)' }, 'eu_date': { 'pattern': r'^\d{1,2}/\d{1,2}/\d{4}$', 'strptime': '%d/%m/%Y', 'description': 'European date format (DD/MM/YYYY)' }, 'dot_date': { 'pattern': r'^\d{1,2}\.\d{1,2}\.\d{4}$', 'strptime': '%d.%m.%Y', 'description': 'Dot-separated date (DD.MM.YYYY)' }, 'compact_date': { 'pattern': r'^\d{8}$', 'strptime': '%Y%m%d', 'description': 'Compact date format (YYYYMMDD)' } } # Default constraints self.default_constraints = { 'min_year': 1900, 'max_year': 2100, 'allow_future': True, 'max_future_days': 365 * 10, # 10 years 'allow_past': True, 'max_past_days': 365 * 50, # 50 years } def validate_date_string( self, date_string: str, field_name: str = "date", allowed_formats: Optional[List[str]] = None, constraints: Optional[Dict[str, Any]] = None, auto_detect_format: bool = True ) -> Tuple[datetime, str]: """ Validate a date string with comprehensive format and constraint checking Args: date_string: Date string to validate field_name: Name of the field for error messages allowed_formats: List of allowed format names (None = all formats) constraints: Date constraints (None = use defaults) auto_detect_format: Whether to auto-detect format Returns: Tuple of (parsed_datetime, detected_format) Raises: ValidationError: If validation fails """ if not date_string or not isinstance(date_string, str): raise ValidationError( message=f"{field_name} must be a non-empty string", field_name=field_name, validation_rule="non_empty_string" ) date_string = date_string.strip() if not date_string: raise ValidationError( message=f"{field_name} cannot be empty", field_name=field_name, validation_rule="non_empty" ) # Determine which formats to try formats_to_try = allowed_formats or list(self.date_formats.keys()) parsed_date = None detected_format = None parsing_errors = [] # Try each format for format_name in formats_to_try: if format_name not in self.date_formats: self.logger.warning(f"Unknown date format: {format_name}") continue format_info = self.date_formats[format_name] # Check regex pattern first (faster than strptime) if not re.match(format_info['pattern'], date_string): continue # Try to parse with strptime try: parsed_date = datetime.strptime(date_string, format_info['strptime']) detected_format = format_name break except ValueError as e: parsing_errors.append(f"{format_name}: {str(e)}") continue # If no format worked, provide helpful error if parsed_date is None: if auto_detect_format: error_msg = f"{field_name} format not recognized. Tried formats: {', '.join(formats_to_try)}" if parsing_errors: error_msg += f". Errors: {'; '.join(parsing_errors[:3])}" # Limit error details else: format_descriptions = [f'{fmt} ({self.date_formats[fmt]["description"]})' for fmt in formats_to_try if fmt in self.date_formats] error_msg = f"{field_name} must match one of these formats: {', '.join(format_descriptions)}" raise ValidationError( message=error_msg, field_name=field_name, validation_rule="date_format" ) # Apply constraints self._validate_date_constraints(parsed_date, field_name, constraints or self.default_constraints) return parsed_date, detected_format def validate_date_range( self, start_date: Union[str, datetime, date], end_date: Union[str, datetime, date], field_name_start: str = "start_date", field_name_end: str = "end_date", allow_same_date: bool = True, max_range_days: Optional[int] = None ) -> Tuple[datetime, datetime]: """ Validate a date range with logical constraints Args: start_date: Start date (string or datetime) end_date: End date (string or datetime) field_name_start: Name of start date field field_name_end: Name of end date field allow_same_date: Whether start and end can be the same max_range_days: Maximum allowed range in days Returns: Tuple of (start_datetime, end_datetime) Raises: ValidationError: If validation fails """ # Parse start date if isinstance(start_date, str): start_dt, _ = self.validate_date_string(start_date, field_name_start) elif isinstance(start_date, datetime): start_dt = start_date elif isinstance(start_date, date): start_dt = datetime.combine(start_date, datetime.min.time()) else: raise ValidationError( message=f"{field_name_start} must be a string, date, or datetime", field_name=field_name_start, validation_rule="date_type" ) # Parse end date if isinstance(end_date, str): end_dt, _ = self.validate_date_string(end_date, field_name_end) elif isinstance(end_date, datetime): end_dt = end_date elif isinstance(end_date, date): end_dt = datetime.combine(end_date, datetime.min.time()) else: raise ValidationError( message=f"{field_name_end} must be a string, date, or datetime", field_name=field_name_end, validation_rule="date_type" ) # Validate range logic if start_dt > end_dt: raise ValidationError( message=f"{field_name_start} cannot be after {field_name_end}", field_name=field_name_start, validation_rule="date_range_order" ) if not allow_same_date and start_dt.date() == end_dt.date(): raise ValidationError( message=f"{field_name_start} and {field_name_end} cannot be the same date", field_name=field_name_start, validation_rule="date_range_same" ) # Check maximum range if max_range_days is not None: range_days = (end_dt - start_dt).days if range_days > max_range_days: raise ValidationError( message=f"Date range cannot exceed {max_range_days} days (current: {range_days} days)", field_name=field_name_start, validation_rule="date_range_too_large" ) return start_dt, end_dt def validate_journal_date( self, date_string: str, field_name: str = "journal_date" ) -> datetime: """ Validate a date for journal entries with specific constraints Args: date_string: Date string to validate field_name: Name of the field for error messages Returns: Parsed datetime Raises: ValidationError: If validation fails """ # Journal dates should typically be in ISO format and not too far in the future journal_constraints = { 'min_year': 2000, # Journals unlikely before 2000 'max_year': datetime.now().year + 1, # Allow up to next year 'allow_future': True, 'max_future_days': 30, # Allow up to 30 days in future 'allow_past': True, 'max_past_days': 365 * 20, # Allow up to 20 years in past } # Prefer ISO date format for journals preferred_formats = ['iso_date', 'compact_date', 'us_date', 'eu_date'] parsed_date, detected_format = self.validate_date_string( date_string, field_name, allowed_formats=preferred_formats, constraints=journal_constraints ) # Additional journal-specific validations today = datetime.now().date() date_only = parsed_date.date() # Warn about future dates (but don't reject) if date_only > today: days_future = (date_only - today).days if days_future > 7: # More than a week in future self.logger.warning(f"Journal date is {days_future} days in the future: {date_string}") # Check for weekend dates (informational) if date_only.weekday() >= 5: # Saturday = 5, Sunday = 6 self.logger.debug(f"Journal date is on weekend: {date_string}") return parsed_date def validate_obsidian_date_format( self, date_string: str, obsidian_format: str = "YYYY-MM-DD", field_name: str = "date" ) -> datetime: """ Validate date against Obsidian's date format configuration Args: date_string: Date string to validate obsidian_format: Obsidian date format (e.g., "YYYY-MM-DD", "DD-MM-YYYY") field_name: Name of the field for error messages Returns: Parsed datetime Raises: ValidationError: If validation fails """ # Convert Obsidian format to Python strptime format python_format = self._obsidian_to_python_format(obsidian_format) if not date_string or not isinstance(date_string, str): raise ValidationError( message=f"{field_name} must be a non-empty string", field_name=field_name, validation_rule="non_empty_string" ) date_string = date_string.strip() try: parsed_date = datetime.strptime(date_string, python_format) except ValueError as e: raise ValidationError( message=f"{field_name} must match Obsidian format '{obsidian_format}': {str(e)}", field_name=field_name, validation_rule="obsidian_date_format" ) # Apply basic constraints self._validate_date_constraints(parsed_date, field_name, self.default_constraints) return parsed_date def get_supported_formats(self) -> Dict[str, str]: """ Get list of supported date formats Returns: Dictionary of format_name -> description """ return {name: info['description'] for name, info in self.date_formats.items()} def suggest_date_format(self, date_string: str) -> List[Tuple[str, str]]: """ Suggest possible date formats for a given string Args: date_string: Date string to analyze Returns: List of (format_name, description) tuples for matching patterns """ suggestions = [] for format_name, format_info in self.date_formats.items(): if re.match(format_info['pattern'], date_string.strip()): suggestions.append((format_name, format_info['description'])) return suggestions def _validate_date_constraints( self, date_obj: datetime, field_name: str, constraints: Dict[str, Any] ) -> None: """Validate date against constraints""" # Year constraints if date_obj.year < constraints.get('min_year', 1900): raise ValidationError( message=f"{field_name} year cannot be before {constraints['min_year']}", field_name=field_name, validation_rule="min_year" ) if date_obj.year > constraints.get('max_year', 2100): raise ValidationError( message=f"{field_name} year cannot be after {constraints['max_year']}", field_name=field_name, validation_rule="max_year" ) # Future date constraints today = datetime.now() if date_obj > today: if not constraints.get('allow_future', True): raise ValidationError( message=f"{field_name} cannot be in the future", field_name=field_name, validation_rule="no_future_dates" ) days_future = (date_obj - today).days max_future = constraints.get('max_future_days', 365 * 10) if days_future > max_future: raise ValidationError( message=f"{field_name} cannot be more than {max_future} days in the future", field_name=field_name, validation_rule="max_future_days" ) # Past date constraints if date_obj < today: if not constraints.get('allow_past', True): raise ValidationError( message=f"{field_name} cannot be in the past", field_name=field_name, validation_rule="no_past_dates" ) days_past = (today - date_obj).days max_past = constraints.get('max_past_days', 365 * 50) if days_past > max_past: raise ValidationError( message=f"{field_name} cannot be more than {max_past} days in the past", field_name=field_name, validation_rule="max_past_days" ) def _obsidian_to_python_format(self, obsidian_format: str) -> str: """Convert Obsidian date format to Python strptime format""" # Common Obsidian format mappings mappings = { 'YYYY': '%Y', # 4-digit year 'YY': '%y', # 2-digit year 'MM': '%m', # Month with zero padding 'M': '%m', # Month without zero padding (Python doesn't distinguish) 'DD': '%d', # Day with zero padding 'D': '%d', # Day without zero padding (Python doesn't distinguish) 'HH': '%H', # Hour (24-hour) 'hh': '%I', # Hour (12-hour) 'mm': '%M', # Minute 'ss': '%S', # Second 'A': '%p', # AM/PM } python_format = obsidian_format # Replace in order of length (longest first to avoid partial replacements) for obsidian_token in sorted(mappings.keys(), key=len, reverse=True): python_format = python_format.replace(obsidian_token, mappings[obsidian_token]) return python_format class DateRangeValidator: """Specialized validator for date ranges and periods""" def __init__(self): self.logger = logging.getLogger(__name__) self.date_validator = DateValidator() def validate_journal_date_range( self, start_date: Optional[str] = None, end_date: Optional[str] = None, period: Optional[str] = None ) -> Tuple[datetime, datetime]: """ Validate a date range for journal operations Args: start_date: Start date string (optional) end_date: End date string (optional) period: Period specification (e.g., "week", "month", "year") Returns: Tuple of (start_datetime, end_datetime) Raises: ValidationError: If validation fails """ today = datetime.now() # Handle period-based ranges if period: return self._get_period_range(period, today) # Handle explicit date ranges if start_date and end_date: return self.date_validator.validate_date_range( start_date, end_date, max_range_days=365 * 2 # Max 2 years for journal ranges ) # Handle single date (default to that day) if start_date: start_dt = self.date_validator.validate_journal_date(start_date) end_dt = start_dt.replace(hour=23, minute=59, second=59) return start_dt, end_dt if end_date: end_dt = self.date_validator.validate_journal_date(end_date) start_dt = end_dt.replace(hour=0, minute=0, second=0) return start_dt, end_dt # Default to today start_dt = today.replace(hour=0, minute=0, second=0, microsecond=0) end_dt = today.replace(hour=23, minute=59, second=59, microsecond=999999) return start_dt, end_dt def _get_period_range(self, period: str, reference_date: datetime) -> Tuple[datetime, datetime]: """Get date range for a period specification""" period = period.lower().strip() if period in ['today', 'day']: start = reference_date.replace(hour=0, minute=0, second=0, microsecond=0) end = reference_date.replace(hour=23, minute=59, second=59, microsecond=999999) elif period in ['yesterday']: yesterday = reference_date - timedelta(days=1) start = yesterday.replace(hour=0, minute=0, second=0, microsecond=0) end = yesterday.replace(hour=23, minute=59, second=59, microsecond=999999) elif period in ['week', 'this_week']: # Start of week (Monday) days_since_monday = reference_date.weekday() start = (reference_date - timedelta(days=days_since_monday)).replace( hour=0, minute=0, second=0, microsecond=0 ) end = (start + timedelta(days=6)).replace( hour=23, minute=59, second=59, microsecond=999999 ) elif period in ['last_week']: # Previous week days_since_monday = reference_date.weekday() this_week_start = reference_date - timedelta(days=days_since_monday) start = (this_week_start - timedelta(days=7)).replace( hour=0, minute=0, second=0, microsecond=0 ) end = (start + timedelta(days=6)).replace( hour=23, minute=59, second=59, microsecond=999999 ) elif period in ['month', 'this_month']: # Start of month start = reference_date.replace(day=1, hour=0, minute=0, second=0, microsecond=0) # End of month if reference_date.month == 12: next_month = reference_date.replace(year=reference_date.year + 1, month=1, day=1) else: next_month = reference_date.replace(month=reference_date.month + 1, day=1) end = (next_month - timedelta(days=1)).replace( hour=23, minute=59, second=59, microsecond=999999 ) elif period in ['last_month']: # Previous month if reference_date.month == 1: last_month = reference_date.replace(year=reference_date.year - 1, month=12, day=1) else: last_month = reference_date.replace(month=reference_date.month - 1, day=1) start = last_month.replace(hour=0, minute=0, second=0, microsecond=0) # End of last month this_month_start = reference_date.replace(day=1) end = (this_month_start - timedelta(days=1)).replace( hour=23, minute=59, second=59, microsecond=999999 ) elif period in ['year', 'this_year']: start = reference_date.replace( month=1, day=1, hour=0, minute=0, second=0, microsecond=0 ) end = reference_date.replace( month=12, day=31, hour=23, minute=59, second=59, microsecond=999999 ) elif period in ['last_year']: last_year = reference_date.year - 1 start = reference_date.replace( year=last_year, month=1, day=1, hour=0, minute=0, second=0, microsecond=0 ) end = reference_date.replace( year=last_year, month=12, day=31, hour=23, minute=59, second=59, microsecond=999999 ) else: # Try to parse as number of days try: if period.endswith('d') or period.endswith('days'): days = int(period.rstrip('days').rstrip('d')) start = (reference_date - timedelta(days=days)).replace( hour=0, minute=0, second=0, microsecond=0 ) end = reference_date.replace(hour=23, minute=59, second=59, microsecond=999999) else: raise ValueError("Invalid period format") except ValueError: raise ValidationError( message=f"Unknown period specification: {period}. " f"Supported: today, yesterday, week, month, year, last_week, last_month, last_year, or Nd (N days)", field_name="period", validation_rule="unknown_period" ) return start, end # Global validator instances date_validator = DateValidator() date_range_validator = DateRangeValidator() def validate_date_input( date_input: Union[str, datetime, date, None], field_name: str = "date", required: bool = True, format_hint: Optional[str] = None ) -> Optional[datetime]: """ Convenience function for validating date inputs Args: date_input: Date input to validate field_name: Name of the field for error messages required: Whether the date is required format_hint: Hint about expected format Returns: Validated datetime or None if not required and empty Raises: ValidationError: If validation fails """ if date_input is None or (isinstance(date_input, str) and not date_input.strip()): if required: raise ValidationError( message=f"{field_name} is required", field_name=field_name, validation_rule="required" ) return None if isinstance(date_input, datetime): return date_input elif isinstance(date_input, date): return datetime.combine(date_input, datetime.min.time()) elif isinstance(date_input, str): # Use format hint if provided allowed_formats = None if format_hint: if format_hint in date_validator.date_formats: allowed_formats = [format_hint] else: # Try to match format hint to known formats for fmt_name, fmt_info in date_validator.date_formats.items(): if format_hint.lower() in fmt_info['description'].lower(): allowed_formats = [fmt_name] break parsed_date, _ = date_validator.validate_date_string( date_input, field_name, allowed_formats=allowed_formats ) return parsed_date else: raise ValidationError( message=f"{field_name} must be a string, date, or datetime", field_name=field_name, validation_rule="date_type" )