Skip to content

Commit 7840d47

Browse files
authored
DateTime can't be pickled when the container's timezone was set by copying /etc/localtime (#1040)
Kept the raw TZif bytes around on the instance when built via from_file(), and used them to rebuild an equivalent instance on unpickling instead of inheriting zoneinfo's version, which unconditionally rejects this. Regular Timezone(key) instances still pickle by key as before.
1 parent 24ff0e5 commit 7840d47

2 files changed

Lines changed: 59 additions & 0 deletions

File tree

‎src/pendulum/tz/timezone.py‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@
22
from __future__ import annotations
33

44
import datetime as _datetime
5+
import io
56
import zoneinfo
67

78
from abc import ABC
89
from abc import abstractmethod
910
from typing import TYPE_CHECKING
11+
from typing import Any
1012
from typing import TypeVar
1113
from typing import cast
1214

@@ -16,6 +18,9 @@
1618

1719

1820
if TYPE_CHECKING:
21+
from collections.abc import Callable
22+
from zoneinfo._common import _IOBytes
23+
1924
from typing_extensions import Self
2025

2126
POST_TRANSITION = "post"
@@ -60,12 +65,39 @@ class Timezone(zoneinfo.ZoneInfo, PendulumTimezone):
6065
>>> tz = Timezone('Europe/Paris')
6166
"""
6267

68+
_file_bytes: bytes | None = None
69+
6370
def __new__(cls, key: str) -> Self:
6471
try:
6572
return super().__new__(cls, key)
6673
except zoneinfo.ZoneInfoNotFoundError:
6774
raise InvalidTimezone(key)
6875

76+
@classmethod
77+
def from_file(cls, fobj: _IOBytes, /, key: str | None = None) -> Self:
78+
# zoneinfo.ZoneInfo can't pickle this, so keep the bytes to rebuild it.
79+
data = fobj.read(-1)
80+
instance = cast("Self", super().from_file(io.BytesIO(data), key=key))
81+
instance._file_bytes = data
82+
83+
return instance
84+
85+
def __reduce__(
86+
self,
87+
) -> (
88+
tuple[Callable[[bytes, str | None], Self], tuple[bytes, str | None]]
89+
| str
90+
| tuple[Any, ...]
91+
):
92+
if self._file_bytes is None:
93+
return super().__reduce__()
94+
95+
return self.__class__._from_pickled_file, (self._file_bytes, self.key)
96+
97+
@classmethod
98+
def _from_pickled_file(cls, data: bytes, key: str | None) -> Self:
99+
return cls.from_file(io.BytesIO(data), key=key)
100+
69101
def __eq__(self, other: object) -> bool:
70102
return isinstance(other, Timezone) and self.key == other.key
71103

‎tests/tz/test_timezone.py‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import pickle
34
import zoneinfo
45

56
from datetime import datetime
@@ -476,3 +477,29 @@ def test_repr():
476477
tz = timezone("Europe/Paris")
477478

478479
assert repr(tz) == "Timezone('Europe/Paris')"
480+
481+
482+
def _paris_tzif_bytes() -> bytes:
483+
# tzdata works the same on every platform pendulum supports, unlike /usr/share/zoneinfo.
484+
from importlib import resources
485+
486+
return resources.files("tzdata.zoneinfo").joinpath("Europe", "Paris").read_bytes()
487+
488+
489+
def test_from_file_without_a_key_can_be_pickled():
490+
# get_local_timezone()'s last-resort fallback builds a Timezone this same way, with no key.
491+
from io import BytesIO
492+
493+
import pendulum.tz.timezone as timezone_module
494+
495+
tz = timezone_module.Timezone.from_file(BytesIO(_paris_tzif_bytes()))
496+
497+
assert tz.key is None
498+
499+
unpickled = pickle.loads(pickle.dumps(tz))
500+
501+
assert unpickled.key is None
502+
dt = datetime(2024, 7, 1, 12, tzinfo=unpickled)
503+
assert dt.utcoffset() == timedelta(hours=2) # CEST, matches Europe/Paris in July
504+
dt = datetime(2024, 1, 1, 12, tzinfo=unpickled)
505+
assert dt.utcoffset() == timedelta(hours=1) # CET, matches Europe/Paris in January

0 commit comments

Comments
 (0)