Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
159 changes: 159 additions & 0 deletions api_core/google/api_core/datetime_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,168 @@

"""Helpers for :mod:`datetime`."""

import calendar
import datetime
import re

import pytz


_UTC_EPOCH = datetime.datetime.utcfromtimestamp(0).replace(tzinfo=pytz.utc)
_RFC3339_MICROS = '%Y-%m-%dT%H:%M:%S.%fZ'
_RFC3339_NO_FRACTION = '%Y-%m-%dT%H:%M:%S'
# datetime.strptime cannot handle nanosecond precision: parse w/ regex
_RFC3339_NANOS = re.compile(r"""
(?P<no_fraction>
\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2} # YYYY-MM-DDTHH:MM:SS
)
( # Optional decimal part
\. # decimal point
(?P<nanos>\d{1,9}) # nanoseconds, maybe truncated
)?
Z # Zulu
""", re.VERBOSE)


def utcnow():
"""A :meth:`datetime.datetime.utcnow()` alias to allow mocking in tests."""
return datetime.datetime.utcnow()


def to_milliseconds(value):
"""Convert a zone-aware datetime to milliseconds since the unix epoch.

Args:
value (datetime.datetime): The datetime to covert.

Returns:
int: Milliseconds since the unix epoch.
"""
micros = to_microseconds(value)
return micros // 1000


def from_microseconds(value):
"""Convert timestamp in microseconds since the unix epoch to datetime.

Args:
value (float): The timestamp to convert, in microseconds.

Returns:
datetime.datetime: The datetime object equivalent to the timestamp in
UTC.
"""
return _UTC_EPOCH + datetime.timedelta(microseconds=value)


def to_microseconds(value):
"""Convert a datetime to microseconds since the unix epoch.

Args:
value (datetime.datetime): The datetime to covert.

Returns:
int: Microseconds since the unix epoch.
"""
if not value.tzinfo:
value = value.replace(tzinfo=pytz.utc)
# Regardless of what timezone is on the value, convert it to UTC.
value = value.astimezone(pytz.utc)
# Convert the datetime to a microsecond timestamp.
return int(calendar.timegm(value.timetuple()) * 1e6) + value.microsecond


def from_iso8601_date(value):
"""Convert a ISO8601 date string to a date.

Args:
value (str): The ISO8601 date string.

Returns:
datetime.date: A date equivalent to the date string.
"""
return datetime.datetime.strptime(value, '%Y-%m-%d').date()


def from_iso8601_time(value):
"""Convert a zoneless ISO8601 time string to a time.

Args:
value (str): The ISO8601 time string.

Returns:
datetime.time: A time equivalent to the time string.
"""
return datetime.datetime.strptime(value, '%H:%M:%S').time()


def from_rfc3339(value):
"""Convert a microsecond-precision timestamp to datetime.

Args:
value (str): The RFC3339 string to convert.

Returns:
datetime.datetime: The datetime object equivalent to the timestamp in
UTC.
"""
return datetime.datetime.strptime(
value, _RFC3339_MICROS).replace(tzinfo=pytz.utc)


def from_rfc3339_nanos(value):
"""Convert a nanosecond-precision timestamp to a native datetime.

.. note::
Python datetimes do not support nanosecond precision; this function
therefore truncates such values to microseconds.

Args:
value (str): The RFC3339 string to convert.

Returns:
datetime.datetime: The datetime object equivalent to the timestamp in
UTC.

Raises:
ValueError: If the timestamp does not match the RFC 3339
regular expression.
"""
with_nanos = _RFC3339_NANOS.match(value)

if with_nanos is None:
raise ValueError(
'Timestamp: {!r}, does not match pattern: {!r}'.format(
value, _RFC3339_NANOS.pattern))

bare_seconds = datetime.datetime.strptime(
with_nanos.group('no_fraction'), _RFC3339_NO_FRACTION)
fraction = with_nanos.group('nanos')

