Module: time_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 typing import Tuple

# Compile the regex pattern for time in ISO-8601 format hh:mm:ss.fff without timezone
time_pattern = re.compile(r"^d{2}:d{2}:d{2}.d{3}$")


class TimeUtil:
    """Utility class for dt.time."""

    @classmethod
    def round(cls, value: dt.time) -> dt.time:
        """Round to whole milliseconds (the argument must already be in UTC timezone)."""

        # Check that timezone is not set
        if value.tzinfo is not None:
            raise RuntimeError(
                f"Time {value} is not accepted because it specifies timezone {value.tzname()}. "
                f"Time must have tzinfo=None which is the default value."
            )

        fractional_milliseconds_float = 1000.0 * value.second + value.microsecond / 1000.0
        rounded_microseconds = round(fractional_milliseconds_float)

        second: int = rounded_microseconds // 1_000
        rounded_microseconds -= second * 1_000
        if second > 59 or second < 0:
            raise RuntimeError(f"Invalid second {second} for datetime {value} after rounding.")

        millisecond: int = rounded_microseconds
        if millisecond > 999 or millisecond < 0:
            raise RuntimeError(f"Invalid millisecond {millisecond} for datetime {value} after rounding.")

        result = dt.time(
            value.hour,
            value.minute,
            second,  # New value from rounding
            1_000 * millisecond,  # New value from rounding
        )
        return result

    @classmethod
    def to_str(cls, value: dt.time) -> str:
        """Convert to string in ISO-8601 format rounded to milliseconds: 'hh:mm:ss.fff'"""

        # Validate timezone and rounding to milliseconds
        cls.validate_time(value)

        # Already round number of milliseconds
        millisecond = value.microsecond // 1000

        # Convert to string
        result = f"{value.hour:02}:{value.minute:02}:{value.second:02}.{millisecond:03}"
        return result

    @classmethod
    def from_str(cls, value: str) -> dt.time:
        """Convert from string in ISO-8601 format rounded to milliseconds: 'hh:mm:ss.fff'"""

        # Validate string format and that tzinfo is None
        cls.validate_str(value)

        # Convert assuming rounding to milliseconds is already done
        time_from_str: dt.time = dt.time.fromisoformat(value)
        result = cls.from_fields(
            time_from_str.hour,
            time_from_str.minute,
            time_from_str.second,
            millisecond=round(time_from_str.microsecond / 1000.0),
        )
        return result

    @classmethod
    def to_fields(cls, value: dt.time) -> Tuple[int, int, int, int]:
        """Convert dt.time in UTC timezone with millisecond precision to fields."""

        # Validate the time first, this will also confirm rounding to milliseconds
        cls.validate_time(value)

        # Already round number of milliseconds
        millisecond = value.microsecond // 1000

        # Convert assuming rounding to milliseconds has already been done
        return value.hour, value.minute, value.second, millisecond

    @classmethod
    def from_fields(
        cls,
        hour: int,
        minute: int,
        second: int,
        *,
        millisecond: int | None = None,
    ) -> dt.time:
        """Convert fields with millisecond precision to dt.time."""

        if millisecond is None:
            millisecond = 0

        result = dt.time(hour, minute, second, microsecond=1000 * millisecond)
        return result

    @classmethod
    def to_iso_int(cls, value: dt.time) -> int:
        """Convert dt.time with millisecond precision to int in hhmmssfff format."""

        # Validate the time first, this will also confirm rounding to milliseconds
        cls.validate_time(value)

        # Convert assuming rounding to milliseconds has already been done
        iso_int = 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.time:
        """Convert int in hhmmssfff format with millisecond precision to dt.time."""

        if value < 100000000:
            raise RuntimeError(f"Time {value} is too short for 'hhmmssfff' format.")
        if value > 999999999:
            raise RuntimeError(f"Time {value} is too long for 'hhmmssfff' 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 time {value} in 'hhmmssfff' format.")

        minute: int = value // 1000_00
        value -= minute * 1000_00
        if minute > 59 or minute < 0:
            raise RuntimeError(f"Invalid minute {minute} for time {value} in 'hhmmssfff' format.")

        second: int = value // 1000
        value -= second * 1000
        if second > 59 or second < 0:
            raise RuntimeError(f"Invalid second {second} for time {value} in 'hhmmssfff' format.")

        millisecond: int = value
        if millisecond > 999 or millisecond < 0:
            raise RuntimeError(f"Invalid millisecond {millisecond} for time {value} in 'hhmmssfff' format.")

        result = dt.time(hour, minute, second, microsecond=1000 * millisecond)
        return result

    @classmethod
    def validate_str(cls, value: str) -> None:
        """Validate that time string is in ISO-8601 format rounded to milliseconds: 'hh:mm:ss.fff'"""
        if not time_pattern.match(value):
            raise RuntimeError(
                f"Time string {value} must be in ISO-8601 format rounded to milliseconds "
                f"without timezone: 'hh:mm:ss.fff'."
            )

    @classmethod
    def validate_time(cls, value: dt.time) -> None:
        """Validate that time object does not have time zone and is rounded to milliseconds."""

        # Check that timezone is not set
        if value.tzinfo is not None:
            raise RuntimeError(
                f"Time {value} is not accepted because it specifies timezone {value.tzname()}. "
                f"Time must have tzinfo=None which is the default value."
            )

        # Check that time is rounded to whole milliseconds
        if value.microsecond % 1000 != 0:
            raise RuntimeError(
                f"Time {value} has fractional milliseconds. It must be rounded to"
                f"whole milliseconds using 'TimeUtil.round' or similar method."
            )

