From d2de48f61c10a93ca36c0f7d00ecc6da5a029770 Mon Sep 17 00:00:00 2001 From: Rakesh Pai <41351936+developer-rpai@users.noreply.github.com> Date: Mon, 12 Oct 2026 11:43:05 -0700 Subject: [PATCH] Fix instance() ignoring the tz argument for aware datetimes The tz argument of pendulum.instance() and DateTime.instance() was silently ignored whenever the given datetime already carried a timezone: the datetime's own tzinfo always won and no conversion happened. An explicitly passed timezone now takes precedence. Aware datetimes are converted to it, naive ones are interpreted in it as before. When no timezone is passed, the previous behavior is unchanged: aware datetimes keep their timezone and naive ones default to UTC. DateTime.utcfromtimestamp(), fromordinal() and combine() relied on passing tz=None to stay naive, so they now build their result directly instead of going through instance(). --- src/pendulum/__init__.py | 16 +++++++---- src/pendulum/datetime.py | 62 +++++++++++++++++++++++++++++++++------- src/pendulum/parser.py | 8 +++--- src/pendulum/time.py | 8 +++--- tests/test_main.py | 36 +++++++++++++++++++++++ 5 files changed, 107 insertions(+), 23 deletions(-) diff --git a/src/pendulum/__init__.py b/src/pendulum/__init__.py index 16ae0865c..73a71c347 100644 --- a/src/pendulum/__init__.py +++ b/src/pendulum/__init__.py @@ -201,32 +201,38 @@ def time(hour: int, minute: int = 0, second: int = 0, microsecond: int = 0) -> T @overload def instance( obj: _datetime.datetime, - tz: str | Timezone | FixedTimezone | _datetime.tzinfo | None = UTC, + tz: str | Timezone | FixedTimezone | _datetime.tzinfo | None = None, ) -> DateTime: ... @overload def instance( obj: _datetime.date, - tz: str | Timezone | FixedTimezone | _datetime.tzinfo | None = UTC, + tz: str | Timezone | FixedTimezone | _datetime.tzinfo | None = None, ) -> Date: ... @overload def instance( obj: _datetime.time, - tz: str | Timezone | FixedTimezone | _datetime.tzinfo | None = UTC, + tz: str | Timezone | FixedTimezone | _datetime.tzinfo | None = None, ) -> Time: ... def instance( obj: _datetime.datetime | _datetime.date | _datetime.time, - tz: str | Timezone | FixedTimezone | _datetime.tzinfo | None = UTC, + tz: str | Timezone | FixedTimezone | _datetime.tzinfo | None = None, ) -> DateTime | Date | Time: """ Create a DateTime/Date/Time instance from a datetime/date/time native one. """ - if isinstance(obj, (DateTime, Date, Time)): + if isinstance(obj, DateTime): + if tz is not None: + return DateTime.instance(obj, tz=tz) + + return obj + + if isinstance(obj, (Date, Time)): return obj if isinstance(obj, _datetime.date) and not isinstance(obj, _datetime.datetime): diff --git a/src/pendulum/datetime.py b/src/pendulum/datetime.py index 96aff25b2..a6c1951cf 100644 --- a/src/pendulum/datetime.py +++ b/src/pendulum/datetime.py @@ -128,13 +128,19 @@ def create( def instance( cls, dt: datetime.datetime, - tz: str | Timezone | FixedTimezone | datetime.tzinfo | None = UTC, + tz: str | Timezone | FixedTimezone | datetime.tzinfo | None = None, ) -> Self: - tz = dt.tzinfo or tz - if tz is not None: + # An explicitly given timezone takes precedence over the one + # carried by the datetime: aware datetimes are converted to it, + # while naive ones are interpreted in it. tz = pendulum._safe_timezone(tz, dt=dt) + if dt.tzinfo is not None: + dt = dt.astimezone(tz) + else: + tz = pendulum._safe_timezone(dt.tzinfo or UTC, dt=dt) + return cls.create( dt.year, dt.month, @@ -1256,16 +1262,26 @@ def fromtimestamp(cls, t: float, tz: datetime.tzinfo | None = None) -> Self: @classmethod def utcfromtimestamp(cls, t: float) -> Self: - return cls.instance( - datetime.datetime.fromtimestamp(t, tz=datetime.timezone.utc).replace( - tzinfo=None - ), - tz=None, + dt = datetime.datetime.fromtimestamp(t, tz=datetime.timezone.utc).replace( + tzinfo=None + ) + + return cls( + dt.year, + dt.month, + dt.day, + dt.hour, + dt.minute, + dt.second, + dt.microsecond, + fold=dt.fold, ) @classmethod def fromordinal(cls, n: int) -> Self: - return cls.instance(datetime.datetime.fromordinal(n), tz=None) + dt = datetime.datetime.fromordinal(n) + + return cls(dt.year, dt.month, dt.day) @classmethod def combine( @@ -1274,7 +1290,33 @@ def combine( time: datetime.time, tzinfo: datetime.tzinfo | None = None, ) -> Self: - return cls.instance(datetime.datetime.combine(date, time), tz=tzinfo) + dt = datetime.datetime.combine(date, time) + + tz = tzinfo if tzinfo is not None else dt.tzinfo + + if tz is None: + return cls( + dt.year, + dt.month, + dt.day, + dt.hour, + dt.minute, + dt.second, + dt.microsecond, + fold=dt.fold, + ) + + return cls.create( + dt.year, + dt.month, + dt.day, + dt.hour, + dt.minute, + dt.second, + dt.microsecond, + tz=tz, + fold=dt.fold, + ) def astimezone(self, tz: datetime.tzinfo | None = None) -> Self: # Convert a plain datetime: tzinfo.fromutc() implementations add diff --git a/src/pendulum/parser.py b/src/pendulum/parser.py index 833bae3c1..0f4980351 100644 --- a/src/pendulum/parser.py +++ b/src/pendulum/parser.py @@ -75,7 +75,7 @@ def _parse( duration = parsed.duration if parsed.start is not None: - dt = pendulum.instance(parsed.start, tz=options.get("tz", UTC)) + dt = pendulum.instance(parsed.start, tz=options.get("tz")) return pendulum.interval( dt, @@ -92,7 +92,7 @@ def _parse( ) dt = pendulum.instance( - t.cast("datetime.datetime", parsed.end), tz=options.get("tz", UTC) + t.cast("datetime.datetime", parsed.end), tz=options.get("tz") ) return pendulum.interval( @@ -111,10 +111,10 @@ def _parse( return pendulum.interval( pendulum.instance( - t.cast("datetime.datetime", parsed.start), tz=options.get("tz", UTC) + t.cast("datetime.datetime", parsed.start), tz=options.get("tz") ), pendulum.instance( - t.cast("datetime.datetime", parsed.end), tz=options.get("tz", UTC) + t.cast("datetime.datetime", parsed.end), tz=options.get("tz") ), ) diff --git a/src/pendulum/time.py b/src/pendulum/time.py index 58ca7e819..a38f741d2 100644 --- a/src/pendulum/time.py +++ b/src/pendulum/time.py @@ -36,12 +36,12 @@ class Time(FormattableMixin, time): @classmethod def instance( - cls, t: time, tz: str | Timezone | FixedTimezone | datetime.tzinfo | None = UTC + cls, t: time, tz: str | Timezone | FixedTimezone | datetime.tzinfo | None = None ) -> Self: - tz = t.tzinfo or tz + if tz is None: + tz = t.tzinfo or UTC - if tz is not None: - tz = pendulum._safe_timezone(tz) + tz = pendulum._safe_timezone(tz) return cls(t.hour, t.minute, t.second, t.microsecond, tzinfo=tz, fold=t.fold) diff --git a/tests/test_main.py b/tests/test_main.py index 76033450a..a86f02286 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -36,6 +36,42 @@ def test_instance_with_aware_datetime_any_tzinfo() -> None: assert now.timezone_name == "+02:00" +def test_instance_with_aware_datetime_and_explicit_tz_converts() -> None: + dt = datetime(2024, 9, 3, 13, 56, 35, tzinfo=timezone("Europe/Brussels")) + now = pendulum.instance(dt, tz="EST") + + assert now.timezone_name == "EST" + # 13:56 in Brussels (UTC+2) is 06:56 EST (UTC-5) + assert (now.hour, now.minute, now.second) == (6, 56, 35) + assert now == dt + + +def test_instance_with_pendulum_datetime_and_explicit_tz_converts() -> None: + dt = pendulum.datetime(2024, 9, 3, 13, 56, 35, tz="Europe/Brussels") + now = pendulum.instance(dt, tz="EST") + + assert now.timezone_name == "EST" + assert (now.hour, now.minute, now.second) == (6, 56, 35) + assert now == dt + + +def test_instance_with_naive_datetime_and_explicit_tz() -> None: + now = pendulum.instance(datetime(2024, 9, 3, 13, 56, 35), tz="Europe/Paris") + + assert now.timezone_name == "Europe/Paris" + assert (now.hour, now.minute, now.second) == (13, 56, 35) + + +def test_datetime_instance_with_explicit_tz_converts() -> None: + dt = pendulum.datetime(2024, 9, 3, 13, 56, 35, tz="Europe/Brussels") + converted = pendulum.DateTime.instance(dt, tz="America/New_York") + + assert converted.timezone_name == "America/New_York" + # 13:56 in Brussels (UTC+2) is 07:56 in New York (UTC-4) + assert (converted.hour, converted.minute, converted.second) == (7, 56, 35) + assert converted == dt + + def test_instance_with_date() -> None: dt = pendulum.instance(date(2022, 12, 23))