diff --git a/docs/docs/comparison.md b/docs/docs/comparison.md index 6be8d0da..7e06b0ee 100644 --- a/docs/docs/comparison.md +++ b/docs/docs/comparison.md @@ -75,3 +75,19 @@ True # Compares to now by default True ``` + +The `closest()` and `farthest()` methods also work with naive datetimes. +When the reference datetime is naive, naive candidates remain naive: + +```python +>>> reference = pendulum.naive(2026, 10, 20, 12) +>>> before = pendulum.naive(2026, 10, 20, 11) +>>> after = pendulum.naive(2026, 10, 20, 14) +>>> reference.closest(before, after) == before +True +>>> reference.farthest(before, after) == after +True +``` + +As with datetime subtraction, a naive reference cannot be compared with an aware +candidate. For an aware reference, naive candidates continue to use UTC. diff --git a/src/pendulum/datetime.py b/src/pendulum/datetime.py index 96aff25b..45403c4c 100644 --- a/src/pendulum/datetime.py +++ b/src/pendulum/datetime.py @@ -499,7 +499,8 @@ def closest(self, *dts: datetime.datetime) -> Self: # type: ignore[override] """ Get the closest date to the instance. """ - pdts = [self.instance(x) for x in dts] + tz = UTC if self.tzinfo is not None else None + pdts = [self.instance(x, tz=tz) for x in dts] return min((abs(self - dt), dt) for dt in pdts)[1] @@ -507,7 +508,8 @@ def farthest(self, *dts: datetime.datetime) -> Self: # type: ignore[override] """ Get the farthest date from the instance. """ - pdts = [self.instance(x) for x in dts] + tz = UTC if self.tzinfo is not None else None + pdts = [self.instance(x, tz=tz) for x in dts] return max((abs(self - dt), dt) for dt in pdts)[1] diff --git a/tests/datetime/test_comparison.py b/tests/datetime/test_comparison.py index 4e25f0d6..a02ed040 100644 --- a/tests/datetime/test_comparison.py +++ b/tests/datetime/test_comparison.py @@ -4,6 +4,8 @@ from datetime import datetime +import pytest + import pendulum from tests.conftest import assert_datetime @@ -374,6 +376,106 @@ def test_farthest_with_equals(): assert farthest == dts[-1] +@pytest.mark.parametrize("method", ["closest", "farthest"]) +@pytest.mark.parametrize("native", [False, True]) +@pytest.mark.parametrize("reverse", [False, True]) +def test_closest_and_farthest_with_naive_datetimes(method, native, reverse): + instance = pendulum.naive(2026, 10, 20, 12, 34, 56, 123456) + near = instance.subtract(microseconds=1) + far = instance.add(microseconds=2) + candidates = [near, far] + if native: + candidates = [ + datetime( + dt.year, + dt.month, + dt.day, + dt.hour, + dt.minute, + dt.second, + dt.microsecond, + ) + for dt in candidates + ] + if reverse: + candidates.reverse() + + result = getattr(instance, method)(*candidates) + + assert result == (near if method == "closest" else far) + assert type(result) is pendulum.DateTime + assert result.tzinfo is None + + +@pytest.mark.parametrize("method", ["closest", "farthest"]) +def test_closest_and_farthest_with_naive_single_candidate(method): + instance = pendulum.naive(2026, 10, 20) + + assert getattr(instance, method)(instance) == instance + + +@pytest.mark.parametrize("method", ["closest", "farthest"]) +@pytest.mark.parametrize("reverse", [False, True]) +@pytest.mark.parametrize("tz", [None, "UTC"]) +def test_closest_and_farthest_with_ties(method, reverse, tz): + instance = pendulum.datetime(2026, 10, 20, 12, tz=tz) + before = instance.subtract(hours=1) + after = instance.add(hours=1) + candidates = [before, after] + if reverse: + candidates.reverse() + + result = getattr(instance, method)(*candidates) + + assert result == candidates[0] + assert result.tzinfo == instance.tzinfo + + +@pytest.mark.parametrize("method", ["closest", "farthest"]) +def test_closest_and_farthest_with_naive_subclass(method): + class CustomDateTime(pendulum.DateTime): + pass + + instance = CustomDateTime(2026, 10, 20, 12) + candidates = [datetime(2026, 10, 20, 11), datetime(2026, 10, 20, 14)] + + result = getattr(instance, method)(*candidates) + + assert type(result) is CustomDateTime + assert result.hour == (11 if method == "closest" else 14) + assert result.tzinfo is None + + +@pytest.mark.parametrize("method", ["closest", "farthest"]) +@pytest.mark.parametrize("tz", [None, "UTC", "Europe/Paris"]) +def test_closest_and_farthest_without_candidates(method, tz): + instance = pendulum.datetime(2026, 10, 20, tz=tz) + + with pytest.raises(ValueError): + getattr(instance, method)() + + +@pytest.mark.parametrize("method", ["closest", "farthest"]) +def test_closest_and_farthest_with_naive_reference_and_aware_candidate(method): + instance = pendulum.naive(2026, 10, 20) + candidate = pendulum.datetime(2026, 10, 21) + + with pytest.raises(TypeError, match="offset-naive and offset-aware"): + getattr(instance, method)(candidate) + + +@pytest.mark.parametrize("method", ["closest", "farthest"]) +def test_closest_and_farthest_aware_reference_keeps_naive_candidate_default(method): + instance = pendulum.datetime(2026, 10, 20, 12, tz="Europe/Paris") + naive = datetime(2026, 10, 20, 10) + aware = pendulum.datetime(2026, 10, 20, 13, tz="Europe/Paris") + + result = getattr(instance, method)(naive, aware) + + assert result == (pendulum.instance(naive) if method == "closest" else aware) + assert result.timezone_name == ("UTC" if method == "closest" else "Europe/Paris") + + def test_is_same_day(): dt1 = pendulum.datetime(2015, 5, 28, 12, 0, 0) dt2 = pendulum.datetime(2015, 5, 29, 12, 0, 0)