From 191fbd712521d874d585c3bbc98350d0d22e6d7b Mon Sep 17 00:00:00 2001 From: Paul Ganssle Date: Mon, 19 Oct 2026 13:17:29 -0400 Subject: [PATCH 1/5] 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. --- Lib/test/test_zoneinfo/test_zoneinfo.py | 124 ++++++++++++++++++ Lib/zoneinfo/_zoneinfo.py | 100 ++++++++++---- ...12-00-00.gh-issue-159715.year-boundary.rst | 3 + Modules/_zoneinfo.c | 93 ++++++------- 4 files changed, 247 insertions(+), 73 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-10-19-12-00-00.gh-issue-159715.year-boundary.rst diff --git a/Lib/test/test_zoneinfo/test_zoneinfo.py b/Lib/test/test_zoneinfo/test_zoneinfo.py index 41ba92342ff2cb..c5eaa2bfc6a240 100644 --- a/Lib/test/test_zoneinfo/test_zoneinfo.py +++ b/Lib/test/test_zoneinfo/test_zoneinfo.py @@ -1077,6 +1077,130 @@ def test_tzstr_from_utc(self): self.assertEqual(dt_act, dt_utc) + def test_permanent_dst_year_boundary(self): + cases = ( + ("STD-2DST-1,J1/0,J365/23", 1, -1), + ("STD2DST1,J1/0,J365/25", -1, 1), + ) + for tzstr, offset, dst in cases: + zi = self.zone_from_tzstr(tzstr) + for year in (1, 2, 2019, 2020, 2100, 2400, 9998, 9999): + for month, day in ((1, 1), (12, 31)): + for hour in (2, 12, 21, 22, 23): + utc = datetime(year, month, day, hour, + tzinfo=timezone.utc) + # Keep the expected local time within datetime's range. + if year == 9999 and month == 12 and hour == 23: + continue + with self.subTest(tzstr=tzstr, utc=utc): + local = utc.astimezone(zi) + expected = utc.replace(tzinfo=None) + expected += timedelta(hours=offset) + self.assertEqual(local.replace(tzinfo=None), expected) + self.assertEqual(local.fold, 0) + self.assertEqual(local.tzname(), "DST") + self.assertEqual(local.dst(), timedelta(hours=dst)) + self.assertEqual(local.astimezone(timezone.utc), utc) + for fold in (0, 1): + self.assertEqual( + local.replace(fold=fold).utcoffset(), + timedelta(hours=offset), + ) + + def test_transitions_in_adjacent_year(self): + # UTC transition instants are stated explicitly, independently of the + # rule evaluation. Cover starts and ends, either sign of DST, and + # rules displaced by both UTC offsets and extended transition hours. + cases = ( + ("STD-2DST-3,J1/0,J200/0", datetime(2100, 12, 31, 22), 2, 3), + ("STD2DST1,J365/26,J200/0", datetime(2101, 1, 1, 4), -2, -1), + ("STD-2DST-1,J1/0,J200/0", datetime(2100, 12, 31, 22), 2, 1), + ("STD2DST3,J365/26,J200/0", datetime(2101, 1, 1, 4), -2, -3), + ("STD-2DST-3,J200/0,J1/0", datetime(2100, 12, 31, 21), 3, 2), + ("STD2DST1,J200/0,J365/26", datetime(2101, 1, 1, 3), -1, -2), + ("STD-2DST-1,J200/0,J1/0", datetime(2100, 12, 31, 23), 1, 2), + ("STD2DST3,J200/0,J365/26", datetime(2101, 1, 1, 5), -3, -2), + ("STD0DST-1,J1/-167,J200/0", datetime(2099, 12, 25, 1), 0, 1), + ("STD0DST-1,J200/0,J365/167", datetime(2101, 1, 6, 22), 1, 0), + ("STD0DST-1,M12.5.0/167,M6.1.0/0", datetime(1, 1, 6, 23), 0, 1), + ("STD0DST-1,J1/-167,J200/0", datetime(9999, 12, 25, 1), 0, 1), + ("STD0DST-1,J365/167,J365/166", datetime(2025, 1, 6, 21), 1, 0), + ("STD0DST-1,J365/167,J365/166", datetime(2025, 1, 6, 23), 0, 1), + ("STD0DST-1,J365/167,J365/166", datetime(1, 1, 6, 21), 1, 0), + ) + for tzstr, transition, old_hours, new_hours in cases: + zi = self.zone_from_tzstr(tzstr) + old_offset = timedelta(hours=old_hours) + new_offset = timedelta(hours=new_hours) + for seconds in (-3600, -1, 0, 1, 3599, 3600, 3601): + utc = (transition + timedelta(seconds=seconds)).replace( + tzinfo=timezone.utc + ) + offset = old_offset if seconds < 0 else new_offset + expected = utc.replace(tzinfo=None) + offset + fold = int(new_hours < old_hours and 0 <= seconds < 3600) + with self.subTest(tzstr=tzstr, utc=utc): + local = utc.astimezone(zi) + self.assertEqual(local.replace(tzinfo=None), expected) + self.assertEqual(local.utcoffset(), offset) + self.assertEqual(local.fold, fold) + self.assertEqual(local.astimezone(timezone.utc), utc) + + # Local lookups must disambiguate the gap or fold on both sides + # of New Year even when it belongs to a different rule year. + low = transition + min(old_offset, new_offset) + for seconds in (0, 1, 3599): + for fold, offset in ((0, old_offset), (1, new_offset)): + local = (low + timedelta(seconds=seconds)).replace( + tzinfo=zi, fold=fold + ) + with self.subTest(tzstr=tzstr, local=local, fold=fold): + self.assertEqual(local.utcoffset(), offset) + + def test_rule_order_changes_between_years(self): + # In 2021 the first Sunday in March follows the first Monday, + # whereas in 2022 it precedes it. The 2021 DST period continues + # until March 2022, overlapping the short 2022 period. + zi = self.zone_from_tzstr("STD0DST-1,M3.1.0/0,M3.1.1/0") + for utc in ( + datetime(2022, 1, 15, tzinfo=timezone.utc), + datetime(2022, 2, 1, tzinfo=timezone.utc), + datetime(2022, 3, 6, tzinfo=timezone.utc), + ): + with self.subTest(utc=utc): + local = utc.astimezone(zi) + self.assertEqual(local.utcoffset(), ONE_H) + self.assertEqual(local.fold, 0) + self.assertEqual(local.astimezone(timezone.utc), utc) + + def test_short_dst_period_fold(self): + # A short DST period can end before its initial gap has elapsed. + # Only local times that actually occur twice should have fold=1. + # Exercise short periods near and away from New Year. + for day, month, month_day in ((1, 1, 1), (100, 4, 10)): + for dst_hours, end in ((1, "1:30"), (-1, "-0:30")): + tzstr = f"STD0DST{-dst_hours},J{day}/0,J{day}/{end}" + zi = self.zone_from_tzstr(tzstr) + transition = datetime(2025, month, month_day, + tzinfo=timezone.utc) + for minute in (-1, 0, 29, 30, 40, 59, 60, 89, 90): + utc = transition + timedelta(minutes=minute) + in_dst = 0 <= minute < 30 + offset = timedelta(hours=dst_hours if in_dst else 0) + if dst_hours > 0: + fold = int(60 <= minute < 90) + else: + fold = int(in_dst) + with self.subTest(tzstr=tzstr, utc=utc): + local = utc.astimezone(zi) + self.assertEqual(local.fold, fold) + self.assertEqual(local.utcoffset(), offset) + self.assertEqual( + local.replace(tzinfo=None), + utc.replace(tzinfo=None) + offset, + ) + self.assertEqual(local.astimezone(timezone.utc), utc) + def test_extreme_tzstr(self): tzstrs = [ # Extreme offset hour diff --git a/Lib/zoneinfo/_zoneinfo.py b/Lib/zoneinfo/_zoneinfo.py index 48154775568b6d..841331732418b5 100644 --- a/Lib/zoneinfo/_zoneinfo.py +++ b/Lib/zoneinfo/_zoneinfo.py @@ -462,9 +462,36 @@ def transitions(self, year): end = self.end.year_to_epoch(year) return start, end + def _dst_period(self, year): + start, end = self.transitions(year) + start -= self.std.utcoff.total_seconds() + end -= self.dst.utcoff.total_seconds() + if end <= start: + end = self.end.year_to_epoch(year + 1) + end -= self.dst.utcoff.total_seconds() + return start, end + def _get_trans_info(self, ts, year, fold): """Get the information about the current transition - tti""" - start, end = self.transitions(year) + + # Even the largest parsed transition time (<168h), UTC offset + # (<25h), and fold (<50h) cannot reach 11 days from a year boundary. + year_start = (_post_epoch_days_before_year(year) + 1) * 86400 + if year_start + 11 * 86400 <= ts < year_start + 354 * 86400: + start, end = self.transitions(year) + # Each rule's day-of-year can move by up to seven days between + # years. Nearby rules can reverse order, allowing a previous + # year's southern season to overlap this year's northern one. + if abs(end - start - self.dst_diff) > 14 * 86400: + if fold == (self.dst_diff >= 0): + end -= self.dst_diff + else: + start += self.dst_diff + if start < end: + isdst = start <= ts < end + else: + isdst = not (end <= ts < start) + return self.dst if isdst else self.std # With fold = 0, the period (denominated in local time) with the # 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): # the fold and whether DST is positive or negative (rare), and it # turns out that this boils down to fold XOR is_positive. if fold == (self.dst_diff >= 0): - end -= self.dst_diff - else: - start += self.dst_diff - - if start < end: - isdst = start <= ts < end + ts -= self.std.utcoff.total_seconds() else: - isdst = not (end <= ts < start) - - return self.dst if isdst else self.std + ts -= self.dst.utcoff.total_seconds() + + # Transition times and UTC offsets can move a rule into an adjacent + # year. Taking the union also joins the touching periods used to + # represent permanent DST, without introducing a gap or fold. + # A southern season with a start shifted into the following January + # can still be active two rule years after its start year. + for rule_year in (year, year - 1, year + 1, year - 2): + start, end = self._dst_period(rule_year) + if start <= ts < end: + return self.dst + return self.std def _get_trans_info_fromutc(self, ts, year): - start, end = self.transitions(year) - start -= self.std.utcoff.total_seconds() - end -= self.dst.utcoff.total_seconds() - - if start < end: - isdst = start <= ts < end - else: - isdst = not (end <= ts < start) + year_start = (_post_epoch_days_before_year(year) + 1) * 86400 + if year_start + 11 * 86400 <= ts < year_start + 354 * 86400: + start, end = self.transitions(year) + start -= self.std.utcoff.total_seconds() + end -= self.dst.utcoff.total_seconds() + if abs(end - start) > 14 * 86400: + before = ts - abs(self.dst_diff) + if start < end: + isdst = start <= ts < end + wasdst = start <= before < end + else: + isdst = not (end <= ts < start) + wasdst = not (end <= before < start) + if self.dst_diff > 0: + fold = wasdst and not isdst + else: + fold = isdst and not wasdst + return self.dst if isdst else self.std, fold + + isdst = False + wasdst = False + before = ts - abs(self.dst_diff) + for rule_year in (year, year - 1, year + 1, year - 2): + start, end = self._dst_period(rule_year) + isdst |= start <= ts < end + wasdst |= start <= before < end + if isdst and wasdst: + break - # For positive DST, the ambiguous period is one dst_diff after the end - # of DST; for negative DST, the ambiguous period is one dst_diff before - # the start of DST. + # A fold requires an actual decrease in offset. A nominal transition + # at the join between permanent-DST periods does not create one. if self.dst_diff > 0: - ambig_start = end - ambig_end = end + self.dst_diff + fold = wasdst and not isdst else: - ambig_start = start - ambig_end = start - self.dst_diff - - fold = ambig_start <= ts < ambig_end + fold = isdst and not wasdst return (self.dst if isdst else self.std, fold) diff --git a/Misc/NEWS.d/next/Library/2026-10-19-12-00-00.gh-issue-159715.year-boundary.rst b/Misc/NEWS.d/next/Library/2026-10-19-12-00-00.gh-issue-159715.year-boundary.rst new file mode 100644 index 00000000000000..63be21ca64bcab --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-10-19-12-00-00.gh-issue-159715.year-boundary.rst @@ -0,0 +1,3 @@ +Fix :class:`zoneinfo.ZoneInfo` handling of TZ string transitions that fall in +an adjacent year, including permanent daylight saving time and folds near +New Year. diff --git a/Modules/_zoneinfo.c b/Modules/_zoneinfo.c index 46ab3499e00354..a11fde40050b7a 100644 --- a/Modules/_zoneinfo.c +++ b/Modules/_zoneinfo.c @@ -1481,14 +1481,29 @@ dayrule_new(int julian, int day, int hour, int minute, return 0; } -/* Calculate the start and end rules for a _tzrule in the given year. */ +/* Calculate a DST period starting in the given rule year, in UTC. */ static void -tzrule_transitions(_tzrule *rule, int year, int64_t *start, int64_t *end) +tzrule_dst_period(_tzrule *rule, int year, int64_t *start, int64_t *end) { assert(rule->start != NULL); assert(rule->end != NULL); + // ymd_to_ord assumes positive years. The Gregorian calendar repeats + // every 400 years, allowing year 1 lookups to use earlier rules too. + int64_t shift = 0; + if (year <= 0) { + year += 400; + shift = 146097LL * 86400; + } *start = rule->start->year_to_timestamp(rule->start, year); *end = rule->end->year_to_timestamp(rule->end, year); + *start -= rule->std.utcoff_seconds; + *end -= rule->dst.utcoff_seconds; + if (*end <= *start) { + *end = rule->end->year_to_timestamp(rule->end, year + 1); + *end -= rule->dst.utcoff_seconds; + } + *start -= shift; + *end -= shift; } /* 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) return &(rule->std); } - int64_t start, end; - uint8_t isdst; - - tzrule_transitions(rule, year, &start, &end); - // With fold = 0, the period (denominated in local time) with the smaller // offset starts at the end of the gap and ends at the end of the fold; // 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) // fold and whether DST is positive or negative (rare), and it turns out // that this boils down to fold XOR is_positive. if (fold == (rule->dst_diff >= 0)) { - end -= rule->dst_diff; - } - else { - start += rule->dst_diff; - } - - if (start < end) { - isdst = (ts >= start) && (ts < end); + ts -= rule->std.utcoff_seconds; } else { - isdst = (ts < end) || (ts >= start); + ts -= rule->dst.utcoff_seconds; } - if (isdst) { - return &(rule->dst); - } - else { - return &(rule->std); + // Transition times and UTC offsets can move a rule into an adjacent + // year. The union joins touching permanent-DST periods without a gap + // or fold. + // A southern season shifted into the following January can still be + // active two rule years after its start year. + int years[] = {year, year - 1, year + 1, year - 2}; + for (size_t i = 0; i < 4; i++) { + int64_t start, end; + tzrule_dst_period(rule, years[i], &start, &end); + if (start <= ts && ts < end) { + return &(rule->dst); + } } + return &(rule->std); } /* 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, return &(rule->std); } - int64_t start, end; - uint8_t isdst; - tzrule_transitions(rule, year, &start, &end); - start -= rule->std.utcoff_seconds; - end -= rule->dst.utcoff_seconds; - - if (start < end) { - isdst = (ts >= start) && (ts < end); - } - else { - isdst = (ts < end) || (ts >= start); - } - - // For positive DST, the ambiguous period is one dst_diff after the end of - // DST; for negative DST, the ambiguous period is one dst_diff before the - // start of DST. - int64_t ambig_start, ambig_end; - if (rule->dst_diff > 0) { - ambig_start = end; - ambig_end = end + rule->dst_diff; - } - else { - ambig_start = start; - ambig_end = start - rule->dst_diff; + uint8_t isdst = 0; + uint8_t wasdst = 0; + int64_t before = ts - llabs(rule->dst_diff); + int years[] = {year, year - 1, year + 1, year - 2}; + for (size_t i = 0; i < 4; i++) { + int64_t start, end; + tzrule_dst_period(rule, years[i], &start, &end); + isdst |= (start <= ts && ts < end); + wasdst |= (start <= before && before < end); + if (isdst && wasdst) { + break; + } } - *fold = (ts >= ambig_start) && (ts < ambig_end); + // A nominal transition between permanent-DST periods does not create + // a fold: the offset must actually decrease. + *fold = rule->dst_diff > 0 ? (wasdst && !isdst) : (isdst && !wasdst); if (isdst) { return &(rule->dst); From bd5ef4d37e6ed3d9612ae44386972648a60ca9df Mon Sep 17 00:00:00 2001 From: Paul Ganssle Date: Mon, 19 Oct 2026 14:36:10 -0400 Subject: [PATCH 2/5] gh-159715: Explain year-boundary cases and lookup margins --- Lib/test/test_zoneinfo/test_zoneinfo.py | 15 ++++++++++++++ Lib/zoneinfo/_zoneinfo.py | 27 +++++++++++++++++-------- 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/Lib/test/test_zoneinfo/test_zoneinfo.py b/Lib/test/test_zoneinfo/test_zoneinfo.py index c5eaa2bfc6a240..43cb9518d29c42 100644 --- a/Lib/test/test_zoneinfo/test_zoneinfo.py +++ b/Lib/test/test_zoneinfo/test_zoneinfo.py @@ -1112,20 +1112,35 @@ def test_transitions_in_adjacent_year(self): # rule evaluation. Cover starts and ends, either sign of DST, and # rules displaced by both UTC offsets and extended transition hours. cases = ( + # Positive DST starts on January 1 locally, but December 31 UTC. ("STD-2DST-3,J1/0,J200/0", datetime(2100, 12, 31, 22), 2, 3), + # Positive DST starts in January from the previous year's J365. ("STD2DST1,J365/26,J200/0", datetime(2101, 1, 1, 4), -2, -1), + # Negative DST starts on January 1 locally, but December 31 UTC. ("STD-2DST-1,J1/0,J200/0", datetime(2100, 12, 31, 22), 2, 1), + # Negative DST starts in January from the previous year's J365. ("STD2DST3,J365/26,J200/0", datetime(2101, 1, 1, 4), -2, -3), + # Positive DST ends on January 1 locally, but December 31 UTC. ("STD-2DST-3,J200/0,J1/0", datetime(2100, 12, 31, 21), 3, 2), + # Positive DST ends in January from the previous year's J365. ("STD2DST1,J200/0,J365/26", datetime(2101, 1, 1, 3), -1, -2), + # Negative DST ends on January 1 locally, but December 31 UTC. ("STD-2DST-1,J200/0,J1/0", datetime(2100, 12, 31, 23), 1, 2), + # Negative DST ends in January from the previous year's J365. ("STD2DST3,J200/0,J365/26", datetime(2101, 1, 1, 5), -3, -2), + # The most negative transition hour moves J1 into December. ("STD0DST-1,J1/-167,J200/0", datetime(2099, 12, 25, 1), 0, 1), + # The largest transition hour moves J365 into January. ("STD0DST-1,J200/0,J365/167", datetime(2101, 1, 6, 22), 1, 0), + # A month/week rule from year 0 supplies a transition in year 1. ("STD0DST-1,M12.5.0/167,M6.1.0/0", datetime(1, 1, 6, 23), 0, 1), + # A year-10000 rule supplies a transition within datetime's range. ("STD0DST-1,J1/-167,J200/0", datetime(9999, 12, 25, 1), 0, 1), + # A season starting in rule year 2023 ends in January 2025. ("STD0DST-1,J365/167,J365/166", datetime(2025, 1, 6, 21), 1, 0), + # The next season starts two hours after the preceding one ends. ("STD0DST-1,J365/167,J365/166", datetime(2025, 1, 6, 23), 0, 1), + # The same two-rule-year lookback must work through year -1. ("STD0DST-1,J365/167,J365/166", datetime(1, 1, 6, 21), 1, 0), ) for tzstr, transition, old_hours, new_hours in cases: diff --git a/Lib/zoneinfo/_zoneinfo.py b/Lib/zoneinfo/_zoneinfo.py index 841331732418b5..d8b374ee6d5f5f 100644 --- a/Lib/zoneinfo/_zoneinfo.py +++ b/Lib/zoneinfo/_zoneinfo.py @@ -11,6 +11,17 @@ EPOCH = datetime(1970, 1, 1) EPOCHORDINAL = datetime(1970, 1, 1).toordinal() +_SECONDS_PER_DAY = 24 * 60 * 60 +# Parsed transition times are less than 168 hours and explicit UTC offsets +# less than 25 hours (26 with the default one-hour DST adjustment). Offset +# differences are less than 50 hours. Their sum is less than 244 hours, so +# an 11-day margin also covers looking back across a fold. +_TZSTR_YEAR_BOUNDARY_MARGIN = 11 * _SECONDS_PER_DAY +# Use the common-year length; in leap years this leaves an extra day of margin. +_TZSTR_FAST_PATH_END = 365 * _SECONDS_PER_DAY - _TZSTR_YEAR_BOUNDARY_MARGIN +# Each rule's day-of-year can vary by up to seven days between years. +_TZSTR_RULE_ORDER_MARGIN = 2 * 7 * _SECONDS_PER_DAY + # It is relatively expensive to construct new timedelta objects, and in most # cases we're looking at the same deltas, like integer numbers of hours, etc. # To improve speed and memory use, we'll keep a dictionary with references @@ -474,15 +485,14 @@ def _dst_period(self, year): def _get_trans_info(self, ts, year, fold): """Get the information about the current transition - tti""" - # Even the largest parsed transition time (<168h), UTC offset - # (<25h), and fold (<50h) cannot reach 11 days from a year boundary. - year_start = (_post_epoch_days_before_year(year) + 1) * 86400 - if year_start + 11 * 86400 <= ts < year_start + 354 * 86400: + year_start = (_post_epoch_days_before_year(year) + 1) * _SECONDS_PER_DAY + if (year_start + _TZSTR_YEAR_BOUNDARY_MARGIN <= ts + < year_start + _TZSTR_FAST_PATH_END): start, end = self.transitions(year) # Each rule's day-of-year can move by up to seven days between # years. Nearby rules can reverse order, allowing a previous # year's southern season to overlap this year's northern one. - if abs(end - start - self.dst_diff) > 14 * 86400: + if abs(end - start - self.dst_diff) > _TZSTR_RULE_ORDER_MARGIN: if fold == (self.dst_diff >= 0): end -= self.dst_diff else: @@ -518,12 +528,13 @@ def _get_trans_info(self, ts, year, fold): return self.std def _get_trans_info_fromutc(self, ts, year): - year_start = (_post_epoch_days_before_year(year) + 1) * 86400 - if year_start + 11 * 86400 <= ts < year_start + 354 * 86400: + year_start = (_post_epoch_days_before_year(year) + 1) * _SECONDS_PER_DAY + if (year_start + _TZSTR_YEAR_BOUNDARY_MARGIN <= ts + < year_start + _TZSTR_FAST_PATH_END): start, end = self.transitions(year) start -= self.std.utcoff.total_seconds() end -= self.dst.utcoff.total_seconds() - if abs(end - start) > 14 * 86400: + if abs(end - start) > _TZSTR_RULE_ORDER_MARGIN: before = ts - abs(self.dst_diff) if start < end: isdst = start <= ts < end From 1bcb2eb1d78e59485640c171b7315470bd53faf5 Mon Sep 17 00:00:00 2001 From: Paul Ganssle Date: Mon, 19 Oct 2026 14:52:07 -0400 Subject: [PATCH 3/5] gh-159715: Explain lookup margins where they are used --- Lib/zoneinfo/_zoneinfo.py | 37 +++++++++++++++++++------------------ 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/Lib/zoneinfo/_zoneinfo.py b/Lib/zoneinfo/_zoneinfo.py index d8b374ee6d5f5f..670cd421010790 100644 --- a/Lib/zoneinfo/_zoneinfo.py +++ b/Lib/zoneinfo/_zoneinfo.py @@ -12,15 +12,6 @@ EPOCHORDINAL = datetime(1970, 1, 1).toordinal() _SECONDS_PER_DAY = 24 * 60 * 60 -# Parsed transition times are less than 168 hours and explicit UTC offsets -# less than 25 hours (26 with the default one-hour DST adjustment). Offset -# differences are less than 50 hours. Their sum is less than 244 hours, so -# an 11-day margin also covers looking back across a fold. -_TZSTR_YEAR_BOUNDARY_MARGIN = 11 * _SECONDS_PER_DAY -# Use the common-year length; in leap years this leaves an extra day of margin. -_TZSTR_FAST_PATH_END = 365 * _SECONDS_PER_DAY - _TZSTR_YEAR_BOUNDARY_MARGIN -# Each rule's day-of-year can vary by up to seven days between years. -_TZSTR_RULE_ORDER_MARGIN = 2 * 7 * _SECONDS_PER_DAY # It is relatively expensive to construct new timedelta objects, and in most # cases we're looking at the same deltas, like integer numbers of hours, etc. @@ -486,13 +477,19 @@ def _get_trans_info(self, ts, year, fold): """Get the information about the current transition - tti""" year_start = (_post_epoch_days_before_year(year) + 1) * _SECONDS_PER_DAY - if (year_start + _TZSTR_YEAR_BOUNDARY_MARGIN <= ts - < year_start + _TZSTR_FAST_PATH_END): + # _parse_transition_time caps the hour field at 167; _parse_tz_delta + # caps it at 24, with one extra hour possible for default DST. + # Including minute/second fields and the offset difference across a + # fold, the displacement is less than 11 days. Use a common year's + # length here, leaving an extra day of margin in leap years. + if (year_start + 11 * _SECONDS_PER_DAY <= ts + < year_start + (365 - 11) * _SECONDS_PER_DAY): start, end = self.transitions(year) - # Each rule's day-of-year can move by up to seven days between - # years. Nearby rules can reverse order, allowing a previous - # year's southern season to overlap this year's northern one. - if abs(end - start - self.dst_diff) > _TZSTR_RULE_ORDER_MARGIN: + # An M rule selects a weekday within a week: its date can vary + # by six days, plus one day-of-year for leap years. Allow seven + # days for each rule. Nearby rules can reverse order, allowing + # a previous southern season to overlap this northern one. + if abs(end - start - self.dst_diff) > 2 * 7 * _SECONDS_PER_DAY: if fold == (self.dst_diff >= 0): end -= self.dst_diff else: @@ -529,12 +526,16 @@ def _get_trans_info(self, ts, year, fold): def _get_trans_info_fromutc(self, ts, year): year_start = (_post_epoch_days_before_year(year) + 1) * _SECONDS_PER_DAY - if (year_start + _TZSTR_YEAR_BOUNDARY_MARGIN <= ts - < year_start + _TZSTR_FAST_PATH_END): + # As in _get_trans_info, the parser limits and fold lookback fit + # within 11 days. Using 365 leaves an extra margin in leap years. + if (year_start + 11 * _SECONDS_PER_DAY <= ts + < year_start + (365 - 11) * _SECONDS_PER_DAY): start, end = self.transitions(year) start -= self.std.utcoff.total_seconds() end -= self.dst.utcoff.total_seconds() - if abs(end - start) > _TZSTR_RULE_ORDER_MARGIN: + # Each M rule can move six calendar days plus a leap day; + # allow seven days for each rule to keep their order stable. + if abs(end - start) > 2 * 7 * _SECONDS_PER_DAY: before = ts - abs(self.dst_diff) if start < end: isdst = start <= ts < end From b9374b49c7d442de55d9779c180ed72f88a57aa5 Mon Sep 17 00:00:00 2001 From: Paul Ganssle Date: Mon, 19 Oct 2026 16:08:06 -0400 Subject: [PATCH 4/5] gh-159715: Cite the specification for transition hour limits --- Lib/zoneinfo/_zoneinfo.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/Lib/zoneinfo/_zoneinfo.py b/Lib/zoneinfo/_zoneinfo.py index 670cd421010790..482e729958d4ec 100644 --- a/Lib/zoneinfo/_zoneinfo.py +++ b/Lib/zoneinfo/_zoneinfo.py @@ -477,8 +477,11 @@ def _get_trans_info(self, ts, year, fold): """Get the information about the current transition - tti""" year_start = (_post_epoch_days_before_year(year) + 1) * _SECONDS_PER_DAY - # _parse_transition_time caps the hour field at 167; _parse_tz_delta - # caps it at 24, with one extra hour possible for default DST. + # RFC 9636, section 3.3.2 specifies transition hours from -167 to 167 + # for TZif v3+; _parse_transition_time enforces this limit. + # https://www.rfc-editor.org/rfc/rfc9636.html#section-3.3.2 + # _parse_tz_delta caps offset hours at 24 (the POSIX TZ limit), + # with one extra hour possible for default DST. # Including minute/second fields and the offset difference across a # fold, the displacement is less than 11 days. Use a common year's # length here, leaving an extra day of margin in leap years. From 5b2ce220a8b4a525d8d78d2caf4139baa844aa5d Mon Sep 17 00:00:00 2001 From: Paul Ganssle Date: Mon, 19 Oct 2026 13:10:58 -0400 Subject: [PATCH 5/5] gh-159712: Fix zero-based day rules in zoneinfo Numeric TZ day rules count January 1 as day zero, but both implementations were using the same year baseline as one-based Julian rules. Account for the difference so numeric rules no longer transition one day early. Add local-time and UTC conversion cases for numeric rules, including gaps, folds, leap days and the first and last days of the year. --- Lib/test/test_zoneinfo/test_zoneinfo.py | 23 +++++++++++++++++++ Lib/zoneinfo/_zoneinfo.py | 3 +++ ...-00-00.gh-issue-159712.zero-based-days.rst | 2 ++ Modules/_zoneinfo.c | 4 ++++ 4 files changed, 32 insertions(+) create mode 100644 Misc/NEWS.d/next/Library/2026-10-19-12-00-00.gh-issue-159712.zero-based-days.rst diff --git a/Lib/test/test_zoneinfo/test_zoneinfo.py b/Lib/test/test_zoneinfo/test_zoneinfo.py index 43cb9518d29c42..8e1cd39105c49c 100644 --- a/Lib/test/test_zoneinfo/test_zoneinfo.py +++ b/Lib/test/test_zoneinfo/test_zoneinfo.py @@ -1673,6 +1673,29 @@ def _add(): (datetime(2012, 11, 4, 2, 15, 45), AAA, NORMAL), ) + @call + def _add(): + # Numeric day rules count from January 1, including leap days. + STD = ZoneOffset("STD", ZERO) + DST = ZoneOffset("DST", ONE_H, ONE_H) + + for day in (0, 10, 59, 60, 364, 365): + tzstr = f"STD0DST,{day}/2,{day}/14" + day_cases = [] + for year in (2019, 2020): + date = datetime(year, 1, 1) + timedelta(days=day) + day_cases.extend(( + (date.replace(hour=1, minute=59), STD, NORMAL), + (date.replace(hour=2, fold=0), STD, GAP), + (date.replace(hour=2, fold=1), DST, GAP), + (date.replace(hour=3), DST, NORMAL), + (date.replace(hour=12, minute=59), DST, NORMAL), + (date.replace(hour=13, fold=0), DST, FOLD), + (date.replace(hour=13, fold=1), STD, FOLD), + (date.replace(hour=14), STD, NORMAL), + )) + cases[tzstr] = day_cases + cls.test_cases = cases diff --git a/Lib/zoneinfo/_zoneinfo.py b/Lib/zoneinfo/_zoneinfo.py index 482e729958d4ec..bf896e92f9152c 100644 --- a/Lib/zoneinfo/_zoneinfo.py +++ b/Lib/zoneinfo/_zoneinfo.py @@ -598,6 +598,9 @@ def year_to_epoch(self, year): d = self.d if self.julian and d >= 59 and calendar.isleap(year): d += 1 + elif not self.julian: + # Numeric day rules are zero-based, unlike Julian day rules. + d += 1 epoch = (days_before_year + d) * 86400 epoch += self.hour * 3600 + self.minute * 60 + self.second diff --git a/Misc/NEWS.d/next/Library/2026-10-19-12-00-00.gh-issue-159712.zero-based-days.rst b/Misc/NEWS.d/next/Library/2026-10-19-12-00-00.gh-issue-159712.zero-based-days.rst new file mode 100644 index 00000000000000..0e8cd078f86700 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-10-19-12-00-00.gh-issue-159712.zero-based-days.rst @@ -0,0 +1,2 @@ +Fix :class:`zoneinfo.ZoneInfo` evaluating numeric day-of-year rules in TZ +strings one day early. diff --git a/Modules/_zoneinfo.c b/Modules/_zoneinfo.c index a11fde40050b7a..7158f51a0cdd2b 100644 --- a/Modules/_zoneinfo.c +++ b/Modules/_zoneinfo.c @@ -1439,6 +1439,10 @@ dayrule_year_to_timestamp(TransitionRuleType *base_self, int year) if (self->julian && day >= 59 && is_leap_year(year)) { day += 1; } + else if (!self->julian) { + // Numeric day rules are zero-based, unlike Julian day rules. + day += 1; + } return (days_before_year + day) * 86400 + (int64_t)self->hour * 3600 + (int64_t)self->minute * 60 + self->second;