"""Date and ISO helpers for duty ranges and API validation.""" import re from datetime import date, datetime, timedelta, timezone from typing import Literal def day_start_iso(d: date) -> str: """ISO 8601 start of calendar day UTC: YYYY-MM-DDT00:00:00Z.""" return d.isoformat() + "T00:00:00Z" def day_end_iso(d: date) -> str: """ISO 8601 end of calendar day UTC: YYYY-MM-DDT23:59:59Z.""" return d.isoformat() + "T23:59:59Z" def duty_to_iso(d: date, hour_utc: int, minute_utc: int) -> str: """ISO 8601 with Z for start of duty on date d at given UTC time.""" dt = datetime(d.year, d.month, d.day, hour_utc, minute_utc, 0, tzinfo=timezone.utc) return dt.strftime("%Y-%m-%dT%H:%M:%SZ") _ISO_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$") # Maximum allowed date range in days (e.g. 731 = 2 years). MAX_DATE_RANGE_DAYS = 731 class DateRangeValidationError(ValueError): """Raised when from_date/to_date validation fails. API uses kind for i18n key.""" def __init__( self, kind: Literal["bad_format", "from_after_to", "range_too_large"], ) -> None: self.kind = kind super().__init__(kind) def to_date_exclusive_iso(to_date: str) -> str: """Return the day after to_date in YYYY-MM-DD for exclusive range end. Use for queries like start_at < to_date_next (e.g. filter end before next day). Args: to_date: End date in YYYY-MM-DD. Returns: (to_date + 1 day) in YYYY-MM-DD. """ dt = datetime.fromisoformat(to_date + "T00:00:00") + timedelta(days=1) return dt.strftime("%Y-%m-%d") def parse_utc_iso(iso_str: str) -> datetime: """Parse UTC ISO 8601 string with Z suffix to timezone-aware datetime (UTC). Args: iso_str: e.g. '2025-01-15T09:00:00Z'. Returns: Timezone-aware datetime in UTC. """ normalized = (iso_str or "").strip().replace("Z", "+00:00") return datetime.fromisoformat(normalized) def parse_utc_iso_naive(iso_str: str) -> datetime: """Parse UTC ISO 8601 string with Z to naive datetime in UTC. Use for job queue or code that expects naive UTC. Args: iso_str: e.g. '2025-01-15T09:00:00Z'. Returns: Naive datetime with same UTC wall time. """ dt = parse_utc_iso(iso_str) return dt.replace(tzinfo=None) def parse_iso_date(s: str) -> date | None: """Parse YYYY-MM-DD string to date. Returns None if invalid.""" if not s or not _ISO_DATE_RE.match(s.strip()): return None try: return date.fromisoformat(s.strip()) except ValueError: return None def validate_date_range(from_date: str, to_date: str) -> None: """Validate from_date and to_date are YYYY-MM-DD, from_date <= to_date, and range <= MAX_DATE_RANGE_DAYS. Raises: DateRangeValidationError: bad_format if format invalid, from_after_to if from > to, range_too_large if (to_date - from_date) > MAX_DATE_RANGE_DAYS. """ if not _ISO_DATE_RE.match(from_date or "") or not _ISO_DATE_RE.match(to_date or ""): raise DateRangeValidationError("bad_format") if from_date > to_date: raise DateRangeValidationError("from_after_to") try: from_d = date.fromisoformat(from_date) to_d = date.fromisoformat(to_date) except ValueError: raise DateRangeValidationError("bad_format") from None if (to_d - from_d).days > MAX_DATE_RANGE_DAYS: raise DateRangeValidationError("range_too_large")