From 7cd4ec75bdd3711266332429974645b86fc2468f Mon Sep 17 00:00:00 2001 From: Apoorv Darshan Date: Mon, 6 Jul 2026 15:30:56 +0530 Subject: [PATCH 1/2] Fix #807: handle pytz.FixedOffset in pendulum.instance() pendulum.instance() raised 'AttributeError: NoneType object has no attribute lower' for datetimes whose tzinfo is a pytz.FixedOffset. _safe_timezone() detects pytz zones via their localize() method and then uses their .zone name. Named pytz zones carry a zone string, but fixed-offset zones (pytz.FixedOffset) have zone = None, so that None was passed to timezone(), which crashed. Fall back to the tzinfo's UTC offset when there is no zone name, mirroring how other offset-only tzinfos are handled. Added a regression test (skipped when pytz is unavailable). --- CHANGELOG.md | 5 +++++ src/pendulum/__init__.py | 12 +++++++++++- tests/test_main.py | 15 +++++++++++++++ 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 58bb1312..5c0d68a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Change Log +## [Unreleased] + +### Changed +- Fixed `pendulum.instance()` raising `AttributeError` for datetimes with a `pytz.FixedOffset` timezone [#981](https://github.com/python-pendulum/pendulum/pull/981) + ## [3.2.0] - 2026-01-30 ### Added diff --git a/src/pendulum/__init__.py b/src/pendulum/__init__.py index 16ae0865..71425cd9 100644 --- a/src/pendulum/__init__.py +++ b/src/pendulum/__init__.py @@ -105,7 +105,17 @@ def _safe_timezone( obj = obj.key # pytz elif hasattr(obj, "localize"): - obj = obj.zone # type: ignore[attr-defined] + # Named pytz zones expose a ``zone`` name, but fixed-offset zones + # (``pytz.FixedOffset``) have ``zone = None``; use their offset. + if obj.zone is not None: # type: ignore[attr-defined] + obj = obj.zone # type: ignore[attr-defined] + else: + offset = obj.utcoffset(dt) + + if offset is None: + offset = _datetime.timedelta(0) + + obj = int(offset.total_seconds()) elif obj.tzname(None) == "UTC": return UTC else: diff --git a/tests/test_main.py b/tests/test_main.py index 76033450..e7a5ac29 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -5,6 +5,9 @@ from datetime import date from datetime import datetime from datetime import time +from datetime import timedelta + +import pytest from dateutil import tz @@ -36,6 +39,18 @@ def test_instance_with_aware_datetime_any_tzinfo() -> None: assert now.timezone_name == "+02:00" +def test_instance_with_pytz_fixed_offset() -> None: + # ``pytz.FixedOffset`` has a ``localize`` method but no ``zone`` name, + # which used to raise ``AttributeError`` in ``_safe_timezone`` (#807). + pytz = pytest.importorskip("pytz") + + dt = pendulum.instance(datetime(2021, 2, 3, tzinfo=pytz.FixedOffset(60))) + assert dt.utcoffset() == timedelta(minutes=60) + + dt = pendulum.instance(datetime(2021, 2, 3, tzinfo=pytz.FixedOffset(-330))) + assert dt.utcoffset() == timedelta(minutes=-330) + + def test_instance_with_date() -> None: dt = pendulum.instance(date(2022, 12, 23)) From ae59e54c02e6dc850cc0503e232f4537c7657c77 Mon Sep 17 00:00:00 2001 From: Apoorv Darshan Date: Thu, 6 Aug 2026 13:07:39 +0530 Subject: [PATCH 2/2] Make fixed-offset regression test self-contained --- CHANGELOG.md | 5 ----- tests/test_main.py | 30 +++++++++++++++++++++++------- 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c0d68a6..58bb1312 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,5 @@ # Change Log -## [Unreleased] - -### Changed -- Fixed `pendulum.instance()` raising `AttributeError` for datetimes with a `pytz.FixedOffset` timezone [#981](https://github.com/python-pendulum/pendulum/pull/981) - ## [3.2.0] - 2026-01-30 ### Added diff --git a/tests/test_main.py b/tests/test_main.py index e7a5ac29..c9502397 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -6,8 +6,7 @@ from datetime import datetime from datetime import time from datetime import timedelta - -import pytest +from datetime import tzinfo from dateutil import tz @@ -39,15 +38,32 @@ def test_instance_with_aware_datetime_any_tzinfo() -> None: assert now.timezone_name == "+02:00" -def test_instance_with_pytz_fixed_offset() -> None: +class PytzLikeFixedOffset(tzinfo): + zone: str | None = None + + def __init__(self, minutes: int) -> None: + self._offset = timedelta(minutes=minutes) + + def localize(self, dt: datetime) -> datetime: + return dt.replace(tzinfo=self) + + def utcoffset(self, dt: datetime | None) -> timedelta: + return self._offset + + def dst(self, dt: datetime | None) -> timedelta: + return timedelta(0) + + def tzname(self, dt: datetime | None) -> str: + return str(self._offset) + + +def test_instance_with_pytz_like_fixed_offset() -> None: # ``pytz.FixedOffset`` has a ``localize`` method but no ``zone`` name, # which used to raise ``AttributeError`` in ``_safe_timezone`` (#807). - pytz = pytest.importorskip("pytz") - - dt = pendulum.instance(datetime(2021, 2, 3, tzinfo=pytz.FixedOffset(60))) + dt = pendulum.instance(datetime(2021, 2, 3, tzinfo=PytzLikeFixedOffset(60))) assert dt.utcoffset() == timedelta(minutes=60) - dt = pendulum.instance(datetime(2021, 2, 3, tzinfo=pytz.FixedOffset(-330))) + dt = pendulum.instance(datetime(2021, 2, 3, tzinfo=PytzLikeFixedOffset(-330))) assert dt.utcoffset() == timedelta(minutes=-330)