"""Utility functions for date formatting and validation."""
from __future__ import annotations
import datetime as _dt
from datetime import timedelta
from typing import TYPE_CHECKING
from pytz import UTC
from epicsarchiver.common.validation import ValidationError
if TYPE_CHECKING:
import datetime
[docs]
NANO_PER_SECOND = 1_000_000_000
[docs]
MICRO_PER_SECOND = 1_000_000
[docs]
NANO_PER_MICROSECOND = 1_000
[docs]
EPOCH = _dt.datetime(1970, 1, 1, tzinfo=UTC)
[docs]
def ensure_utc(
input_time: datetime.datetime,
) -> datetime.datetime:
"""Add UTC timezone if timezone missing, otherwise convert to UTC.
Args:
input_time (datetime.datetime): A timestamp object.
Returns:
datetime.datetime: A timestamp object with timezone set to UTC.
"""
return (
input_time.replace(tzinfo=UTC)
if input_time.tzinfo is None
else input_time.astimezone(UTC)
)
[docs]
def year_start_epoch_seconds(year: int) -> int:
"""Seconds from Unix epoch at the start of a given year.
Args:
year (int): year
Returns:
int: seconds from epoch of start of year.
"""
return int(
(_dt.datetime(year, 1, 1, tzinfo=UTC) - EPOCH).total_seconds(),
)
[docs]
def _parse_datetime_str(date_str: str) -> datetime.datetime:
"""Parse a date string using known formats.
Args:
date_str: A date/datetime string.
Returns:
datetime.datetime: UTC-aware datetime.
Raises:
DateFormatError: If the string cannot be parsed.
"""
for fmt in _DATE_FORMATS:
try:
return ensure_utc(
_dt.datetime.strptime(date_str, fmt), # noqa: DTZ007
)
except ValueError: # noqa: PERF203
continue
raise DateFormatError(date_str)
[docs]
class QueryTimestamp:
"""A microsecond-precision UTC timestamp for archiver queries.
Wraps a UTC-aware datetime. Used for the input flow: user
string/datetime to archiver HTTP query parameter.
"""
def __init__(self, dt: datetime.datetime) -> None:
"""Create from a UTC-aware datetime.
Args:
dt: UTC-aware datetime
"""
@classmethod
@classmethod
[docs]
def from_datetime(cls, dt: datetime.datetime) -> QueryTimestamp:
"""From a datetime (naive assumed UTC, aware converted).
Args:
dt: A datetime object.
Returns:
QueryTimestamp
"""
return cls(ensure_utc(dt))
@property
[docs]
def datetime(self) -> datetime.datetime:
"""UTC-aware datetime.
Returns:
datetime.datetime: UTC datetime
"""
return self._dt
[docs]
def to_query_string(self) -> str:
"""ISO 8601 with 'Z' suffix for archiver HTTP params.
Returns:
str: e.g. '2018-07-04T13:00:00.000000Z'
"""
dt = self._dt.replace(tzinfo=None)
return dt.isoformat(timespec="microseconds") + "Z"
[docs]
class ResponseTimestamp:
"""A nanosecond-precision UTC timestamp from archiver responses.
Internal representation is nanoseconds since Unix epoch (int).
Used for the response flow: archiver data to datetime or
display string. Precision is only lost when outputting to
datetime (microsecond resolution).
"""
def __init__(self, timestamp_ns: int) -> None:
"""Create from nanosecond epoch timestamp.
Args:
timestamp_ns (int): nanoseconds since Unix epoch
"""
[docs]
self._ns = timestamp_ns
@classmethod
[docs]
def from_yearsecondnanos(
cls, year: int, seconds: int, nanos: int
) -> ResponseTimestamp:
"""From archiver PB format.
Full nanosecond precision is preserved.
Args:
year (int): year
seconds (int): seconds into year
nanos (int): nanoseconds
Returns:
ResponseTimestamp
"""
year_start = year_start_epoch_seconds(year)
total_ns = (year_start + seconds) * NANO_PER_SECOND + nanos
return cls(total_ns)
@property
[docs]
def ns(self) -> int:
"""Nanoseconds since Unix epoch. Full precision.
Returns:
int: nanoseconds since epoch
"""
return self._ns
@property
[docs]
def datetime(self) -> datetime.datetime:
"""UTC-aware datetime (microsecond precision).
Sub-microsecond nanoseconds are truncated.
Returns:
datetime.datetime: UTC datetime
"""
return EPOCH + timedelta(
microseconds=self._ns // NANO_PER_MICROSECOND,
)
[docs]
def to_local_string(self) -> str:
"""Local timezone string for display.
Returns:
str: local timezone datetime string
"""
return str(self.datetime.astimezone())