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)