Skip to content

Commit 28697df

Browse files
authored
PYTHON-4691 Fix non-UTC timezones with DATETIME_CLAMP/DATETIME_AUTO (mongodb#1811)
1 parent 9d3b503 commit 28697df

File tree

2 files changed

+98
-20
lines changed

2 files changed

+98
-20
lines changed

bson/datetime_ms.py

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -114,17 +114,40 @@ def __int__(self) -> int:
114114
return self._value
115115

116116

117+
def _datetime_to_millis(dtm: datetime.datetime) -> int:
118+
"""Convert datetime to milliseconds since epoch UTC."""
119+
if dtm.utcoffset() is not None:
120+
dtm = dtm - dtm.utcoffset() # type: ignore
121+
return int(calendar.timegm(dtm.timetuple()) * 1000 + dtm.microsecond // 1000)
122+
123+
124+
_MIN_UTC = datetime.datetime.min.replace(tzinfo=utc)
125+
_MAX_UTC = datetime.datetime.max.replace(tzinfo=utc)
126+
_MIN_UTC_MS = _datetime_to_millis(_MIN_UTC)
127+
_MAX_UTC_MS = _datetime_to_millis(_MAX_UTC)
128+
129+
117130
# Inclusive and exclusive min and max for timezones.
118131
# Timezones are hashed by their offset, which is a timedelta
119132
# and therefore there are more than 24 possible timezones.
120133
@functools.lru_cache(maxsize=None)
121134
def _min_datetime_ms(tz: datetime.timezone = datetime.timezone.utc) -> int:
122-
return _datetime_to_millis(datetime.datetime.min.replace(tzinfo=tz))
135+
delta = tz.utcoffset(_MIN_UTC)
136+
if delta is not None:
137+
offset_millis = (delta.days * 86400 + delta.seconds) * 1000 + delta.microseconds // 1000
138+
else:
139+
offset_millis = 0
140+
return max(_MIN_UTC_MS, _MIN_UTC_MS - offset_millis)
123141

124142

125143
@functools.lru_cache(maxsize=None)
126144
def _max_datetime_ms(tz: datetime.timezone = datetime.timezone.utc) -> int:
127-
return _datetime_to_millis(datetime.datetime.max.replace(tzinfo=tz))
145+
delta = tz.utcoffset(_MAX_UTC)
146+
if delta is not None:
147+
offset_millis = (delta.days * 86400 + delta.seconds) * 1000 + delta.microseconds // 1000
148+
else:
149+
offset_millis = 0
150+
return min(_MAX_UTC_MS, _MAX_UTC_MS - offset_millis)
128151

129152

130153
def _millis_to_datetime(
@@ -162,10 +185,3 @@ def _millis_to_datetime(
162185
return DatetimeMS(millis)
163186
else:
164187
raise ValueError("datetime_conversion must be an element of DatetimeConversion")
165-
166-
167-
def _datetime_to_millis(dtm: datetime.datetime) -> int:
168-
"""Convert datetime to milliseconds since epoch UTC."""
169-
if dtm.utcoffset() is not None:
170-
dtm = dtm - dtm.utcoffset() # type: ignore
171-
return int(calendar.timegm(dtm.timetuple()) * 1000 + dtm.microsecond // 1000)

test/test_bson.py

Lines changed: 73 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1252,54 +1252,116 @@ def test_class_conversions(self):
12521252

12531253
def test_clamping(self):
12541254
# Test clamping from below and above.
1255-
opts1 = CodecOptions(
1255+
opts = CodecOptions(
12561256
datetime_conversion=DatetimeConversion.DATETIME_CLAMP,
12571257
tz_aware=True,
12581258
tzinfo=datetime.timezone.utc,
12591259
)
12601260
below = encode({"x": DatetimeMS(_datetime_to_millis(datetime.datetime.min) - 1)})
1261-
dec_below = decode(below, opts1)
1261+
dec_below = decode(below, opts)
12621262
self.assertEqual(
12631263
dec_below["x"], datetime.datetime.min.replace(tzinfo=datetime.timezone.utc)
12641264
)
12651265

12661266
above = encode({"x": DatetimeMS(_datetime_to_millis(datetime.datetime.max) + 1)})
1267-
dec_above = decode(above, opts1)
1267+
dec_above = decode(above, opts)
12681268
self.assertEqual(
12691269
dec_above["x"],
12701270
datetime.datetime.max.replace(tzinfo=datetime.timezone.utc, microsecond=999000),
12711271
)
12721272

1273-
def test_tz_clamping(self):
1273+
def test_tz_clamping_local(self):
12741274
# Naive clamping to local tz.
1275-
opts1 = CodecOptions(datetime_conversion=DatetimeConversion.DATETIME_CLAMP, tz_aware=False)
1275+
opts = CodecOptions(datetime_conversion=DatetimeConversion.DATETIME_CLAMP, tz_aware=False)
12761276
below = encode({"x": DatetimeMS(_datetime_to_millis(datetime.datetime.min) - 24 * 60 * 60)})
12771277

1278-
dec_below = decode(below, opts1)
1278+
dec_below = decode(below, opts)
12791279
self.assertEqual(dec_below["x"], datetime.datetime.min)
12801280

12811281
above = encode({"x": DatetimeMS(_datetime_to_millis(datetime.datetime.max) + 24 * 60 * 60)})
1282-
dec_above = decode(above, opts1)
1282+
dec_above = decode(above, opts)
12831283
self.assertEqual(
12841284
dec_above["x"],
12851285
datetime.datetime.max.replace(microsecond=999000),
12861286
)
12871287

1288-
# Aware clamping.
1289-
opts2 = CodecOptions(datetime_conversion=DatetimeConversion.DATETIME_CLAMP, tz_aware=True)
1288+
def test_tz_clamping_utc(self):
1289+
# Aware clamping default utc.
1290+
opts = CodecOptions(datetime_conversion=DatetimeConversion.DATETIME_CLAMP, tz_aware=True)
12901291
below = encode({"x": DatetimeMS(_datetime_to_millis(datetime.datetime.min) - 24 * 60 * 60)})
1291-
dec_below = decode(below, opts2)
1292+
dec_below = decode(below, opts)
12921293
self.assertEqual(
12931294
dec_below["x"], datetime.datetime.min.replace(tzinfo=datetime.timezone.utc)
12941295
)
12951296

12961297
above = encode({"x": DatetimeMS(_datetime_to_millis(datetime.datetime.max) + 24 * 60 * 60)})
1297-
dec_above = decode(above, opts2)
1298+
dec_above = decode(above, opts)
12981299
self.assertEqual(
12991300
dec_above["x"],
13001301
datetime.datetime.max.replace(tzinfo=datetime.timezone.utc, microsecond=999000),
13011302
)
13021303

1304+
def test_tz_clamping_non_utc(self):
1305+
for tz in [FixedOffset(60, "+1H"), FixedOffset(-60, "-1H")]:
1306+
opts = CodecOptions(
1307+
datetime_conversion=DatetimeConversion.DATETIME_CLAMP, tz_aware=True, tzinfo=tz
1308+
)
1309+
# Min/max values in this timezone which can be represented in both BSON and datetime UTC.
1310+
try:
1311+
min_tz = datetime.datetime.min.replace(tzinfo=utc).astimezone(tz)
1312+
except OverflowError:
1313+
min_tz = datetime.datetime.min.replace(tzinfo=tz)
1314+
try:
1315+
max_tz = datetime.datetime.max.replace(tzinfo=utc, microsecond=999000).astimezone(
1316+
tz
1317+
)
1318+
except OverflowError:
1319+
max_tz = datetime.datetime.max.replace(tzinfo=tz, microsecond=999000)
1320+
1321+
for in_range in [
1322+
min_tz,
1323+
min_tz + datetime.timedelta(milliseconds=1),
1324+
max_tz - datetime.timedelta(milliseconds=1),
1325+
max_tz,
1326+
]:
1327+
doc = decode(encode({"x": in_range}), opts)
1328+
self.assertEqual(doc["x"], in_range)
1329+
1330+
for too_low in [
1331+
DatetimeMS(_datetime_to_millis(min_tz) - 1),
1332+
DatetimeMS(_datetime_to_millis(min_tz) - 60 * 60 * 1000),
1333+
DatetimeMS(_datetime_to_millis(min_tz) - 1 - 60 * 60 * 1000),
1334+
DatetimeMS(_datetime_to_millis(datetime.datetime.min) - 1),
1335+
DatetimeMS(_datetime_to_millis(datetime.datetime.min) - 60 * 60 * 1000),
1336+
DatetimeMS(_datetime_to_millis(datetime.datetime.min) - 1 - 60 * 60 * 1000),
1337+
]:
1338+
doc = decode(encode({"x": too_low}), opts)
1339+
self.assertEqual(doc["x"], min_tz)
1340+
1341+
for too_high in [
1342+
DatetimeMS(_datetime_to_millis(max_tz) + 1),
1343+
DatetimeMS(_datetime_to_millis(max_tz) + 60 * 60 * 1000),
1344+
DatetimeMS(_datetime_to_millis(max_tz) + 1 + 60 * 60 * 1000),
1345+
DatetimeMS(_datetime_to_millis(datetime.datetime.max) + 1),
1346+
DatetimeMS(_datetime_to_millis(datetime.datetime.max) + 60 * 60 * 1000),
1347+
DatetimeMS(_datetime_to_millis(datetime.datetime.max) + 1 + 60 * 60 * 1000),
1348+
]:
1349+
doc = decode(encode({"x": too_high}), opts)
1350+
self.assertEqual(doc["x"], max_tz)
1351+
1352+
def test_tz_clamping_non_utc_simple(self):
1353+
dtm = datetime.datetime(2024, 8, 23)
1354+
encoded = encode({"d": dtm})
1355+
self.assertEqual(decode(encoded)["d"], dtm)
1356+
for conversion in [
1357+
DatetimeConversion.DATETIME,
1358+
DatetimeConversion.DATETIME_CLAMP,
1359+
DatetimeConversion.DATETIME_AUTO,
1360+
]:
1361+
for tz in [FixedOffset(60, "+1H"), FixedOffset(-60, "-1H")]:
1362+
opts = CodecOptions(datetime_conversion=conversion, tz_aware=True, tzinfo=tz)
1363+
self.assertEqual(decode(encoded, opts)["d"], dtm.replace(tzinfo=utc).astimezone(tz))
1364+
13031365
def test_datetime_auto(self):
13041366
# Naive auto, in range.
13051367
opts1 = CodecOptions(datetime_conversion=DatetimeConversion.DATETIME_AUTO)

0 commit comments

Comments
 (0)