Skip to content

Commit

Permalink
Move datetime helpers from google.cloud._helpers to google.api_core.d…
Browse files Browse the repository at this point in the history
…atetime_helpers (#4399)

* Move datetime helpers from google.cloud._helpers to google.api_core.datetime_helpers

* Add pragma statements

* Move them around

* Fix test coverage
  • Loading branch information
Jon Wayne Parrott authored Nov 16, 2017
1 parent fb9f70b commit 39e4cd4
Show file tree
Hide file tree
Showing 5 changed files with 304 additions and 4 deletions.
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

0 comments on commit 39e4cd4

Please sign in to comment.