From a6b17224f9e2246fd7c94ce4795081e7653431d2 Mon Sep 17 00:00:00 2001 From: fan wu <336798967+FanWu-ai@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:37:32 -0700 Subject: [PATCH] Include calendar components in duration quotient arithmetic --- docs/docs/duration.md | 23 ++++++++ src/pendulum/duration.py | 23 +++++--- tests/duration/test_arithmetic.py | 89 +++++++++++++++++++++++++++++++ tests/interval/test_arithmetic.py | 23 ++++++++ 4 files changed, 151 insertions(+), 7 deletions(-) diff --git a/docs/docs/duration.md b/docs/docs/duration.md index a657d9aef..1b6ca1f2c 100644 --- a/docs/docs/duration.md +++ b/docs/docs/duration.md @@ -175,3 +175,26 @@ It also has a handy `in_words()` method, which determines the duration represent >>> it.in_words(locale='de') '168 Wochen 1 Tag 2 Stunden 1 Minute 24 Sekunden' ``` + +## Division and remainders + +When dividing one duration by another, years and months use the same approximations +as `total_seconds()`: 365 days per year and 30 days per month. Floor division, +modulo and `divmod()` use these approximations too. Remainders are fixed-length +durations without year or month components. + +```python +>>> month = pendulum.duration(months=1) +>>> month / pendulum.duration(days=10) +3.0 +>>> month // pendulum.duration(days=7) +4 +>>> month % pendulum.duration(days=7) +Duration(days=2) +>>> divmod(month, pendulum.duration(days=7)) +(4, Duration(days=2)) +``` + +An `Interval` uses the elapsed time between its endpoints instead of approximating +its calendar components. For example, the interval from January 1 to February 1 +has 31 days, whereas `duration(months=1)` represents 30 days in these operations. diff --git a/src/pendulum/duration.py b/src/pendulum/duration.py index d6cc0657d..ba2a4c388 100644 --- a/src/pendulum/duration.py +++ b/src/pendulum/duration.py @@ -356,6 +356,11 @@ def __neg__(self) -> Self: def _to_microseconds(self) -> int: return (self._days * (24 * 3600) + self._seconds) * 1000000 + self._microseconds + def _to_total_microseconds(self) -> int: + # Match total_seconds()'s fixed approximations for calendar components. + calendar_days = self._years * 365 + self._months * 30 + return self._to_microseconds() + calendar_days * SECONDS_PER_DAY * US_PER_SECOND + def __mul__(self, other: int | float) -> Self: if isinstance(other, int): return self.__class__( @@ -384,13 +389,13 @@ def __floordiv__(self, other: int | timedelta) -> int | Duration: if not isinstance(other, (int, timedelta)): return NotImplemented - usec = self._to_microseconds() if isinstance(other, timedelta): return cast( "int", - usec // other._to_microseconds(), # type: ignore[attr-defined] + self._to_total_microseconds() // other._to_total_microseconds(), # type: ignore[attr-defined] ) + usec = self._to_microseconds() if isinstance(other, int): return self.__class__( 0, @@ -410,13 +415,13 @@ def __truediv__(self, other: int | float | timedelta) -> Self | float: if not isinstance(other, (int, float, timedelta)): return NotImplemented - usec = self._to_microseconds() if isinstance(other, timedelta): return cast( "float", - usec / other._to_microseconds(), # type: ignore[attr-defined] + self._to_total_microseconds() / other._to_total_microseconds(), # type: ignore[attr-defined] ) + usec = self._to_microseconds() if isinstance(other, int): return self.__class__( 0, @@ -441,7 +446,7 @@ def __truediv__(self, other: int | float | timedelta) -> Self | float: def __mod__(self, other: timedelta) -> Self: if isinstance(other, timedelta): - r = self._to_microseconds() % other._to_microseconds() # type: ignore[attr-defined] + r = self._to_total_microseconds() % other._to_total_microseconds() # type: ignore[attr-defined] return self.__class__(0, 0, r) @@ -450,8 +455,8 @@ def __mod__(self, other: timedelta) -> Self: def __divmod__(self, other: timedelta) -> tuple[int, Duration]: if isinstance(other, timedelta): q, r = divmod( - self._to_microseconds(), - other._to_microseconds(), # type: ignore[attr-defined] + self._to_total_microseconds(), + other._to_total_microseconds(), # type: ignore[attr-defined] ) return q, self.__class__(0, 0, r) @@ -521,6 +526,10 @@ def __new__( return self + def _to_total_microseconds(self) -> int: + # AbsoluteDuration already includes calendar components in _days. + return self._to_microseconds() + def total_seconds(self) -> float: return abs(self._total) diff --git a/tests/duration/test_arithmetic.py b/tests/duration/test_arithmetic.py index cba4d39d0..1b16e7bc4 100644 --- a/tests/duration/test_arithmetic.py +++ b/tests/duration/test_arithmetic.py @@ -1,7 +1,14 @@ from __future__ import annotations +import operator + +from datetime import timedelta + +import pytest + import pendulum +from pendulum.duration import AbsoluteDuration from tests.conftest import assert_duration @@ -83,3 +90,85 @@ def test_floor_divide(): assert isinstance(mul, pendulum.Duration) assert_duration(mul, 0, 1, 0, 0, 16, 0, 11, 840740) + + +@pytest.mark.parametrize( + "operation", [operator.truediv, operator.floordiv, operator.mod, divmod] +) +@pytest.mark.parametrize( + ("left", "right"), + [ + ({"months": 2}, {"months": 1}), + ({"years": 1}, {"months": 1}), + ({"months": 1}, {"days": 7}), + ({"days": 7}, {"months": 1}), + ({"years": -1, "months": 2, "days": -3}, {"months": 2, "days": 1}), + ({"years": 1, "months": -2, "seconds": 3}, {"months": -2, "seconds": -1}), + ({"months": 1, "days": -30}, {"years": 1}), + ({"months": 1, "microseconds": 2}, {"months": 1, "microseconds": 1}), + ], +) +def test_calendar_duration_quotient_and_remainder(operation, left, right): + def native_duration(parts): + fixed = { + key: value for key, value in parts.items() if key not in ("years", "months") + } + fixed["days"] = ( + fixed.get("days", 0) + + parts.get("years", 0) * 365 + + parts.get("months", 0) * 30 + ) + return timedelta(**fixed) + + result = operation(pendulum.duration(**left), pendulum.duration(**right)) + assert result == operation(native_duration(left), native_duration(right)) + + +@pytest.mark.parametrize( + "operation", [operator.truediv, operator.floordiv, operator.mod, divmod] +) +def test_calendar_duration_zero_divisor(operation): + with pytest.raises(ZeroDivisionError): + operation(pendulum.duration(months=1), pendulum.duration(months=1, days=-30)) + + +@pytest.mark.parametrize("divisor", [2, 2.0]) +def test_calendar_duration_scalar_division_unchanged(divisor): + result = pendulum.duration(years=4, months=6, days=10) / divisor + assert_duration(result, 2, 3, 0, 5, 0, 0, 0) + + +def test_calendar_duration_scalar_floor_division_unchanged(): + result = pendulum.duration(years=4, months=6, days=10) // 2 + assert_duration(result, 2, 3, 0, 5, 0, 0, 0) + + +def test_calendar_duration_remainder_preserves_subclass(): + class CustomDuration(pendulum.Duration): + pass + + duration = CustomDuration(months=1) + divisor = pendulum.duration(days=7) + assert type(duration % divisor) is CustomDuration + assert type(divmod(duration, divisor)[1]) is CustomDuration + assert duration % divisor == timedelta(days=2) + + +@pytest.mark.parametrize( + "operation", [operator.truediv, operator.floordiv, operator.mod, divmod] +) +@pytest.mark.parametrize( + ("left", "right", "left_days", "right_days"), + [ + (AbsoluteDuration(years=1, months=1), pendulum.duration(days=30), 395, 30), + (pendulum.duration(months=2), AbsoluteDuration(months=1), 60, 30), + (AbsoluteDuration(months=1), pendulum.duration(months=2), 30, 60), + (AbsoluteDuration(years=-1), AbsoluteDuration(months=-1), 365, 30), + ], +) +def test_calendar_duration_arithmetic_with_absolute_duration( + operation, left, right, left_days, right_days +): + assert operation(left, right) == operation( + timedelta(days=left_days), timedelta(days=right_days) + ) diff --git a/tests/interval/test_arithmetic.py b/tests/interval/test_arithmetic.py index e5ba01f4e..9ea53f487 100644 --- a/tests/interval/test_arithmetic.py +++ b/tests/interval/test_arithmetic.py @@ -1,5 +1,11 @@ from __future__ import annotations +import operator + +from datetime import timedelta + +import pytest + import pendulum from tests.conftest import assert_duration @@ -51,3 +57,20 @@ def test_floor_divide(): mul = it // 3 assert isinstance(mul, pendulum.Duration) assert_duration(mul, 0, 0, 0, 0, 16, 0, 11) + + +@pytest.mark.parametrize( + "operation", [operator.truediv, operator.floordiv, operator.mod, divmod] +) +@pytest.mark.parametrize("reverse", [False, True]) +def test_calendar_duration_arithmetic_with_interval(operation, reverse): + start = pendulum.datetime(2024, 1, 1) + interval = pendulum.interval(start, start.add(months=1)) + duration = pendulum.duration(months=1) + left, right = (duration, interval) if reverse else (interval, duration) + native_left, native_right = ( + (timedelta(days=30), timedelta(days=31)) + if reverse + else (timedelta(days=31), timedelta(days=30)) + ) + assert operation(left, right) == operation(native_left, native_right)