diff --git a/src/pendulum/datetime.py b/src/pendulum/datetime.py index da89b13db..cada7f9a8 100644 --- a/src/pendulum/datetime.py +++ b/src/pendulum/datetime.py @@ -1273,7 +1273,19 @@ def combine( return cls.instance(datetime.datetime.combine(date, time), tz=tzinfo) def astimezone(self, tz: datetime.tzinfo | None = None) -> Self: - dt = super().astimezone(tz) + # Timezone implementations may do arithmetic in fromutc(), which must + # use datetime's rules rather than Pendulum's duration arithmetic. + dt = datetime.datetime( + self.year, + self.month, + self.day, + self.hour, + self.minute, + self.second, + self.microsecond, + tzinfo=self.tzinfo, + fold=self.fold, + ).astimezone(tz) return self.__class__( dt.year, diff --git a/tests/datetime/test_timezone.py b/tests/datetime/test_timezone.py index 343435d86..3c5b0ce61 100644 --- a/tests/datetime/test_timezone.py +++ b/tests/datetime/test_timezone.py @@ -1,5 +1,14 @@ from __future__ import annotations +from datetime import datetime +from datetime import timedelta +from datetime import timezone +from zoneinfo import ZoneInfo + +import pytest + +from dateutil import tz + import pendulum from tests.conftest import assert_datetime @@ -36,3 +45,54 @@ def test_astimezone(): d = d.astimezone(pendulum.timezone("Europe/Paris")) assert d.timezone_name == "Europe/Paris" assert_datetime(d, now.year, now.month, now.day, now.hour + 1, now.minute) + + +class CustomDateTime(pendulum.DateTime): + pass + + +@pytest.mark.parametrize("datetime_class", [pendulum.DateTime, CustomDateTime]) +@pytest.mark.parametrize( + "target", + [ + pytest.param(tz.gettz("Etc/GMT+6"), id="dateutil-fixed-zone"), + pytest.param(tz.gettz("Europe/Paris"), id="dateutil-dst-zone"), + pytest.param(tz.tzoffset("offset", 19800), id="dateutil-offset"), + pytest.param(timezone(timedelta(hours=5, minutes=30)), id="builtin"), + pytest.param(ZoneInfo("Europe/Paris"), id="zoneinfo"), + pytest.param(pendulum.timezone("Europe/Paris"), id="pendulum"), + pytest.param(None, id="local"), + ], +) +@pytest.mark.parametrize( + "source", + [ + datetime(2024, 1, 15, 18, 15, 34, 123456, tzinfo=timezone.utc), + datetime(2024, 7, 15, 18, 15, 34, 123456, tzinfo=timezone.utc), + datetime(2024, 10, 27, 0, 30, tzinfo=timezone.utc), + datetime(2024, 10, 27, 1, 30, tzinfo=timezone.utc), + datetime(2024, 10, 27, 2, 30, tzinfo=ZoneInfo("Europe/Paris"), fold=1), + datetime(2024, 1, 15, 18, 15, 34, 123456), + ], +) +def test_astimezone_matches_datetime(datetime_class, target, source): + value = datetime_class( + source.year, + source.month, + source.day, + source.hour, + source.minute, + source.second, + source.microsecond, + tzinfo=source.tzinfo, + fold=source.fold, + ) + + expected = source.astimezone(target) + actual = value.astimezone(target) + + assert type(actual) is datetime_class + assert actual.tzinfo == expected.tzinfo + assert actual.isoformat() == expected.isoformat() + assert actual.timestamp() == expected.timestamp() + assert actual.fold == expected.fold