Module: datetime_util
Expand source code
# Copyright (C) 2023-present The Project Contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import datetime as dt
import re
from math import ceil
from math import floor
from typing import Callable
from typing import Tuple
from cl.runtime.primitive.timestamp import Timestamp
# Compile the regex pattern for datetime in ISO-8601 format yyyy-mm-ddThh:mm:ss.fffZ
datetime_pattern = re.compile(r"^d{4}-d{2}-d{2}Td{2}:d{2}:d{2}.d{3}Z$")
class DatetimeUtil:
"""Helper class for datetime rounded to whole milliseconds to ensure lossless serialization roundtrip."""
@classmethod
def now(cls) -> dt.datetime:
"""Current datetime in UTC timezone rounded to the nearest whole milliseconds to match UUIDv7 RFC-9562 spec."""
# Use Timestamp which relies on uuid_utils to avoid time ordering errors due to the difference
# in how dt.datetime and uuid_utils read the system timer
return Timestamp.to_datetime(Timestamp.create())
@classmethod
def round(cls, value: dt.datetime) -> dt.datetime:
"""Round to the nearest whole milliseconds (the argument must already be in UTC timezone)."""
return cls._round(value, round)
@classmethod
def floor(cls, value: dt.datetime) -> dt.datetime:
"""Round down to whole milliseconds (the argument must already be in UTC timezone)."""
return cls._round(value, floor)
@classmethod
def ceil(cls, value: dt.datetime) -> dt.datetime:
"""Round up to whole milliseconds (the argument must already be in UTC timezone)."""
return cls._round(value, ceil)
@classmethod
def to_str(cls, value: dt.datetime) -> str:
"""Convert to string in ISO-8601 format rounded to milliseconds: 'yyyy-mm-ddThh:mm:ss.fffZ'"""
# Validate timezone and rounding to milliseconds
DatetimeUtil.validate_datetime(value)
# Already round number of milliseconds
millisecond = value.microsecond // 1000
# Convert to string
result = (
f"{value.year:04}-{value.month:02}-{value.day:02}"
f"T{value.hour:02}:{value.minute:02}:{value.second:02}.{millisecond:03}Z"
)
return result
@classmethod
def from_str(cls, value: str) -> dt.datetime:
"""Convert from string in ISO-8601 format rounded to milliseconds: 'yyyy-mm-ddThh:mm:ss.fffZ'"""
# Validate string format
DatetimeUtil.validate_str(value)
# Convert assuming rounding to milliseconds is already done
datetime_from_str: dt.datetime = dt.datetime.fromisoformat(value[:-1])
result = DatetimeUtil.from_fields(
datetime_from_str.year,
datetime_from_str.month,
datetime_from_str.day,
datetime_from_str.hour,
datetime_from_str.minute,
datetime_from_str.second,
# Round for floating point error during division, milliseconds should already be an int
millisecond=round(datetime_from_str.microsecond / 1000.0),
)
return result
@classmethod
def to_fields(cls, value: dt.datetime) -> Tuple[int, int, int, int, int, int, int]:
"""Convert dt.datetime in UTC timezone with millisecond precision to fields."""
# Validate the datetime first, this will also confirm rounding to milliseconds
DatetimeUtil.validate_datetime(value)
# Already round number of milliseconds
millisecond = value.microsecond // 1000
# Convert assuming rounding to milliseconds has already been done
return value.year, value.month, value.day, value.hour, value.minute, value.second, millisecond
@classmethod
def from_fields(
cls,
year: int,
month: int,
day: int,
hour: int,
minute: int,
second: int,
*,
millisecond: int | None = None,
) -> dt.datetime:
"""Convert fields with millisecond precision to dt.datetime in UTC timezone."""
if millisecond is None:
millisecond = 0
result = dt.datetime(
year, month, day, hour, minute, second, microsecond=1000 * millisecond, tzinfo=dt.timezone.utc
)
return result
@classmethod
def to_iso_int(cls, value: dt.datetime) -> int:
"""Convert dt.datetime in UTC timezone with millisecond precision to int in yyyymmddhhmmssfff format."""
# Validate the datetime first, this will also confirm rounding to milliseconds
DatetimeUtil.validate_datetime(value)
# Convert assuming rounding to milliseconds has already been done
iso_int = (
1000_00_00_00_00_00 * value.year
+ 1000_00_00_00_00 * value.month
+ 1000_00_00_00 * value.day
+ 1000_00_00 * value.hour
+ 1000_00 * value.minute
+ 1000 * value.second
+ value.microsecond // 1000
)
return iso_int
@classmethod
def from_iso_int(cls, value: int) -> dt.datetime:
"""Convert int in yyyymmddhhmmssfff format with millisecond precision to dt.datetime in UTC timezone."""
if value < 10000000000000000:
raise RuntimeError(f"Datetime {value} is too short for 'yyyymmddhhmmssfff' format.")
if value > 99999999999999999:
raise RuntimeError(f"Datetime {value} is too long for 'yyyymmddhhmmssfff' format.")
year: int = value // 1000_00_00_00_00_00
value -= year * 1000_00_00_00_00_00
if year > 9999 or year < 1899:
raise RuntimeError(f"Invalid year {year} for datetime {value} in 'yyyymmddhhmmssfff' format.")
month: int = value // 1000_00_00_00_00
value -= month * 1000_00_00_00_00
if month > 12 or month < 1:
raise RuntimeError(f"Invalid month {month} for datetime {value} in 'yyyymmddhhmmssfff' format.")
day: int = value // 1000_00_00_00
value -= day * 1000_00_00_00
if day > 31 or day < 1:
raise RuntimeError(f"Invalid day {day} for datetime {value} in 'yyyymmddhhmmssfff' format.")
hour: int = value // 1000_00_00
value -= hour * 1000_00_00
if hour > 23 or hour < 0:
raise RuntimeError(f"Invalid hour {hour} for datetime {value} in 'yyyymmddhhmmssfff' format.")
minute: int = value // 1000_00
value -= minute * 1000_00
if minute > 59 or minute < 0:
raise RuntimeError(f"Invalid minute {minute} for datetime {value} in 'yyyymmddhhmmssfff' format.")
second: int = value // 1000
value -= second * 1000
if second > 59 or second < 0:
raise RuntimeError(f"Invalid second {second} for datetime {value} in 'yyyymmddhhmmssfff' format.")
millisecond: int = value
if millisecond > 999 or millisecond < 0:
raise RuntimeError(f"Invalid millisecond {millisecond} for datetime {value} in 'yyyymmddhhmmssfff' format.")
result = dt.datetime(
year,
month,
day,
hour,
minute,
second,
microsecond=1000 * millisecond,
tzinfo=dt.timezone.utc,
)
return result
@classmethod
def validate_str(cls, value: str) -> None:
"""Validate that datetime string is in ISO-8601 format rounded to milliseconds: 'yyyy-mm-ddThh:mm:ss.fffZ'"""
if not datetime_pattern.match(value):
raise RuntimeError(
f"Datetime string {value} must be in ISO-8601 format rounded to milliseconds "
f"with trailing Z to indicate UTC timezone: 'yyyy-mm-ddThh:mm:ss.fffZ'."
)
@classmethod
def validate_datetime(cls, value: dt.datetime) -> None:
"""Validate that datetime object is in UTC time zone and is rounded to milliseconds."""
# Check timezone
offset = value.utcoffset()
if offset is None:
raise RuntimeError(
f"Datetime {value} does not specify timezone. "
f"Only UTC timezone is accepted and must be specified explicitly."
)
elif value.utcoffset().total_seconds() != 0:
raise RuntimeError(
f"Datetime {value} is in {value.tzname()} timezone."
f"Only UTC timezone is accepted and must be specified explicitly."
)
# Check that datetime is rounded to whole milliseconds
if value.microsecond % 1000 != 0:
raise RuntimeError(
f"Datetime {value} has fractional milliseconds. It must be rounded to"
f"whole milliseconds using 'DatetimeUtil.round' or similar method."
)
@classmethod
def _round(cls, value: dt.datetime, rounding_function: Callable) -> dt.datetime:
"""Round to whole milliseconds (the argument must already be in UTC timezone)."""
# Check timezone
offset = value.utcoffset()
if offset is None:
raise RuntimeError(
f"Datetime {value} does not specify timezone. "
f"Only UTC timezone is accepted and must be specified explicitly."
)
elif value.utcoffset().total_seconds() != 0:
raise RuntimeError(
f"Datetime {value} is in {value.tzname()} timezone."
f"Only UTC timezone is accepted and must be specified explicitly."
)
if value.microsecond % 1_000 == 0:
# Already whole milliseconds
rounded_milliseconds = 1_000 * value.second + value.microsecond // 1_000
else:
# Round to whole milliseconds
fractional_milliseconds_float = 1_000 * value.second + value.microsecond / 1_000
rounded_milliseconds = rounding_function(fractional_milliseconds_float)
second: int = rounded_milliseconds // 1_000
rounded_milliseconds -= second * 1_000
if second > 59 or second < 0:
raise RuntimeError(f"Invalid second {second} for datetime {value} after rounding.")
if rounded_milliseconds > 999 or rounded_milliseconds < 0:
raise RuntimeError(f"Invalid millisecond {rounded_milliseconds} for datetime {value} after rounding.")
result = dt.datetime(
value.year,
value.month,
value.day,
value.hour,
value.minute,
second, # New value from rounding
1_000 * rounded_milliseconds,
dt.timezone.utc, # New value from rounding
)
return result
Classes
class DatetimeUtil
-
Helper class for datetime rounded to whole milliseconds to ensure lossless serialization roundtrip.
Expand source code
class DatetimeUtil: """Helper class for datetime rounded to whole milliseconds to ensure lossless serialization roundtrip.""" @classmethod def now(cls) -> dt.datetime: """Current datetime in UTC timezone rounded to the nearest whole milliseconds to match UUIDv7 RFC-9562 spec.""" # Use Timestamp which relies on uuid_utils to avoid time ordering errors due to the difference # in how dt.datetime and uuid_utils read the system timer return Timestamp.to_datetime(Timestamp.create()) @classmethod def round(cls, value: dt.datetime) -> dt.datetime: """Round to the nearest whole milliseconds (the argument must already be in UTC timezone).""" return cls._round(value, round) @classmethod def floor(cls, value: dt.datetime) -> dt.datetime: """Round down to whole milliseconds (the argument must already be in UTC timezone).""" return cls._round(value, floor) @classmethod def ceil(cls, value: dt.datetime) -> dt.datetime: """Round up to whole milliseconds (the argument must already be in UTC timezone).""" return cls._round(value, ceil) @classmethod def to_str(cls, value: dt.datetime) -> str: """Convert to string in ISO-8601 format rounded to milliseconds: 'yyyy-mm-ddThh:mm:ss.fffZ'""" # Validate timezone and rounding to milliseconds DatetimeUtil.validate_datetime(value) # Already round number of milliseconds millisecond = value.microsecond // 1000 # Convert to string result = ( f"{value.year:04}-{value.month:02}-{value.day:02}" f"T{value.hour:02}:{value.minute:02}:{value.second:02}.{millisecond:03}Z" ) return result @classmethod def from_str(cls, value: str) -> dt.datetime: """Convert from string in ISO-8601 format rounded to milliseconds: 'yyyy-mm-ddThh:mm:ss.fffZ'""" # Validate string format DatetimeUtil.validate_str(value) # Convert assuming rounding to milliseconds is already done datetime_from_str: dt.datetime = dt.datetime.fromisoformat(value[:-1]) result = DatetimeUtil.from_fields( datetime_from_str.year, datetime_from_str.month, datetime_from_str.day, datetime_from_str.hour, datetime_from_str.minute, datetime_from_str.second, # Round for floating point error during division, milliseconds should already be an int millisecond=round(datetime_from_str.microsecond / 1000.0), ) return result @classmethod def to_fields(cls, value: dt.datetime) -> Tuple[int, int, int, int, int, int, int]: """Convert dt.datetime in UTC timezone with millisecond precision to fields.""" # Validate the datetime first, this will also confirm rounding to milliseconds DatetimeUtil.validate_datetime(value) # Already round number of milliseconds millisecond = value.microsecond // 1000 # Convert assuming rounding to milliseconds has already been done return value.year, value.month, value.day, value.hour, value.minute, value.second, millisecond @classmethod def from_fields( cls, year: int, month: int, day: int, hour: int, minute: int, second: int, *, millisecond: int | None = None, ) -> dt.datetime: """Convert fields with millisecond precision to dt.datetime in UTC timezone.""" if millisecond is None: millisecond = 0 result = dt.datetime( year, month, day, hour, minute, second, microsecond=1000 * millisecond, tzinfo=dt.timezone.utc ) return result @classmethod def to_iso_int(cls, value: dt.datetime) -> int: """Convert dt.datetime in UTC timezone with millisecond precision to int in yyyymmddhhmmssfff format.""" # Validate the datetime first, this will also confirm rounding to milliseconds DatetimeUtil.validate_datetime(value) # Convert assuming rounding to milliseconds has already been done iso_int = ( 1000_00_00_00_00_00 * value.year + 1000_00_00_00_00 * value.month + 1000_00_00_00 * value.day + 1000_00_00 * value.hour + 1000_00 * value.minute + 1000 * value.second + value.microsecond // 1000 ) return iso_int @classmethod def from_iso_int(cls, value: int) -> dt.datetime: """Convert int in yyyymmddhhmmssfff format with millisecond precision to dt.datetime in UTC timezone.""" if value < 10000000000000000: raise RuntimeError(f"Datetime {value} is too short for 'yyyymmddhhmmssfff' format.") if value > 99999999999999999: raise RuntimeError(f"Datetime {value} is too long for 'yyyymmddhhmmssfff' format.") year: int = value // 1000_00_00_00_00_00 value -= year * 1000_00_00_00_00_00 if year > 9999 or year < 1899: raise RuntimeError(f"Invalid year {year} for datetime {value} in 'yyyymmddhhmmssfff' format.") month: int = value // 1000_00_00_00_00 value -= month * 1000_00_00_00_00 if month > 12 or month < 1: raise RuntimeError(f"Invalid month {month} for datetime {value} in 'yyyymmddhhmmssfff' format.") day: int = value // 1000_00_00_00 value -= day * 1000_00_00_00 if day > 31 or day < 1: raise RuntimeError(f"Invalid day {day} for datetime {value} in 'yyyymmddhhmmssfff' format.") hour: int = value // 1000_00_00 value -= hour * 1000_00_00 if hour > 23 or hour < 0: raise RuntimeError(f"Invalid hour {hour} for datetime {value} in 'yyyymmddhhmmssfff' format.") minute: int = value // 1000_00 value -= minute * 1000_00 if minute > 59 or minute < 0: raise RuntimeError(f"Invalid minute {minute} for datetime {value} in 'yyyymmddhhmmssfff' format.") second: int = value // 1000 value -= second * 1000 if second > 59 or second < 0: raise RuntimeError(f"Invalid second {second} for datetime {value} in 'yyyymmddhhmmssfff' format.") millisecond: int = value if millisecond > 999 or millisecond < 0: raise RuntimeError(f"Invalid millisecond {millisecond} for datetime {value} in 'yyyymmddhhmmssfff' format.") result = dt.datetime( year, month, day, hour, minute, second, microsecond=1000 * millisecond, tzinfo=dt.timezone.utc, ) return result @classmethod def validate_str(cls, value: str) -> None: """Validate that datetime string is in ISO-8601 format rounded to milliseconds: 'yyyy-mm-ddThh:mm:ss.fffZ'""" if not datetime_pattern.match(value): raise RuntimeError( f"Datetime string {value} must be in ISO-8601 format rounded to milliseconds " f"with trailing Z to indicate UTC timezone: 'yyyy-mm-ddThh:mm:ss.fffZ'." ) @classmethod def validate_datetime(cls, value: dt.datetime) -> None: """Validate that datetime object is in UTC time zone and is rounded to milliseconds.""" # Check timezone offset = value.utcoffset() if offset is None: raise RuntimeError( f"Datetime {value} does not specify timezone. " f"Only UTC timezone is accepted and must be specified explicitly." ) elif value.utcoffset().total_seconds() != 0: raise RuntimeError( f"Datetime {value} is in {value.tzname()} timezone." f"Only UTC timezone is accepted and must be specified explicitly." ) # Check that datetime is rounded to whole milliseconds if value.microsecond % 1000 != 0: raise RuntimeError( f"Datetime {value} has fractional milliseconds. It must be rounded to" f"whole milliseconds using 'DatetimeUtil.round' or similar method." ) @classmethod def _round(cls, value: dt.datetime, rounding_function: Callable) -> dt.datetime: """Round to whole milliseconds (the argument must already be in UTC timezone).""" # Check timezone offset = value.utcoffset() if offset is None: raise RuntimeError( f"Datetime {value} does not specify timezone. " f"Only UTC timezone is accepted and must be specified explicitly." ) elif value.utcoffset().total_seconds() != 0: raise RuntimeError( f"Datetime {value} is in {value.tzname()} timezone." f"Only UTC timezone is accepted and must be specified explicitly." ) if value.microsecond % 1_000 == 0: # Already whole milliseconds rounded_milliseconds = 1_000 * value.second + value.microsecond // 1_000 else: # Round to whole milliseconds fractional_milliseconds_float = 1_000 * value.second + value.microsecond / 1_000 rounded_milliseconds = rounding_function(fractional_milliseconds_float) second: int = rounded_milliseconds // 1_000 rounded_milliseconds -= second * 1_000 if second > 59 or second < 0: raise RuntimeError(f"Invalid second {second} for datetime {value} after rounding.") if rounded_milliseconds > 999 or rounded_milliseconds < 0: raise RuntimeError(f"Invalid millisecond {rounded_milliseconds} for datetime {value} after rounding.") result = dt.datetime( value.year, value.month, value.day, value.hour, value.minute, second, # New value from rounding 1_000 * rounded_milliseconds, dt.timezone.utc, # New value from rounding ) return result
Static methods
def ceil(value: datetime.datetime) -> datetime.datetime
-
Round up to whole milliseconds (the argument must already be in UTC timezone).
def floor(value: datetime.datetime) -> datetime.datetime
-
Round down to whole milliseconds (the argument must already be in UTC timezone).
def from_fields(year: int, month: int, day: int, hour: int, minute: int, second: int, *, millisecond: int | None = None) -> datetime.datetime
-
Convert fields with millisecond precision to dt.datetime in UTC timezone.
def from_iso_int(value: int) -> datetime.datetime
-
Convert int in yyyymmddhhmmssfff format with millisecond precision to dt.datetime in UTC timezone.
def from_str(value: str) -> datetime.datetime
-
Convert from string in ISO-8601 format rounded to milliseconds: ‘yyyy-mm-ddThh:mm:ss.fffZ’
def now() -> datetime.datetime
-
Current datetime in UTC timezone rounded to the nearest whole milliseconds to match UUIDv7 RFC-9562 spec.
def round(value: datetime.datetime) -> datetime.datetime
-
Round to the nearest whole milliseconds (the argument must already be in UTC timezone).
def to_fields(value: datetime.datetime) -> Tuple[int, int, int, int, int, int, int]
-
Convert dt.datetime in UTC timezone with millisecond precision to fields.
def to_iso_int(value: datetime.datetime) -> int
-
Convert dt.datetime in UTC timezone with millisecond precision to int in yyyymmddhhmmssfff format.
def to_str(value: datetime.datetime) -> str
-
Convert to string in ISO-8601 format rounded to milliseconds: ‘yyyy-mm-ddThh:mm:ss.fffZ’
def validate_datetime(value: datetime.datetime) -> None
-
Validate that datetime object is in UTC time zone and is rounded to milliseconds.
def validate_str(value: str) -> None
-
Validate that datetime string is in ISO-8601 format rounded to milliseconds: ‘yyyy-mm-ddThh:mm:ss.fffZ’