|
| 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 | + ) |
0 commit comments