Classes

class TimeUtil

Utility class for dt.time.

Expand source code
class TimeUtil:
    """Utility class for dt.time."""

    @classmethod
    def round(cls, value: dt.time) -> dt.time:
        """Round to whole milliseconds (the argument must already be in UTC timezone)."""

        # Check that timezone is not set
        if value.tzinfo is not None:
            raise RuntimeError(
                f"Time {value} is not accepted because it specifies timezone {value.tzname()}. "
                f"Time must have tzinfo=None which is the default value."
            )

        fractional_milliseconds_float = 1000.0 * value.second + value.microsecond / 1000.0
        rounded_microseconds = round(fractional_milliseconds_float)

        second: int = rounded_microseconds // 1_000
        rounded_microseconds -= second * 1_000
        if second > 59 or second < 0:
            raise RuntimeError(f"Invalid second {second} for datetime {value} after rounding.")

        millisecond: int = rounded_microseconds
        if millisecond > 999 or millisecond < 0:
            raise RuntimeError(f"Invalid millisecond {millisecond} for datetime {value} after rounding.")

        result = dt.time(
            value.hour,
            value.minute,
            second,  # New value from rounding
            1_000 * millisecond,  # New value from rounding
        )
        return result

    @classmethod
    def to_str(cls, value: dt.time) -> str:
        """Convert to string in ISO-8601 format rounded to milliseconds: 'hh:mm:ss.fff'"""

        # Validate timezone and rounding to milliseconds
        cls.validate_time(value)

        # Already round number of milliseconds
        millisecond = value.microsecond // 1000

        # Convert to string
        result = f"{value.hour:02}:{value.minute:02}:{value.second:02}.{millisecond:03}"
        return result

    @classmethod
    def from_str(cls, value: str) -> dt.time:
        """Convert from string in ISO-8601 format rounded to milliseconds: 'hh:mm:ss.fff'"""

        # Validate string format and that tzinfo is None
        cls.validate_str(value)

        # Convert assuming rounding to milliseconds is already done
        time_from_str: dt.time = dt.time.fromisoformat(value)
        result = cls.from_fields(
            time_from_str.hour,
            time_from_str.minute,
            time_from_str.second,
            millisecond=round(time_from_str.microsecond / 1000.0),
        )
        return result

    @classmethod
    def to_fields(cls, value: dt.time) -> Tuple[int, int, int, int]:
        """Convert dt.time in UTC timezone with millisecond precision to fields."""

        # Validate the time first, this will also confirm rounding to milliseconds
        cls.validate_time(value)

        # Already round number of milliseconds
        millisecond = value.microsecond // 1000

        # Convert assuming rounding to milliseconds has already been done
        return value.hour, value.minute, value.second, millisecond

    @classmethod
    def from_fields(
        cls,
        hour: int,
        minute: int,
        second: int,
        *,
        millisecond: int | None = None,
    ) -> dt.time:
        """Convert fields with millisecond precision to dt.time."""

        if millisecond is None:
            millisecond = 0

        result = dt.time(hour, minute, second, microsecond=1000 * millisecond)
        return result

    @classmethod
    def to_iso_int(cls, value: dt.time) -> int:
        """Convert dt.time with millisecond precision to int in hhmmssfff format."""

        # Validate the time first, this will also confirm rounding to milliseconds
        cls.validate_time(value)

        # Convert assuming rounding to milliseconds has already been done
        iso_int = 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.time:
        """Convert int in hhmmssfff format with millisecond precision to dt.time."""

        if value < 100000000:
            raise RuntimeError(f"Time {value} is too short for 'hhmmssfff' format.")
        if value > 999999999:
            raise RuntimeError(f"Time {value} is too long for 'hhmmssfff' 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 time {value} in 'hhmmssfff' format.")

        minute: int = value // 1000_00
        value -= minute * 1000_00
        if minute > 59 or minute < 0:
            raise RuntimeError(f"Invalid minute {minute} for time {value} in 'hhmmssfff' format.")

        second: int = value // 1000
        value -= second * 1000
        if second > 59 or second < 0:
            raise RuntimeError(f"Invalid second {second} for time {value} in 'hhmmssfff' format.")

        millisecond: int = value
        if millisecond > 999 or millisecond < 0:
            raise RuntimeError(f"Invalid millisecond {millisecond} for time {value} in 'hhmmssfff' format.")

        result = dt.time(hour, minute, second, microsecond=1000 * millisecond)
        return result

    @classmethod
    def validate_str(cls, value: str) -> None:
        """Validate that time string is in ISO-8601 format rounded to milliseconds: 'hh:mm:ss.fff'"""
        if not time_pattern.match(value):
            raise RuntimeError(
                f"Time string {value} must be in ISO-8601 format rounded to milliseconds "
                f"without timezone: 'hh:mm:ss.fff'."
            )

    @classmethod
    def validate_time(cls, value: dt.time) -> None:
        """Validate that time object does not have time zone and is rounded to milliseconds."""

        # Check that timezone is not set
        if value.tzinfo is not None:
            raise RuntimeError(
                f"Time {value} is not accepted because it specifies timezone {value.tzname()}. "
                f"Time must have tzinfo=None which is the default value."
            )

        # Check that time is rounded to whole milliseconds
        if value.microsecond % 1000 != 0:
            raise RuntimeError(
                f"Time {value} has fractional milliseconds. It must be rounded to"
                f"whole milliseconds using 'TimeUtil.round' or similar method."
            )

Static methods

def from_fields(hour: int, minute: int, second: int, *, millisecond: int | None = None) -> datetime.time

Convert fields with millisecond precision to dt.time.

def from_iso_int(value: int) -> datetime.time

Convert int in hhmmssfff format with millisecond precision to dt.time.

def from_str(value: str) -> datetime.time

Convert from string in ISO-8601 format rounded to milliseconds: ‘hh:mm:ss.fff’

def round(value: datetime.time) -> datetime.time

Round to whole milliseconds (the argument must already be in UTC timezone).

def to_fields(value: datetime.time) -> Tuple[int, int, int, int]

Convert dt.time in UTC timezone with millisecond precision to fields.

def to_iso_int(value: datetime.time) -> int

Convert dt.time with millisecond precision to int in hhmmssfff format.

def to_str(value: datetime.time) -> str

Convert to string in ISO-8601 format rounded to milliseconds: ‘hh:mm:ss.fff’

def validate_str(value: str) -> None

Validate that time string is in ISO-8601 format rounded to milliseconds: ‘hh:mm:ss.fff’

def validate_time(value: datetime.time) -> None

Validate that time object does not have time zone and is rounded to milliseconds.