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
25 changes: 25 additions & 0 deletions docs/docs/addition_subtraction.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ To easily add and subtract time, you can use the `add()` and `subtract()`
methods.
Each method returns a new `DateTime` instance.

For timezone-aware datetimes, years, months, weeks and days shift the local
date and time, while hours, minutes, seconds and microseconds change elapsed
time. When both kinds of units are passed in one call, the calendar shift is
applied and normalized first, followed by the elapsed-time shift on the UTC
timeline. This ordering applies to both `add()` and `subtract()`.

```python
>>> import pendulum

Expand Down Expand Up @@ -85,3 +91,22 @@ Each method returns a new `DateTime` instance.

Passing negative values to `add()` is also possible and will act exactly
like `subtract()`

For example, subtracting one calendar day and then one second across a
spring-forward transition:

```python
>>> dt = pendulum.datetime(2024, 4, 1, 4, tz='Europe/Sofia')
>>> dt.subtract(days=1, seconds=1).isoformat()
'2024-03-31T02:59:59+02:00'
Comment on lines +100 to +101

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I hate datetime math around DST transitions.

What would this do at the autumn transtion. What should it do?

```

The same ordering applies across a fall-back transition. The calendar shift
first selects the normalized local time and offset, then the fixed-length shift
uses elapsed time from that instant:

```python
>>> dt = pendulum.datetime(2024, 10, 26, 4, 30, tz='Europe/Sofia')
>>> dt.add(days=1, hours=-1).isoformat()
'2024-10-27T03:30:00+02:00'
```
22 changes: 18 additions & 4 deletions src/pendulum/datetime.py
Original file line number Diff line number Diff line change
Expand Up @@ -570,12 +570,26 @@ def add(
"""
Add a duration to the instance.

If we're adding units of variable length (i.e., years, months),
move forward from current time, otherwise move forward from utc, for accuracy
when moving across DST boundaries.
For timezone-aware datetimes, apply years, months, weeks and days
in local time first, then hours, minutes, seconds and microseconds
in UTC to handle DST boundaries accurately.
"""
units_of_variable_length = any([years, months, weeks, days])

if (
units_of_variable_length
and self.tz is not None
and any([hours, minutes, seconds, microseconds])
):
calendar_dt = self.add(years=years, months=months, weeks=weeks, days=days)

return calendar_dt.add(
hours=hours,
minutes=minutes,
seconds=seconds,
microseconds=microseconds,
)

current_dt = datetime.datetime(
self.year,
self.month,
Expand Down Expand Up @@ -651,7 +665,7 @@ def subtract(
microseconds: int = 0,
) -> Self:
"""
Remove duration from the instance.
Remove a duration, applying calendar units before fixed-length units.
"""
return self.add(
years=-years,
Expand Down
183 changes: 183 additions & 0 deletions tests/datetime/test_add.py
Original file line number Diff line number Diff line change
Expand Up @@ -278,3 +278,186 @@ def test_interval_over_midnight_tz():
new_end = start + interval

assert new_end == end


@pytest.mark.parametrize("method, sign", [("add", 1), ("subtract", -1)])
@pytest.mark.parametrize(
"tz, start, calendar_units, fixed_units, expected",
[
pytest.param(
"Europe/Sofia",
(2024, 4, 1, 4),
{"days": -1},
{"seconds": -1},
"2024-03-31T02:59:59+02:00",
id="spring-forward-gap",
),
pytest.param(
"Europe/Paris",
(2013, 3, 24, 2, 30),
{"weeks": 1},
{"hours": 1},
"2013-03-31T04:30:00+02:00",
id="calendar-gap-normalization",
),
pytest.param(
"America/New_York",
(2024, 2, 10, 1, 30),
{"months": 1},
{"hours": 25},
"2024-03-11T03:30:00-04:00",
id="fixed-hours-over-one-day",
),
pytest.param(
"Europe/Paris",
(2012, 10, 27, 1, 59, 59, 999999),
{"years": 1},
{"hours": 1, "microseconds": 1},
"2013-10-27T02:00:00+01:00",
id="fall-back-forward-shift",
),
pytest.param(
"Europe/Paris",
(2013, 11, 27, 2),
{"months": -1},
{"microseconds": -1},
"2013-10-27T02:59:59.999999+02:00",
id="fall-back-backward-shift",
),
pytest.param(
"Europe/Sofia",
(2024, 10, 26, 4, 30),
{"days": 1},
{"hours": -1},
"2024-10-27T03:30:00+02:00",
id="fall-back-calendar-then-elapsed",
),
pytest.param(
"Europe/Sofia",
(2024, 10, 20, 3, 30),
{"weeks": 1},
{"minutes": -60},
"2024-10-27T03:30:00+03:00",
id="ambiguous-time-fold",
),
pytest.param(
"Europe/Sofia",
(2024, 3, 30, 4),
{"days": 1},
{"seconds": -0.5},
"2024-03-31T02:59:59.500000+02:00",
id="opposite-directions-fractional-seconds",
),
pytest.param(
"America/New_York",
(2025, 11, 3, 0, 30),
{"years": -1},
{"hours": 2},
"2024-11-03T01:30:00-05:00",
id="negative-calendar-positive-elapsed",
),
pytest.param(
"Australia/Lord_Howe",
(2024, 10, 7, 2, 30),
{"days": -1},
{"microseconds": -1},
"2024-10-06T01:59:59.999999+10:30",
id="half-hour-spring-forward",
),
pytest.param(
"Australia/Lord_Howe",
(2024, 4, 14, 1),
{"weeks": -1},
{"hours": 1},
"2024-04-07T01:30:00+10:30",
id="half-hour-fall-back",
),
pytest.param(
"Europe/Paris",
(2024, 2, 29, 1, 30),
{"years": 1, "months": 1, "days": 1},
{"hours": 2},
"2025-03-30T04:30:00+02:00",
id="multiple-calendar-units",
),
pytest.param(
"Europe/Sofia",
(2024, 4, 1, 16),
{"days": -1.5},
{"seconds": -1},
"2024-03-31T02:59:59+02:00",
id="fractional-calendar-days",
),
pytest.param(
"UTC",
(2024, 4, 1, 4),
{"days": -1},
{"seconds": -1},
"2024-03-31T03:59:59+00:00",
id="utc-control",
),
pytest.param(
None,
(2024, 4, 1, 4),
{"days": -1},
{"seconds": -1},
"2024-03-31T03:59:59",
id="naive-control",
),
pytest.param(
5.5,
(2024, 1, 31, 4),
{"months": 1},
{"seconds": -0.25, "microseconds": -1000001},
"2024-02-29T03:59:58.749999+05:30",
id="fixed-offset-month-clamping",
),
],
)
def test_mixed_arithmetic_applies_calendar_before_elapsed_time(
method: str,
sign: int,
tz: str | float | None,
start: tuple[int, ...],
calendar_units: dict[str, int | float],
fixed_units: dict[str, int | float],
expected: str,
) -> None:
dt = pendulum.DateTime.create(*start, tz=tz)
calendar_args = {unit: sign * value for unit, value in calendar_units.items()}
fixed_args = {unit: sign * value for unit, value in fixed_units.items()}

result = getattr(dt, method)(**calendar_args, **fixed_args)

assert result.isoformat() == expected
assert result.tzinfo is dt.tzinfo


@pytest.mark.parametrize(
"tz, expected",
[
("Europe/Sofia", "2024-03-31T02:59:59.500000+02:00"),
(None, "2024-03-31T03:59:59.500000"),
],
)
def test_mixed_arithmetic_preserves_subclass(tz: str | None, expected: str) -> None:
class Subclass(pendulum.DateTime):
pass

dt = Subclass.create(2024, 4, 1, 4, tz=tz)
result = dt.subtract(days=1, seconds=0.5)

assert type(result) is Subclass
assert result.tzinfo is dt.tzinfo
assert result.isoformat() == expected


def test_naive_mixed_arithmetic_rounds_fractional_units_once() -> None:
dt = pendulum.naive(2024, 4, 1, 4)
days = 0.6 / 86_400_000_000
seconds = 0.0000006

result = dt.add(days=days, seconds=seconds) # type: ignore[arg-type]

assert result.isoformat() == "2024-04-01T04:00:00.000001"
assert result.tzinfo is None