Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions docs/docs/duration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
23 changes: 16 additions & 7 deletions src/pendulum/duration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__(
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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)

Expand All @@ -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)
Expand Down Expand Up @@ -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)

Expand Down
89 changes: 89 additions & 0 deletions tests/duration/test_arithmetic.py
Original file line number Diff line number Diff line change
@@ -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


Expand Down Expand Up @@ -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)
)
23 changes: 23 additions & 0 deletions tests/interval/test_arithmetic.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
from __future__ import annotations

import operator

from datetime import timedelta

import pytest

import pendulum

from tests.conftest import assert_duration
Expand Down Expand Up @@ -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)
Loading