Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/docs/timezones.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@ tries to provide an easy and accurate system to handle them properly.
can also be used with the standard ``datetime`` library with a few limitations.
See [Using the timezone library directly](#using-the-timezone-library-directly).

## Timezone equality

Named timezones compare equal when their names match. When the local timezone is
loaded from a file without a name, equality instead compares the timezone file
contents. This keeps different unnamed timezones distinct in sets and dictionary
keys, while preserving equality after pickling and unpickling the same timezone.

## Normalization

When you create a `DateTime` instance, the library will normalize it for the
Expand Down
8 changes: 7 additions & 1 deletion src/pendulum/tz/timezone.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,9 +99,15 @@ 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
if not isinstance(other, Timezone) or self.key != other.key:
return False

return self.key is not None or self._file_bytes == other._file_bytes

def __hash__(self) -> int:
if self.key is None:
return hash((Timezone, self.key, self._file_bytes))

return hash((Timezone, self.key))

@property
Expand Down
83 changes: 78 additions & 5 deletions tests/tz/test_timezone.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

from datetime import datetime
from datetime import timedelta
from io import BytesIO

import pytest

Expand All @@ -15,6 +16,7 @@
from pendulum.tz.exceptions import AmbiguousTime
from pendulum.tz.exceptions import NonExistingTime
from pendulum.tz.timezone import FixedTimezone
from pendulum.tz.timezone import Timezone
from tests.conftest import assert_datetime


Expand Down Expand Up @@ -479,20 +481,18 @@ def test_repr():
assert repr(tz) == "Timezone('Europe/Paris')"


def _paris_tzif_bytes() -> bytes:
def _tzif_bytes(name: str) -> bytes:
# 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()
return resources.files("tzdata.zoneinfo").joinpath(*name.split("/")).read_bytes()


def test_from_file_without_a_key_can_be_pickled():
# 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

tz = timezone_module.Timezone.from_file(BytesIO(_paris_tzif_bytes()))
tz = timezone_module.Timezone.from_file(BytesIO(_tzif_bytes("Europe/Paris")))

assert tz.key is None

Expand All @@ -503,3 +503,76 @@ 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


@pytest.mark.parametrize("name", ["UTC", "America/New_York", "Europe/London"])
def test_from_file_without_a_key_distinguishes_zones(name):
paris = Timezone.from_file(BytesIO(_tzif_bytes("Europe/Paris")))
other = Timezone.from_file(BytesIO(_tzif_bytes(name)))

assert paris.key is None
assert other.key is None
assert paris.utcoffset(datetime(2024, 7, 1)) != other.utcoffset(
datetime(2024, 7, 1)
)
assert paris != other
assert other != paris
assert len({paris, other}) == 2
zones = {paris: "Paris", other: name}
assert zones[paris] == "Paris"
assert zones[other] == name


def test_from_file_without_a_key_compares_file_contents():
data = _tzif_bytes("Europe/Paris")
first = Timezone.from_file(BytesIO(data))
second = Timezone.from_file(BytesIO(data))

assert first is not second
assert first == second
assert hash(first) == hash(second)
assert len({first, second}) == 1
assert {first: "Paris"}[second] == "Paris"


@pytest.mark.parametrize("protocol", range(pickle.HIGHEST_PROTOCOL + 1))
def test_from_file_without_a_key_preserves_equality_when_pickled(protocol):
original = Timezone.from_file(BytesIO(_tzif_bytes("Europe/Paris")))
restored = pickle.loads(pickle.dumps(original, protocol=protocol))

assert original is not restored
assert original == restored
assert hash(original) == hash(restored)
assert {original: "Paris"}[restored] == "Paris"


def test_from_file_named_equality_uses_the_key():
data = _tzif_bytes("Europe/Paris")
named = Timezone.from_file(BytesIO(data), key="Europe/Paris")
uncached = Timezone.no_cache("Europe/Paris")
unnamed = Timezone.from_file(BytesIO(data))

assert named == uncached
assert hash(named) == hash(uncached)
assert named == Timezone.from_file(BytesIO(_tzif_bytes("UTC")), key="Europe/Paris")
assert named != unnamed
assert unnamed != named
assert named != Timezone.from_file(BytesIO(data), key="custom")
assert unnamed != object()


def test_local_timezone_files_remain_distinct(tmp_path, monkeypatch):
from pendulum.tz.local_timezone import _get_unix_timezone

monkeypatch.delenv("TZ", raising=False)
localtime = tmp_path / "etc" / "localtime"
localtime.parent.mkdir()
localtime.write_bytes(_tzif_bytes("Europe/Paris"))
paris = _get_unix_timezone(_root=str(tmp_path))
localtime.write_bytes(_tzif_bytes("UTC"))
utc = _get_unix_timezone(_root=str(tmp_path))

assert paris.key is None
assert utc.key is None
assert paris != utc
assert len({paris, utc}) == 2
Loading