dbader / schedule

Python job scheduling for humans.
https://schedule.readthedocs.io/
MIT License
11.86k stars 966 forks source link

odd daylight saving behaviour #598

Closed bellt closed 1 year ago

bellt commented 1 year ago

I have a job I want to schedule every day at a particular time UTC.

import schedule
from datetime import datetime

def job():
    return True

schedule.every().day.at("00:00", "Etc/UTC").do(job)

print(
    f"Current local time: {datetime.now()}\n"
    f"Next run time: {schedule.jobs[0].next_run}\n"
    f"Idle time: {round(schedule.idle_seconds() / 3600)} hours"
)

Running at 10:59 local time (which is Pacific/Auckland - currently UTC+12) I get the following output:

Current local time: 2023-09-18 10:59:23.170523
Next run time: 2023-09-19 12:00:00
Idle time: 25 hours

This shows the bug highlighted in issue #579 with it skipping the closest 00:00UTC.

If I change the schedule to
schedule.every().monday.at("00:00", "Etc/UTC").do(job)

Then I get the following output

Current local time: 2023-09-18 11:01:08.323132
Next run time: 2023-09-18 13:00:00
Idle time: 2 hours

As you can see, it is now scheduling the job for 13:00 local time. However it should be 12:00 local time because we are currently UTC+12. This behaviour started yesterday (Sunday). Prior to that it would schedule the job at the correct time. So it looks like somewhere daylight saving time is being incorrectly applied a week early (my local timezone shifts to daylight saving [UTC+13] next Sunday). But this is only happening with schedule.every().weekday and not schedule.every().day

SijmenHuizenga commented 1 year ago

Resolved with https://github.com/dbader/schedule/pull/583 and released in 1.2.1. I am also adding a test to cover your specific case in #602. Cheers!

bellt commented 1 year ago

Hi There, From my testing, it looks like 1.2.1 has fixed this problem for the example given. However, if I go the other way and fastforward to one week before daylight saving time ends next year, I encounter the problem again. Here is some code which demonstrates this:

import schedule
import datetime
import time
import os

# POSIX TZ string format
TZ_AUCKLAND = "NZST-12NZDT-13,M10.1.0/02:00:00,M3.3.0/03:00:00"

def job():
    return True

class mock_datetime:
    """
    Monkey-patch datetime for predictable results
    """

    def __init__(self, year, month, day, hour, minute, second=0, zone=None):
        self.year = year
        self.month = month
        self.day = day
        self.hour = hour
        self.minute = minute
        self.second = second
        self.zone = zone
        self.original_datetime = None
        self.original_zone = None

    def __enter__(self):
        class MockDate(datetime.datetime):
            @classmethod
            def today(cls):
                return cls(self.year, self.month, self.day)

            @classmethod
            def now(cls):
                return cls(
                    self.year,
                    self.month,
                    self.day,
                    self.hour,
                    self.minute,
                    self.second,
                )

        self.original_datetime = datetime.datetime
        datetime.datetime = MockDate

        self.original_zone = os.environ.get("TZ")
        if self.zone:
            os.environ["TZ"] = self.zone
            time.tzset()

        return MockDate(
            self.year, self.month, self.day, self.hour, self.minute, self.second
        )

    def __exit__(self, *args, **kwargs):
        datetime.datetime = self.original_datetime
        if self.original_zone:
            os.environ["TZ"] = self.original_zone
            time.tzset()

with mock_datetime(2024, 4, 1, 10, 00, 0, TZ_AUCKLAND):
    # We've set the date to 10:00 on Monday 1 April NZDT (which is 21:00 on Sunday 31 March UTC)
    # This is the week before daylight saving ends

    # Testing correct application of daylight saving
    schedule.clear()
    next = schedule.every().sunday.at("23:00", "UTC").do(job).next_run
    print(
    f"Current local time: {datetime.datetime.now()}\n"
    f"Next run time: {schedule.jobs[0].next_run} (expected: 2024-04-01 12:00:00)\n"
    f"Idle time: {round(schedule.idle_seconds() / 3600)} hours (expected: 2 hours)\n"
    )

with mock_datetime(2024, 4, 8, 10, 00, 0, TZ_AUCKLAND):
    # We've set the date to 10:00 on Monday 8 April NZST (which is 22:00 on Sunday 7 April UTC)
    # This is the week after daylight saving ends

    # Testing correct application of daylight saving (or lack of, at this date)
    schedule.clear()
    next = schedule.every().sunday.at("23:00", "UTC").do(job).next_run
    print(
    f"Current local time: {datetime.datetime.now()}\n"
    f"Next run time: {schedule.jobs[0].next_run} (expected: 2023-04-08 11:00:00)\n"
    f"Idle time: {round(schedule.idle_seconds() / 3600)} hours (expected: 1 hours)"
    )

The output from this is:

Current local time: 2024-04-01 10:00:00
Next run time: 2024-04-01 11:00:00 (expected: 2024-04-01 12:00:00)
Idle time: 1 hours (expected: 2 hours)

Current local time: 2024-04-08 10:00:00
Next run time: 2024-04-08 11:00:00 (expected: 2023-04-08 11:00:00)
Idle time: 1 hours (expected: 1 hours)

Which as you can see doesn't have the expected next run time or idle time for the first example, which is one week before daylight saving ends.