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"