From 46b6fb7023899b1da93570421f21500ca1d51e7d Mon Sep 17 00:00:00 2001 From: Pierrick Brun Date: Tue, 26 Nov 2024 17:15:13 +0100 Subject: [PATCH] support TZID in RRule string instead of using use_local_timezone var --- rq_scheduler/scheduler.py | 7 +-- rq_scheduler/utils.py | 17 +++--- tests/test_scheduler.py | 119 ++++++++++++++++++++++++++++++++------ 3 files changed, 114 insertions(+), 29 deletions(-) diff --git a/rq_scheduler/scheduler.py b/rq_scheduler/scheduler.py index 0e3cb4c..64cafa9 100644 --- a/rq_scheduler/scheduler.py +++ b/rq_scheduler/scheduler.py @@ -300,12 +300,12 @@ def cron(self, cron_string, func, args=None, kwargs=None, repeat=None, def rrule(self, rrule_string, func, args=None, kwargs=None, repeat=None, - queue_name=None, result_ttl=-1, ttl=None, id=None, timeout=None, description=None, meta=None, use_local_timezone=False, + queue_name=None, result_ttl=-1, ttl=None, id=None, timeout=None, description=None, meta=None, depends_on=None, on_success=None, on_failure=None, at_front: bool = False): """ Schedule a recurring job via RRule """ - scheduled_time = get_next_rrule_scheduled_time(rrule_string, use_local_timezone=use_local_timezone) + scheduled_time = get_next_rrule_scheduled_time(rrule_string) job = self._create_job(func, args=args, kwargs=kwargs, commit=False, result_ttl=result_ttl, ttl=ttl, id=id, queue_name=queue_name, @@ -313,7 +313,6 @@ def rrule(self, rrule_string, func, args=None, kwargs=None, repeat=None, on_success=on_success, on_failure=on_failure) job.meta['rrule_string'] = rrule_string - job.meta['use_local_timezone'] = use_local_timezone if repeat is not None: job.meta['repeat'] = int(repeat) @@ -468,7 +467,7 @@ def enqueue_job(self, job): self.connection.zadd(self.scheduled_jobs_key, {job.id: to_unix(next_scheduled_time)}) elif rrule_string: - next_scheduled_time = get_next_rrule_scheduled_time(rrule_string, use_local_timezone=use_local_timezone) + next_scheduled_time = get_next_rrule_scheduled_time(rrule_string) self.connection.zadd(self.scheduled_jobs_key, {job.id: to_unix(next_scheduled_time)}) diff --git a/rq_scheduler/utils.py b/rq_scheduler/utils.py index 1fe23a2..c9ee40c 100644 --- a/rq_scheduler/utils.py +++ b/rq_scheduler/utils.py @@ -2,7 +2,7 @@ import crontab import dateutil.tz import dateutil.rrule -import dateutil.tz +import re from datetime import datetime, timedelta import logging @@ -32,14 +32,17 @@ def get_next_scheduled_time(cron_string, use_local_timezone=False): return next_time.astimezone(tz) -def get_next_rrule_scheduled_time(rrule_string, use_local_timezone=False): +def get_next_rrule_scheduled_time(rrule_string): """Calculate the next scheduled time by creating a rrule object with a rrule string""" - now = datetime.now() - rrule = dateutil.rrule.rrulestr(rrule_string) - next_time = rrule.after(now) - tz = dateutil.tz.tzlocal() if use_local_timezone else dateutil.tz.UTC - return next_time.astimezone(tz) + timezone = dateutil.tz.UTC + rule = dateutil.rrule.rrulestr(rrule_string) + if rule._dtstart.tzinfo is None: + now = datetime.now() # naive datetime + else: + now = datetime.now(tz=timezone) # aware datetime + next_time = rule.after(now) + return next_time.astimezone(timezone) def setup_loghandlers(level='INFO'): diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py index 9a34ebb..9d29d03 100644 --- a/tests/test_scheduler.py +++ b/tests/test_scheduler.py @@ -893,25 +893,9 @@ def test_rrule_persisted_correctly(self): assert datetime_time.second == 0 assert datetime_time - datetime.utcnow() <= timedelta(hours=1), f"{datetime_time - datetime.utcnow()} is greater than 1 hour" - def test_rrule_persisted_correctly_with_local_timezone(self): - """ - Ensure that rrule attribute gets correctly saved in Redis when using local TZ. - """ - # create a job that runs each day at 15:00 - job = self.scheduler.rrule("RRULE:FREQ=DAILY;WKST=MO;BYHOUR=15;BYMINUTE=0;BYSECOND=0", say_hello, use_local_timezone=True) - job_from_queue = Job.fetch(job.id, connection=self.testconn) - self.assertEqual(job_from_queue.meta['rrule_string'], "RRULE:FREQ=DAILY;WKST=MO;BYHOUR=15;BYMINUTE=0;BYSECOND=0") - - # get the scheduled_time and convert it to a datetime object - unix_time = self.testconn.zscore(self.scheduler.scheduled_jobs_key, job.id) - datetime_time = from_unix(unix_time) - - expected_datetime_in_local_tz = datetime.now(tzlocal()).replace(hour=15,minute=0,second=0,microsecond=0) - assert datetime_time.time() == expected_datetime_in_local_tz.astimezone(UTC).time() - - def test_rrule_rescheduled_correctly_with_local_timezone(self): + def test_rrule_rescheduled_correctly(self): # Create a job that runs each day at 15:01 - job = self.scheduler.rrule("RRULE:FREQ=DAILY;WKST=MO;BYHOUR=15;BYMINUTE=1;BYSECOND=0", say_hello, use_local_timezone=True) + job = self.scheduler.rrule("RRULE:FREQ=DAILY;WKST=MO;BYHOUR=15;BYMINUTE=1;BYSECOND=0", say_hello) # Change this job to run each day at 15:02 job.meta['rrule_string'] = "RRULE:FREQ=DAILY;WKST=MO;BYHOUR=15;BYMINUTE=2;BYSECOND=0" @@ -1042,3 +1026,102 @@ def test_job_with_rrule_get_rescheduled(self): expected_next_scheduled_time = to_unix(get_next_rrule_scheduled_time("RRULE:FREQ=HOURLY;WKST=MO;BYMINUTE=2;BYSECOND=0")) self.assertEqual(self.testconn.zscore(self.scheduler.scheduled_jobs_key, job.id), expected_next_scheduled_time) + + def test_rrule_persisted_correctly_with_dtstart(self): + """ + Ensure that rrule attribute gets correctly saved in Redis. + """ + # create a job that runs one minute past each whole hour + job = self.scheduler.rrule("DTSTART:20241126T154900Z\nRRULE:FREQ=HOURLY;WKST=MO;BYMINUTE=1;BYSECOND=0", say_hello) + job_from_queue = Job.fetch(job.id, connection=self.testconn) + self.assertEqual(job_from_queue.meta['rrule_string'], "DTSTART:20241126T154900Z\nRRULE:FREQ=HOURLY;WKST=MO;BYMINUTE=1;BYSECOND=0") + + # get the scheduled_time and convert it to a datetime object + unix_time = self.testconn.zscore(self.scheduler.scheduled_jobs_key, job.id) + datetime_time = from_unix(unix_time) + + # check that minute=1, seconds=0, and is within an hour + assert datetime_time.minute == 1 + assert datetime_time.second == 0 + assert datetime_time - datetime.utcnow() <= timedelta(hours=1), f"{datetime_time - datetime.utcnow()} is greater than 1 hour" + + def test_rrule_persisted_correctly_with_dtstart_and_tzid(self): + """ + Ensure that rrule attribute gets correctly saved in Redis. + """ + # create a job that runs one minute past each whole hour + job = self.scheduler.rrule("DTSTART;TZID=Europe/Brussels:20241126T155000\nRRULE:FREQ=HOURLY;WKST=MO;BYMINUTE=1;BYSECOND=0", say_hello) + job_from_queue = Job.fetch(job.id, connection=self.testconn) + self.assertEqual(job_from_queue.meta['rrule_string'], "DTSTART;TZID=Europe/Brussels:20241126T155000\nRRULE:FREQ=HOURLY;WKST=MO;BYMINUTE=1;BYSECOND=0") + + # get the scheduled_time and convert it to a datetime object + unix_time = self.testconn.zscore(self.scheduler.scheduled_jobs_key, job.id) + datetime_time = from_unix(unix_time) + + # check that minute=1, seconds=0, and is within an hour + assert datetime_time.minute == 1 + assert datetime_time.second == 0 + assert datetime_time - datetime.utcnow() <= timedelta(hours=1), f"{datetime_time - datetime.utcnow()} is greater than 1 hour" + + def test_rrule_persisted_correctly_with_dtstart_and_until(self): + """ + Ensure that rrule attribute gets correctly saved in Redis. + """ + now = datetime.utcnow().replace(year=2024, month=11, day=27) + with freezegun.freeze_time(now): + # create a job that runs one minute past each whole hour + job = self.scheduler.rrule("DTSTART:20241126T155000Z\nRRULE:FREQ=HOURLY;UNTIL=20241129T000000Z;WKST=MO;BYMINUTE=1;BYSECOND=0", say_hello) + job_from_queue = Job.fetch(job.id, connection=self.testconn) + self.assertEqual(job_from_queue.meta['rrule_string'], "DTSTART:20241126T155000Z\nRRULE:FREQ=HOURLY;UNTIL=20241129T000000Z;WKST=MO;BYMINUTE=1;BYSECOND=0") + + # get the scheduled_time and convert it to a datetime object + unix_time = self.testconn.zscore(self.scheduler.scheduled_jobs_key, job.id) + datetime_time = from_unix(unix_time) + + # check that minute=1, seconds=0, and is within an hour + assert datetime_time.minute == 1 + assert datetime_time.second == 0 + assert datetime_time - now <= timedelta(hours=1), f"{datetime_time - now} is greater than 1 hour" + + def test_rrule_persisted_correctly_with_dtstart_and_until_and_tzid(self): + """ + Ensure that rrule attribute gets correctly saved in Redis. + """ + now = datetime.utcnow().replace(year=1997, month=1, day=1) + with freezegun.freeze_time(now): + # create a job that runs one minute past each whole hour + job = self.scheduler.rrule("DTSTART;TZID=America/New_York:19970101T000000\n" + "RRULE:FREQ=HOURLY;UNTIL=19990101T000000Z;BYMINUTE=1;BYSECOND=0\n", say_hello) + job_from_queue = Job.fetch(job.id, connection=self.testconn) + self.assertEqual(job_from_queue.meta['rrule_string'], "DTSTART;TZID=America/New_York:19970101T000000\n" + "RRULE:FREQ=HOURLY;" + "UNTIL=19990101T000000Z;BYMINUTE=1;BYSECOND=0\n") + + # get the scheduled_time and convert it to a datetime object + unix_time = self.testconn.zscore(self.scheduler.scheduled_jobs_key, job.id) + datetime_time = from_unix(unix_time) + + # check that minute=1, seconds=0, and is within an hour + assert datetime_time.minute == 1 + assert datetime_time.second == 0 + assert datetime_time - now <= timedelta(hours=1), f"{datetime_time - now} is greater than 1 hour" + + def test_rrule_persisted_correctly_with_until(self): + """ + Ensure that rrule attribute gets correctly saved in Redis. + """ + now = datetime.utcnow().replace(year=2024, month=11, day=27) + with freezegun.freeze_time(now): + # create a job that runs one minute past each whole hour + job = self.scheduler.rrule("RRULE:FREQ=HOURLY;UNTIL=20241129T000000Z;WKST=MO;BYMINUTE=1;BYSECOND=0", say_hello) + job_from_queue = Job.fetch(job.id, connection=self.testconn) + self.assertEqual(job_from_queue.meta['rrule_string'], "RRULE:FREQ=HOURLY;UNTIL=20241129T000000Z;WKST=MO;BYMINUTE=1;BYSECOND=0") + + # get the scheduled_time and convert it to a datetime object + unix_time = self.testconn.zscore(self.scheduler.scheduled_jobs_key, job.id) + datetime_time = from_unix(unix_time) + + # check that minute=1, seconds=0, and is within an hour + assert datetime_time.minute == 1 + assert datetime_time.second == 0 + assert datetime_time - now <= timedelta(hours=1), f"{datetime_time - now} is greater than 1 hour"