From 6afbeceb940664d3e40469d9b5742cd34ca5400f Mon Sep 17 00:00:00 2001 From: Saad Abdullah Date: Sat, 17 Oct 2026 10:16:44 +0000 Subject: [PATCH] fix(tz): reset fold to 0 for unambiguous local times DateTime.create() resolves new instances with fold=1 so that ambiguous times default to their second occurrence, but Timezone.convert() kept that fold value for every local time. As a result, ordinary datetimes reported fold=1, and the value changed inconsistently depending on the operation performed (minute additions reset it, day additions kept it). Timezone.convert() now sets fold to 0 when the converted local time is not ambiguous, per PEP 495. DateTime.set() only carries the current fold over to the new time when the current time is actually ambiguous, so fluent setters keep resolving DST gaps and overlaps with the default rules otherwise. --- src/pendulum/datetime.py | 13 +++++++++++- src/pendulum/tz/timezone.py | 11 +++++++++- tests/datetime/test_fluent_setters.py | 9 +++++++++ tests/tz/test_timezone.py | 29 +++++++++++++++++++++++++++ 4 files changed, 60 insertions(+), 2 deletions(-) diff --git a/src/pendulum/datetime.py b/src/pendulum/datetime.py index 96aff25b2..c0b167e66 100644 --- a/src/pendulum/datetime.py +++ b/src/pendulum/datetime.py @@ -228,8 +228,19 @@ def set( if tz is None: tz = self.tz + fold = self.fold + if ( + self.tz is not None + and self.replace(fold=0).utcoffset() == self.replace(fold=1).utcoffset() + ): + # The current time is not ambiguous, so its fold attribute + # carries no meaning and must not decide how the new time + # is resolved if it falls in a DST gap or overlap. + # Use the default resolution of create() instead. + fold = 1 + return self.__class__.create( - year, month, day, hour, minute, second, microsecond, tz=tz, fold=self.fold + year, month, day, hour, minute, second, microsecond, tz=tz, fold=fold ) @property diff --git a/src/pendulum/tz/timezone.py b/src/pendulum/tz/timezone.py index 28686e2d3..603d852bf 100644 --- a/src/pendulum/tz/timezone.py +++ b/src/pendulum/tz/timezone.py @@ -169,7 +169,16 @@ def convert(self, dt: _DT, raise_on_unknown_times: bool = False) -> _DT: # Repeated time raise AmbiguousTime(dt) - return dt.replace(tzinfo=self) + dt = dt.replace(tzinfo=self) + + if self.utcoffset(dt.replace(fold=0)) == self.utcoffset(dt.replace(fold=1)): + # The local time is not ambiguous, so the fold attribute + # carries no meaning for it and must be set to 0 (PEP 495). + # Without this, times created with the default fold=1, + # e.g. via DateTime.create(), keep a bogus fold value. + dt = dt.replace(fold=0) + + return dt return cast("_DT", dt.astimezone(self)) diff --git a/tests/datetime/test_fluent_setters.py b/tests/datetime/test_fluent_setters.py index cedbd2694..4ad46f1ad 100644 --- a/tests/datetime/test_fluent_setters.py +++ b/tests/datetime/test_fluent_setters.py @@ -141,6 +141,15 @@ def test_fluid_at_with_transition(): assert new.second == 0 +def test_set_keeps_fold_of_ambiguous_time(): + d = pendulum.datetime(2023, 11, 5, 1, 30, tz="America/New_York", fold=0) + new = d.set(minute=45) + + assert isinstance(new, datetime) + assert new.fold == 0 + assert new.offset == -4 * 3600 + + def test_replace_tzinfo_dst_off(): d = pendulum.datetime(2016, 3, 27, 0, 30) # 30 min before DST turning on new = d.replace(tzinfo=pendulum.timezone("Europe/Paris")) diff --git a/tests/tz/test_timezone.py b/tests/tz/test_timezone.py index 6ce475d36..9479f2093 100644 --- a/tests/tz/test_timezone.py +++ b/tests/tz/test_timezone.py @@ -330,6 +330,35 @@ def test_convert_fold_attribute_is_honored(): assert new.strftime("%z") == "-0500" +def test_convert_unambiguous_time_has_fold_zero(): + tz = pendulum.timezone("CET") + dt = datetime(2024, 5, 1) + + new = tz.convert(dt) + assert new.fold == 0 + + new = tz.convert(dt.replace(fold=1)) + assert new.fold == 0 + + +def test_datetime_unambiguous_time_has_fold_zero(): + dt = pendulum.datetime(2024, 5, 1, tz="CET") + + assert dt.fold == 0 + assert dt.add(minutes=1).fold == 0 + assert dt.add(days=1).fold == 0 + + +def test_datetime_ambiguous_time_keeps_fold(): + dt = pendulum.datetime(2023, 11, 5, 1, 30, tz="America/New_York") + + assert dt.fold == 1 + + dt = pendulum.datetime(2023, 11, 5, 1, 30, tz="America/New_York", fold=0) + + assert dt.fold == 0 + + def test_utcoffset_fold_attribute_is_honored(): tz = pendulum.timezone("US/Eastern") dt = datetime(2014, 11, 2, 1, 30)