2025-06-24 09:05:29 +08:00
|
|
|
import abc
|
|
|
|
|
import datetime
|
|
|
|
|
from typing import Protocol
|
|
|
|
|
|
2025-11-04 10:00:12 +08:00
|
|
|
import pytz
|
|
|
|
|
|
2025-06-24 09:05:29 +08:00
|
|
|
|
|
|
|
|
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)
|
2025-11-04 10:00:12 +08:00
|
|
|
|
|
|
|
|
|
2025-11-12 17:59:37 +08:00
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
2025-11-04 10:00:12 +08:00
|
|
|
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
|