Skip to content

Commit

Permalink
BREAKING CHANGE in v26: check if Timestamp is valid.
Browse files Browse the repository at this point in the history
Seconds should be in range [-62135596800, 253402300799]
Nanos should be in range [0, 999999999]

PiperOrigin-RevId: 592365636
  • Loading branch information
anandolee authored and copybara-github committed Dec 19, 2023
1 parent f75fe9e commit 1250d5f
Show file tree
Hide file tree
Showing 3 changed files with 59 additions and 30 deletions.
9 changes: 8 additions & 1 deletion python/google/protobuf/internal/json_format_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -1060,7 +1060,14 @@ def testInvalidTimestamp(self):
json_format.Parse, text, message)
# Time bigger than maximum time.
message.value.seconds = 253402300800
self.assertRaisesRegex(OverflowError, 'date value out of range',
self.assertRaisesRegex(json_format.SerializeToJsonError,
'Timestamp is not valid',
json_format.MessageToJson, message)
# Nanos smaller than 0
message.value.seconds = 0
message.value.nanos = -1
self.assertRaisesRegex(json_format.SerializeToJsonError,
'Timestamp is not valid',
json_format.MessageToJson, message)
# Lower case t does not accept.
text = '{"value": "0001-01-01t00:00:00Z"}'
Expand Down
56 changes: 44 additions & 12 deletions python/google/protobuf/internal/well_known_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@
_MICROS_PER_SECOND = 1000000
_SECONDS_PER_DAY = 24 * 3600
_DURATION_SECONDS_MAX = 315576000000
_TIMESTAMP_SECONDS_MIN = -62135596800
_TIMESTAMP_SECONDS_MAX = 253402300799

_EPOCH_DATETIME_NAIVE = datetime.datetime(1970, 1, 1, tzinfo=None)
_EPOCH_DATETIME_AWARE = _EPOCH_DATETIME_NAIVE.replace(
Expand Down Expand Up @@ -85,10 +87,10 @@ def ToJsonString(self):
and uses 3, 6 or 9 fractional digits as required to represent the
exact time. Example of the return format: '1972-01-01T10:00:20.021Z'
"""
nanos = self.nanos % _NANOS_PER_SECOND
total_sec = self.seconds + (self.nanos - nanos) // _NANOS_PER_SECOND
seconds = total_sec % _SECONDS_PER_DAY
days = (total_sec - seconds) // _SECONDS_PER_DAY
_CheckTimestampValid(self.seconds, self.nanos)
nanos = self.nanos
seconds = self.seconds % _SECONDS_PER_DAY
days = (self.seconds - seconds) // _SECONDS_PER_DAY
dt = datetime.datetime(1970, 1, 1) + datetime.timedelta(days, seconds)

result = dt.isoformat()
Expand Down Expand Up @@ -166,6 +168,7 @@ def FromJsonString(self, value):
else:
seconds += (int(timezone[1:pos])*60+int(timezone[pos+1:]))*60
# Set seconds and nanos
_CheckTimestampValid(seconds, nanos)
self.seconds = int(seconds)
self.nanos = int(nanos)

Expand All @@ -175,39 +178,53 @@ def GetCurrentTime(self):

def ToNanoseconds(self):
"""Converts Timestamp to nanoseconds since epoch."""
_CheckTimestampValid(self.seconds, self.nanos)
return self.seconds * _NANOS_PER_SECOND + self.nanos

def ToMicroseconds(self):
"""Converts Timestamp to microseconds since epoch."""
_CheckTimestampValid(self.seconds, self.nanos)
return (self.seconds * _MICROS_PER_SECOND +
self.nanos // _NANOS_PER_MICROSECOND)

def ToMilliseconds(self):
"""Converts Timestamp to milliseconds since epoch."""
_CheckTimestampValid(self.seconds, self.nanos)
return (self.seconds * _MILLIS_PER_SECOND +
self.nanos // _NANOS_PER_MILLISECOND)

def ToSeconds(self):
"""Converts Timestamp to seconds since epoch."""
_CheckTimestampValid(self.seconds, self.nanos)
return self.seconds

def FromNanoseconds(self, nanos):
"""Converts nanoseconds since epoch to Timestamp."""
self.seconds = nanos // _NANOS_PER_SECOND
self.nanos = nanos % _NANOS_PER_SECOND
seconds = nanos // _NANOS_PER_SECOND
nanos = nanos % _NANOS_PER_SECOND
_CheckTimestampValid(seconds, nanos)
self.seconds = seconds
self.nanos = nanos

def FromMicroseconds(self, micros):
"""Converts microseconds since epoch to Timestamp."""
self.seconds = micros // _MICROS_PER_SECOND
self.nanos = (micros % _MICROS_PER_SECOND) * _NANOS_PER_MICROSECOND
seconds = micros // _MICROS_PER_SECOND
nanos = (micros % _MICROS_PER_SECOND) * _NANOS_PER_MICROSECOND
_CheckTimestampValid(seconds, nanos)
self.seconds = seconds
self.nanos = nanos

def FromMilliseconds(self, millis):
"""Converts milliseconds since epoch to Timestamp."""
self.seconds = millis // _MILLIS_PER_SECOND
self.nanos = (millis % _MILLIS_PER_SECOND) * _NANOS_PER_MILLISECOND
seconds = millis // _MILLIS_PER_SECOND
nanos = (millis % _MILLIS_PER_SECOND) * _NANOS_PER_MILLISECOND
_CheckTimestampValid(seconds, nanos)
self.seconds = seconds
self.nanos = nanos

def FromSeconds(self, seconds):
"""Converts seconds since epoch to Timestamp."""
_CheckTimestampValid(seconds, 0)
self.seconds = seconds
self.nanos = 0

Expand All @@ -229,6 +246,7 @@ def ToDatetime(self, tzinfo=None):
# https://github.com/python/cpython/issues/109849) or full range (on some
# platforms, see https://github.com/python/cpython/issues/110042) of
# datetime.
_CheckTimestampValid(self.seconds, self.nanos)
delta = datetime.timedelta(
seconds=self.seconds,
microseconds=_RoundTowardZero(self.nanos, _NANOS_PER_MICROSECOND),
Expand All @@ -252,8 +270,22 @@ def FromDatetime(self, dt):
# manipulated into a long value of seconds. During the conversion from
# struct_time to long, the source date in UTC, and so it follows that the
# correct transformation is calendar.timegm()
self.seconds = calendar.timegm(dt.utctimetuple())
self.nanos = dt.microsecond * _NANOS_PER_MICROSECOND
seconds = calendar.timegm(dt.utctimetuple())
nanos = dt.microsecond * _NANOS_PER_MICROSECOND
_CheckTimestampValid(seconds, nanos)
self.seconds = seconds
self.nanos = nanos


def _CheckTimestampValid(seconds, nanos):
if seconds < _TIMESTAMP_SECONDS_MIN or seconds > _TIMESTAMP_SECONDS_MAX:
raise ValueError(
'Timestamp is not valid: Seconds {0} must be in range '
'[-62135596800, 253402300799].'.format(seconds))
if nanos < 0 or nanos >= _NANOS_PER_SECOND:
raise ValueError(
'Timestamp is not valid: Nanos {} must be in a range '
'[0, 999999].'.format(nanos))


class Duration(object):
Expand Down
24 changes: 7 additions & 17 deletions python/google/protobuf/internal/well_known_types_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -352,27 +352,15 @@ def testTimezoneAwareMinDatetimeConversion(self):
)

def testNanosOneSecond(self):
# TODO: b/301980950 - Test error behavior instead once ToDatetime validates
# that nanos are in expected range.
tz = _TZ_PACIFIC
ts = timestamp_pb2.Timestamp(nanos=1_000_000_000)
self.assertEqual(ts.ToDatetime(), datetime.datetime(1970, 1, 1, 0, 0, 1))
self.assertEqual(
ts.ToDatetime(tz), datetime.datetime(1969, 12, 31, 16, 0, 1, tzinfo=tz)
)
self.assertRaisesRegex(ValueError, 'Timestamp is not valid',
ts.ToDatetime)

def testNanosNegativeOneSecond(self):
# TODO: b/301980950 - Test error behavior instead once ToDatetime validates
# that nanos are in expected range.
tz = _TZ_PACIFIC
ts = timestamp_pb2.Timestamp(nanos=-1_000_000_000)
self.assertEqual(
ts.ToDatetime(), datetime.datetime(1969, 12, 31, 23, 59, 59)
)
self.assertEqual(
ts.ToDatetime(tz),
datetime.datetime(1969, 12, 31, 15, 59, 59, tzinfo=tz),
)
self.assertRaisesRegex(ValueError, 'Timestamp is not valid',
ts.ToDatetime)

def testTimedeltaConversion(self):
message = duration_pb2.Duration()
Expand Down Expand Up @@ -421,8 +409,10 @@ def testInvalidTimestamp(self):
self.assertRaisesRegex(ValueError, 'year (0 )?is out of range',
message.FromJsonString, '0000-01-01T00:00:00Z')
message.seconds = 253402300800
self.assertRaisesRegex(OverflowError, 'date value out of range',
self.assertRaisesRegex(ValueError, 'Timestamp is not valid',
message.ToJsonString)
self.assertRaisesRegex(ValueError, 'Timestamp is not valid',
message.FromSeconds, -62135596801)

def testInvalidDuration(self):
message = duration_pb2.Duration()
Expand Down

0 comments on commit 1250d5f

Please sign in to comment.