Move datetime helpers from google.cloud._helpers to google.api_core.datetime_helpers (#4399)
* Move datetime helpers from google.cloud._helpers to google.api_core.datetime_helpers
* Add pragma statements
* Move them around
* Fix test coverage
diff --git a/google/api_core/datetime_helpers.py b/google/api_core/datetime_helpers.py
index b4a4b51..e3f720a 100644
--- a/google/api_core/datetime_helpers.py
+++ b/google/api_core/datetime_helpers.py
@@ -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)
diff --git a/setup.py b/setup.py
index 386eb93..d44abf4 100644
--- a/setup.py
+++ b/setup.py
@@ -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 = {
diff --git a/tests/unit/test_datetime_helpers.py b/tests/unit/test_datetime_helpers.py
index 7e421bf..b25b1d1 100644
--- a/tests/unit/test_datetime_helpers.py
+++ b/tests/unit/test_datetime_helpers.py
@@ -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