Skip to content

Commit dc389be

Browse files
committed
Fix fold detection behavior for early transitions
When the gap between the timestamp being tested and the most recent transition is too big to fit in a C integer, the Python implementation will fail. This was discovered from testing with hypothesis on Ubuntu (which has has some super early transition in at least Africa/Abidjan for some reason). Adding a determinitsic test for this required reworking the arbitrary zone generator a bit to allow for timestamp values outside the range of valid datetimes.
1 parent 1d76ff6 commit dc389be

File tree

2 files changed

+75
-5
lines changed

2 files changed

+75
-5
lines changed

src/zoneinfo/_zoneinfo.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@ def fromutc(self, dt):
144144

145145
# Detect fold
146146
shift = tti_prev.utcoff - tti.utcoff
147-
fold = shift > timedelta(0, timestamp - self._trans_utc[idx - 1])
147+
fold = shift.total_seconds() > timestamp - self._trans_utc[idx - 1]
148148
dt += tti.utcoff
149149
if fold:
150150
return dt.replace(fold=1)

tests/test_zoneinfo.py

+74-4
Original file line numberDiff line numberDiff line change
@@ -616,6 +616,67 @@ def test_empty_zone(self):
616616
with self.assertRaises(ValueError):
617617
self.klass.from_file(zf)
618618

619+
def test_zone_very_large_timestamp(self):
620+
"""Test when a transition is in the far past or future.
621+
622+
Particularly, this is a concern if something:
623+
624+
1. Attempts to call ``datetime.timestamp`` for a datetime outside
625+
of ``[datetime.min, datetime.max]``.
626+
2. Attempts to construct a timedelta outside of
627+
``[timedelta.min, timedelta.max]``.
628+
629+
This actually occurs "in the wild", as some time zones on Ubuntu (at
630+
least as of 2020) have an initial transition added at ``-2**58``.
631+
"""
632+
633+
LMT = ZoneOffset("LMT", timedelta(seconds=-968))
634+
GMT = ZoneOffset("GMT", ZERO)
635+
636+
transitions = [
637+
(-(1 << 62), LMT, LMT),
638+
ZoneTransition(datetime(1912, 1, 1), LMT, GMT),
639+
((1 << 62), GMT, GMT),
640+
]
641+
642+
after = "GMT0"
643+
644+
zf = self.construct_zone(transitions, after)
645+
zi = self.klass.from_file(zf, key="Africa/Abidjan")
646+
647+
offset_cases = [
648+
(datetime.min, LMT),
649+
(datetime.max, GMT),
650+
(datetime(1911, 12, 31), LMT),
651+
(datetime(1912, 1, 2), GMT),
652+
]
653+
654+
for dt_naive, offset in offset_cases:
655+
dt = dt_naive.replace(tzinfo=zi)
656+
with self.subTest(name="offset", dt=dt, offset=offset):
657+
self.assertEqual(dt.tzname(), offset.tzname)
658+
self.assertEqual(dt.utcoffset(), offset.utcoffset)
659+
self.assertEqual(dt.dst(), offset.dst)
660+
661+
utc_cases = [
662+
(datetime.min, datetime.min + timedelta(seconds=968)),
663+
(datetime(1898, 12, 31, 23, 43, 52), datetime(1899, 1, 1)),
664+
(
665+
datetime(1911, 12, 31, 23, 59, 59, 999999),
666+
datetime(1912, 1, 1, 0, 16, 7, 999999),
667+
),
668+
(datetime(1912, 1, 1, 0, 16, 8), datetime(1912, 1, 1, 0, 16, 8)),
669+
(datetime(1970, 1, 1), datetime(1970, 1, 1)),
670+
(datetime.max, datetime.max),
671+
]
672+
673+
for naive_dt, naive_dt_utc in utc_cases:
674+
dt = naive_dt.replace(tzinfo=zi)
675+
dt_utc = naive_dt_utc.replace(tzinfo=timezone.utc)
676+
677+
self.assertEqual(dt_utc.astimezone(zi), dt)
678+
self.assertEqual(dt, dt_utc)
679+
619680
def construct_zone(self, transitions, after=None, version=3):
620681
# These are not used for anything, so we're not going to include
621682
# them for now.
@@ -631,16 +692,25 @@ def construct_zone(self, transitions, after=None, version=3):
631692
v2_range = (-(2 ** 63), 2 ** 63)
632693
ranges = [v1_range, v2_range]
633694

634-
transitions.sort(key=lambda x: x.transition)
695+
def zt_as_tuple(zt):
696+
# zt may be a tuple (timestamp, offset_before, offset_after) or
697+
# a ZoneTransition object — this is to allow the timestamp to be
698+
# values that are outside the valid range for datetimes but still
699+
# valid 64-bit timestamps.
700+
if isinstance(zt, tuple):
701+
return zt
635702

636-
for zt in transitions:
637703
if zt.transition:
638704
trans_time = int(zt.transition_utc.timestamp())
639705
else:
640706
trans_time = None
641707

642-
offset_before = zt.offset_before
643-
offset_after = zt.offset_after
708+
return (trans_time, zt.offset_before, zt.offset_after)
709+
710+
transitions = sorted(map(zt_as_tuple, transitions), key=lambda x: x[0])
711+
712+
for zt in transitions:
713+
trans_time, offset_before, offset_after = zt
644714

645715
for v, (dt_min, dt_max) in enumerate(ranges):
646716
offsets = offset_lists[v]

0 commit comments

Comments
 (0)