Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ Invalid input
2014-06-5 Invalid timestamp '2014-06-5'.
2014-06-05 * %Y-%m-%d %H:%M:%S.%f
2015-xxx * %Y-%f
${NONE} Unsupported input 'None'.
${NONE} Invalid timestamp 'None'.

*** Keywords ***
Date Conversion Should Succeed
Expand Down
159 changes: 98 additions & 61 deletions src/robot/libraries/DateTime.py
Original file line number Diff line number Diff line change
Expand Up @@ -304,9 +304,15 @@
| # ...
"""

# can be removed when using Python 3.13+
# needed for typing to use | operator together with forward referenceing
# see: def __sub__(self, other: Date | Time) -> Date | Time:
from __future__ import annotations

import datetime
import sys
import time
from typing import Union

from robot.utils import (
elapsed_time_to_string, secs_to_timestr, timestr_to_secs, type_name
Expand All @@ -325,13 +331,20 @@
"subtract_time_from_time",
]

FloatOrString = Union[float, str]
FloatOrStringOrInt = Union[FloatOrString, int]
DatetimeMixed = Union[datetime.datetime, FloatOrString]
DateTimeExpanded = Union[datetime.datetime, datetime.date, FloatOrStringOrInt]
TimedeltaExpanded = Union[datetime.timedelta, FloatOrStringOrInt]
TimedeltaMixed = Union[datetime.timedelta, FloatOrString]


def get_current_date(
time_zone="local",
increment=0,
result_format="timestamp",
exclude_millis=False,
):
time_zone: str = "local",
increment: TimedeltaExpanded = 0,
result_format: str = "timestamp",
exclude_millis: bool = False,
) -> DatetimeMixed:
"""Returns current local or UTC time with an optional increment.

Arguments:
Expand Down Expand Up @@ -373,11 +386,11 @@ def get_current_date(


def convert_date(
date,
result_format="timestamp",
exclude_millis=False,
date_format=None,
):
date: DateTimeExpanded,
result_format: str = "timestamp",
exclude_millis: bool = False,
date_format: "str|None" = None,
) -> DatetimeMixed:
"""Converts between supported `date formats`.

Arguments:
Expand All @@ -398,7 +411,11 @@ def convert_date(
return Date(date, date_format).convert(result_format, millis=not exclude_millis)


def convert_time(time, result_format="number", exclude_millis=False):
def convert_time(
time: TimedeltaExpanded,
result_format: str = "number",
exclude_millis: bool = False,
) -> TimedeltaMixed:
"""Converts between supported `time formats`.

Arguments:
Expand All @@ -419,13 +436,13 @@ def convert_time(time, result_format="number", exclude_millis=False):


def subtract_date_from_date(
date1,
date2,
result_format="number",
exclude_millis=False,
date1_format=None,
date2_format=None,
):
date1: DateTimeExpanded,
date2: DateTimeExpanded,
result_format: str = "number",
exclude_millis: bool = False,
date1_format: "str|None" = None,
date2_format: "str|None" = None,
) -> TimedeltaMixed:
"""Subtracts date from another date and returns time between.

Arguments:
Expand All @@ -450,12 +467,12 @@ def subtract_date_from_date(


def add_time_to_date(
date,
time,
result_format="timestamp",
exclude_millis=False,
date_format=None,
):
date: DateTimeExpanded,
time: TimedeltaExpanded,
result_format: str = "timestamp",
exclude_millis: bool = False,
date_format: "str|None" = None,
) -> DatetimeMixed:
"""Adds time to date and returns the resulting date.

