diff --git a/docs/docs/addition_subtraction.md b/docs/docs/addition_subtraction.md index 32cb875d0..de916b48e 100644 --- a/docs/docs/addition_subtraction.md +++ b/docs/docs/addition_subtraction.md @@ -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 @@ -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' +``` + +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' +``` diff --git a/src/pendulum/datetime.py b/src/pendulum/datetime.py index 0d7e4f9a0..63da884ce 100644 --- a/src/pendulum/datetime.py +++ b/src/pendulum/datetime.py @@ -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, @@ -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, diff --git a/tests/datetime/test_add.py b/tests/datetime/test_add.py index 409f5bd40..19617fa69 100644 --- a/tests/datetime/test_add.py +++ b/tests/datetime/test_add.py @@ -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