Skip to content

Commit 06b7c45

Browse files
author
Jon Wayne Parrott
authored
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
1 parent 9fde8de commit 06b7c45

File tree

3 files changed

+290
-0
lines changed

3 files changed

+290
-0
lines changed

google/api_core/datetime_helpers.py

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,168 @@
1414

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

17+
import calendar
1718
import datetime
19+
import re
20+
21+
import pytz
22+
23+
24+
_UTC_EPOCH = datetime.datetime.utcfromtimestamp(0).replace(tzinfo=pytz.utc)
25+
_RFC3339_MICROS = '%Y-%m-%dT%H:%M:%S.%fZ'
26+
_RFC3339_NO_FRACTION = '%Y-%m-%dT%H:%M:%S'
27+
# datetime.strptime cannot handle nanosecond precision: parse w/ regex
28+
_RFC3339_NANOS = re.compile(r"""
29+
(?P<no_fraction>
30+
\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2} # YYYY-MM-DDTHH:MM:SS
31+
)
32+
( # Optional decimal part
33+
\. # decimal point
34+
(?P<nanos>\d{1,9}) # nanoseconds, maybe truncated
35+
)?
36+
Z # Zulu
37+
""", re.VERBOSE)
1838

1939

2040
def utcnow():
2141
"""A :meth:`datetime.datetime.utcnow()` alias to allow mocking in tests."""
2242
return datetime.datetime.utcnow()
43+
44+
45+
def to_milliseconds(value):
46+
"""Convert a zone-aware datetime to milliseconds since the unix epoch.
47+
48+
Args:
49+
value (datetime.datetime): The datetime to covert.
50+
51+
Returns:
52+
int: Milliseconds since the unix epoch.
53+
"""
54+
micros = to_microseconds(value)
55+
return micros // 1000
56+
57+
58+
def from_microseconds(value):
59+
"""Convert timestamp in microseconds since the unix epoch to datetime.
60+
61+
Args:
62+
value (float): The timestamp to convert, in microseconds.
63+
64+
Returns:
65+
datetime.datetime: The datetime object equivalent to the timestamp in
66+
UTC.
67+
"""
68+
return _UTC_EPOCH + datetime.timedelta(microseconds=value)
69+
70+
71+
def to_microseconds(value):
72+
"""Convert a datetime to microseconds since the unix epoch.
73+
74+
Args:
75+
value (datetime.datetime): The datetime to covert.
76+
77+
Returns:
78+
int: Microseconds since the unix epoch.
79+
"""
80+
if not value.tzinfo:
81+
value = value.replace(tzinfo=pytz.utc)
82+
# Regardless of what timezone is on the value, convert it to UTC.
83+
value = value.astimezone(pytz.utc)
84+
# Convert the datetime to a microsecond timestamp.
85+
return int(calendar.timegm(value.timetuple()) * 1e6) + value.microsecond
86+
87+
88+
def from_iso8601_date(value):
89+
"""Convert a ISO8601 date string to a date.
90+
91+
Args:
92+
value (str): The ISO8601 date string.
93+
94+
Returns:
95+
datetime.date: A date equivalent to the date string.
96+
"""
97+
return datetime.datetime.strptime(value, '%Y-%m-%d').date()
98+
99+
100+
def from_iso8601_time(value):
101+
"""Convert a zoneless ISO8601 time string to a time.
102+
103+
Args:
104+
value (str): The ISO8601 time string.
105+
106+
Returns:
107+
datetime.time: A time equivalent to the time string.
108+
"""
109+
return datetime.datetime.strptime(value, '%H:%M:%S').time()
110+
111+
112+
def from_rfc3339(value):
113+
"""Convert a microsecond-precision timestamp to datetime.
114+
115+
Args:
116+
value (str): The RFC3339 string to convert.
117+
118+
Returns:
119+
datetime.datetime: The datetime object equivalent to the timestamp in
120+
UTC.
121+
"""
122+
return datetime.datetime.strptime(
123+
value, _RFC3339_MICROS).replace(tzinfo=pytz.utc)
124+
125+
126+
def from_rfc3339_nanos(value):
127+
"""Convert a nanosecond-precision timestamp to a native datetime.
128+
129+
.. note::
130+
Python datetimes do not support nanosecond precision; this function
131+
therefore truncates such values to microseconds.
132+
133+
Args:
134+
value (str): The RFC3339 string to convert.
135+
136+
Returns:
137+
datetime.datetime: The datetime object equivalent to the timestamp in
138+
UTC.
139+
140+
Raises:
141+
ValueError: If the timestamp does not match the RFC 3339
142+
regular expression.
143+
"""
144+
with_nanos = _RFC3339_NANOS.match(value)
145+
146+
if with_nanos is None:
147+
raise ValueError(
148+
'Timestamp: {!r}, does not match pattern: {!r}'.format(
149+
value, _RFC3339_NANOS.pattern))
150+
151+
bare_seconds = datetime.datetime.strptime(
152+
with_nanos.group('no_fraction'), _RFC3339_NO_FRACTION)
153+
fraction = with_nanos.group('nanos')
154+
155+
if fraction is None:
156+
micros = 0
157+
else:
158+
scale = 9 - len(fraction)
159+
nanos = int(fraction) * (10 ** scale)
160+
micros = nanos // 1000
161+
162+
return bare_seconds.replace(microsecond=micros, tzinfo=pytz.utc)
163+
164+
165+
def to_rfc3339(value, ignore_zone=True):
166+
"""Convert a datetime to an RFC3339 timestamp string.
167+
168+
Args:
169+
value (datetime.datetime):
170+
The datetime object to be converted to a string.
171+
ignore_zone (bool): If True, then the timezone (if any) of the
172+
datetime object is ignored and the datetime is treated as UTC.
173+
174+
Returns:
175+
str: The RFC3339 formated string representing the datetime.
176+
"""
177+
if not ignore_zone and value.tzinfo is not None:
178+
# Convert to UTC and remove the time zone info.
179+
value = value.replace(tzinfo=None) - value.utcoffset()
180+
181+
return value.strftime(_RFC3339_MICROS)

setup.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,9 @@
5656
'requests >= 2.18.0, < 3.0.0dev',
5757
'setuptools >= 34.0.0',
5858
'six >= 1.10.0',
59+
# pytz does not adhere to semver and uses a year.month based scheme.
60+
# Any valid version of pytz should work for us.
61+
'pytz',
5962
]
6063

6164
EXTRAS_REQUIREMENTS = {

tests/unit/test_datetime_helpers.py

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,137 @@
1414

1515
import datetime
1616

17+
import pytest
18+
import pytz
19+
1720
from google.api_core import datetime_helpers
1821

22+
ONE_MINUTE_IN_MICROSECONDS = 60 * 1e6
23+
1924

2025
def test_utcnow():
2126
result = datetime_helpers.utcnow()
2227
assert isinstance(result, datetime.datetime)
28+
29+
30+
def test_to_milliseconds():
31+
dt = datetime.datetime(1970, 1, 1, 0, 0, 1, tzinfo=pytz.utc)
32+
assert datetime_helpers.to_milliseconds(dt) == 1000
33+
34+
35+
def test_to_microseconds():
36+
microseconds = 314159
37+
dt = datetime.datetime(
38+
1970, 1, 1, 0, 0, 0, microsecond=microseconds)
39+
assert datetime_helpers.to_microseconds(dt) == microseconds
40+
41+
42+
def test_to_microseconds_non_utc():
43+
zone = pytz.FixedOffset(-1)
44+
dt = datetime.datetime(1970, 1, 1, 0, 0, 0, tzinfo=zone)
45+
assert datetime_helpers.to_microseconds(dt) == ONE_MINUTE_IN_MICROSECONDS
46+
47+
48+
def test_to_microseconds_naive():
49+
microseconds = 314159
50+
dt = datetime.datetime(
51+
1970, 1, 1, 0, 0, 0, microsecond=microseconds, tzinfo=None)
52+
assert datetime_helpers.to_microseconds(dt) == microseconds
53+
54+
55+
def test_from_microseconds():
56+
five_mins_from_epoch_in_microseconds = 5 * ONE_MINUTE_IN_MICROSECONDS
57+
five_mins_from_epoch_datetime = datetime.datetime(
58+
1970, 1, 1, 0, 5, 0, tzinfo=pytz.utc)
59+
60+
result = datetime_helpers.from_microseconds(
61+
five_mins_from_epoch_in_microseconds)
62+
63+
assert result == five_mins_from_epoch_datetime
64+
65+
66+
def test_from_iso8601_date():
67+
today = datetime.date.today()
68+
iso_8601_today = today.strftime('%Y-%m-%d')
69+
70+
assert datetime_helpers.from_iso8601_date(iso_8601_today) == today
71+
72+
73+
def test_from_iso8601_time():
74+
assert (
75+
datetime_helpers.from_iso8601_time('12:09:42') ==
76+
datetime.time(12, 9, 42))
77+
78+
79+
def test_from_rfc3339():
80+
value = '2009-12-17T12:44:32.123456Z'
81+
assert datetime_helpers.from_rfc3339(value) == datetime.datetime(
82+
2009, 12, 17, 12, 44, 32, 123456, pytz.utc)
83+
84+
85+
def test_from_rfc3339_with_bad_tz():
86+
value = '2009-12-17T12:44:32.123456BAD'
87+
88+
with pytest.raises(ValueError):
89+
datetime_helpers.from_rfc3339(value)
90+
91+
92+
def test_from_rfc3339_with_nanos():
93+
value = '2009-12-17T12:44:32.123456789Z'
94+
95+
with pytest.raises(ValueError):
96+
datetime_helpers.from_rfc3339(value)
97+
98+
99+
def test_from_rfc3339_nanos_without_nanos():
100+
value = '2009-12-17T12:44:32Z'
101+
assert datetime_helpers.from_rfc3339_nanos(value) == datetime.datetime(
102+
2009, 12, 17, 12, 44, 32, 0, pytz.utc)
103+
104+
105+
def test_from_rfc3339_nanos_with_bad_tz():
106+
value = '2009-12-17T12:44:32.123456789BAD'
107+
108+
with pytest.raises(ValueError):
109+
datetime_helpers.from_rfc3339_nanos(value)
110+
111+
112+
@pytest.mark.parametrize('truncated, micros', [
113+
('12345678', 123456),
114+
('1234567', 123456),
115+
('123456', 123456),
116+
('12345', 123450),
117+
('1234', 123400),
118+
('123', 123000),
119+
('12', 120000),
120+
('1', 100000)])
121+
def test_from_rfc3339_nanos_with_truncated_nanos(truncated, micros):
122+
value = '2009-12-17T12:44:32.{}Z'.format(truncated)
123+
assert datetime_helpers.from_rfc3339_nanos(value) == datetime.datetime(
124+
2009, 12, 17, 12, 44, 32, micros, pytz.utc)
125+
126+
127+
def test_to_rfc3339():
128+
value = datetime.datetime(2016, 4, 5, 13, 30, 0)
129+
expected = '2016-04-05T13:30:00.000000Z'
130+
assert datetime_helpers.to_rfc3339(value) == expected
131+
132+
133+
def test_to_rfc3339_with_utc():
134+
value = datetime.datetime(2016, 4, 5, 13, 30, 0, tzinfo=pytz.utc)
135+
expected = '2016-04-05T13:30:00.000000Z'
136+
assert datetime_helpers.to_rfc3339(value, ignore_zone=False) == expected
137+
138+
139+
def test_to_rfc3339_with_non_utc():
140+
zone = pytz.FixedOffset(-60)
141+
value = datetime.datetime(2016, 4, 5, 13, 30, 0, tzinfo=zone)
142+
expected = '2016-04-05T14:30:00.000000Z'
143+
assert datetime_helpers.to_rfc3339(value, ignore_zone=False) == expected
144+
145+
146+
def test_to_rfc3339_with_non_utc_ignore_zone():
147+
zone = pytz.FixedOffset(-60)
148+
value = datetime.datetime(2016, 4, 5, 13, 30, 0, tzinfo=zone)
149+
expected = '2016-04-05T13:30:00.000000Z'
150+
assert datetime_helpers.to_rfc3339(value, ignore_zone=True) == expected

0 commit comments

Comments
 (0)