153 lines
5.0 KiB
Python
153 lines
5.0 KiB
Python
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)
|
|
|