Skip to content

Conversation

@cebtenzzre
Copy link
Contributor

@cebtenzzre cebtenzzre commented Dec 28, 2025

Problem

Users reported that run_daily() and other scheduler functions were scheduling callbacks at incorrect times, sometimes with unexpected offsets from the expected time. This regression was introduced after v4.4.2.

Example from issue:

run = datetime.time(8, 0, 0)
self.run_daily(self.run_daily_c, run, val='on')

Expected execution: 08:00:00
Actual execution: 09:07:00

What Caused the Regression

In v4.4.2, the code correctly handled pytz timezones:

# Old approach (correct)
event = datetime.combine(today, when)          # 1. Create NAIVE datetime
aware_event = self.AD.sched.convert_naive(event)  # 2. Localize with tz.localize()

The convert_naive() method properly used self.AD.tz.localize(dt), which is the required pattern for pytz timezones to correctly determine the UTC offset based on DST for that specific date.

When the time parsing was refactored into parse.py, this pattern was replaced with:

# New approach (broken)
result = datetime.combine(date, parsed_time, tzinfo=now.tzinfo)

This is incorrect for pytz timezones. Unlike standard tzinfo implementations, pytz timezones are stateful and require the localize() method to properly determine the UTC offset. When you pass a pytz timezone directly to datetime.combine() or use dt.replace(tzinfo=...), pytz may apply an incorrect default offset that doesn't account for DST on that specific date.

Solution

Instead of reverting to the old localize() pattern (which is pytz-specific and non-standard), we normalize pytz timezones to ZoneInfo (PEP 615) at the boundary:

  1. normalize_tz(tz) - Converts pytz timezones to their equivalent ZoneInfo using the IANA zone name. ZoneInfo behaves like a standard tzinfo and handles DST correctly with normal replace(tzinfo=...) calls.

  2. localize_naive(naive_dt, tz) - A helper that normalizes the timezone first, then uses standard replace(tzinfo=...).

This approach:

  • Centralizes the pytz→ZoneInfo conversion in one place
  • Uses standard Python datetime semantics after normalization
  • Properly handles DST by letting ZoneInfo interpret wall-clock times correctly
  • Avoids pytz-specific API calls scattered throughout the codebase

Fixes #2338
Fixes #2388
Fixes #2391
Fixes #2393
Fixes #2400

@cebtenzzre cebtenzzre changed the title parse: fix time parsing regression parse: fix time zone handling in scheduler time parsing Dec 28, 2025
@cebtenzzre
Copy link
Contributor Author

I think this fix can be a stepping stone toward a medium/long-term cleanup: prefer zoneinfo internally, while continuing to accept pytz at the boundary for backwards compatibility.

Why move toward zoneinfo?

  • pytz does not implement “normal” tzinfo semantics: it requires localize()/normalize(), and attaching a pytz zone via replace(tzinfo=...) is explicitly error-prone (this regression is a concrete example of how easy it is to get wrong).
  • zoneinfo (PEP 615) is the stdlib timezone implementation. It behaves like a regular tzinfo, so the “obvious” datetime operations do the right thing (replace(tzinfo=...), astimezone(), arithmetic).
  • Centralizing on zoneinfo reduces the surface area for DST bugs and makes the code easier to reason about.
  • It also reduces dependency complexity long-term (stdlib + optional tzdata on platforms that need it).

@cebtenzzre
Copy link
Contributor Author

The workaround SteLu1803 found in #2391 (comment) confirms the root cause. They discovered that passing a string to run_daily instead of a datetime.time object works correctly.

This makes sense because:

  1. Broken path (using datetime.time):

    self.run_daily(callback, datetime.time(8, 0, 0))  # ❌ Triggers buggy code

    This hit the case time(): branch in parse.py that was incorrectly using datetime.combine(..., tzinfo=pytz_tz).

  2. Working workaround (using string):

    parsed_time = self.app.parse_time("08:00:00", aware=True)
    self.app.run_daily(callback, parsed_time)  # ✅ Bypasses the bug

    When you pass a string, it goes through a different code path. And by passing aware=True, they get back a properly localized datetime that was already correctly converted.

The fix addresses this directly - after the fix, users can use datetime.time objects as intended without needing the string workaround. The fix ensures that when a time object comes in, it's properly converted by:

  1. Creating a naive datetime first
  2. Normalizing the pytz timezone to ZoneInfo
  3. Using standard replace(tzinfo=...) which works correctly with ZoneInfo

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant