From f96311a95cadc9dbcaabfbc00381e4bf9b88c054 Mon Sep 17 00:00:00 2001 From: Pranav Date: Tue, 6 Oct 2026 03:41:48 +0530 Subject: [PATCH 1/2] 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 From dc204769df6569edf8df47624c7475bcb5d27ba0 Mon Sep 17 00:00:00 2001 From: Pranav Date: Sat, 17 Oct 2026 13:20:04 +0530 Subject: [PATCH 2/2] test: clarify mixed DST arithmetic coverage --- docs/docs/addition_subtraction.md | 10 ++ tests/datetime/test_add.py | 183 ++++++++++++++++++++ tests/datetime/test_mixed_arithmetic.py | 213 ------------------------ 3 files changed, 193 insertions(+), 213 deletions(-) delete mode 100644 tests/datetime/test_mixed_arithmetic.py diff --git a/docs/docs/addition_subtraction.md b/docs/docs/addition_subtraction.md index c68ed4a93..de916b48e 100644 --- a/docs/docs/addition_subtraction.md +++ b/docs/docs/addition_subtraction.md @@ -100,3 +100,13 @@ spring-forward transition: >>> 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/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 diff --git a/tests/datetime/test_mixed_arithmetic.py b/tests/datetime/test_mixed_arithmetic.py deleted file mode 100644 index 2cf3dc9c4..000000000 --- a/tests/datetime/test_mixed_arithmetic.py +++ /dev/null @@ -1,213 +0,0 @@ -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