From b3318c4124b4c72d8e4bbf087c1bf95d386dc79a Mon Sep 17 00:00:00 2001 From: varun Date: Sat, 10 Oct 2026 03:40:05 +0530 Subject: [PATCH 1/3] Allow pickling a Timezone built from the local-timezone file fallback --- src/pendulum/tz/timezone.py | 36 +++++++++++++++++++++++++++++ tests/tz/test_timezone.py | 46 +++++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+) diff --git a/src/pendulum/tz/timezone.py b/src/pendulum/tz/timezone.py index 91c5ae21..9f802884 100644 --- a/src/pendulum/tz/timezone.py +++ b/src/pendulum/tz/timezone.py @@ -2,11 +2,13 @@ from __future__ import annotations import datetime as _datetime +import io import zoneinfo from abc import ABC from abc import abstractmethod from typing import TYPE_CHECKING +from typing import Any from typing import TypeVar from typing import cast @@ -16,6 +18,9 @@ if TYPE_CHECKING: + from collections.abc import Callable + from zoneinfo._common import _IOBytes + from typing_extensions import Self POST_TRANSITION = "post" @@ -60,12 +65,43 @@ class Timezone(zoneinfo.ZoneInfo, PendulumTimezone): >>> tz = Timezone('Europe/Paris') """ + _file_bytes: bytes | None = None + def __new__(cls, key: str) -> Self: try: return super().__new__(cls, key) except zoneinfo.ZoneInfoNotFoundError: raise InvalidTimezone(key) + @classmethod + def from_file(cls, fobj: _IOBytes, /, key: str | None = None) -> Self: + # zoneinfo.ZoneInfo.from_file() refuses to pickle any instance built this way, + # key or no key, since it has no record of which file it came from to + # reconstruct on unpickling. get_local_timezone() falls back to this when the + # system's local timezone name can't be identified, so keep the raw TZif bytes + # around to rebuild an equivalent instance if this ever needs to be pickled. + data = fobj.read(-1) + instance = cast("Self", super().from_file(io.BytesIO(data), key=key)) + instance._file_bytes = data + + return instance + + def __reduce__( + self, + ) -> ( + tuple[Callable[[bytes, str | None], Self], tuple[bytes, str | None]] + | str + | tuple[Any, ...] + ): + if self._file_bytes is None: + return super().__reduce__() + + return self.__class__._from_pickled_file, (self._file_bytes, self.key) + + @classmethod + def _from_pickled_file(cls, data: bytes, key: str | None) -> Self: + return cls.from_file(io.BytesIO(data), key=key) + def __eq__(self, other: object) -> bool: return isinstance(other, Timezone) and self.key == other.key diff --git a/tests/tz/test_timezone.py b/tests/tz/test_timezone.py index 63840692..0b6ef5f4 100644 --- a/tests/tz/test_timezone.py +++ b/tests/tz/test_timezone.py @@ -1,5 +1,6 @@ from __future__ import annotations +import pickle import zoneinfo from datetime import datetime @@ -476,3 +477,48 @@ def test_repr(): tz = timezone("Europe/Paris") assert repr(tz) == "Timezone('Europe/Paris')" + + +def _paris_tzif_bytes() -> bytes: + # tzdata bundles the same IANA zoneinfo files the system copy under + # /usr/share/zoneinfo would have, in a location that works on every + # platform pendulum supports (including Windows, which has no system + # zoneinfo directory at all). + from importlib import resources + + return resources.files("tzdata.zoneinfo").joinpath("Europe", "Paris").read_bytes() + + +def test_from_file_without_a_key_can_be_pickled(): + # get_local_timezone()'s last-resort fallback (no /etc/timezone, no + # readable /etc/localtime symlink to derive a name from) reads the raw + # zoneinfo file and builds a Timezone this same way, with no key. + from io import BytesIO + + import pendulum.tz.timezone as timezone_module + + tz = timezone_module.Timezone.from_file(BytesIO(_paris_tzif_bytes())) + + assert tz.key is None + + unpickled = pickle.loads(pickle.dumps(tz)) + + assert unpickled.key is None + dt = datetime(2024, 7, 1, 12, tzinfo=unpickled) + assert dt.utcoffset() == timedelta(hours=2) # CEST, matches Europe/Paris in July + dt = datetime(2024, 1, 1, 12, tzinfo=unpickled) + assert dt.utcoffset() == timedelta(hours=1) # CET, matches Europe/Paris in January + + +def test_from_file_with_a_key_still_pickles_by_key(): + from io import BytesIO + + import pendulum.tz.timezone as timezone_module + + tz = timezone_module.Timezone.from_file( + BytesIO(_paris_tzif_bytes()), key="Europe/Paris" + ) + + unpickled = pickle.loads(pickle.dumps(tz)) + + assert unpickled.key == "Europe/Paris" From f6ad737ada08a8f07cffcbd3f6241a092623c6be Mon Sep 17 00:00:00 2001 From: varun Date: Sat, 10 Oct 2026 04:12:30 +0530 Subject: [PATCH 2/3] Trim multi-line comments down to one line each --- src/pendulum/tz/timezone.py | 6 +----- tests/tz/test_timezone.py | 9 ++------- 2 files changed, 3 insertions(+), 12 deletions(-) diff --git a/src/pendulum/tz/timezone.py b/src/pendulum/tz/timezone.py index 9f802884..28686e2d 100644 --- a/src/pendulum/tz/timezone.py +++ b/src/pendulum/tz/timezone.py @@ -75,11 +75,7 @@ def __new__(cls, key: str) -> Self: @classmethod def from_file(cls, fobj: _IOBytes, /, key: str | None = None) -> Self: - # zoneinfo.ZoneInfo.from_file() refuses to pickle any instance built this way, - # key or no key, since it has no record of which file it came from to - # reconstruct on unpickling. get_local_timezone() falls back to this when the - # system's local timezone name can't be identified, so keep the raw TZif bytes - # around to rebuild an equivalent instance if this ever needs to be pickled. + # zoneinfo.ZoneInfo can't pickle this, so keep the bytes to rebuild it. data = fobj.read(-1) instance = cast("Self", super().from_file(io.BytesIO(data), key=key)) instance._file_bytes = data diff --git a/tests/tz/test_timezone.py b/tests/tz/test_timezone.py index 0b6ef5f4..346ae725 100644 --- a/tests/tz/test_timezone.py +++ b/tests/tz/test_timezone.py @@ -480,19 +480,14 @@ def test_repr(): def _paris_tzif_bytes() -> bytes: - # tzdata bundles the same IANA zoneinfo files the system copy under - # /usr/share/zoneinfo would have, in a location that works on every - # platform pendulum supports (including Windows, which has no system - # zoneinfo directory at all). + # tzdata works the same on every platform pendulum supports, unlike /usr/share/zoneinfo. from importlib import resources return resources.files("tzdata.zoneinfo").joinpath("Europe", "Paris").read_bytes() def test_from_file_without_a_key_can_be_pickled(): - # get_local_timezone()'s last-resort fallback (no /etc/timezone, no - # readable /etc/localtime symlink to derive a name from) reads the raw - # zoneinfo file and builds a Timezone this same way, with no key. + # get_local_timezone()'s last-resort fallback builds a Timezone this same way, with no key. from io import BytesIO import pendulum.tz.timezone as timezone_module From 5982ec7b574606b5f1cc84fe5e79fc5708e220e2 Mon Sep 17 00:00:00 2001 From: varun Date: Sat, 10 Oct 2026 23:44:08 +0530 Subject: [PATCH 3/3] Drop the keyed from_file test - pendulum never calls it with a key --- tests/tz/test_timezone.py | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/tests/tz/test_timezone.py b/tests/tz/test_timezone.py index 346ae725..6ce475d3 100644 --- a/tests/tz/test_timezone.py +++ b/tests/tz/test_timezone.py @@ -503,17 +503,3 @@ def test_from_file_without_a_key_can_be_pickled(): assert dt.utcoffset() == timedelta(hours=2) # CEST, matches Europe/Paris in July dt = datetime(2024, 1, 1, 12, tzinfo=unpickled) assert dt.utcoffset() == timedelta(hours=1) # CET, matches Europe/Paris in January - - -def test_from_file_with_a_key_still_pickles_by_key(): - from io import BytesIO - - import pendulum.tz.timezone as timezone_module - - tz = timezone_module.Timezone.from_file( - BytesIO(_paris_tzif_bytes()), key="Europe/Paris" - ) - - unpickled = pickle.loads(pickle.dumps(tz)) - - assert unpickled.key == "Europe/Paris"