Skip to content

Commit

Permalink
support TZID in RRule string instead of using use_local_timezone var
Browse files Browse the repository at this point in the history
  • Loading branch information
PierrickBrun committed Nov 27, 2024
1 parent 328d56c commit 46b6fb7
Show file tree
Hide file tree
Showing 3 changed files with 114 additions and 29 deletions.
7 changes: 3 additions & 4 deletions rq_scheduler/scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -300,20 +300,19 @@ 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,
description=description, timeout=timeout, meta=meta, depends_on=depends_on,
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)
Expand Down Expand Up @@ -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)})

Expand Down
17 changes: 10 additions & 7 deletions rq_scheduler/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import crontab
import dateutil.tz
import dateutil.rrule
import dateutil.tz
import re

from datetime import datetime, timedelta
import logging
Expand Down Expand Up @@ -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'):
Expand Down
119 changes: 101 additions & 18 deletions tests/test_scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"

0 comments on commit 46b6fb7

Please sign in to comment.