"""Dates in the wild → aware datetimes (UTC). Partial dates ('March 2025', '2024') keep a precision marker.""" from __future__ import annotations import re from datetime import UTC, date, datetime from dateutil import parser as duparser _MONTHS = "january|february|march|april|may|june|july|august|september|october|november|december|jan|feb|mar|apr|jun|jul|aug|sep|sept|oct|nov|dec" _MONTH_YEAR = re.compile(rf"\b({_MONTHS})\.?\s+(\d{{4}})\b", re.IGNORECASE) _YEAR = re.compile(r"\b(19[89]\d|20[0-4]\d)\b") def parse_datetime(value: str | datetime | date | None, *, default_tz=UTC) -> datetime | None: if value is None: return None if isinstance(value, datetime): return value if value.tzinfo else value.replace(tzinfo=default_tz) if isinstance(value, date): return datetime(value.year, value.month, value.day, tzinfo=default_tz) s = str(value).strip() if not s: return None if re.fullmatch(r"\d{10}(\.\d+)?", s): return datetime.fromtimestamp(float(s), tz=UTC) if re.fullmatch(r"\d{13}", s): return datetime.fromtimestamp(int(s) / 1000, tz=UTC) try: dt = duparser.parse(s, fuzzy=False) return dt if dt.tzinfo else dt.replace(tzinfo=default_tz) except (ValueError, OverflowError, TypeError): pass return None def parse_date(value: str | None) -> tuple[date | None, str]: """Return (date, precision) where precision ∈ {'day','month','year','none'}. Fuzzy text allowed.""" if not value: return None, "none" s = str(value).strip() dt = parse_datetime(s) if dt and re.search(r"\d{1,2}", s) and not _MONTH_YEAR.fullmatch(s) and not _YEAR.fullmatch(s): return dt.date(), "day" m = _MONTH_YEAR.search(s) if m: try: d = duparser.parse(f"1 {m.group(1)} {m.group(2)}") return d.date(), "month" except ValueError: pass m = _YEAR.search(s) if m: return date(int(m.group(1)), 1, 1), "year" try: d = duparser.parse(s, fuzzy=True, default=datetime(1900, 1, 1)) if d.year > 1900: return d.date(), "day" except (ValueError, OverflowError): pass return None, "none" def iso(dt: datetime | date | None) -> str | None: if dt is None: return None if isinstance(dt, datetime): return dt.astimezone(UTC).isoformat(timespec="seconds") return dt.isoformat() __all__ = ["iso", "parse_date", "parse_datetime"]