Skip to content

Commit 191fbd7

Browse files
committed
gh-159715: Fix ZoneInfo TZ rules spanning year boundaries
TZ string lookups only considered rules from the datetime's year, even when an offset or extended transition time moved a rule into a different year. Consider neighboring DST periods and join touching periods so that permanent DST does not introduce a gap or fold at New Year. Determine folds from actual offset changes. This also avoids marking times as ambiguous when the first occurrence would have fallen in a gap at the start of a short DST period. Keep the Python single-year lookup away from year boundaries when the rule order cannot change between years, to avoid scanning neighboring periods for ordinary footer lookups. Add regressions for both implementations, including positive and negative DST, extended transition hours, and datetime's extreme years.
1 parent 415ebff commit 191fbd7

4 files changed

Lines changed: 247 additions & 73 deletions

File tree

‎Lib/test/test_zoneinfo/test_zoneinfo.py‎

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1077,6 +1077,130 @@ def test_tzstr_from_utc(self):
10771077

10781078
self.assertEqual(dt_act, dt_utc)
10791079

1080+
def test_permanent_dst_year_boundary(self):
1081+
cases = (
1082+
("STD-2DST-1,J1/0,J365/23", 1, -1),
1083+
("STD2DST1,J1/0,J365/25", -1, 1),
1084+
)
1085+
for tzstr, offset, dst in cases:
1086+
zi = self.zone_from_tzstr(tzstr)
1087+
for year in (1, 2, 2019, 2020, 2100, 2400, 9998, 9999):
1088+
for month, day in ((1, 1), (12, 31)):
1089+
for hour in (2, 12, 21, 22, 23):
1090+
utc = datetime(year, month, day, hour,
1091+
tzinfo=timezone.utc)
1092+
# Keep the expected local time within datetime's range.
1093+
if year == 9999 and month == 12 and hour == 23:
1094+
continue
1095+
with self.subTest(tzstr=tzstr, utc=utc):
1096+
local = utc.astimezone(zi)
1097+
expected = utc.replace(tzinfo=None)
1098+
expected += timedelta(hours=offset)
1099+
self.assertEqual(local.replace(tzinfo=None), expected)
1100+
self.assertEqual(local.fold, 0)
1101+
self.assertEqual(local.tzname(), "DST")
1102+
self.assertEqual(local.dst(), timedelta(hours=dst))
1103+
self.assertEqual(local.astimezone(timezone.utc), utc)
1104+
for fold in (0, 1):
1105+
self.assertEqual(
1106+
local.replace(fold=fold).utcoffset(),
1107+
timedelta(hours=offset),
1108+
)
1109+
1110+
def test_transitions_in_adjacent_year(self):
1111+
# UTC transition instants are stated explicitly, independently of the
1112+
# rule evaluation. Cover starts and ends, either sign of DST, and
1113+
# rules displaced by both UTC offsets and extended transition hours.
1114+
cases = (
1115+
("STD-2DST-3,J1/0,J200/0", datetime(2100, 12, 31, 22), 2, 3),
1116+
("STD2DST1,J365/26,J200/0", datetime(2101, 1, 1, 4), -2, -1),
1117+
("STD-2DST-1,J1/0,J200/0", datetime(2100, 12, 31, 22), 2, 1),
1118+
("STD2DST3,J365/26,J200/0", datetime(2101, 1, 1, 4), -2, -3),
1119+
("STD-2DST-3,J200/0,J1/0", datetime(2100, 12, 31, 21), 3, 2),
1120+
("STD2DST1,J200/0,J365/26", datetime(2101, 1, 1, 3), -1, -2),
1121+
("STD-2DST-1,J200/0,J1/0", datetime(2100, 12, 31, 23), 1, 2),
1122+
("STD2DST3,J200/0,J365/26", datetime(2101, 1, 1, 5), -3, -2),
1123+
("STD0DST-1,J1/-167,J200/0", datetime(2099, 12, 25, 1), 0, 1),
1124+
("STD0DST-1,J200/0,J365/167", datetime(2101, 1, 6, 22), 1, 0),
1125+
("STD0DST-1,M12.5.0/167,M6.1.0/0", datetime(1, 1, 6, 23), 0, 1),
1126+
("STD0DST-1,J1/-167,J200/0", datetime(9999, 12, 25, 1), 0, 1),
1127+
("STD0DST-1,J365/167,J365/166", datetime(2025, 1, 6, 21), 1, 0),
1128+
("STD0DST-1,J365/167,J365/166", datetime(2025, 1, 6, 23), 0, 1),
1129+
("STD0DST-1,J365/167,J365/166", datetime(1, 1, 6, 21), 1, 0),
1130+
)
1131+
for tzstr, transition, old_hours, new_hours in cases:
1132+
zi = self.zone_from_tzstr(tzstr)
1133+
old_offset = timedelta(hours=old_hours)
1134+
new_offset = timedelta(hours=new_hours)
1135+
for seconds in (-3600, -1, 0, 1, 3599, 3600, 3601):
1136+
utc = (transition + timedelta(seconds=seconds)).replace(
1137+
tzinfo=timezone.utc
1138+
)
1139+
offset = old_offset if seconds < 0 else new_offset
1140+
expected = utc.replace(tzinfo=None) + offset
1141+
fold = int(new_hours < old_hours and 0 <= seconds < 3600)
1142+
with self.subTest(tzstr=tzstr, utc=utc):
1143+
local = utc.astimezone(zi)
1144+
self.assertEqual(local.replace(tzinfo=None), expected)
1145+
self.assertEqual(local.utcoffset(), offset)
1146+
self.assertEqual(local.fold, fold)
1147+
self.assertEqual(local.astimezone(timezone.utc), utc)
1148+
1149+
# Local lookups must disambiguate the gap or fold on both sides
1150+
# of New Year even when it belongs to a different rule year.
1151+
low = transition + min(old_offset, new_offset)
1152+
for seconds in (0, 1, 3599):
1153+
for fold, offset in ((0, old_offset), (1, new_offset)):
1154+
local = (low + timedelta(seconds=seconds)).replace(
1155+
tzinfo=zi, fold=fold
1156+
)
1157+
with self.subTest(tzstr=tzstr, local=local, fold=fold):
1158+
self.assertEqual(local.utcoffset(), offset)
1159+
1160+
def test_rule_order_changes_between_years(self):
1161+
# In 2021 the first Sunday in March follows the first Monday,
1162+
# whereas in 2022 it precedes it. The 2021 DST period continues
1163+
# until March 2022, overlapping the short 2022 period.
1164+
zi = self.zone_from_tzstr("STD0DST-1,M3.1.0/0,M3.1.1/0")
1165+
for utc in (
1166+
datetime(2022, 1, 15, tzinfo=timezone.utc),
1167+
datetime(2022, 2, 1, tzinfo=timezone.utc),
1168+
datetime(2022, 3, 6, tzinfo=timezone.utc),
1169+
):
1170+
with self.subTest(utc=utc):
1171+
local = utc.astimezone(zi)
1172+
self.assertEqual(local.utcoffset(), ONE_H)
1173+
self.assertEqual(local.fold, 0)
1174+
self.assertEqual(local.astimezone(timezone.utc), utc)
1175+
1176+
def test_short_dst_period_fold(self):
1177+
# A short DST period can end before its initial gap has elapsed.
1178+
# Only local times that actually occur twice should have fold=1.
1179+
# Exercise short periods near and away from New Year.
1180+
for day, month, month_day in ((1, 1, 1), (100, 4, 10)):
1181+
for dst_hours, end in ((1, "1:30"), (-1, "-0:30")):
1182+
tzstr = f"STD0DST{-dst_hours},J{day}/0,J{day}/{end}"
1183+
zi = self.zone_from_tzstr(tzstr)
1184+
transition = datetime(2025, month, month_day,
1185+
tzinfo=timezone.utc)
1186+
for minute in (-1, 0, 29, 30, 40, 59, 60, 89, 90):
1187+
utc = transition + timedelta(minutes=minute)
1188+
in_dst = 0 <= minute < 30
1189+
offset = timedelta(hours=dst_hours if in_dst else 0)
1190+
if dst_hours > 0:
1191+
fold = int(60 <= minute < 90)
1192+
else:
1193+
fold = int(in_dst)
1194+
with self.subTest(tzstr=tzstr, utc=utc):
1195+
local = utc.astimezone(zi)
1196+
self.assertEqual(local.fold, fold)
1197+
self.assertEqual(local.utcoffset(), offset)
1198+
self.assertEqual(
1199+
local.replace(tzinfo=None),
1200+
utc.replace(tzinfo=None) + offset,
1201+
)
1202+
self.assertEqual(local.astimezone(timezone.utc), utc)
1203+
10801204
def test_extreme_tzstr(self):
10811205
tzstrs = [
10821206
# Extreme offset hour

‎Lib/zoneinfo/_zoneinfo.py‎

Lines changed: 73 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -462,9 +462,36 @@ def transitions(self, year):
462462
end = self.end.year_to_epoch(year)
463463
return start, end
464464

465+
def _dst_period(self, year):
466+
start, end = self.transitions(year)
467+
start -= self.std.utcoff.total_seconds()
468+
end -= self.dst.utcoff.total_seconds()
469+
if end <= start:
470+
end = self.end.year_to_epoch(year + 1)
471+
end -= self.dst.utcoff.total_seconds()
472+
return start, end
473+
465474
def _get_trans_info(self, ts, year, fold):
466475
"""Get the information about the current transition - tti"""
467-
start, end = self.transitions(year)
476+
477+
# Even the largest parsed transition time (<168h), UTC offset
478+
# (<25h), and fold (<50h) cannot reach 11 days from a year boundary.
479+
year_start = (_post_epoch_days_before_year(year) + 1) * 86400
480+
if year_start + 11 * 86400 <= ts < year_start + 354 * 86400:
481+
start, end = self.transitions(year)
482+
# Each rule's day-of-year can move by up to seven days between
483+
# years. Nearby rules can reverse order, allowing a previous
484+
# year's southern season to overlap this year's northern one.
485+
if abs(end - start - self.dst_diff) > 14 * 86400:
486+
if fold == (self.dst_diff >= 0):
487+
end -= self.dst_diff
488+
else:
489+
start += self.dst_diff
490+
if start < end:
491+
isdst = start <= ts < end
492+
else:
493+
isdst = not (end <= ts < start)
494+
return self.dst if isdst else self.std
468495

469496
# With fold = 0, the period (denominated in local time) with the
470497
# smaller offset starts at the end of the gap and ends at the end of
@@ -475,38 +502,57 @@ def _get_trans_info(self, ts, year, fold):
475502
# the fold and whether DST is positive or negative (rare), and it
476503
# turns out that this boils down to fold XOR is_positive.
477504
if fold == (self.dst_diff >= 0):
478-
end -= self.dst_diff
479-
else:
480-
start += self.dst_diff
481-
482-
if start < end:
483-
isdst = start <= ts < end
505+
ts -= self.std.utcoff.total_seconds()
484506
else:
485-
isdst = not (end <= ts < start)
486-
487-
return self.dst if isdst else self.std
507+
ts -= self.dst.utcoff.total_seconds()
508+
509+
# Transition times and UTC offsets can move a rule into an adjacent
510+
# year. Taking the union also joins the touching periods used to
511+
# represent permanent DST, without introducing a gap or fold.
512+
# A southern season with a start shifted into the following January
513+
# can still be active two rule years after its start year.
514+
for rule_year in (year, year - 1, year + 1, year - 2):
515+
start, end = self._dst_period(rule_year)
516+
if start <= ts < end:
517+
return self.dst
518+
return self.std
488519

489520
def _get_trans_info_fromutc(self, ts, year):
490-
start, end = self.transitions(year)
491-
start -= self.std.utcoff.total_seconds()
492-
end -= self.dst.utcoff.total_seconds()
493-
494-
if start < end:
495-
isdst = start <= ts < end
496-
else:
497-
isdst = not (end <= ts < start)
521+
year_start = (_post_epoch_days_before_year(year) + 1) * 86400
522+
if year_start + 11 * 86400 <= ts < year_start + 354 * 86400:
523+
start, end = self.transitions(year)
524+
start -= self.std.utcoff.total_seconds()
525+
end -= self.dst.utcoff.total_seconds()
526+
if abs(end - start) > 14 * 86400:
527+
before = ts - abs(self.dst_diff)
528+
if start < end:
529+
isdst = start <= ts < end
530+
wasdst = start <= before < end
531+
else:
532+
isdst = not (end <= ts < start)
533+
wasdst = not (end <= before < start)
534+
if self.dst_diff > 0:
535+
fold = wasdst and not isdst
536+
else:
537+
fold = isdst and not wasdst
538+
return self.dst if isdst else self.std, fold
539+
540+
isdst = False
541+
wasdst = False
542+
before = ts - abs(self.dst_diff)
543+
for rule_year in (year, year - 1, year + 1, year - 2):
544+
start, end = self._dst_period(rule_year)
545+
isdst |= start <= ts < end
546+
wasdst |= start <= before < end
547+
if isdst and wasdst:
548+
break
498549

499-
# For positive DST, the ambiguous period is one dst_diff after the end
500-
# of DST; for negative DST, the ambiguous period is one dst_diff before
501-
# the start of DST.
550+
# A fold requires an actual decrease in offset. A nominal transition
551+
# at the join between permanent-DST periods does not create one.
502552
if self.dst_diff > 0:
503-
ambig_start = end
504-
ambig_end = end + self.dst_diff
553+
fold = wasdst and not isdst
505554
else:
506-
ambig_start = start
507-
ambig_end = start - self.dst_diff
508-
509-
fold = ambig_start <= ts < ambig_end
555+
fold = isdst and not wasdst
510556

511557
return (self.dst if isdst else self.std, fold)
512558

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Fix :class:`zoneinfo.ZoneInfo` handling of TZ string transitions that fall in
2+
an adjacent year, including permanent daylight saving time and folds near
3+
New Year.

‎Modules/_zoneinfo.c‎

Lines changed: 47 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1481,14 +1481,29 @@ dayrule_new(int julian, int day, int hour, int minute,
14811481
return 0;
14821482
}
14831483

1484-
/* Calculate the start and end rules for a _tzrule in the given year. */
1484+
/* Calculate a DST period starting in the given rule year, in UTC. */
14851485
static void
1486-
tzrule_transitions(_tzrule *rule, int year, int64_t *start, int64_t *end)
1486+
tzrule_dst_period(_tzrule *rule, int year, int64_t *start, int64_t *end)
14871487
{
14881488
assert(rule->start != NULL);
14891489
assert(rule->end != NULL);
1490+
// ymd_to_ord assumes positive years. The Gregorian calendar repeats
1491+
// every 400 years, allowing year 1 lookups to use earlier rules too.
1492+
int64_t shift = 0;
1493+
if (year <= 0) {
1494+
year += 400;
1495+
shift = 146097LL * 86400;
1496+
}
14901497
*start = rule->start->year_to_timestamp(rule->start, year);
14911498
*end = rule->end->year_to_timestamp(rule->end, year);
1499+
*start -= rule->std.utcoff_seconds;
1500+
*end -= rule->dst.utcoff_seconds;
1501+
if (*end <= *start) {
1502+
*end = rule->end->year_to_timestamp(rule->end, year + 1);
1503+
*end -= rule->dst.utcoff_seconds;
1504+
}
1505+
*start -= shift;
1506+
*end -= shift;
14921507
}
14931508

14941509
/* Calculate the _ttinfo that applies at a given local time from a _tzrule.
@@ -1506,11 +1521,6 @@ find_tzrule_ttinfo(_tzrule *rule, int64_t ts, unsigned char fold, int year)
15061521
return &(rule->std);
15071522
}
15081523

1509-
int64_t start, end;
1510-
uint8_t isdst;
1511-
1512-
tzrule_transitions(rule, year, &start, &end);
1513-
15141524
// With fold = 0, the period (denominated in local time) with the smaller
15151525
// offset starts at the end of the gap and ends at the end of the fold;
15161526
// with fold = 1, it runs from the start of the gap to the beginning of the
@@ -1520,25 +1530,26 @@ find_tzrule_ttinfo(_tzrule *rule, int64_t ts, unsigned char fold, int year)
15201530
// fold and whether DST is positive or negative (rare), and it turns out
15211531
// that this boils down to fold XOR is_positive.
15221532
if (fold == (rule->dst_diff >= 0)) {
1523-
end -= rule->dst_diff;
1524-
}
1525-
else {
1526-
start += rule->dst_diff;
1527-
}
1528-
1529-
if (start < end) {
1530-
isdst = (ts >= start) && (ts < end);
1533+
ts -= rule->std.utcoff_seconds;
15311534
}
15321535
else {
1533-
isdst = (ts < end) || (ts >= start);
1536+
ts -= rule->dst.utcoff_seconds;
15341537
}
15351538

1536-
if (isdst) {
1537-
return &(rule->dst);
1538-
}
1539-
else {
1540-
return &(rule->std);
1539+
// Transition times and UTC offsets can move a rule into an adjacent
1540+
// year. The union joins touching permanent-DST periods without a gap
1541+
// or fold.
1542+
// A southern season shifted into the following January can still be
1543+
// active two rule years after its start year.
1544+
int years[] = {year, year - 1, year + 1, year - 2};
1545+
for (size_t i = 0; i < 4; i++) {
1546+
int64_t start, end;
1547+
tzrule_dst_period(rule, years[i], &start, &end);
1548+
if (start <= ts && ts < end) {
1549+
return &(rule->dst);
1550+
}
15411551
}
1552+
return &(rule->std);
15421553
}
15431554

15441555
/* Calculate the ttinfo and fold that applies for a _tzrule at an epoch time.
@@ -1561,33 +1572,23 @@ find_tzrule_ttinfo_fromutc(_tzrule *rule, int64_t ts, int year,
15611572
return &(rule->std);
15621573
}
15631574

1564-
int64_t start, end;
1565-
uint8_t isdst;
1566-
tzrule_transitions(rule, year, &start, &end);
1567-
start -= rule->std.utcoff_seconds;
1568-
end -= rule->dst.utcoff_seconds;
1569-
1570-
if (start < end) {
1571-
isdst = (ts >= start) && (ts < end);
1572-
}
1573-
else {
1574-
isdst = (ts < end) || (ts >= start);
1575-
}
1576-
1577-
// For positive DST, the ambiguous period is one dst_diff after the end of
1578-
// DST; for negative DST, the ambiguous period is one dst_diff before the
1579-
// start of DST.
1580-
int64_t ambig_start, ambig_end;
1581-
if (rule->dst_diff > 0) {
1582-
ambig_start = end;
1583-
ambig_end = end + rule->dst_diff;
1584-
}
1585-
else {
1586-
ambig_start = start;
1587-
ambig_end = start - rule->dst_diff;
1575+
uint8_t isdst = 0;
1576+
uint8_t wasdst = 0;
1577+
int64_t before = ts - llabs(rule->dst_diff);
1578+
int years[] = {year, year - 1, year + 1, year - 2};
1579+
for (size_t i = 0; i < 4; i++) {
1580+
int64_t start, end;
1581+
tzrule_dst_period(rule, years[i], &start, &end);
1582+
isdst |= (start <= ts && ts < end);
1583+
wasdst |= (start <= before && before < end);
1584+
if (isdst && wasdst) {
1585+
break;
1586+
}
15881587
}
15891588

1590-
*fold = (ts >= ambig_start) && (ts < ambig_end);
1589+
// A nominal transition between permanent-DST periods does not create
1590+
// a fold: the offset must actually decrease.
1591+
*fold = rule->dst_diff > 0 ? (wasdst && !isdst) : (isdst && !wasdst);
15911592

15921593
if (isdst) {
15931594
return &(rule->dst);

0 commit comments

Comments
 (0)