diff --git a/Lib/test/test_zoneinfo/test_zoneinfo.py b/Lib/test/test_zoneinfo/test_zoneinfo.py index 41ba92342ff2cbd..8e1cd39105c49c9 100644 --- a/Lib/test/test_zoneinfo/test_zoneinfo.py +++ b/Lib/test/test_zoneinfo/test_zoneinfo.py @@ -1077,6 +1077,145 @@ 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 = ( + # 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: + 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 @@ -1534,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 48154775568b6dc..bf896e92f9152c3 100644 --- a/Lib/zoneinfo/_zoneinfo.py +++ b/Lib/zoneinfo/_zoneinfo.py @@ -11,6 +11,8 @@ EPOCH = datetime(1970, 1, 1) EPOCHORDINAL = datetime(1970, 1, 1).toordinal() +_SECONDS_PER_DAY = 24 * 60 * 60 + # 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 @@ -462,9 +464,44 @@ 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) + + year_start = (_post_epoch_days_before_year(year) + 1) * _SECONDS_PER_DAY + # 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. + if (year_start + 11 * _SECONDS_PER_DAY <= ts + < year_start + (365 - 11) * _SECONDS_PER_DAY): + start, end = self.transitions(year) + # 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: + 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 +512,62 @@ 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) * _SECONDS_PER_DAY + # 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() + # 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 + 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) @@ -537,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 000000000000000..0e8cd078f86700d --- /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/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 000000000000000..63be21ca64bcab9 --- /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 46ab3499e003542..7158f51a0cdd2b3 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; @@ -1481,14 +1485,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 +1525,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 +1534,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; + ts -= rule->std.utcoff_seconds; } else { - start += rule->dst_diff; + ts -= rule->dst.utcoff_seconds; } - if (start < end) { - isdst = (ts >= start) && (ts < end); - } - else { - isdst = (ts < end) || (ts >= start); - } - - 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 +1576,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);