Arguments:
Expand All @@ -479,12 +496,12 @@ def add_time_to_date(


def subtract_time_from_date(
date,
time,
result_format="timestamp",
exclude_millis=False,
date_format=None,
):
date: DateTimeExpanded,
time: TimedeltaExpanded,
result_format: str = "timestamp",
exclude_millis: bool = False,
date_format: "str|None" = None,
) -> DatetimeMixed:
"""Subtracts time from date and returns the resulting date.

Arguments:
Expand All @@ -507,7 +524,12 @@ def subtract_time_from_date(
return date.convert(result_format, millis=not exclude_millis)


def add_time_to_time(time1, time2, result_format="number", exclude_millis=False):
def add_time_to_time(
time1: TimedeltaExpanded,
time2: TimedeltaExpanded,
result_format: str = "number",
exclude_millis: bool = False,
) -> TimedeltaMixed:
"""Adds time to another time and returns the resulting time.

Arguments:
Expand All @@ -527,7 +549,12 @@ def add_time_to_time(time1, time2, result_format="number", exclude_millis=False)
return time.convert(result_format, millis=not exclude_millis)


def subtract_time_from_time(time1, time2, result_format="number", exclude_millis=False):
def subtract_time_from_time(
time1: TimedeltaExpanded,
time2: TimedeltaExpanded,
result_format: str = "number",
exclude_millis: bool = False,
) -> TimedeltaMixed:
"""Subtracts time from another time and returns the resulting time.

Arguments:
Expand All @@ -549,16 +576,23 @@ def subtract_time_from_time(time1, time2, result_format="number", exclude_millis


class Date:

def __init__(self, date, input_format=None):
self.datetime = self._convert_to_datetime(date, input_format)
def __init__(
self,
date: DateTimeExpanded,
input_format: "str|None" = None,
):
self.datetime: datetime.datetime = self._convert_to_datetime(date, input_format)

@property
def seconds(self):
def seconds(self) -> float:
# Mainly for backwards compatibility with RF 2.9.1 and earlier.
return self._convert_to_epoch(self.datetime)

def _convert_to_datetime(self, date, input_format):
def _convert_to_datetime(
self,
date: DateTimeExpanded,
input_format: "str|None",
) -> datetime.datetime:
if isinstance(date, datetime.datetime):
return date
if isinstance(date, datetime.date):
Expand All @@ -569,24 +603,28 @@ def _convert_to_datetime(self, date, input_format):
return self._string_to_datetime(date, input_format)
raise ValueError(f"Unsupported input '{date}'.")

def _epoch_seconds_to_datetime(self, secs):
def _epoch_seconds_to_datetime(self, secs: float) -> datetime.datetime:
return datetime.datetime.fromtimestamp(secs)

def _string_to_datetime(self, ts, input_format):
def _string_to_datetime(
self,
timestamp: str,
input_format: "str|None",
) -> datetime.datetime:
if not input_format:
ts = self._normalize_timestamp(ts)
timestamp = self._normalize_timestamp(timestamp)
input_format = "%Y-%m-%d %H:%M:%S.%f"
return datetime.datetime.strptime(ts, input_format)
return datetime.datetime.strptime(timestamp, input_format)

def _normalize_timestamp(self, timestamp):
def _normalize_timestamp(self, timestamp: str) -> str:
numbers = "".join(d for d in timestamp if d.isdigit())
if not (8 <= len(numbers) <= 20):
raise ValueError(f"Invalid timestamp '{timestamp}'.")
d = numbers[:8]
t = numbers[8:].ljust(12, "0")
return f"{d[:4]}-{d[4:6]}-{d[6:8]} {t[:2]}:{t[2:4]}:{t[4:6]}.{t[6:]}"

def convert(self, format, millis=True):
def convert(self, format: str, millis: bool = True) -> DatetimeMixed:
dt = self.datetime
if not millis:
secs = 1 if dt.microsecond >= 5e5 else 0
Expand All @@ -602,10 +640,10 @@ def convert(self, format, millis=True):
return self._convert_to_epoch(dt)
raise ValueError(f"Unknown format '{format}'.")

def _convert_to_custom_timestamp(self, dt, format):
def _convert_to_custom_timestamp(self, dt: datetime.datetime, format: str) -> str:
return dt.strftime(format)

def _convert_to_timestamp(self, dt, millis=True):
def _convert_to_timestamp(self, dt: datetime.datetime, millis: bool = True) -> str:
if not millis:
return dt.strftime("%Y-%m-%d %H:%M:%S")
ms = round(dt.microsecond / 1000)
Expand All @@ -614,19 +652,19 @@ def _convert_to_timestamp(self, dt, millis=True):
ms = 0
return dt.strftime("%Y-%m-%d %H:%M:%S") + f".{ms:03d}"

def _convert_to_epoch(self, dt):
def _convert_to_epoch(self, dt: datetime.datetime) -> float:
try:
return dt.timestamp()
except OSError:
# https://github.com/python/cpython/issues/81708
return time.mktime(dt.timetuple()) + dt.microsecond / 1e6

def __add__(self, other):
def __add__(self, other: Time) -> Date:
if isinstance(other, Time):
return Date(self.datetime + other.timedelta)
raise TypeError(f"Can only add Time to Date, got {type_name(other)}.")

def __sub__(self, other):
def __sub__(self, other: Date | Time) -> Date | Time:
if isinstance(other, Date):
return Time(self.datetime - other.datetime)
if isinstance(other, Time):
Expand All @@ -637,48 +675,47 @@ def __sub__(self, other):


class Time:
def __init__(self, time: TimedeltaExpanded):
self.seconds: float = self._convert_time_to_seconds(time)

def __init__(self, time):
self.seconds = float(self._convert_time_to_seconds(time))

def _convert_time_to_seconds(self, time):
def _convert_time_to_seconds(self, time: TimedeltaExpanded) -> float:
if isinstance(time, datetime.timedelta):
return time.total_seconds()
return timestr_to_secs(time, round_to=None)

@property
def timedelta(self):
def timedelta(self) -> datetime.timedelta:
return datetime.timedelta(seconds=self.seconds)

def convert(self, format, millis=True):
def convert(self, format: str, millis: bool = True) -> TimedeltaMixed:
try:
result_converter = getattr(self, f"_convert_to_{format.lower()}")
except AttributeError:
raise ValueError(f"Unknown format '{format}'.")
seconds = self.seconds if millis else float(round(self.seconds))
return result_converter(seconds, millis)

def _convert_to_number(self, seconds, millis=True):
def _convert_to_number(self, seconds: float, _) -> float:
return seconds

def _convert_to_verbose(self, seconds, millis=True):
def _convert_to_verbose(self, seconds: float, _) -> str:
return secs_to_timestr(seconds)

def _convert_to_compact(self, seconds, millis=True):
def _convert_to_compact(self, seconds: float, _) -> str:
return secs_to_timestr(seconds, compact=True)

def _convert_to_timer(self, seconds, millis=True):
def _convert_to_timer(self, seconds: float, millis: bool = True) -> str:
return elapsed_time_to_string(seconds, include_millis=millis, seconds=True)

def _convert_to_timedelta(self, seconds, millis=True):
def _convert_to_timedelta(self, seconds: float, _) -> datetime.timedelta:
return datetime.timedelta(seconds=seconds)

def __add__(self, other):
def __add__(self, other: Time) -> Time:
if isinstance(other, Time):
return Time(self.seconds + other.seconds)
raise TypeError(f"Can only add Time to Time, got {type_name(other)}.")

def __sub__(self, other):
def __sub__(self, other: Time) -> Time:
if isinstance(other, Time):
return Time(self.seconds - other.seconds)
raise TypeError(f"Can only subtract Time from Time, got {type_name(other)}.")
2 changes: 1 addition & 1 deletion src/robot/utils/robottime.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ def _float_secs_to_secs_and_millis(secs):
return (isecs, millis) if millis < 1000 else (isecs + 1, 0)


def timestr_to_secs(timestr, round_to=3):
def timestr_to_secs(timestr: "str|int|float|timedelta", round_to: int = 3) -> float:
"""Parses time strings like '1h 10s', '01:00:10' and '42' and returns seconds.

Time can also be given as an integer or float or, starting from RF 6.0.1,
Expand Down