From 8882e94220ae6b5d729fd8263cd68a408ae5ff79 Mon Sep 17 00:00:00 2001 From: fan wu <336798967+FanWu-ai@users.noreply.github.com> Date: Wed, 21 Oct 2026 06:33:50 +0900 Subject: [PATCH] Include dates inside inverted intervals --- docs/docs/interval.md | 3 + src/pendulum/interval.py | 5 +- tests/interval/test_membership_regression.py | 61 ++++++++++++++++++++ 3 files changed, 68 insertions(+), 1 deletion(-) create mode 100644 tests/interval/test_membership_regression.py diff --git a/docs/docs/interval.md b/docs/docs/interval.md index fc70fb3c4..1ff4347ce 100644 --- a/docs/docs/interval.md +++ b/docs/docs/interval.md @@ -166,3 +166,6 @@ You can check if a `DateTime` instance is inside a interval using the `in` keywo >>> dt in interval True ``` + +Membership includes both endpoints, including for inverted intervals. The order of +the endpoints controls iteration direction, not whether a date is inside the interval. diff --git a/src/pendulum/interval.py b/src/pendulum/interval.py index c5e0713a7..5574e8927 100644 --- a/src/pendulum/interval.py +++ b/src/pendulum/interval.py @@ -314,7 +314,10 @@ def __iter__(self) -> Iterator[_T]: return self.range("days") def __contains__(self, item: _T) -> bool: - return self.start <= item <= self.end + if self.start <= self.end: + return self.start <= item <= self.end + + return self.end <= item <= self.start def __add__(self, other: timedelta) -> Duration: # type: ignore[override] return self.as_duration().__add__(other) diff --git a/tests/interval/test_membership_regression.py b/tests/interval/test_membership_regression.py new file mode 100644 index 000000000..2d8d0987f --- /dev/null +++ b/tests/interval/test_membership_regression.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +from datetime import date +from datetime import datetime +from datetime import timezone +from functools import partial +from typing import TYPE_CHECKING + +import pytest + +import pendulum + + +if TYPE_CHECKING: + from collections.abc import Callable + + +@pytest.mark.parametrize( + "factory", + [date, partial(datetime, tzinfo=timezone.utc), pendulum.date, pendulum.datetime], +) +@pytest.mark.parametrize("reverse", [False, True]) +@pytest.mark.parametrize("absolute", [False, True]) +def test_interval_membership_includes_both_endpoints( + factory: Callable[[int, int, int], date], reverse: bool, absolute: bool +) -> None: + first = factory(2024, 1, 10) + last = factory(2024, 1, 12) + start, end = (last, first) if reverse else (first, last) + interval = pendulum.Interval(start, end, absolute=absolute) + + for day in (10, 11, 12): + assert factory(2024, 1, day) in interval + for day in (9, 13): + assert factory(2024, 1, day) not in interval + + assert interval.start == (first if absolute else start) + assert interval.end == (last if absolute else end) + + +@pytest.mark.parametrize( + "factory", + [date, partial(datetime, tzinfo=timezone.utc), pendulum.date, pendulum.datetime], +) +def test_single_point_interval_membership( + factory: Callable[[int, int, int], date], +) -> None: + point = factory(2024, 1, 10) + interval = pendulum.Interval(point, point) + assert point in interval + assert factory(2024, 1, 9) not in interval + assert factory(2024, 1, 11) not in interval + + +def test_inverted_interval_membership_with_different_offsets() -> None: + start = pendulum.datetime(2024, 1, 10, 16, tz="Europe/Paris") + end = pendulum.datetime(2024, 1, 10, 9, tz="America/New_York") + interval = pendulum.Interval(start, end) + assert pendulum.datetime(2024, 1, 10, 14, 30, tz="UTC") in interval + assert pendulum.datetime(2024, 1, 10, 13, 59, tz="UTC") not in interval + assert pendulum.datetime(2024, 1, 10, 15, 1, tz="UTC") not in interval