Files
more_dots/services/common/datetime_utils.py
T

153 lines
5.0 KiB
Python
Raw Normal View History

2026-03-24 18:07:22 +08:00
from __future__ import annotations
from dataclasses import dataclass
from datetime import date, datetime, time as dt_time
from typing import Any
from zoneinfo import ZoneInfo
@dataclass(frozen=True)
class DateTimeBundle:
"""Unified datetime payload for storage and API usage."""
dt: datetime
db_datetime: datetime
epoch_seconds: int
epoch_millis: int
yyyymmdd: str
date_str: str
datetime_str: str
iso_str: str
class DateTimeGenerator:
"""Parse and generate datetime values in multiple common formats."""
DEFAULT_TZ = ZoneInfo("Asia/Shanghai")
SUPPORTED_FORMATS = (
"%Y%m%d",
"%Y%m%d%H%M%S",
"%Y-%m-%d",
"%Y/%m/%d",
"%Y-%m-%d %H:%M",
"%Y/%m/%d %H:%M",
"%Y-%m-%d %H:%M:%S",
"%Y/%m/%d %H:%M:%S",
"%Y-%m-%d %H:%M:%S.%f",
"%Y/%m/%d %H:%M:%S.%f",
)
@classmethod
def now(cls) -> DateTimeBundle:
return cls.bundle()
@classmethod
def bundle(cls, value: Any = None, *, default_to_now: bool = True) -> DateTimeBundle:
dt = cls.parse(value, default_to_now=default_to_now)
epoch_seconds = int(dt.timestamp())
epoch_millis = int(dt.timestamp() * 1000)
return DateTimeBundle(
dt=dt,
db_datetime=dt.replace(tzinfo=None),
epoch_seconds=epoch_seconds,
epoch_millis=epoch_millis,
yyyymmdd=dt.strftime("%Y%m%d"),
date_str=dt.strftime("%Y-%m-%d"),
datetime_str=dt.strftime("%Y-%m-%d %H:%M:%S"),
iso_str=dt.isoformat(),
)
@classmethod
def pair(cls, created_value: Any = None, updated_value: Any = None) -> tuple[DateTimeBundle, DateTimeBundle]:
created = cls.bundle(created_value, default_to_now=True)
updated = cls.bundle(updated_value if updated_value is not None else created.epoch_millis, default_to_now=True)
return created, updated
@classmethod
def parse(cls, value: Any = None, *, default_to_now: bool = True) -> datetime:
if value is None:
if default_to_now:
return datetime.now(cls.DEFAULT_TZ)
raise ValueError("datetime value is None")
if isinstance(value, datetime):
return cls._normalize_datetime(value)
if isinstance(value, date):
return datetime.combine(value, dt_time.min).replace(tzinfo=cls.DEFAULT_TZ)
if isinstance(value, (int, float)):
return cls._parse_numeric(str(int(value)), default_to_now=default_to_now)
if isinstance(value, str):
text = value.strip()
if not text:
if default_to_now:
return datetime.now(cls.DEFAULT_TZ)
raise ValueError("datetime value is blank")
if text.isdigit():
return cls._parse_numeric(text, default_to_now=default_to_now)
iso_candidate = text.replace("Z", "+00:00")
try:
return cls._normalize_datetime(datetime.fromisoformat(iso_candidate))
except Exception:
pass
for fmt in cls.SUPPORTED_FORMATS:
try:
parsed = datetime.strptime(text, fmt)
return parsed.replace(tzinfo=cls.DEFAULT_TZ)
except Exception:
continue
if default_to_now:
return datetime.now(cls.DEFAULT_TZ)
raise ValueError(f"unsupported datetime value: {value!r}")
@classmethod
def _parse_numeric(cls, text: str, *, default_to_now: bool) -> datetime:
if len(text) == 8:
try:
return datetime.strptime(text, "%Y%m%d").replace(tzinfo=cls.DEFAULT_TZ)
except Exception:
if default_to_now:
return datetime.now(cls.DEFAULT_TZ)
raise
if len(text) == 14:
try:
return datetime.strptime(text, "%Y%m%d%H%M%S").replace(tzinfo=cls.DEFAULT_TZ)
except Exception:
if default_to_now:
return datetime.now(cls.DEFAULT_TZ)
raise
if len(text) == 10:
try:
return datetime.fromtimestamp(int(text), tz=cls.DEFAULT_TZ)
except Exception:
if default_to_now:
return datetime.now(cls.DEFAULT_TZ)
raise
if len(text) == 13:
try:
return datetime.fromtimestamp(int(text) / 1000, tz=cls.DEFAULT_TZ)
except Exception:
if default_to_now:
return datetime.now(cls.DEFAULT_TZ)
raise
if default_to_now:
return datetime.now(cls.DEFAULT_TZ)
raise ValueError(f"unsupported numeric datetime value: {text!r}")
@classmethod
def _normalize_datetime(cls, value: datetime) -> datetime:
if value.tzinfo is None:
return value.replace(tzinfo=cls.DEFAULT_TZ)
return value.astimezone(cls.DEFAULT_TZ)