diff --git a/src/pendulum/datetime.py b/src/pendulum/datetime.py index 96aff25b..c0b167e6 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 28686e2d..603d852b 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 cedbd269..4ad46f1a 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 6ce475d3..9479f209 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)