Skip to content

Commit f96311a

Browse files
committed
Fix mixed calendar and fixed-unit arithmetic across DST
1 parent 6394a10 commit f96311a

3 files changed

Lines changed: 246 additions & 4 deletions

File tree

‎docs/docs/addition_subtraction.md‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@ To easily add and subtract time, you can use the `add()` and `subtract()`
44
methods.
55
Each method returns a new `DateTime` instance.
66

7+
For timezone-aware datetimes, years, months, weeks and days shift the local
8+
date and time, while hours, minutes, seconds and microseconds change elapsed
9+
time. When both kinds of units are passed in one call, the calendar shift is
10+
applied and normalized first, followed by the elapsed-time shift on the UTC
11+
timeline. This ordering applies to both `add()` and `subtract()`.
12+
713
```python
814
>>> import pendulum
915

@@ -85,3 +91,12 @@ Each method returns a new `DateTime` instance.
8591

8692
Passing negative values to `add()` is also possible and will act exactly
8793
like `subtract()`
94+
95+
For example, subtracting one calendar day and then one second across a
96+
spring-forward transition:
97+
98+
```python
99+
>>> dt = pendulum.datetime(2024, 4, 1, 4, tz='Europe/Sofia')
100+
>>> dt.subtract(days=1, seconds=1).isoformat()
101+
'2024-03-31T02:59:59+02:00'
102+
```

‎src/pendulum/datetime.py‎

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -570,12 +570,26 @@ def add(
570570
"""
571571
Add a duration to the instance.
572572
573-
If we're adding units of variable length (i.e., years, months),
574-
move forward from current time, otherwise move forward from utc, for accuracy
575-
when moving across DST boundaries.
573+
For timezone-aware datetimes, apply years, months, weeks and days
574+
in local time first, then hours, minutes, seconds and microseconds
575+
in UTC to handle DST boundaries accurately.
576576
"""
577577
units_of_variable_length = any([years, months, weeks, days])
578578

579+
if (
580+
units_of_variable_length
581+
and self.tz is not None
582+
and any([hours, minutes, seconds, microseconds])
583+
):
584+
calendar_dt = self.add(years=years, months=months, weeks=weeks, days=days)
585+
586+
return calendar_dt.add(
587+
hours=hours,
588+
minutes=minutes,
589+
seconds=seconds,
590+
microseconds=microseconds,
591+
)
592+
579593
current_dt = datetime.datetime(
580594
self.year,
581595
self.month,
@@ -651,7 +665,7 @@ def subtract(
651665
microseconds: int = 0,
652666
) -> Self:
653667
"""
654-
Remove duration from the instance.
668+
Remove a duration, applying calendar units before fixed-length units.
655669
"""
656670
return self.add(
657671
years=-years,
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
from __future__ import annotations
2+
3+
from datetime import datetime
4+
from datetime import timedelta
5+
from datetime import timezone
6+
from zoneinfo import ZoneInfo
7+
8+
import pytest
9+
10+
import pendulum
11+
12+
13+
@pytest.mark.parametrize("method, sign", [("add", 1), ("subtract", -1)])
14+
@pytest.mark.parametrize(
15+
"tz, start, calendar_units, fixed_units, calendar_target",
16+
[
17+
pytest.param(
18+
"Europe/Sofia",
19+
"2024-04-01T04:00:00",
20+
{"days": -1},
21+
{"seconds": -1},
22+
"2024-03-31T04:00:00+03:00",
23+
id="issue-838",
24+
),
25+
pytest.param(
26+
"Europe/Paris",
27+
"2013-03-24T02:30:00",
28+
{"weeks": 1},
29+
{"hours": 1},
30+
"2013-03-31T03:30:00+02:00",
31+
id="normalize-calendar-gap-first",
32+
),
33+
pytest.param(
34+
"America/New_York",
35+
"2024-02-10T01:30:00",
36+
{"months": 1},
37+
{"hours": 25},
38+
"2024-03-10T01:30:00-05:00",
39+
id="fixed-hours-over-one-day",
40+
),
41+
pytest.param(
42+
"Europe/Paris",
43+
"2012-10-27T01:59:59.999999",
44+
{"years": 1},
45+
{"hours": 1, "microseconds": 1},
46+
"2013-10-27T01:59:59.999999+02:00",
47+
id="forward-fall-back",
48+
),
49+
pytest.param(
50+
"Europe/Paris",
51+
"2013-11-27T02:00:00",
52+
{"months": -1},
53+
{"microseconds": -1},
54+
"2013-10-27T02:00:00+01:00",
55+
id="backward-fall-back",
56+
),
57+
pytest.param(
58+
"Europe/Sofia",
59+
"2024-10-20T03:30:00",
60+
{"weeks": 1},
61+
{"minutes": -60},
62+
"2024-10-27T03:30:00+02:00",
63+
id="calendar-overlap-default-fold",
64+
),
65+
pytest.param(
66+
"Europe/Sofia",
67+
"2024-03-30T04:00:00",
68+
{"days": 1},
69+
{"seconds": -0.5},
70+
"2024-03-31T04:00:00+03:00",
71+
id="opposite-directions-fractional-seconds",
72+
),
73+
pytest.param(
74+
"America/New_York",
75+
"2025-11-03T00:30:00",
76+
{"years": -1},
77+
{"hours": 2},
78+
"2024-11-03T00:30:00-04:00",
79+
id="negative-years-positive-hours",
80+
),
81+
pytest.param(
82+
"Australia/Lord_Howe",
83+
"2024-10-07T02:30:00",
84+
{"days": -1},
85+
{"microseconds": -1},
86+
"2024-10-06T02:30:00+11:00",
87+
id="half-hour-spring-forward",
88+
),
89+
pytest.param(
90+
"Australia/Lord_Howe",
91+
"2024-04-14T01:00:00",
92+
{"weeks": -1},
93+
{"hours": 1},
94+
"2024-04-07T01:00:00+11:00",
95+
id="half-hour-fall-back",
96+
),
97+
pytest.param(
98+
"Europe/Paris",
99+
"2024-02-29T01:30:00",
100+
{"years": 1, "months": 1, "days": 1},
101+
{"hours": 2},
102+
"2025-03-30T01:30:00+01:00",
103+
id="calendar-units-applied-together",
104+
),
105+
pytest.param(
106+
"Europe/Sofia",
107+
"2024-04-01T16:00:00",
108+
{"days": -1.5},
109+
{"seconds": -1},
110+
"2024-03-31T04:00:00+03:00",
111+
id="fractional-calendar-days",
112+
),
113+
pytest.param(
114+
"UTC",
115+
"2024-04-01T04:00:00",
116+
{"days": -1},
117+
{"seconds": -1},
118+
"2024-03-31T04:00:00+00:00",
119+
id="utc-control",
120+
),
121+
pytest.param(
122+
None,
123+
"2024-04-01T04:00:00",
124+
{"days": -1},
125+
{"seconds": -1},
126+
"2024-03-31T04:00:00",
127+
id="naive-control",
128+
),
129+
pytest.param(
130+
5.5,
131+
"2024-01-31T04:00:00",
132+
{"months": 1},
133+
{"seconds": -0.25, "microseconds": -1000001},
134+
"2024-02-29T04:00:00+05:30",
135+
id="fixed-offset-month-clamping",
136+
),
137+
],
138+
)
139+
def test_mixed_arithmetic_applies_calendar_then_elapsed_time(
140+
method: str,
141+
sign: int,
142+
tz: str | float | None,
143+
start: str,
144+
calendar_units: dict[str, int | float],
145+
fixed_units: dict[str, int | float],
146+
calendar_target: str,
147+
) -> None:
148+
native_start = datetime.fromisoformat(start)
149+
dt = pendulum.DateTime.create(
150+
native_start.year,
151+
native_start.month,
152+
native_start.day,
153+
native_start.hour,
154+
native_start.minute,
155+
native_start.second,
156+
native_start.microsecond,
157+
tz=tz,
158+
)
159+
operation = getattr(dt, method)
160+
calendar_args = {unit: sign * value for unit, value in calendar_units.items()}
161+
fixed_args = {unit: sign * value for unit, value in fixed_units.items()}
162+
163+
# These explicit targets verify calendar shifting/normalization independently
164+
# of the mixed call. The elapsed-time oracle uses only stdlib arithmetic.
165+
calendar_result = operation(**calendar_args)
166+
assert calendar_result.isoformat() == calendar_target
167+
expected_calendar = datetime.fromisoformat(calendar_target)
168+
elapsed = timedelta(**fixed_units)
169+
if tz is None:
170+
expected = expected_calendar + elapsed
171+
else:
172+
native_tz = (
173+
ZoneInfo(tz) if isinstance(tz, str) else timezone(timedelta(hours=tz))
174+
)
175+
expected = (expected_calendar.astimezone(timezone.utc) + elapsed).astimezone(
176+
native_tz
177+
)
178+
179+
result = operation(**calendar_args, **fixed_args)
180+
assert result.isoformat() == expected.isoformat()
181+
assert result.tzinfo is dt.tzinfo
182+
if expected.replace(fold=0).utcoffset() != expected.replace(fold=1).utcoffset():
183+
assert result.fold == expected.fold
184+
185+
186+
@pytest.mark.parametrize("tz", ["Europe/Sofia", None])
187+
def test_mixed_arithmetic_preserves_subclass(tz: str | None) -> None:
188+
class Subclass(pendulum.DateTime):
189+
pass
190+
191+
dt = Subclass.create(2024, 4, 1, 4, tz=tz)
192+
result = dt.subtract(days=1, seconds=0.5)
193+
194+
assert type(result) is Subclass
195+
assert result.tzinfo is dt.tzinfo
196+
assert result.isoformat() == (
197+
"2024-03-31T02:59:59.500000+02:00"
198+
if tz is not None
199+
else "2024-03-31T03:59:59.500000"
200+
)
201+
202+
203+
def test_naive_mixed_arithmetic_preserves_fractional_rounding() -> None:
204+
dt = pendulum.naive(2024, 4, 1, 4)
205+
days = 0.6 / 86_400_000_000
206+
seconds = 0.0000006
207+
208+
# Round the combined duration once, as before, for naive datetimes.
209+
result = dt.add(days=days, seconds=seconds) # type: ignore[arg-type]
210+
expected = datetime(2024, 4, 1, 4) + timedelta(days=days, seconds=seconds)
211+
212+
assert result.isoformat() == expected.isoformat()
213+
assert result.tzinfo is None

0 commit comments

Comments
 (0)