From 9f060d071c8de352f636d005d07607eebaaf34c1 Mon Sep 17 00:00:00 2001 From: Abdu Ahmed Date: Mon, 25 May 2026 13:06:16 +0300 Subject: [PATCH] Fixes #1162, Fix DST fall-back repeated hour skipped in Arrow.range() When arrow.get() was called with an offset-aware string and a named tzinfo kwarg (e.g. arrow.get('2021-11-07T01:00:00-05:00', tzinfo='US/Eastern')), the tzinfo was replaced rather than used for conversion. This caused the UTC moment to change and the fold to be lost, so Arrow.range() would skip the repeated hour during DST fall-back. Fix 1 (factory.py): When a string with offset info is parsed and a tzinfo kwarg is provided, convert using .to() instead of replacing, preserving the original UTC moment. Fix 2 (arrow.py): In fromdatetime(), when replacing tzinfo, infer the correct fold by comparing the original UTC offset against what fold=1 produces in the new timezone. --- arrow/arrow.py | 24 ++++++++++++++++++++++-- arrow/factory.py | 5 +++++ tests/test_arrow.py | 15 +++++++++++++++ 3 files changed, 42 insertions(+), 2 deletions(-) diff --git a/arrow/arrow.py b/arrow/arrow.py index eecf2326..d6168624 100644 --- a/arrow/arrow.py +++ b/arrow/arrow.py @@ -321,7 +321,27 @@ def fromdatetime(cls, dt: dt_datetime, tzinfo: Optional[TZ_EXPR] = None) -> "Arr tzinfo = timezone.utc else: tzinfo = dt.tzinfo - + fold = getattr(dt, "fold", 0) + else: + # When replacing tzinfo, infer the correct fold by comparing + # the original UTC offset to what fold=1 produces in the new tz. + # This correctly handles ambiguous times (e.g. DST fall-back) issue #1162. + fold = getattr(dt, "fold", 0) + if dt.tzinfo is not None and dt.utcoffset() is not None: + resolved = cls._get_tzinfo(tzinfo) + dt_fold1 = dt_datetime( + dt.year, + dt.month, + dt.day, + dt.hour, + dt.minute, + dt.second, + dt.microsecond, + tzinfo=resolved, + fold=1, + ) + if dt_fold1.utcoffset() == dt.utcoffset(): + fold = 1 return cls( dt.year, dt.month, @@ -331,7 +351,7 @@ def fromdatetime(cls, dt: dt_datetime, tzinfo: Optional[TZ_EXPR] = None) -> "Arr dt.second, dt.microsecond, tzinfo, - fold=getattr(dt, "fold", 0), + fold=fold, ) @classmethod diff --git a/arrow/factory.py b/arrow/factory.py index 0913bfe1..57b90243 100644 --- a/arrow/factory.py +++ b/arrow/factory.py @@ -245,6 +245,11 @@ def get(self, *args: Any, **kwargs: Any) -> Arrow: # (str) -> parse @ tzinfo elif isinstance(arg, str): dt = parser.DateTimeParser(locale).parse_iso(arg, normalize_whitespace) + if tz is not None and dt.tzinfo is not None: + # If the parsed string already has offset info, convert to + # the target timezone (preserving the UTC moment) rather than + # replacing the tzinfo, so ambiguous times are resolved correctly issue #1162. + return self.type.fromdatetime(dt).to(tz) return self.type.fromdatetime(dt, tzinfo=tz) # (struct_time) -> from struct_time diff --git a/tests/test_arrow.py b/tests/test_arrow.py index b595e4e2..6b29c948 100644 --- a/tests/test_arrow.py +++ b/tests/test_arrow.py @@ -1141,6 +1141,21 @@ def test_range_over_year_maintains_end_date_across_leap_year(self): arrow.Arrow(2016, 2, 29), ] + def test_range_dst_fall_back_includes_repeated_hour(self): + # Issue #1162: When iterating over a DST fall-back period, the repeated + # hour should not be skipped. A string with an explicit UTC offset and a + # named tzinfo kwarg should be converted (not replaced), preserving the + # correct UTC moment and fold. + import arrow as arrow_module + + dts = arrow_module.get("2021-11-07T01:00:00-05:00", tzinfo="US/Eastern") + dte = arrow_module.get("2021-11-07T02:00:00-06:00", tzinfo="US/Eastern") + result = list(arrow.Arrow.range("hours", dts, dte)) + assert len(result) == 3 + assert result[0].utcoffset().total_seconds() == -5 * 3600 + assert result[1].utcoffset().total_seconds() == -5 * 3600 + assert result[2].utcoffset().total_seconds() == -5 * 3600 + class TestArrowSpanRange: def test_year(self):