import abc import datetime from typing import Protocol import pytz class _NowFunction(Protocol): @abc.abstractmethod def __call__(self, tz: datetime.timezone | None) -> datetime.datetime: pass # _now_func is a callable with the _NowFunction signature. # Its sole purpose is to abstract time retrieval, enabling # developers to mock this behavior in tests and time-dependent scenarios. _now_func: _NowFunction = datetime.datetime.now def naive_utc_now() -> datetime.datetime: """Return a naive datetime object (without timezone information) representing current UTC time. """ return _now_func(datetime.UTC).replace(tzinfo=None) def ensure_naive_utc(dt: datetime.datetime) -> datetime.datetime: """Return the datetime as naive UTC (tzinfo=None). If the input is timezone-aware, convert to UTC and drop the tzinfo. Assumes naive datetimes are already expressed in UTC. """ if dt.tzinfo is None: return dt return dt.astimezone(datetime.UTC).replace(tzinfo=None) def parse_time_range( start: str | None, end: str | None, tzname: str ) -> tuple[datetime.datetime | None, datetime.datetime | None]: """ Parse time range strings and convert to UTC datetime objects. Handles DST ambiguity and non-existent times gracefully. Args: start: Start time string (YYYY-MM-DD HH:MM) end: End time string (YYYY-MM-DD HH:MM) tzname: Timezone name Returns: tuple: (start_datetime_utc, end_datetime_utc) Raises: ValueError: When time range is invalid or start > end """ tz = pytz.timezone(tzname) utc = pytz.utc def _parse(time_str: str | None, label: str) -> datetime.datetime | None: if not time_str: return None try: dt = datetime.datetime.strptime(time_str, "%Y-%m-%d %H:%M").replace(second=0) except ValueError as e: raise ValueError(f"Invalid {label} time format: {e}") try: return tz.localize(dt, is_dst=None).astimezone(utc) except pytz.AmbiguousTimeError: return tz.localize(dt, is_dst=False).astimezone(utc) except pytz.NonExistentTimeError: dt += datetime.timedelta(hours=1) return tz.localize(dt, is_dst=None).astimezone(utc) start_dt = _parse(start, "start") end_dt = _parse(end, "end") # Range validation if start_dt and end_dt and start_dt > end_dt: raise ValueError("start must be earlier than or equal to end") return start_dt, end_dt