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
15 changes: 15 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,12 @@ 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?

```
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
213 changes: 213 additions & 0 deletions tests/datetime/test_mixed_arithmetic.py

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.

This shouldn't be a new test file

Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
from __future__ import annotations

from datetime import datetime
from datetime import timedelta
from datetime import timezone
from zoneinfo import ZoneInfo

import pytest

import pendulum


@pytest.mark.parametrize("method, sign", [("add", 1), ("subtract", -1)])
@pytest.mark.parametrize(
"tz, start, calendar_units, fixed_units, calendar_target",
[
pytest.param(
"Europe/Sofia",
"2024-04-01T04:00:00",
{"days": -1},
{"seconds": -1},
"2024-03-31T04:00:00+03:00",
id="issue-838",
),
pytest.param(
"Europe/Paris",
"2013-03-24T02:30:00",
{"weeks": 1},
{"hours": 1},
"2013-03-31T03:30:00+02:00",
id="normalize-calendar-gap-first",
),
pytest.param(
"America/New_York",
"2024-02-10T01:30:00",
{"months": 1},
{"hours": 25},
"2024-03-10T01:30:00-05:00",
id="fixed-hours-over-one-day",
),
pytest.param(
"Europe/Paris",
"2012-10-27T01:59:59.999999",
{"years": 1},
{"hours": 1, "microseconds": 1},
"2013-10-27T01:59:59.999999+02:00",
id="forward-fall-back",
),
pytest.param(
"Europe/Paris",
"2013-11-27T02:00:00",
{"months": -1},
{"microseconds": -1},
"2013-10-27T02:00:00+01:00",
id="backward-fall-back",
),
pytest.param(
"Europe/Sofia",
"2024-10-20T03:30:00",
{"weeks": 1},
{"minutes": -60},
"2024-10-27T03:30:00+02:00",
id="calendar-overlap-default-fold",
),
pytest.param(
"Europe/Sofia",
"2024-03-30T04:00:00",
{"days": 1},
{"seconds": -0.5},
"2024-03-31T04:00:00+03:00",
id="opposite-directions-fractional-seconds",
),
pytest.param(
"America/New_York",
"2025-11-03T00:30:00",
{"years": -1},
{"hours": 2},
"2024-11-03T00:30:00-04:00",
id="negative-years-positive-hours",
),
pytest.param(
"Australia/Lord_Howe",
"2024-10-07T02:30:00",
{"days": -1},
{"microseconds": -1},
"2024-10-06T02:30:00+11:00",
id="half-hour-spring-forward",
),
pytest.param(
"Australia/Lord_Howe",
"2024-04-14T01:00:00",
{"weeks": -1},
{"hours": 1},
"2024-04-07T01:00:00+11:00",
id="half-hour-fall-back",
),
pytest.param(
"Europe/Paris",
"2024-02-29T01:30:00",
{"years": 1, "months": 1, "days": 1},
{"hours": 2},
"2025-03-30T01:30:00+01:00",
id="calendar-units-applied-together",
),
pytest.param(
"Europe/Sofia",
"2024-04-01T16:00:00",
{"days": -1.5},
{"seconds": -1},
"2024-03-31T04:00:00+03:00",
id="fractional-calendar-days",
),
pytest.param(
"UTC",
"2024-04-01T04:00:00",
{"days": -1},
{"seconds": -1},
"2024-03-31T04:00:00+00:00",
id="utc-control",
),
pytest.param(
None,
"2024-04-01T04:00:00",
{"days": -1},
{"seconds": -1},
"2024-03-31T04:00:00",
id="naive-control",
),
pytest.param(
5.5,
"2024-01-31T04:00:00",
{"months": 1},
{"seconds": -0.25, "microseconds": -1000001},
"2024-02-29T04:00:00+05:30",
id="fixed-offset-month-clamping",
),
],
)
def test_mixed_arithmetic_applies_calendar_then_elapsed_time(
method: str,
sign: int,
tz: str | float | None,
start: str,
calendar_units: dict[str, int | float],
fixed_units: dict[str, int | float],
calendar_target: str,
) -> None:
native_start = datetime.fromisoformat(start)
dt = pendulum.DateTime.create(
native_start.year,
native_start.month,
native_start.day,
native_start.hour,
native_start.minute,
native_start.second,
native_start.microsecond,
tz=tz,
)
operation = getattr(dt, method)
calendar_args = {unit: sign * value for unit, value in calendar_units.items()}
fixed_args = {unit: sign * value for unit, value in fixed_units.items()}

# These explicit targets verify calendar shifting/normalization independently
# of the mixed call. The elapsed-time oracle uses only stdlib arithmetic.
calendar_result = operation(**calendar_args)
assert calendar_result.isoformat() == calendar_target
expected_calendar = datetime.fromisoformat(calendar_target)
elapsed = timedelta(**fixed_units)
if tz is None:
expected = expected_calendar + elapsed
else:
native_tz = (
ZoneInfo(tz) if isinstance(tz, str) else timezone(timedelta(hours=tz))
)
expected = (expected_calendar.astimezone(timezone.utc) + elapsed).astimezone(
native_tz
)

result = operation(**calendar_args, **fixed_args)
assert result.isoformat() == expected.isoformat()
assert result.tzinfo is dt.tzinfo
if expected.replace(fold=0).utcoffset() != expected.replace(fold=1).utcoffset():
assert result.fold == expected.fold


@pytest.mark.parametrize("tz", ["Europe/Sofia", None])
def test_mixed_arithmetic_preserves_subclass(tz: str | None) -> 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() == (
"2024-03-31T02:59:59.500000+02:00"
if tz is not None
else "2024-03-31T03:59:59.500000"
)


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

# Round the combined duration once, as before, for naive datetimes.
result = dt.add(days=days, seconds=seconds) # type: ignore[arg-type]
expected = datetime(2024, 4, 1, 4) + timedelta(days=days, seconds=seconds)

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.

This being a calculation is hiding what the expected value is. If we keep it, it should be a literal


assert result.isoformat() == expected.isoformat()
assert result.tzinfo is None
Comment on lines +203 to +213

@ashb ashb Oct 8, 2026 •

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 don't understand the point of this whole test fn. What is it asserting?