From 0a02d2311fd3ae2956aaf4b8126f757d4317786f Mon Sep 17 00:00:00 2001 From: Deepak Ganesh Date: Sat, 1 Aug 2026 22:25:33 +0530 Subject: [PATCH] fix: ensure contiguous spans in span_range with exact=True and month frame MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When span_range iterates with frame='month' and exact=True, months with fewer days cause day clipping in range() (e.g. Jan 31 -> Feb 28). The subsequent span(exact=True) computes the ceiling from the clipped date, but range() restores the original day for the next iteration point, leaving a multi-day gap between consecutive spans. Fix: track the previous span's ceiling and use it (+1 µs) as the floor of the next span whenever the computed floor would leave a gap. Fixes #1185 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- arrow/arrow.py | 9 +++++++++ tests/test_arrow.py | 21 +++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/arrow/arrow.py b/arrow/arrow.py index eecf23266..eeac0f73d 100644 --- a/arrow/arrow.py +++ b/arrow/arrow.py @@ -709,8 +709,16 @@ def span_range( for r in _range: yield r.span(frame, bounds=bounds, exact=exact) + prev_ceil = None for r in _range: floor, ceil = r.span(frame, bounds=bounds, exact=exact) + if prev_ceil is not None: + # Ensure consecutive spans are contiguous by using the + # previous span's ceiling (+1 microsecond) as the floor, + # preventing gaps caused by month-length day clipping. + expected_floor = prev_ceil.shift(microseconds=+1) + if floor > expected_floor: + floor = expected_floor if ceil > end: ceil = end if bounds[1] == ")": @@ -719,6 +727,7 @@ def span_range( break elif floor + relativedelta(microseconds=-1) == end: break + prev_ceil = ceil yield floor, ceil @classmethod diff --git a/tests/test_arrow.py b/tests/test_arrow.py index b595e4e21..bb858c879 100644 --- a/tests/test_arrow.py +++ b/tests/test_arrow.py @@ -1555,6 +1555,27 @@ def test_small_interval_exact_open_bounds(self): assert result == expected + def test_exact_month_no_gaps_when_day_clipped(self): + """Regression test for #1185: span_range with frame='month' and + exact=True must produce contiguous spans even when the start day + exceeds the number of days in a shorter month (day clipping).""" + result = list( + arrow.Arrow.span_range( + "month", + datetime(2023, 1, 31, tzinfo=ZoneInfo("Europe/Berlin")), + datetime(2023, 4, 1, tzinfo=ZoneInfo("Europe/Berlin")), + exact=True, + ) + ) + + # Spans must be contiguous: each floor == previous ceil + 1 µs + for i in range(1, len(result)): + prev_ceil = result[i - 1][1] + curr_floor = result[i][0] + assert curr_floor == prev_ceil.shift( + microseconds=+1 + ), f"Gap between span {i - 1} and {i}" + class TestArrowInterval: def test_incorrect_input(self):