From f5330069d28e3610ee46571018fa03448ca879a1 Mon Sep 17 00:00:00 2001 From: youdie006 Date: Tue, 18 Aug 2026 15:19:05 +0900 Subject: [PATCH] Fix start_of("day") landing on the previous day when a zone springs forward at midnight _start_of_day resets to midnight via self.at(0, 0, 0, 0), which propagates the receiver's fold. On a timezone that springs forward exactly at midnight (e.g. Chile/Continental, where 2025-09-07 00:00:00 does not exist), reaching that day through .add(days=1) yields fold=0. For a nonexistent (skipped) local time with fold=0, the timezone resolver shifts the instant backward, so midnight lands at 23:00 on the previous day and start_of("day") is wrong and non-idempotent. After resetting to midnight, if the calendar day changed then midnight was skipped, so re-create the day's first valid instant resolving forward (fold=1). The guard fires only when midnight actually moved to the previous day, so all other zones/dates are byte-identical (verified against UTC, America/New_York, Asia/Tokyo, Europe/London, Europe/Vienna's 02:00 gap, and America/Sao_Paulo's midnight fall-back). start_of("week") is covered via delegation. Fixes #915. --- src/pendulum/datetime.py | 14 +++++++++++++- tests/datetime/test_start_end_of.py | 15 +++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/pendulum/datetime.py b/src/pendulum/datetime.py index da89b13d..cd15f5b7 100644 --- a/src/pendulum/datetime.py +++ b/src/pendulum/datetime.py @@ -826,7 +826,19 @@ def _start_of_day(self) -> Self: """ Reset the time to 00:00:00. """ - return self.at(0, 0, 0, 0) + dt = self.at(0, 0, 0, 0) + + # In zones that spring forward exactly at midnight, 00:00:00 does not + # exist. When ``fold`` is 0, resolving that nonexistent time shifts it + # backward into the previous day, which is not the start of this day. + # Detect that the day changed and rebuild the first valid instant of + # the day by resolving forward (fold=1) instead. + if dt.day != self.day: + dt = self.__class__.create( + self.year, self.month, self.day, 0, 0, 0, 0, tz=self.tz, fold=1 + ) + + return dt def _end_of_day(self) -> Self: """ diff --git a/tests/datetime/test_start_end_of.py b/tests/datetime/test_start_end_of.py index 1937e74d..5455d797 100644 --- a/tests/datetime/test_start_end_of.py +++ b/tests/datetime/test_start_end_of.py @@ -323,3 +323,18 @@ def test_end_of_on_date_after_transition(): assert d.end_of("day").offset == 3600 assert d.end_of("month").offset == 3600 assert d.end_of("year").offset == 3600 + + +def test_start_of_day_when_midnight_does_not_exist(): + # Chile/Continental springs forward exactly at midnight on 2025-09-07, + # so 00:00:00 does not exist that day. start_of("day") must resolve to the + # first valid instant of the day (01:00:00-03:00), not fall back into the + # previous day. See issue #915. + d = pendulum.datetime(2025, 9, 6, 0, 0, tz="Chile/Continental").add(days=1) + new = d.start_of("day") + + assert new.day == 7 + assert_datetime(new, 2025, 9, 7, 1, 0, 0, 0) + assert new.offset == -3 * 3600 + # start_of("day") must be idempotent even across the midnight gap. + assert new == new.start_of("day")