Skip to content

Commit 1eabc20

Browse files
authored
Fix time zone normalization bug in interval schedules (#19301)
1 parent 1083cf4 commit 1eabc20

File tree

2 files changed

+59
-38
lines changed

2 files changed

+59
-38
lines changed

src/prefect/server/schemas/schedules.py

Lines changed: 21 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -165,50 +165,33 @@ def _get_dates_generator(
165165

166166
if sys.version_info >= (3, 13):
167167
# `pendulum` is not supported in Python 3.13, so we use `whenever` instead
168-
from whenever import ZonedDateTime
168+
from whenever import PlainDateTime, ZonedDateTime
169169

170170
if start is None:
171171
start = ZonedDateTime.now("UTC").py_datetime()
172172

173-
if self.anchor_date.tzinfo is None:
174-
from whenever import PlainDateTime
175-
176-
anchor_zdt = PlainDateTime.from_py_datetime(self.anchor_date).assume_tz(
177-
"UTC"
178-
)
179-
elif isinstance(self.anchor_date.tzinfo, ZoneInfo):
180-
anchor_zdt = ZonedDateTime.from_py_datetime(self.anchor_date).to_tz(
181-
self.timezone or "UTC"
173+
target_timezone = self.timezone or "UTC"
174+
175+
def to_local_zdt(dt: datetime.datetime | None) -> ZonedDateTime | None:
176+
if dt is None:
177+
return None
178+
if dt.tzinfo is None:
179+
return PlainDateTime.from_py_datetime(dt).assume_tz(target_timezone)
180+
if isinstance(dt.tzinfo, ZoneInfo):
181+
return ZonedDateTime.from_py_datetime(dt).to_tz(target_timezone)
182+
# For offset-based tzinfo instances (e.g. datetime.timezone(+09:00)),
183+
# use astimezone to preserve the instant, then convert to ZonedDateTime.
184+
return ZonedDateTime.from_py_datetime(
185+
dt.astimezone(ZoneInfo(target_timezone))
182186
)
183-
else:
184-
# This case handles rogue tzinfo objects that `whenever` doesn't play will with
185-
anchor_zdt = ZonedDateTime.from_py_datetime(
186-
self.anchor_date.replace(
187-
tzinfo=ZoneInfo(self.anchor_date.tzname() or "UTC")
188-
)
189-
).to_tz(self.timezone or "UTC")
190187

191-
if start.tzinfo is None:
192-
local_start = PlainDateTime.from_py_datetime(start).assume_tz("UTC")
193-
elif isinstance(start.tzinfo, ZoneInfo):
194-
local_start = ZonedDateTime.from_py_datetime(start).to_tz(
195-
self.timezone or "UTC"
196-
)
197-
else:
198-
local_start = ZonedDateTime.from_py_datetime(
199-
start.replace(tzinfo=ZoneInfo(start.tzname() or "UTC"))
200-
).to_tz(self.timezone or "UTC")
201-
202-
if end is None:
203-
local_end = None
204-
elif isinstance(end.tzinfo, ZoneInfo):
205-
local_end = ZonedDateTime.from_py_datetime(end).to_tz(
206-
self.timezone or "UTC"
207-
)
208-
else:
209-
local_end = ZonedDateTime.from_py_datetime(
210-
end.replace(tzinfo=ZoneInfo(end.tzname() or "UTC"))
211-
).to_tz(self.timezone or "UTC")
188+
anchor_zdt = to_local_zdt(self.anchor_date)
189+
assert anchor_zdt is not None
190+
191+
local_start = to_local_zdt(start)
192+
assert local_start is not None
193+
194+
local_end = to_local_zdt(end)
212195

213196
offset = (
214197
local_start - anchor_zdt

tests/server/schemas/test_schedules.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,44 @@ async def test_get_dates_from_offset_naive_end(self):
261261
datetime(2022, 1, 3, tzinfo=ZoneInfo("UTC")),
262262
]
263263

264+
@pytest.mark.skipif(
265+
sys.version_info < (3, 13),
266+
reason="Bug only affects Python 3.13+ with whenever library",
267+
)
268+
async def test_offset_anchor_preserves_local_hour(self):
269+
"""
270+
Regression test for timezone bug from pendulum -> datetime/zoneinfo refactor.
271+
When an anchor_date has a fixed-offset tzinfo (e.g. timezone(timedelta(hours=9)))
272+
alongside a named timezone (e.g. "Asia/Tokyo"), the scheduler must preserve the
273+
instant rather than misinterpreting it as UTC.
274+
"""
275+
from datetime import timezone
276+
277+
anchor = datetime(
278+
2024,
279+
7,
280+
1,
281+
1,
282+
15,
283+
tzinfo=timezone(timedelta(hours=9)), # Asia/Tokyo offset
284+
)
285+
clock = IntervalSchedule(
286+
interval=timedelta(days=1),
287+
anchor_date=anchor,
288+
timezone="Asia/Tokyo",
289+
)
290+
291+
start = datetime(2025, 10, 23, tzinfo=ZoneInfo("UTC"))
292+
dates = await clock.get_dates(n=3, start=start)
293+
expected_first = datetime(2025, 10, 24, 1, 15, tzinfo=ZoneInfo("Asia/Tokyo"))
294+
295+
assert dates[0] == expected_first
296+
assert all(
297+
date.astimezone(ZoneInfo("Asia/Tokyo")).time() == expected_first.time()
298+
for date in dates
299+
)
300+
assert all(date.tzinfo.key == "Asia/Tokyo" for date in dates)
301+
264302

265303
class TestCreateCronSchedule:
266304
def test_create_cron_schedule(self):

0 commit comments

Comments
 (0)