Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit b652d41

Browse files
authored
feat: add DatetimeWithNanoseconds class to maintain Timestamp pb precision. (#40)
Ports pieces of https://github.com/googleapis/python-api-core/blob/5e5559202891f7e5b6c22c2cbc549e1ec26eb857/google/api_core/datetime_helpers.py to protoplus. Particularly, a datetime implementation that provides nanoseconds. Fixes #38
1 parent 12ba7d1 commit b652d41

File tree

7 files changed

+545
-21
lines changed

7 files changed

+545
-21
lines changed

packages/proto-plus/docs/marshal.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ Protocol buffer type Python type Nullable
3737
Protocol buffers include well-known types for ``Timestamp`` and
3838
``Duration``, both of which have nanosecond precision. However, the
3939
Python ``datetime`` and ``timedelta`` objects have only microsecond
40+
precision. This library converts timestamps to an implementation of
41+
``datetime.datetime``, DatetimeWithNanoseconds, that includes nanosecond
4042
precision.
4143

4244
If you *write* a timestamp field using a Python ``datetime`` value,

packages/proto-plus/noxfile.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ def unit(session, proto="python"):
2323
"""Run the unit test suite."""
2424

2525
session.env["PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION"] = proto
26-
session.install("coverage", "pytest", "pytest-cov")
26+
session.install("coverage", "pytest", "pytest-cov", "pytz")
2727
session.install("-e", ".")
2828

2929
session.run(
Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
# Copyright 2017 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Helpers for :mod:`datetime`."""
16+
17+
import calendar
18+
import datetime
19+
import re
20+
21+
from google.protobuf import timestamp_pb2
22+
23+
24+
_UTC_EPOCH = datetime.datetime.utcfromtimestamp(0).replace(tzinfo=datetime.timezone.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(
29+
r"""
30+
(?P<no_fraction>
31+
\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2} # YYYY-MM-DDTHH:MM:SS
32+
)
33+
( # Optional decimal part
34+
\. # decimal point
35+
(?P<nanos>\d{1,9}) # nanoseconds, maybe truncated
36+
)?
37+
Z # Zulu
38+
""",
39+
re.VERBOSE,
40+
)
41+
42+
43+
def _from_microseconds(value):
44+
"""Convert timestamp in microseconds since the unix epoch to datetime.
45+
46+
Args:
47+
value (float): The timestamp to convert, in microseconds.
48+
49+
Returns:
50+
datetime.datetime: The datetime object equivalent to the timestamp in
51+
UTC.
52+
"""
53+
return _UTC_EPOCH + datetime.timedelta(microseconds=value)
54+
55+
56+
def _to_rfc3339(value, ignore_zone=True):
57+
"""Convert a datetime to an RFC3339 timestamp string.
58+
59+
Args:
60+
value (datetime.datetime):
61+
The datetime object to be converted to a string.
62+
ignore_zone (bool): If True, then the timezone (if any) of the
63+
datetime object is ignored and the datetime is treated as UTC.
64+
65+
Returns:
66+
str: The RFC3339 formated string representing the datetime.
67+
"""
68+
if not ignore_zone and value.tzinfo is not None:
69+
# Convert to UTC and remove the time zone info.
70+
value = value.replace(tzinfo=None) - value.utcoffset()
71+
72+
return value.strftime(_RFC3339_MICROS)
73+
74+
75+
class DatetimeWithNanoseconds(datetime.datetime):
76+
"""Track nanosecond in addition to normal datetime attrs.
77+
78+
Nanosecond can be passed only as a keyword argument.
79+
"""
80+
81+
__slots__ = ("_nanosecond",)
82+
83+
# pylint: disable=arguments-differ
84+
def __new__(cls, *args, **kw):
85+
nanos = kw.pop("nanosecond", 0)
86+
if nanos > 0:
87+
if "microsecond" in kw:
88+
raise TypeError("Specify only one of 'microsecond' or 'nanosecond'")
89+
kw["microsecond"] = nanos // 1000
90+
inst = datetime.datetime.__new__(cls, *args, **kw)
91+
inst._nanosecond = nanos or 0
92+
return inst
93+
94+
# pylint: disable=arguments-differ
95+
def replace(self, *args, **kw):
96+
"""Return a date with the same value, except for those parameters given
97+
new values by whichever keyword arguments are specified. For example,
98+
if d == date(2002, 12, 31), then
99+
d.replace(day=26) == date(2002, 12, 26).
100+
NOTE: nanosecond and microsecond are mutually exclusive arguemnts.
101+
"""
102+
103+
ms_provided = "microsecond" in kw
104+
ns_provided = "nanosecond" in kw
105+
provided_ns = kw.pop("nanosecond", 0)
106+
107+
prev_nanos = self.nanosecond
108+
109+
if ms_provided and ns_provided:
110+
raise TypeError("Specify only one of 'microsecond' or 'nanosecond'")
111+
112+
if ns_provided:
113+
# if nanos were provided, manipulate microsecond kw arg to super
114+
kw["microsecond"] = provided_ns // 1000
115+
inst = super().replace(*args, **kw)
116+
117+
if ms_provided:
118+
# ms were provided, nanos are invalid, build from ms
119+
inst._nanosecond = inst.microsecond * 1000
120+
elif ns_provided:
121+
# ns were provided, replace nanoseconds to match after calling super
122+
inst._nanosecond = provided_ns
123+
else:
124+
# if neither ms or ns were provided, passthru previous nanos.
125+
inst._nanosecond = prev_nanos
126+
127+
return inst
128+
129+
@property
130+
def nanosecond(self):
131+
"""Read-only: nanosecond precision."""
132+
return self._nanosecond or self.microsecond * 1000
133+
134+
def rfc3339(self):
135+
"""Return an RFC3339-compliant timestamp.
136+
137+
Returns:
138+
(str): Timestamp string according to RFC3339 spec.
139+
"""
140+
if self._nanosecond == 0:
141+
return _to_rfc3339(self)
142+
nanos = str(self._nanosecond).rjust(9, "0").rstrip("0")
143+
return "{}.{}Z".format(self.strftime(_RFC3339_NO_FRACTION), nanos)
144+
145+
@classmethod
146+
def from_rfc3339(cls, stamp):
147+
"""Parse RFC3339-compliant timestamp, preserving nanoseconds.
148+
149+
Args:
150+
stamp (str): RFC3339 stamp, with up to nanosecond precision
151+
152+
Returns:
153+
:class:`DatetimeWithNanoseconds`:
154+
an instance matching the timestamp string
155+
156+
Raises:
157+
ValueError: if `stamp` does not match the expected format
158+
"""
159+
with_nanos = _RFC3339_NANOS.match(stamp)
160+
if with_nanos is None:
161+
raise ValueError(
162+
"Timestamp: {}, does not match pattern: {}".format(
163+
stamp, _RFC3339_NANOS.pattern
164+
)
165+
)
166+
bare = datetime.datetime.strptime(
167+
with_nanos.group("no_fraction"), _RFC3339_NO_FRACTION
168+
)
169+
fraction = with_nanos.group("nanos")
170+
if fraction is None:
171+
nanos = 0
172+
else:
173+
scale = 9 - len(fraction)
174+
nanos = int(fraction) * (10 ** scale)
175+
return cls(
176+
bare.year,
177+
bare.month,
178+
bare.day,
179+
bare.hour,
180+
bare.minute,
181+
bare.second,
182+
nanosecond=nanos,
183+
tzinfo=datetime.timezone.utc,
184+
)
185+
186+
def timestamp_pb(self):
187+
"""Return a timestamp message.
188+
189+
Returns:
190+
(:class:`~google.protobuf.timestamp_pb2.Timestamp`): Timestamp message
191+
"""
192+
inst = (
193+
self
194+
if self.tzinfo is not None
195+
else self.replace(tzinfo=datetime.timezone.utc)
196+
)
197+
delta = inst - _UTC_EPOCH
198+
seconds = int(delta.total_seconds())
199+
nanos = self._nanosecond or self.microsecond * 1000
200+
return timestamp_pb2.Timestamp(seconds=seconds, nanos=nanos)
201+
202+
@classmethod
203+
def from_timestamp_pb(cls, stamp):
204+
"""Parse RFC3339-compliant timestamp, preserving nanoseconds.
205+
206+
Args:
207+
stamp (:class:`~google.protobuf.timestamp_pb2.Timestamp`): timestamp message
208+
209+
Returns:
210+
:class:`DatetimeWithNanoseconds`:
211+
an instance matching the timestamp message
212+
"""
213+
microseconds = int(stamp.seconds * 1e6)
214+
bare = _from_microseconds(microseconds)
215+
return cls(
216+
bare.year,
217+
bare.month,
218+
bare.day,
219+
bare.hour,
220+
bare.minute,
221+
bare.second,
222+
nanosecond=stamp.nanos,
223+
tzinfo=datetime.timezone.utc,
224+
)

packages/proto-plus/proto/marshal/rules/dates.py

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
from google.protobuf import duration_pb2
2020
from google.protobuf import timestamp_pb2
21+
from proto import datetime_helpers, utils
2122

2223

2324
class TimestampRule:
@@ -29,20 +30,18 @@ class TimestampRule:
2930
proto directly.
3031
"""
3132

32-
def to_python(self, value, *, absent: bool = None) -> datetime:
33+
def to_python(
34+
self, value, *, absent: bool = None
35+
) -> datetime_helpers.DatetimeWithNanoseconds:
3336
if isinstance(value, timestamp_pb2.Timestamp):
3437
if absent:
3538
return None
36-
return datetime.fromtimestamp(
37-
value.seconds + value.nanos / 1e9, tz=timezone.utc,
38-
)
39+
return datetime_helpers.DatetimeWithNanoseconds.from_timestamp_pb(value)
3940
return value
4041

4142
def to_proto(self, value) -> timestamp_pb2.Timestamp:
42-
if isinstance(value, datetime):
43-
return timestamp_pb2.Timestamp(
44-
seconds=int(value.timestamp()), nanos=value.microsecond * 1000,
45-
)
43+
if isinstance(value, datetime_helpers.DatetimeWithNanoseconds):
44+
return value.timestamp_pb()
4645
return value
4746

4847

0 commit comments

Comments
 (0)