diff --git a/Lib/test/test_zoneinfo/test_zoneinfo.py b/Lib/test/test_zoneinfo/test_zoneinfo.py index 41ba92342ff2cbd..020a7f4a1e47353 100644 --- a/Lib/test/test_zoneinfo/test_zoneinfo.py +++ b/Lib/test/test_zoneinfo/test_zoneinfo.py @@ -747,6 +747,39 @@ def test_tz_before_only(self): self.assertEqual(dt.utcoffset(), offset.utcoffset) self.assertEqual(dt.dst(), offset.dst) + def test_footer_fold_at_last_transition(self): + STD = ZoneOffset("STD", -2 * ONE_H) + transition = ZoneTransition(datetime(2023, 10, 28, 23), STD, STD) + earlier = ZoneTransition(datetime(2020, 1, 1), STD, STD) + cases = [ + datetime(2023, 10, 28, 22, 59, 59, 999999), + datetime(2023, 10, 28, 23), + datetime(2023, 10, 28, 23, 0, 0, 1), + datetime(2023, 10, 28, 23, 30), + datetime(2023, 10, 28, 23, 59, 59, 999999), + datetime(2023, 10, 29), + ] + + # As in America/Nuuk, the footer describes a fold that would start + # before the last explicit transition, while the table has no fold. + for transitions in ([transition], [earlier, transition]): + zf = self.construct_zone( + transitions, after="STD2DST,M3.5.0/-1,M10.5.0/0" + ) + zi = self.klass.from_file(zf) + for dt in cases: + for fold in (0, 1): + with self.subTest( + num_transitions=len(transitions), dt=dt, fold=fold + ): + local = dt.replace(tzinfo=zi, fold=fold) + self.assertEqual(local.utcoffset(), STD.utcoffset) + self.assertEqual(local.dst(), STD.dst) + self.assertEqual(local.tzname(), STD.tzname) + round_trip = local.astimezone(timezone.utc) + round_trip = round_trip.astimezone(zi) + self.assertEqual(round_trip.replace(tzinfo=None), dt) + def test_empty_zone(self): zf = self.construct_zone([], "") diff --git a/Lib/zoneinfo/_zoneinfo.py b/Lib/zoneinfo/_zoneinfo.py index 48154775568b6dc..14cbf3137ae47cf 100644 --- a/Lib/zoneinfo/_zoneinfo.py +++ b/Lib/zoneinfo/_zoneinfo.py @@ -176,7 +176,15 @@ def _find_trans(self, dt): return self._tti_before elif not num_trans or ts > lt[-1]: if isinstance(self._tz_after, _TZStr): - return self._tz_after.get_trans_info(ts, dt.year, dt.fold) + tti = self._tz_after.get_trans_info(ts, dt.year, dt.fold) + # Do not extrapolate recurring rules before the last + # explicit transition. + if ( + self._trans_utc + and ts - tti.utcoff.total_seconds() < self._trans_utc[-1] + ): + return self._ttinfos[-1] + return tti else: return self._tz_after else: diff --git a/Misc/NEWS.d/next/Library/2026-10-20-02-02-00.gh-issue-159708.S7h4cV.rst b/Misc/NEWS.d/next/Library/2026-10-20-02-02-00.gh-issue-159708.S7h4cV.rst new file mode 100644 index 000000000000000..4beef18cecd2393 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-10-20-02-02-00.gh-issue-159708.S7h4cV.rst @@ -0,0 +1,2 @@ +Fix :class:`zoneinfo.ZoneInfo` applying recurring TZ string rules to local +times whose UTC offset is still determined by the explicit transition table. diff --git a/Modules/_zoneinfo.c b/Modules/_zoneinfo.c index 46ab3499e003542..e726e2cfec45d8d 100644 --- a/Modules/_zoneinfo.c +++ b/Modules/_zoneinfo.c @@ -2244,8 +2244,15 @@ find_ttinfo(zoneinfo_state *state, PyZoneInfo_ZoneInfo *self, PyObject *dt) return self->ttinfo_before; } else if (!num_trans || ts > local_transitions[self->num_transitions - 1]) { - return find_tzrule_ttinfo(&(self->tzrule_after), ts, fold, - PyDateTime_GET_YEAR(dt)); + _ttinfo *tti = find_tzrule_ttinfo(&(self->tzrule_after), ts, fold, + PyDateTime_GET_YEAR(dt)); + // Do not extrapolate recurring rules before the last explicit + // transition. + if (num_trans && !self->tzrule_after.std_only && + ts - tti->utcoff_seconds < self->trans_list_utc[num_trans - 1]) { + return self->trans_ttinfos[num_trans - 1]; + } + return tti; } else { size_t idx = _bisect(ts, local_transitions, self->num_transitions) - 1;