From f96311a95cadc9dbcaabfbc00381e4bf9b88c054 Mon Sep 17 00:00:00 2001 From: Pranav Date: Tue, 6 Oct 2026 03:41:48 +0530 Subject: [PATCH] Fix mixed calendar and fixed-unit arithmetic across DST --- docs/docs/addition_subtraction.md | 15 ++ src/pendulum/datetime.py | 22 ++- tests/datetime/test_mixed_arithmetic.py | 213 ++++++++++++++++++++++++ 3 files changed, 246 insertions(+), 4 deletions(-) create mode 100644 tests/datetime/test_mixed_arithmetic.py diff --git a/docs/docs/addition_subtraction.md b/docs/docs/addition_subtraction.md index 32cb875d0..c68ed4a93 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,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' +``` 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_mixed_arithmetic.py b/tests/datetime/test_mixed_arithmetic.py new file mode 100644 index 000000000..2cf3dc9c4 --- /dev/null +++ b/tests/datetime/test_mixed_arithmetic.py @@ -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) + + assert result.isoformat() == expected.isoformat() + assert result.tzinfo is None