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):