Skip to content

Commit f97c213

Browse files
author
adenyoung
committed
Preserve the fold when pickling DateTime
`DateTime.__reduce_ex__()` returned the class plus the 8-tuple state, so the fold flag never made it into the pickle stream. A `DateTime` on the second occurrence of an ambiguous wall time came back as the first one, with a different offset and a timestamp shifted by an hour: ```python chicago = pendulum.timezone("America/Chicago") after = pendulum.datetime(2024, 11, 3, 7, tz="utc").astimezone(chicago) # 2024-11-03 01:00:00-06:00, fold=1, timestamp 1730617200.0 pickle.loads(pickle.dumps(after)).timestamp() # Before: 1730613600.0 (2024-11-03 01:00:00-05:00, fold=0) # After: 1730617200.0 ``` The standard library rebuilds a datetime from its state, but `fold` is keyword-only for `datetime.__new__()`, so it cannot be appended to the positional tuple that gets expanded into `cls(*args)`. Restore through a module-level function instead, which rebuilds the instance from its raw state without going through `create()` (no timezone conversion, no normalization of non-existent wall times, `tzinfo` kept as-is) and passes `fold` as a keyword argument. `copy.copy()` goes through the same `__reduce_ex__()` and is fixed as well. Pickles written by older versions still load: they store the class plus the 8-tuple, which still builds a valid instance; they simply cannot carry a fold they never contained. Closes #908
1 parent e06dace commit f97c213

2 files changed

Lines changed: 51 additions & 11 deletions

File tree

‎src/pendulum/datetime.py‎

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,22 @@
5050
_MONTH_CALENDAR = calendar.Calendar(firstweekday=calendar.MONDAY)
5151

5252

53+
def _restore_datetime(
54+
cls: type[DateTime],
55+
year: int,
56+
month: int,
57+
day: int,
58+
hour: int,
59+
minute: int,
60+
second: int,
61+
microsecond: int,
62+
tzinfo: datetime.tzinfo | None,
63+
fold: int,
64+
) -> DateTime:
65+
"""Rebuild a DateTime from the state produced by ``DateTime._getstate()``."""
66+
return cls(year, month, day, hour, minute, second, microsecond, tzinfo, fold=fold)
67+
68+
5369
class DateTime(datetime.datetime, Date):
5470
EPOCH: ClassVar[DateTime]
5571
min: ClassVar[DateTime]
@@ -1367,21 +1383,13 @@ def _getstate(
13671383
self.tzinfo,
13681384
)
13691385

1370-
def __reduce__(
1371-
self,
1372-
) -> tuple[
1373-
type[Self],
1374-
tuple[int, int, int, int, int, int, int, datetime.tzinfo | None],
1375-
]:
1386+
def __reduce__(self) -> tuple[Callable[..., DateTime], tuple[Any, ...]]:
13761387
return self.__reduce_ex__(2)
13771388

13781389
def __reduce_ex__(
13791390
self, protocol: SupportsIndex
1380-
) -> tuple[
1381-
type[Self],
1382-
tuple[int, int, int, int, int, int, int, datetime.tzinfo | None],
1383-
]:
1384-
return self.__class__, self._getstate(protocol)
1391+
) -> tuple[Callable[..., DateTime], tuple[Any, ...]]:
1392+
return _restore_datetime, (self.__class__, *self._getstate(protocol), self.fold)
13851393

13861394
def __deepcopy__(self, _: dict[int, Self]) -> Self:
13871395
return self.__class__(

‎tests/datetime/test_behavior.py‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import pickle
44
import zoneinfo
55

6+
from copy import copy
67
from copy import deepcopy
78
from datetime import date
89
from datetime import datetime
@@ -166,6 +167,37 @@ def test_pickle():
166167
assert dt1 == dt2
167168

168169

170+
@pytest.mark.parametrize("protocol", range(pickle.HIGHEST_PROTOCOL + 1))
171+
@pytest.mark.parametrize("fold", [0, 1])
172+
def test_pickle_preserves_fold_and_instant(protocol, fold):
173+
original = pendulum.datetime(2024, 11, 3, 1, 30, tz="America/Chicago", fold=fold)
174+
restored = pickle.loads(pickle.dumps(original, protocol=protocol))
175+
176+
assert type(restored) is type(original)
177+
assert restored.fold == fold
178+
assert restored.utcoffset() == original.utcoffset()
179+
assert restored.timestamp() == original.timestamp()
180+
181+
182+
def test_shallow_copy_preserves_fold_and_instant():
183+
original = pendulum.datetime(2024, 11, 3, 1, 30, tz="America/Chicago", fold=1)
184+
restored = copy(original)
185+
186+
assert restored.fold == original.fold
187+
assert restored.utcoffset() == original.utcoffset()
188+
assert restored.timestamp() == original.timestamp()
189+
190+
191+
def test_pickle_keeps_tzinfo():
192+
tz = zoneinfo.ZoneInfo("America/Chicago")
193+
original = pendulum.DateTime(2024, 11, 3, 1, 30, tzinfo=tz, fold=1)
194+
restored = pickle.loads(pickle.dumps(original))
195+
196+
assert type(restored.tzinfo) is zoneinfo.ZoneInfo
197+
assert restored.utcoffset() == original.utcoffset()
198+
assert restored.timestamp() == original.timestamp()
199+
200+
169201
def test_pickle_with_integer_tzinfo():
170202
dt1 = pendulum.datetime(2016, 8, 27, 12, 34, 56, 123456, tz=0)
171203
s = pickle.dumps(dt1)

0 commit comments

Comments
 (0)