Skip to content

Commit dd3738f

Browse files
committed
Fix astimezone conversion with dateutil timezones
1 parent 0d71391 commit dd3738f

2 files changed

Lines changed: 73 additions & 1 deletion

File tree

src/pendulum/datetime.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1273,7 +1273,19 @@ def combine(
12731273
return cls.instance(datetime.datetime.combine(date, time), tz=tzinfo)
12741274

12751275
def astimezone(self, tz: datetime.tzinfo | None = None) -> Self:
1276-
dt = super().astimezone(tz)
1276+
# Timezone implementations may do arithmetic in fromutc(), which must
1277+
# use datetime's rules rather than Pendulum's duration arithmetic.
1278+
dt = datetime.datetime(
1279+
self.year,
1280+
self.month,
1281+
self.day,
1282+
self.hour,
1283+
self.minute,
1284+
self.second,
1285+
self.microsecond,
1286+
tzinfo=self.tzinfo,
1287+
fold=self.fold,
1288+
).astimezone(tz)
12771289

12781290
return self.__class__(
12791291
dt.year,

tests/datetime/test_timezone.py

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

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+
from dateutil import tz
11+
312
import pendulum
413

514
from tests.conftest import assert_datetime
@@ -36,3 +45,54 @@ def test_astimezone():
3645
d = d.astimezone(pendulum.timezone("Europe/Paris"))
3746
assert d.timezone_name == "Europe/Paris"
3847
assert_datetime(d, now.year, now.month, now.day, now.hour + 1, now.minute)
48+
49+
50+
class CustomDateTime(pendulum.DateTime):
51+
pass
52+
53+
54+
@pytest.mark.parametrize("datetime_class", [pendulum.DateTime, CustomDateTime])
55+
@pytest.mark.parametrize(
56+
"target",
57+
[
58+
pytest.param(tz.gettz("Etc/GMT+6"), id="dateutil-fixed-zone"),
59+
pytest.param(tz.gettz("Europe/Paris"), id="dateutil-dst-zone"),
60+
pytest.param(tz.tzoffset("offset", 19800), id="dateutil-offset"),
61+
pytest.param(timezone(timedelta(hours=5, minutes=30)), id="builtin"),
62+
pytest.param(ZoneInfo("Europe/Paris"), id="zoneinfo"),
63+
pytest.param(pendulum.timezone("Europe/Paris"), id="pendulum"),
64+
pytest.param(None, id="local"),
65+
],
66+
)
67+
@pytest.mark.parametrize(
68+
"source",
69+
[
70+
datetime(2024, 1, 15, 18, 15, 34, 123456, tzinfo=timezone.utc),
71+
datetime(2024, 7, 15, 18, 15, 34, 123456, tzinfo=timezone.utc),
72+
datetime(2024, 10, 27, 0, 30, tzinfo=timezone.utc),
73+
datetime(2024, 10, 27, 1, 30, tzinfo=timezone.utc),
74+
datetime(2024, 10, 27, 2, 30, tzinfo=ZoneInfo("Europe/Paris"), fold=1),
75+
datetime(2024, 1, 15, 18, 15, 34, 123456),
76+
],
77+
)
78+
def test_astimezone_matches_datetime(datetime_class, target, source):
79+
value = datetime_class(
80+
source.year,
81+
source.month,
82+
source.day,
83+
source.hour,
84+
source.minute,
85+
source.second,
86+
source.microsecond,
87+
tzinfo=source.tzinfo,
88+
fold=source.fold,
89+
)
90+
91+
expected = source.astimezone(target)
92+
actual = value.astimezone(target)
93+
94+
assert type(actual) is datetime_class
95+
assert actual.tzinfo == expected.tzinfo
96+
assert actual.isoformat() == expected.isoformat()
97+
assert actual.timestamp() == expected.timestamp()
98+
assert actual.fold == expected.fold

0 commit comments

Comments
 (0)