Skip to content

Commit 4fa067e

Browse files
committed
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.
1 parent 7149d52 commit 4fa067e

2 files changed

Lines changed: 68 additions & 0 deletions

File tree

‎src/pendulum/duration.py‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,8 +329,34 @@ def __repr__(self) -> str:
329329

330330
return rep.replace(", )", ")")
331331

332+
def _scalar_seconds(self) -> float:
333+
"""Seconds from weeks, days, and the clock, excluding years and months.
334+
335+
``Duration`` stores years and months beside a ``timedelta`` that also
336+
contains their 365/30-day approximation. Adding those totals together
337+
and rebuilding from seconds alone drops the calendar fields (#751).
338+
"""
339+
calendar = (self._years * 365 + self._months * 30) * SECONDS_PER_DAY
340+
return self.total_seconds() - calendar
341+
332342
def __add__(self, other: timedelta) -> Self:
333343
if isinstance(other, timedelta):
344+
if (
345+
type(self) is Duration
346+
and isinstance(other, Duration)
347+
and type(other) is Duration
348+
):
349+
return self.__class__(
350+
years=self._years + other._years,
351+
months=self._months + other._months,
352+
seconds=self._scalar_seconds() + other._scalar_seconds(),
353+
)
354+
if type(self) is Duration and (self._years or self._months):
355+
return self.__class__(
356+
years=self._years,
357+
months=self._months,
358+
seconds=self._scalar_seconds() + other.total_seconds(),
359+
)
334360
return self.__class__(seconds=self.total_seconds() + other.total_seconds())
335361

336362
return NotImplemented
@@ -339,6 +365,22 @@ def __add__(self, other: timedelta) -> Self:
339365

340366
def __sub__(self, other: timedelta) -> Self:
341367
if isinstance(other, timedelta):
368+
if (
369+
type(self) is Duration
370+
and isinstance(other, Duration)
371+
and type(other) is Duration
372+
):
373+
return self.__class__(
374+
years=self._years - other._years,
375+
months=self._months - other._months,
376+
seconds=self._scalar_seconds() - other._scalar_seconds(),
377+
)
378+
if type(self) is Duration and (self._years or self._months):
379+
return self.__class__(
380+
years=self._years,
381+
months=self._months,
382+
seconds=self._scalar_seconds() - other.total_seconds(),
383+
)
342384
return self.__class__(seconds=self.total_seconds() - other.total_seconds())
343385

344386
return NotImplemented

‎tests/duration/test_add_sub.py‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,32 @@ def test_sub_unsupported():
4949
assert NotImplemented == p.__sub__(5)
5050

5151

52+
def test_add_preserves_years_and_months():
53+
total = pendulum.duration(years=2) + pendulum.duration(days=1)
54+
assert_duration(total, years=2, months=0, weeks=0, days=1)
55+
56+
months = pendulum.duration(months=2) + pendulum.duration(days=1)
57+
assert_duration(months, years=0, months=2, days=1)
58+
59+
combined = pendulum.duration(years=1) + pendulum.duration(years=1)
60+
assert combined.years == 2
61+
assert combined.months == 0
62+
63+
64+
def test_adding_year_durations_matches_successive_datetime_adds():
65+
start = pendulum.datetime(2023, 1, 1, tz="UTC")
66+
stepwise = (start + pendulum.duration(years=1)) + pendulum.duration(years=1)
67+
combined = start + (pendulum.duration(years=1) + pendulum.duration(years=1))
68+
69+
assert stepwise == combined
70+
assert combined == pendulum.datetime(2025, 1, 1, tz="UTC")
71+
72+
73+
def test_sub_preserves_years():
74+
difference = pendulum.duration(years=2, days=3) - pendulum.duration(years=1, days=1)
75+
assert_duration(difference, years=1, days=2)
76+
77+
5278
def test_neg():
5379
p = pendulum.duration(days=23, seconds=32)
5480
assert_duration(-p, 0, 0, -3, -2, 0, 0, -32)

0 commit comments

Comments
 (0)