if fraction is None:
micros = 0
else:
scale = 9 - len(fraction)
nanos = int(fraction) * (10 ** scale)
micros = nanos // 1000

return bare_seconds.replace(microsecond=micros, tzinfo=pytz.utc)


def to_rfc3339(value, ignore_zone=True):
"""Convert a datetime to an RFC3339 timestamp string.

Args:
value (datetime.datetime):
The datetime object to be converted to a string.
ignore_zone (bool): If True, then the timezone (if any) of the
datetime object is ignored and the datetime is treated as UTC.

Returns:
str: The RFC3339 formated string representing the datetime.
"""
if not ignore_zone and value.tzinfo is not None:
# Convert to UTC and remove the time zone info.
value = value.replace(tzinfo=None) - value.utcoffset()

return value.strftime(_RFC3339_MICROS)
3 changes: 3 additions & 0 deletions api_core/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@
'requests >= 2.18.0, < 3.0.0dev',
'setuptools >= 34.0.0',
'six >= 1.10.0',
# pytz does not adhere to semver and uses a year.month based scheme.
# Any valid version of pytz should work for us.
'pytz',
]

EXTRAS_REQUIREMENTS = {
Expand Down
128 changes: 128 additions & 0 deletions api_core/tests/unit/test_datetime_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,137 @@

import datetime

import pytest
import pytz

from google.api_core import datetime_helpers

ONE_MINUTE_IN_MICROSECONDS = 60 * 1e6


def test_utcnow():
result = datetime_helpers.utcnow()
assert isinstance(result, datetime.datetime)


def test_to_milliseconds():
dt = datetime.datetime(1970, 1, 1, 0, 0, 1, tzinfo=pytz.utc)
assert datetime_helpers.to_milliseconds(dt) == 1000


def test_to_microseconds():
microseconds = 314159
dt = datetime.datetime(
1970, 1, 1, 0, 0, 0, microsecond=microseconds)
assert datetime_helpers.to_microseconds(dt) == microseconds


def test_to_microseconds_non_utc():
zone = pytz.FixedOffset(-1)
dt = datetime.datetime(1970, 1, 1, 0, 0, 0, tzinfo=zone)
assert datetime_helpers.to_microseconds(dt) == ONE_MINUTE_IN_MICROSECONDS


def test_to_microseconds_naive():
microseconds = 314159
dt = datetime.datetime(
1970, 1, 1, 0, 0, 0, microsecond=microseconds, tzinfo=None)
assert datetime_helpers.to_microseconds(dt) == microseconds


def test_from_microseconds():
five_mins_from_epoch_in_microseconds = 5 * ONE_MINUTE_IN_MICROSECONDS
five_mins_from_epoch_datetime = datetime.datetime(
1970, 1, 1, 0, 5, 0, tzinfo=pytz.utc)

result = datetime_helpers.from_microseconds(
five_mins_from_epoch_in_microseconds)

assert result == five_mins_from_epoch_datetime


def test_from_iso8601_date():
today = datetime.date.today()
iso_8601_today = today.strftime('%Y-%m-%d')

assert datetime_helpers.from_iso8601_date(iso_8601_today) == today


def test_from_iso8601_time():
assert (
datetime_helpers.from_iso8601_time('12:09:42') ==
datetime.time(12, 9, 42))


def test_from_rfc3339():
value = '2009-12-17T12:44:32.123456Z'
assert datetime_helpers.from_rfc3339(value) == datetime.datetime(
2009, 12, 17, 12, 44, 32, 123456, pytz.utc)


def test_from_rfc3339_with_bad_tz():
value = '2009-12-17T12:44:32.123456BAD'

with pytest.raises(ValueError):
datetime_helpers.from_rfc3339(value)


def test_from_rfc3339_with_nanos():
value = '2009-12-17T12:44:32.123456789Z'

with pytest.raises(ValueError):
datetime_helpers.from_rfc3339(value)


def test_from_rfc3339_nanos_without_nanos():
value = '2009-12-17T12:44:32Z'
assert datetime_helpers.from_rfc3339_nanos(value) == datetime.datetime(
2009, 12, 17, 12, 44, 32, 0, pytz.utc)


def test_from_rfc3339_nanos_with_bad_tz():
value = '2009-12-17T12:44:32.123456789BAD'

with pytest.raises(ValueError):
datetime_helpers.from_rfc3339_nanos(value)


@pytest.mark.parametrize('truncated, micros', [
('12345678', 123456),
('1234567', 123456),
('123456', 123456),
('12345', 123450),
('1234', 123400),
('123', 123000),
('12', 120000),
('1', 100000)])
def test_from_rfc3339_nanos_with_truncated_nanos(truncated, micros):
value = '2009-12-17T12:44:32.{}Z'.format(truncated)
assert datetime_helpers.from_rfc3339_nanos(value) == datetime.datetime(
2009, 12, 17, 12, 44, 32, micros, pytz.utc)


def test_to_rfc3339():
value = datetime.datetime(2016, 4, 5, 13, 30, 0)
expected = '2016-04-05T13:30:00.000000Z'
assert datetime_helpers.to_rfc3339(value) == expected


def test_to_rfc3339_with_utc():
value = datetime.datetime(2016, 4, 5, 13, 30, 0, tzinfo=pytz.utc)
expected = '2016-04-05T13:30:00.000000Z'
assert datetime_helpers.to_rfc3339(value, ignore_zone=False) == expected


def test_to_rfc3339_with_non_utc():
zone = pytz.FixedOffset(-60)
value = datetime.datetime(2016, 4, 5, 13, 30, 0, tzinfo=zone)
expected = '2016-04-05T14:30:00.000000Z'
assert datetime_helpers.to_rfc3339(value, ignore_zone=False) == expected


def test_to_rfc3339_with_non_utc_ignore_zone():
zone = pytz.FixedOffset(-60)
value = datetime.datetime(2016, 4, 5, 13, 30, 0, tzinfo=zone)
expected = '2016-04-05T13:30:00.000000Z'
assert datetime_helpers.to_rfc3339(value, ignore_zone=True) == expected
2 changes: 1 addition & 1 deletion core/google/cloud/_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -620,7 +620,7 @@ def make_insecure_stub(stub_class, host, port=None):

try:
from pytz import UTC # pylint: disable=unused-import,wrong-import-order
except ImportError:
except ImportError: # pragma: NO COVER
UTC = _UTC() # Singleton instance to be used throughout.

# Need to define _EPOCH at the end of module since it relies on UTC.
Expand Down
16 changes: 13 additions & 3 deletions core/tests/unit/test__helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,10 +63,10 @@ def test_module_property(self):
klass = self._get_target_class()
try:
import pytz
except ImportError:
except ImportError: # pragma: NO COVER
self.assertIsInstance(MUT.UTC, klass)
else:
self.assertIs(MUT.UTC, pytz.UTC) # pragma: NO COVER
self.assertIs(MUT.UTC, pytz.UTC)

def test_dst(self):
import datetime
Expand All @@ -77,12 +77,22 @@ def test_dst(self):
def test_fromutc(self):
import datetime

naive_epoch = datetime.datetime.utcfromtimestamp(0)
naive_epoch = datetime.datetime(
1970, 1, 1, 0, 0, 1, tzinfo=None)
self.assertIsNone(naive_epoch.tzinfo)
tz = self._make_one()
epoch = tz.fromutc(naive_epoch)
self.assertEqual(epoch.tzinfo, tz)

def test_fromutc_with_tz(self):
import datetime

tz = self._make_one()
epoch_with_tz = datetime.datetime(
1970, 1, 1, 0, 0, 1, tzinfo=tz)
epoch = tz.fromutc(epoch_with_tz)
self.assertEqual(epoch.tzinfo, tz)

def test_tzname(self):
tz = self._make_one()
self.assertEqual(tz.tzname(None), 'UTC')
Expand Down