From 4fa067eed0e6004ff3a91c21ac1ea69eb8343c0d Mon Sep 17 00:00:00 2001 From: fhgffy <102001626+fhgffy@users.noreply.github.com> Date: Sun, 4 Oct 2026 16:09:38 +0800 Subject: [PATCH] Keep years and months when adding or subtracting durations. Rebuilding the sum from total seconds folds calendar fields into a 365/30-day approximation, so two year-long durations no longer add up to two calendar years. --- src/pendulum/duration.py | 42 ++++++++++++++++++++++++++++++++++ tests/duration/test_add_sub.py | 26 +++++++++++++++++++++ 2 files changed, 68 insertions(+) diff --git a/src/pendulum/duration.py b/src/pendulum/duration.py index d6cc0657d..74c49ae84 100644 --- a/src/pendulum/duration.py +++ b/src/pendulum/duration.py @@ -329,8 +329,34 @@ def __repr__(self) -> str: return rep.replace(", )", ")") + def _scalar_seconds(self) -> float: + """Seconds from weeks, days, and the clock, excluding years and months. + + ``Duration`` stores years and months beside a ``timedelta`` that also + contains their 365/30-day approximation. Adding those totals together + and rebuilding from seconds alone drops the calendar fields (#751). + """ + calendar = (self._years * 365 + self._months * 30) * SECONDS_PER_DAY + return self.total_seconds() - calendar + def __add__(self, other: timedelta) -> Self: if isinstance(other, timedelta): + if ( + type(self) is Duration + and isinstance(other, Duration) + and type(other) is Duration + ): + return self.__class__( + years=self._years + other._years, + months=self._months + other._months, + seconds=self._scalar_seconds() + other._scalar_seconds(), + ) + if type(self) is Duration and (self._years or self._months): + return self.__class__( + years=self._years, + months=self._months, + seconds=self._scalar_seconds() + other.total_seconds(), + ) return self.__class__(seconds=self.total_seconds() + other.total_seconds()) return NotImplemented @@ -339,6 +365,22 @@ def __add__(self, other: timedelta) -> Self: def __sub__(self, other: timedelta) -> Self: if isinstance(other, timedelta): + if ( + type(self) is Duration + and isinstance(other, Duration) + and type(other) is Duration + ): + return self.__class__( + years=self._years - other._years, + months=self._months - other._months, + seconds=self._scalar_seconds() - other._scalar_seconds(), + ) + if type(self) is Duration and (self._years or self._months): + return self.__class__( + years=self._years, + months=self._months, + seconds=self._scalar_seconds() - other.total_seconds(), + ) return self.__class__(seconds=self.total_seconds() - other.total_seconds()) return NotImplemented diff --git a/tests/duration/test_add_sub.py b/tests/duration/test_add_sub.py index 193a4934c..f8abb51be 100644 --- a/tests/duration/test_add_sub.py +++ b/tests/duration/test_add_sub.py @@ -49,6 +49,32 @@ def test_sub_unsupported(): assert NotImplemented == p.__sub__(5) +def test_add_preserves_years_and_months(): + total = pendulum.duration(years=2) + pendulum.duration(days=1) + assert_duration(total, years=2, months=0, weeks=0, days=1) + + months = pendulum.duration(months=2) + pendulum.duration(days=1) + assert_duration(months, years=0, months=2, days=1) + + combined = pendulum.duration(years=1) + pendulum.duration(years=1) + assert combined.years == 2 + assert combined.months == 0 + + +def test_adding_year_durations_matches_successive_datetime_adds(): + start = pendulum.datetime(2023, 1, 1, tz="UTC") + stepwise = (start + pendulum.duration(years=1)) + pendulum.duration(years=1) + combined = start + (pendulum.duration(years=1) + pendulum.duration(years=1)) + + assert stepwise == combined + assert combined == pendulum.datetime(2025, 1, 1, tz="UTC") + + +def test_sub_preserves_years(): + difference = pendulum.duration(years=2, days=3) - pendulum.duration(years=1, days=1) + assert_duration(difference, years=1, days=2) + + def test_neg(): p = pendulum.duration(days=23, seconds=32) assert_duration(-p, 0, 0, -3, -2, 0, 0, -32)