Skip to content

Commit

Permalink
Merge 89f73d3 into 41f88b3
Browse files Browse the repository at this point in the history
  • Loading branch information
gaguirregabiria committed Aug 6, 2019
2 parents 41f88b3 + 89f73d3 commit 1eb0cff
Show file tree
Hide file tree
Showing 2 changed files with 271 additions and 17 deletions.
120 changes: 103 additions & 17 deletions schedule/__init__.py
Expand Up @@ -193,6 +193,7 @@ def __init__(self, interval, scheduler=None):
self.latest = None # upper limit to the interval
self.job_func = None # the job job_func to run
self.unit = None # time units, e.g. 'minutes', 'hours', ...
self.at_day = None # optional day at which this job runs
self.at_time = None # optional time at which this job runs
self.last_run = None # datetime of the last run
self.next_run = None # datetime of the next run
Expand Down Expand Up @@ -315,6 +316,17 @@ def weeks(self):
self.unit = 'weeks'
return self

@property
def month(self):
if self.interval != 1:
raise IntervalError('Use months instead of month')
return self.months

@property
def months(self):
self.unit = 'months'
return self

@property
def monday(self):
if self.interval != 1:
Expand Down Expand Up @@ -382,21 +394,35 @@ def at(self, time_str):
"""
Specify a particular time that the job should be run at.
:param time_str: A string in one of the following formats: `HH:MM:SS`,
`HH:MM`,`:MM`, `:SS`. The format must make sense given how often
the job is repeating; for example, a job that repeats every minute
should not be given a string in the form `HH:MM:SS`. The difference
between `:MM` and `:SS` is inferred from the selected time-unit
(e.g. `every().hour.at(':30')` vs. `every().minute.at(':30')`).
:param time_str: A string in one of the following formats:
`DD-HH:MM:SS`, `DD-HH:MM`, `HH:MM:SS`, `HH:MM`,`:MM`, `:SS`. The
format must make sense given how often the job is repeating; for
example, a job that repeats every minute should not be given a
string in the form `HH:MM:SS`. The difference between `:MM` and
`:SS` is inferred from the selected time-unit (e.g.
`every().hour.at(':30')` vs. `every().minute.at(':30')`).
:return: The invoked job instance
"""
if (self.unit not in ('days', 'hours', 'minutes')
if (self.unit not in ('months', 'days', 'hours', 'minutes')
and not self.start_day):
raise ScheduleValueError('Invalid unit')
if not isinstance(time_str, str):
raise TypeError('at() should be passed a string')
if self.unit == 'months':
day_str, time_str = time_str.split('-')
if not re.match(r'^(2[0-3]|[01][0-9]):([0-5][0-9])(:[0-5][0-9])?$',
time_str):
raise ScheduleValueError('Invalid time format for monthly job.'
' Format should be: "DD-HH:MM:SS" or'
' "DD-HH:MM"')
if not re.match(r'^([0-2][0-9]|[3][01])$',
day_str):
raise ScheduleValueError('Invalid time format for monthly job.'
' Format should be: "DD-HH:MM:SS" or'
' "DD-HH:MM"')
if self.unit == 'days' or self.start_day:
if not re.match(r'^([0-2]\d:)?[0-5]\d:[0-5]\d$', time_str):
if not re.match(r'^(2[0-3]|[01][0-9]):([0-5][0-9])(:[0-5][0-9])?$',
time_str):
raise ScheduleValueError('Invalid time format')
if self.unit == 'hours':
if not re.match(r'^([0-5]\d)?:[0-5]\d$', time_str):
Expand All @@ -416,10 +442,12 @@ def at(self, time_str):
else:
hour, minute = time_values
second = 0
if self.unit == 'days' or self.start_day:
if self.unit == 'months':
day = int(day_str)
self.at_day = day
hour = int(hour)
elif self.unit == 'days' or self.start_day:
hour = int(hour)
if not (0 <= hour <= 23):
raise ScheduleValueError('Invalid number of hours')
elif self.unit == 'hours':
hour = 0
elif self.unit == 'minutes':
Expand Down Expand Up @@ -487,11 +515,26 @@ def run(self):
self._schedule_next_run()
return ret

def addmonth(self, date, interval):
targetmonth = interval + date.month
try:
date = date.replace(year=date.year+int(targetmonth/12),
month=(targetmonth % 12))
except ValueError:
# There is an exception if the day of the month we're in does not
# exist in the target month.
# Go to the FIRST of the month AFTER, then go back one day.
date = date.replace(year=date.year+int((targetmonth+1)/12),
month=((targetmonth+1) % 12), day=1)
date += datetime.timedelta(days=-1)
return date

def _schedule_next_run(self):
"""
Compute the instant when this job should run next.
"""
if self.unit not in ('seconds', 'minutes', 'hours', 'days', 'weeks'):
if self.unit not in ('seconds', 'minutes', 'hours', 'days', 'weeks',
'months'):
raise ScheduleValueError('Invalid unit')

if self.latest is not None:
Expand All @@ -501,8 +544,15 @@ def _schedule_next_run(self):
else:
interval = self.interval

self.period = datetime.timedelta(**{self.unit: interval})
self.next_run = datetime.datetime.now() + self.period
if self.unit == 'months':
if self.at_time is None or self.at_day is None:
raise ScheduleError('Monthly jobs expect "at()" to be defined')
self.next_run = self.addmonth(datetime.datetime.now(),
self.interval)
else:
self.period = datetime.timedelta(**{self.unit: interval})
self.next_run = datetime.datetime.now() + self.period

if self.start_day is not None:
if self.unit != 'weeks':
raise ScheduleValueError('`unit` should be \'weeks\'')
Expand All @@ -523,19 +573,29 @@ def _schedule_next_run(self):
days_ahead += 7
self.next_run += datetime.timedelta(days_ahead) - self.period
if self.at_time is not None:
if (self.unit not in ('days', 'hours', 'minutes')
if (self.unit not in ('months', 'days', 'hours', 'minutes')
and self.start_day is None):
raise ScheduleValueError(('Invalid unit without'
' specifying start day'))
kwargs = {
'second': self.at_time.second,
'microsecond': 0
}
if self.unit == 'days' or self.start_day is not None:
if self.unit in ['months', 'days'] or self.start_day is not None:
kwargs['hour'] = self.at_time.hour
if self.unit in ['days', 'hours'] or self.start_day is not None:
if self.unit in ['months', 'days', 'hours'] or \
self.start_day is not None:
kwargs['minute'] = self.at_time.minute
self.next_run = self.next_run.replace(**kwargs)

if self.unit == 'months':
try:
self.next_run = self.next_run.replace(day=self.at_day)
except ValueError:
temp_date = self.next_run.replace(
month=self.next_run.month + 1, day=1)
self.next_run = temp_date + \
datetime.timedelta(days=-1)
# If we are running for the first time, make sure we run
# at the specified time *today* (or *this hour*) as well
if not self.last_run:
Expand All @@ -552,6 +612,32 @@ def _schedule_next_run(self):
and self.at_time.second > now.second:
self.next_run = self.next_run - \
datetime.timedelta(minutes=1)
elif self.unit == 'months':
if now.day < self.at_day:
self.next_run = now.replace(**kwargs)
try:
self.next_run = now.replace(day=self.at_day)
except ValueError:
temp_date = self.next_run.replace(
month=now.month + 1, day=1)
self.next_run = temp_date + \
datetime.timedelta(days=-1)
elif (now.day == self.at_day and
now.time() < self.at_time):
self.next_run = now.replace(**kwargs)
else:
self.next_run = self.addmonth(now, 1)
self.next_run = self.next_run.replace(**kwargs)
try:
self.next_run = \
self.next_run.replace(day=self.at_day)
except ValueError:
temp_date = now.replace(
month=self.next_run.month + 1,
day=1)
self.next_run = temp_date + \
datetime.timedelta(days=-1)

if self.start_day is not None and self.at_time is not None:
# Let's see if we will still make that time we specified today
if (self.next_run - datetime.datetime.now()).days >= 7:
Expand Down
168 changes: 168 additions & 0 deletions test_schedule.py
Expand Up @@ -110,6 +110,15 @@ def test_time_units(self):
self.assertRaises(ScheduleValueError, job_instance.at, "0:61:0")
self.assertRaises(ScheduleValueError, job_instance.at, "0:0:61")

# test a valid unit with invalid hours/minutes
job_instance.unit = "days"
self.assertRaises(ScheduleValueError, job_instance.at, "24:00")
self.assertRaises(ScheduleValueError, job_instance.at, "00:61")

# test invalid time format
self.assertRaises(ScheduleValueError, job_instance.at, "24:0")
self.assertRaises(ScheduleValueError, job_instance.at, "0:61")

# test (very specific) seconds with unspecified start_day
job_instance.unit = "seconds"
job_instance.at_time = datetime.datetime.now()
Expand Down Expand Up @@ -235,6 +244,165 @@ def test_at_time_minute(self):
self.assertRaises(ScheduleValueError, every().minute.at, ' :30')
self.assertRaises(TypeError, every().minute.at, 2)

def test_at_daytime_month(self):
# Test that the monthly schedulers creates the starting date correctly
with mock_datetime(2010, 1, 6, 12, 20, 30):
mock_job = make_mock_job()
assert every().month.at('06-12:40:30').do(mock_job).\
next_run.month == 1
assert every().month.at('06-12:40:30').do(mock_job).\
next_run.day == 6
assert every().month.at('06-12:40:30').do(mock_job).\
next_run.hour == 12
assert every().month.at('06-12:40:30').do(mock_job).\
next_run.minute == 40
assert every().month.at('06-12:40:30').do(mock_job).\
next_run.second == 30
assert every().month.at('06-10:40:30').do(mock_job).\
next_run.month == 2
assert every().month.at('04-12:40:30').do(mock_job).\
next_run.day == 4
assert every().month.at('04-12:40:30').do(mock_job).\
next_run.month == 2
assert every().month.at('19-12:40:30').do(mock_job).\
next_run.day == 19
assert every().month.at('19-12:40:30').do(mock_job).\
next_run.month == 1
with mock_datetime(2010, 2, 6, 12, 20, 30):
mock_job = make_mock_job()
assert every().month.at('31-12:40:30').do(mock_job).\
next_run.day == 28
assert every().month.at('31-12:40:30').do(mock_job).\
next_run.month == 2

# Test that the monthly scheduler creates correct next_run dates
with mock_datetime(2010, 1, 6, 12, 20, 30):
mock_job = make_mock_job()
scheduler = schedule.Scheduler()
scheduler.every().month.at('04-12:40:30').do(mock_job)
with mock_datetime(2010, 2, 4, 12, 40, 30):
scheduler.run_pending()
assert scheduler.jobs[0].next_run.month == 3
with mock_datetime(2010, 3, 4, 12, 40, 30):
scheduler.run_pending()
assert scheduler.jobs[0].next_run.month == 4

# Test the correct treatment of End of Month dates
with mock_datetime(2010, 1, 1, 12, 20, 30):
mock_job = make_mock_job()
scheduler = schedule.Scheduler()
scheduler.every().month.at('31-12:40:30').do(mock_job)
with mock_datetime(2010, 1, 31, 12, 40, 30):
scheduler.run_pending()
assert scheduler.jobs[0].next_run.month == 2
assert scheduler.jobs[0].next_run.day == 28
with mock_datetime(2010, 2, 28, 12, 40, 30):
scheduler.run_pending()
assert scheduler.jobs[0].next_run.month == 3
assert scheduler.jobs[0].next_run.day == 31
with mock_datetime(2010, 3, 31, 12, 40, 30):
scheduler.run_pending()
assert scheduler.jobs[0].next_run.month == 4
assert scheduler.jobs[0].next_run.day == 30

# test invalid time format
with mock_datetime(2010, 1, 6, 12, 20, 30):
self.assertRaises(ScheduleValueError,
every().month.at, '32-10:00:00')
self.assertRaises(ScheduleValueError,
every().month.at, '05-24:00:00')
self.assertRaises(ScheduleValueError,
every().month.at, '05-20:60:00')
self.assertRaises(ScheduleValueError,
every().month.at, '05-20:00:65')
self.assertRaises(ScheduleValueError,
every().month.at, '32-10:00')
self.assertRaises(ScheduleValueError,
every().month.at, '05-24:00')
self.assertRaises(ScheduleValueError,
every().month.at, '05-20:60')
self.assertRaises(ScheduleValueError,
every().month.at, '15-1:00:00')
self.assertRaises(ScheduleValueError,
every().month.at, '15-10:0:00')
self.assertRaises(ScheduleValueError,
every().month.at, '15-10:00:0')
self.assertRaises(ScheduleValueError,
every().month.at, '5-23:00:00')
self.assertRaises(ScheduleValueError,
every().month.at, '15-1:00')
self.assertRaises(ScheduleValueError,
every().month.at, '15-10:0')
self.assertRaises(ScheduleValueError,
every().month.at, '5-23:00')

# test monthly job asks for "at()"
with mock_datetime(2010, 1, 6, 12, 20, 30):
self.assertRaises(ScheduleError, every().month.do, mock_job)

def test_at_daytime_months(self):
# Test that the monthly scheduler creates correct next_run dates
with mock_datetime(2010, 1, 6, 12, 20, 30):
mock_job = make_mock_job()
scheduler = schedule.Scheduler()
scheduler.every(5).months.at('04-12:40:30').do(mock_job)
assert scheduler.jobs[0].next_run.month == 2
with mock_datetime(2010, 2, 4, 12, 40, 30):
scheduler.run_pending()
assert scheduler.jobs[0].next_run.month == 7
with mock_datetime(2010, 7, 4, 12, 40, 30):
scheduler.run_pending()
assert scheduler.jobs[0].next_run.month == 12
with mock_datetime(2010, 12, 4, 12, 40, 30):
scheduler.run_pending()
assert scheduler.jobs[0].next_run.month == 5
assert scheduler.jobs[0].next_run.year == 2011

# Test the correct treatment of End of Month dates
with mock_datetime(2010, 1, 1, 12, 20, 30):
mock_job = make_mock_job()
scheduler = schedule.Scheduler()
scheduler.every(3).months.at('30-12:40:30').do(mock_job)
with mock_datetime(2010, 1, 30, 12, 40, 30):
scheduler.run_pending()
assert scheduler.jobs[0].next_run.month == 4
assert scheduler.jobs[0].next_run.day == 30
with mock_datetime(2010, 4, 30, 12, 40, 30):
scheduler.run_pending()
assert scheduler.jobs[0].next_run.month == 7
assert scheduler.jobs[0].next_run.day == 30

# test invalid time format
with mock_datetime(2010, 1, 6, 12, 20, 30):
self.assertRaises(ScheduleValueError,
every(2).months.at, '32-10:00:00')
self.assertRaises(ScheduleValueError,
every(2).months.at, '05-24:00:00')
self.assertRaises(ScheduleValueError,
every(2).months.at, '05-20:60:00')
self.assertRaises(ScheduleValueError,
every(2).months.at, '05-20:00:65')
self.assertRaises(ScheduleValueError,
every(2).months.at, '32-10:00')
self.assertRaises(ScheduleValueError,
every(2).months.at, '05-24:00')
self.assertRaises(ScheduleValueError,
every(2).months.at, '05-20:60')
self.assertRaises(ScheduleValueError,
every(2).months.at, '15-1:00:00')
self.assertRaises(ScheduleValueError,
every(2).months.at, '15-10:0:00')
self.assertRaises(ScheduleValueError,
every(2).months.at, '15-10:00:0')
self.assertRaises(ScheduleValueError,
every(2).months.at, '5-23:00:00')
self.assertRaises(ScheduleValueError,
every(2).months.at, '15-1:00')
self.assertRaises(ScheduleValueError,
every(2).months.at, '15-10:0')
self.assertRaises(ScheduleValueError,
every(2).months.at, '5-23:00')

def test_next_run_time(self):
with mock_datetime(2010, 1, 6, 12, 15):
mock_job = make_mock_job()
Expand Down

0 comments on commit 1eb0cff

Please sign in to comment.