"""Canonical UTC :class:`~datetime.datetime` helpers.Two semantics exist across the codebase for handling naive datetimes:* :func:`coerce_utc_aware` — coerce a naive datetime to UTC-aware by attaching :data:`~datetime.UTC`. Used where the source produces naive datetimes (e.g. PKCS#12 certificate timestamps).* :func:`validate_utc_aware` — reject naive datetimes or non-UTC datetimes with :class:`~cadrumo.core.errors.hierarchy.CoreValidationError`. Used at persistence and model boundaries where a naive datetime indicates a programming error.:func:`parse_iso_datetime` is deliberately policy-neutral: it normalises the``Z`` suffix and returns the parsed :class:`~datetime.datetime`, leaving callersto choose :func:`coerce_utc_aware` or :func:`validate_utc_aware`."""from__future__importannotationsfromdatetimeimportUTC,datetimefromtypingimportAnnotatedfrompydanticimportAfterValidatorfrom..errors.hierarchyimportCoreValidationError,pydantic_validation_boundary
[docs]defparse_iso_datetime(raw:str)->datetime:"""Parse an ISO-8601 datetime string, normalising a trailing ``Z`` to ``+00:00``. :meth:`datetime.fromisoformat` historically rejects the ``Z`` UTC suffix; this normalises it first so UTC-suffixed timestamps parse. The result may be naive or aware depending on the input — pass it through :func:`validate_utc_aware` or :func:`coerce_utc_aware` when an aware value is required. """returndatetime.fromisoformat(raw.replace("Z","+00:00"))
[docs]defcoerce_utc_aware(value:datetime)->datetime:"""Return a UTC-aware datetime, coercing a naive one if necessary. Naive datetimes have :data:`datetime.UTC` attached. Timezone-aware datetimes whose offset differs from UTC are converted via :meth:`datetime.astimezone`. Args: value: Any :class:`datetime` instance, naive or aware. Returns: A UTC-aware :class:`datetime` instance. """ifvalue.tzinfoisNone:returnvalue.replace(tzinfo=UTC)returnvalue.astimezone(UTC)
[docs]defvalidate_utc_aware(value:datetime)->datetime:"""Return *value* unchanged if it is a UTC-aware datetime. Raises :class:`cadrumo.core.errors.hierarchy.CoreValidationError` when *value* is naive (no ``tzinfo``) or when its UTC offset is not zero (i.e. not UTC). Args: value: The datetime to validate. Returns: *value* unchanged. Raises: CoreValidationError: When *value* is naive or not in UTC. """ifvalue.tzinfoisNone:raiseCoreValidationError("datetime must be timezone-aware UTC")ifvalue.utcoffset()!=UTC.utcoffset(value):raiseCoreValidationError("datetime must be in UTC")returnvalue
UtcInstant=Annotated[datetime,AfterValidator(pydantic_validation_boundary(validate_utc_aware))]"""A :class:`~datetime.datetime` field pinned to the canonical UTC contract.The declarative form of :func:`validate_utc_aware`, for the many modelswhose fields document an instant. Those models each carried their own``field_validator`` calling the same helper, which made enrolment a thing anauthor had to remember rather than a thing the type says: a field declaredas a bare ``datetime`` looks identical to an enrolled one at the point ofdeclaration, so the omissions are invisible in review and only surface whena naive value has already been hashed or persisted.It validates rather than coerces, deliberately. A naive instant arriving ata model boundary is not a value with a missing offset, it is a value whoseoffset is unknown -- attaching UTC would invent the one fact in doubt, andwould do it silently. Sources that genuinely produce naive local times (aPKCS#12 certificate stamp) belong on :func:`coerce_utc_aware` at the pointthey are read, where the assumption is stated once and visibly.Refusal also protects derived identity. A content-addressed id that hashesan instant's ``isoformat()`` gets a different digest for each spelling ofthe same moment, so admitting several spellings turns one event intoseveral records; admitting only UTC leaves exactly one."""