Skip to content

Commit

Permalink
Simplify timestamp parsing (and get rid of tzlocal/tzwinlocal use)
Browse files Browse the repository at this point in the history
  • Loading branch information
akx committed Dec 13, 2023
1 parent 3aba00e commit ad56845
Show file tree
Hide file tree
Showing 3 changed files with 32 additions and 55 deletions.
2 changes: 2 additions & 0 deletions botocore/compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,8 @@ def _windows_shell_split(s):


def get_tzinfo_options():
# This function is not used internally anymore.

# Due to dateutil/dateutil#197, Windows may fail to parse times in the past
# with the system clock. We can alternatively fallback to tzwininfo when
# this happens, which will get time info from the Windows registry.
Expand Down
73 changes: 30 additions & 43 deletions botocore/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,6 @@
UNSAFE_URL_CHARS,
OrderedDict,
get_md5,
get_tzinfo_options,
json,
quote,
urlparse,
Expand Down Expand Up @@ -919,45 +918,27 @@ def percent_encode(input_str, safe=SAFE_CHARS):
return quote(input_str, safe=safe)


def _epoch_seconds_to_datetime(value, tzinfo):
_EPOCH_ZERO = datetime.datetime(1970, 1, 1, 0, 0, 0, tzinfo=tzutc())


def _epoch_seconds_to_datetime(value):
"""Parse numerical epoch timestamps (seconds since 1970) into a
``datetime.datetime`` in UTC using ``datetime.timedelta``. This is intended
as fallback when ``fromtimestamp`` raises ``OverflowError`` or ``OSError``.
:type value: float or int
:param value: The Unix timestamps as number.
:type tzinfo: callable
:param tzinfo: A ``datetime.tzinfo`` class or compatible callable.
"""
epoch_zero = datetime.datetime(1970, 1, 1, 0, 0, 0, tzinfo=tzutc())
epoch_zero_localized = epoch_zero.astimezone(tzinfo())
return epoch_zero_localized + datetime.timedelta(seconds=value)


def _parse_timestamp_with_tzinfo(value, tzinfo):
"""Parse timestamp with pluggable tzinfo options."""
# For numeric values attempt fallback to using fromtimestamp-free method.
# From Python's ``datetime.datetime.fromtimestamp`` documentation: "This
# may raise ``OverflowError``, if the timestamp is out of the range of
# values supported by the platform C localtime() function, and ``OSError``
# on localtime() failure. It's common for this to be restricted to years
# from 1970 through 2038."
if isinstance(value, (int, float)):
# Possibly an epoch time.
return _epoch_seconds_to_datetime(value, tzinfo)
else:
try:
return _epoch_seconds_to_datetime(float(value), tzinfo)
except (TypeError, ValueError):
pass
try:
# In certain cases, a timestamp marked with GMT can be parsed into a
# different time zone, so here we provide a context which will
# enforce that GMT == UTC.
return dateutil.parser.parse(value, tzinfos={'GMT': tzutc()})
except (TypeError, ValueError) as e:
raise ValueError(f'Invalid timestamp "{value}": {e}')
return datetime.datetime.fromtimestamp(value, tz=tzutc())
except (OverflowError, OSError):
# For numeric values attempt fallback to using fromtimestamp-free method.
# From Python's ``datetime.datetime.fromtimestamp`` documentation: "This
# may raise ``OverflowError``, if the timestamp is out of the range of
# values supported by the platform C localtime() function, and ``OSError``
# on localtime() failure. It's common for this to be restricted to years
# from 1970 through 2038."
return _EPOCH_ZERO + datetime.timedelta(seconds=value)


def parse_timestamp(value):
Expand All @@ -972,17 +953,23 @@ def parse_timestamp(value):
This will return a ``datetime.datetime`` object.
"""
tzinfo_options = get_tzinfo_options()
for tzinfo in tzinfo_options:
try:
return _parse_timestamp_with_tzinfo(value, tzinfo)
except (OSError, OverflowError) as e:
logger.debug(
'Unable to parse timestamp with "%s" timezone info.',
tzinfo.__name__,
exc_info=e,
)
raise RuntimeError(f'Unable to parse timestamp {value!r}')
if isinstance(value, (int, float)):
# Possibly an epoch time.
return _epoch_seconds_to_datetime(value)

# Possibly something we can cast to an epoch time and convert.
try:
return _epoch_seconds_to_datetime(float(value))
except (TypeError, ValueError):
pass

try:
# In certain cases, a timestamp marked with GMT can be parsed into a
# different time zone, so here we provide a context which will
# enforce that GMT == UTC.
return dateutil.parser.parse(value, tzinfos={'GMT': tzutc()})
except (TypeError, ValueError) as e:
raise ValueError(f'Invalid timestamp "{value}": {e}')


def parse_to_aware_datetime(value):
Expand Down
12 changes: 0 additions & 12 deletions tests/unit/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -466,18 +466,6 @@ def test_parse_invalid_timestamp(self):
with self.assertRaises(ValueError):
parse_timestamp('invalid date')

def test_parse_timestamp_fails_with_bad_tzinfo(self):
mock_tzinfo = mock.Mock()
mock_tzinfo.__name__ = 'tzinfo'
mock_tzinfo.side_effect = OSError()
mock_get_tzinfo_options = mock.MagicMock(return_value=(mock_tzinfo,))

with mock.patch(
'botocore.utils.get_tzinfo_options', mock_get_tzinfo_options
):
with self.assertRaises(RuntimeError):
parse_timestamp(0)

@contextmanager
def mocked_fromtimestamp_that_raises(self, exception_type):
class MockDatetime(datetime.datetime):
Expand Down

0 comments on commit ad56845

Please sign in to comment.