diff --git a/docs/docs/interval.md b/docs/docs/interval.md index fc70fb3c4..b310f5637 100644 --- a/docs/docs/interval.md +++ b/docs/docs/interval.md @@ -138,7 +138,9 @@ If you want to iterate over a interval, you can use the `range()` method: Supported units for `range()` are: `years`, `months`, `weeks`, `days`, `hours`, `minutes`, `seconds` and `microseconds` -You can pass an amount for the passed unit to control the length of the gap: +You can pass a positive amount for the passed unit to control the length of the gap. +Zero or negative amounts raise `ValueError` when iteration starts. Inverted intervals +already iterate backwards, so their amount must also be positive: ```python >>> for dt in interval.range('days', 2): diff --git a/src/pendulum/interval.py b/src/pendulum/interval.py index c5e0713a7..ed66e7177 100644 --- a/src/pendulum/interval.py +++ b/src/pendulum/interval.py @@ -288,6 +288,9 @@ def in_words(self, locale: str | None = None, separator: str = " ") -> str: return separator.join(parts) def range(self, unit: str, amount: int = 1) -> Iterator[_T]: + if amount <= 0: + raise ValueError("amount must be positive") + method = "add" op = operator.le if not self._absolute and self.invert: diff --git a/tests/interval/test_range_amount_regression.py b/tests/interval/test_range_amount_regression.py new file mode 100644 index 000000000..21fe23ff0 --- /dev/null +++ b/tests/interval/test_range_amount_regression.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +import pendulum + + +if TYPE_CHECKING: + from collections.abc import Callable + + +@pytest.mark.parametrize("factory", [pendulum.date, pendulum.datetime]) +@pytest.mark.parametrize("reverse", [False, True]) +@pytest.mark.parametrize("absolute", [False, True]) +@pytest.mark.parametrize("amount", [0, -1, -2]) +def test_range_rejects_nonpositive_amount( + factory: Callable[[int, int, int], pendulum.Date], + reverse: bool, + absolute: bool, + amount: int, +) -> None: + start = factory(2024, 1, 10) + end = factory(2024, 1, 14) + if reverse: + start, end = end, start + interval = pendulum.Interval(start, end, absolute=absolute) + with pytest.raises(ValueError, match="amount must be positive"): + next(interval.range("days", amount)) + + +@pytest.mark.parametrize("amount", [0, -1]) +def test_single_point_range_rejects_nonpositive_amount(amount: int) -> None: + point = pendulum.date(2024, 1, 10) + interval = pendulum.Interval(point, point) + with pytest.raises(ValueError, match="amount must be positive"): + next(interval.range("days", amount)) + + +@pytest.mark.parametrize("factory", [pendulum.date, pendulum.datetime]) +@pytest.mark.parametrize("reverse", [False, True]) +@pytest.mark.parametrize("amount", [1, 2, 3, 5]) +def test_positive_range_amount_uses_interval_direction( + factory: Callable[[int, int, int], pendulum.Date], reverse: bool, amount: int +) -> None: + start = factory(2024, 1, 10) + end = factory(2024, 1, 14) + if reverse: + start, end = end, start + interval = pendulum.Interval(start, end) + direction = -1 if reverse else 1 + expected = [ + factory(2024, 1, day) + for day in range(start.day, end.day + direction, direction * amount) + ] + assert list(interval.range("days", amount)) == expected