"""
Iterators for datetime objects.
This work, including the source code, documentation
and related data, is placed into the public domain.
The original author is Robert Brewer.
See http://projects.amor.org/misc/wiki/Recur for more docs.
THIS SOFTWARE IS PROVIDED AS-IS, WITHOUT WARRANTY
OF ANY KIND, NOT EVEN THE IMPLIED WARRANTY OF
MERCHANTABILITY. THE AUTHOR OF THIS SOFTWARE
ASSUMES _NO_ RESPONSIBILITY FOR ANY CONSEQUENCE
RESULTING FROM THE USE, MODIFICATION, OR
REDISTRIBUTION OF THIS SOFTWARE.
Western-language descriptions of recurrence tend to fall into
two distinct types. In order to provide some mnemonic consistency,
the base functions are named differently according to these types.
However, despite the differing names, every function yields
datetime.date or datetime.datetime objects.
First, there are the declarations which define a unit of time,
and then count successive "leaps" of those units. For example,
the declaration, "every 4 days," uses a day as the unit, and adds
4 to produce each value in the series. The functions which provide
these series are named according to the whole unit, in the plural.
Examples:
"Every 4 days" becomes: days(start, 4, [end])
"Every 2 weeks" becomes: weeks(start, 2, [end])
"Every 6 hours" becomes: hours(start, 6, [end])
Second, there are the declarations which define a unit of time,
and then count by subdivisions of that unit. For example, the
declaration, "the ninth day of each month," uses a month as the
whole units and a day as the subdivision. The functions which
provide these series are named according to the whole unit,
in the singular, prefixed by "each".
Examples:
"The ninth [day] of each month" becomes: eachmonth(start, 9, [end])
"The penultimate [day] of each month" becomes:
eachmonth(start, -1, [end])
"Every Thursday" becomes "The 3rd [day] of each week" [since
datetime.weekday() returns Thursday as the value 3]
which becomes: eachweek(start, 3, [end])
"08:30:00 on each day" becomes:
eachday(start, datetime.time(8, 30), [end])
Notice that, in almost every case, the subdivision is understood to be
the "next smallest component". In the example above, one might just as
well have written, "the ninth of each month," and been understood,
since months are "composed of" days (not weeks!). Therefore, our
functions do not incorporate this "smaller unit" in the function name.
"""
import datetime
import re
import threading
def sane_date(year, month, day, highzero=False):
"""Return a valid datetime.date even if parameters are out of bounds.
If the month param is out of bounds, both it and the year will be
modified relative to the first month of the given year.
If the day param is out of bounds, the day, month, and possibly year
will be modified. If the day param is zero or negative, then the
"zeroth day" of the given month is assumed to be the last day of the
previous month, unless highzero is True, in which case the "zeroth day"
is the last day of the given month.
Examples:
sane_date(2003, 2, 1) = datetime.date(2003, 2, 1)
sane_date(2003, -10, 13) = datetime.date(2002, 2, 13)
sane_date(2003, 12, -5) = datetime.date(2003, 11, 25)
sane_date(2003, 12, -5, highzero=True) = datetime.date(2003, 12, 26)
"""
while month > 12:
month -= 12
year += 1
while month < 1:
month += 12
year -= 1
if highzero and day < 1:
# Count backward from the first of *next* month.
firstOfMonth = sane_date(year, month + 1, 1)
else:
# Count backward/forward from the first of the current month.
firstOfMonth = datetime.date(year, month, 1)
newDate = firstOfMonth + datetime.timedelta(day - 1)
return newDate
def sane_time(day, hour, minute, second):
"""Return a valid (day, datetime.time) even if parameters are out of bounds.
If the hour param is out of bounds, both it and the day will
be modified. If negative, the day will be decremented.
If the minute param is out of bounds, both it and the hour will
be modified. If negative, the hour will be decremented.
If the second param is out of bounds, both it and the minute will
be modified. If negative, the minute will be decremented.
Examples:
sane_time(0, 4, 2, 1) = (0, datetime.time(4, 2, 1)
sane_time(0, 25, 2, 1) = (1, datetime.time(1, 2, 1)
sane_time(0, 4, 1440, 1) = (1, datetime.time(4, 2, 1)
sane_time(0, 0, 0, -1) = (-1, datetime.time(23, 59, 59)
"""
while second > 59:
second -= 60
minute += 1
while second < 0:
second += 60
minute -= 1
while minute > 59:
minute -= 60
hour += 1
while minute < 0:
minute += 60
hour -= 1
while hour > 23:
hour -= 24
day += 1
while hour < 0:
hour += 24
day -= 1
newTime = (day, datetime.time(hour, minute, second))
return newTime
def seconds(startDate, frequency=1, endDate=None):
"""Yield a sequence of datetimes, adding 'frequency' seconds each time.
For example:
seconds(datetime.datetime(2004, 5, 4, 14, 0), 6)
yields the sequence: 2004-05-04 14:00:00, 2004-05-04 14:00:06,
2004-05-04 14:00:12, ...
If startDate has no time component (i.e. if it is a datetime.date),
then the first yielded time will be midnight (0:00:00) on that date.
If endDate has no time component (i.e. if it is a datetime.date),
then the last yielded time will be the last valid time before
midnight on that date.
For example:
seconds(datetime.datetime(2004, 5, 4), 15, datetime.datetime(2004, 5, 5))
yields the sequence: 2004-05-04 00:00:00, 2004-05-04 00:00:15,
2004-05-04 00:00:30, ...
... 2004-05-05 23:59:15,
2004-05-05 23:59:30, 2004-05-05 23:59:45.
"""
if not hasattr(startDate, u'time'):
startDate = datetime.datetime.combine(startDate, datetime.time(0))
while (endDate is None) or (startDate <= endDate):
yield startDate
startDate += datetime.timedelta(seconds=frequency)
def eachminute(startDate, seconds=0, endDate=None):
"""Yield the same time for each minute. Defaults to 0 seconds.
Yielded values are datetime.datetime objects.
For example:
eachminute(datetime.date(2004, 5, 4, 23, 55), 15)
yields the sequence: 2004-05-04 23:55:15, 2004-05-04 23:56:15,
2004-05-04 23:57:15, ...
If startDate has no time component (i.e. if it is a datetime.date),
then the first yielded time will be the first valid time after
midnight (0:00:00) on that date.
If endDate has no time component (i.e. if it is a datetime.date),
then the last yielded time will be the last valid time before
midnight on that date.
"""
seconds = int(seconds)
if hasattr(startDate, u'time'):
days, zerotime = sane_time(0, startDate.hour,
startDate.minute, seconds)
if days < 0 or zerotime < startDate.time():
days, zerotime = sane_time(0, startDate.hour,
startDate.minute + 1, seconds)
else:
days, zerotime = sane_time(0, 0, 0, seconds)
startDate = sane_date(startDate.year, startDate.month,
startDate.day + days)
startDate = datetime.datetime.combine(startDate, zerotime)
while (endDate is None) or (startDate <= endDate):
yield startDate
startDate += datetime.timedelta(minutes=1)
def minutes(startDate, frequency=1, endDate=None):
"""Yield a sequence of datetimes, adding 'frequency' minutes each time.
For example:
minutes(datetime.datetime(2004, 5, 4, 14), 30)
yields the sequence: 2004-05-04 14:00:00, 2004-05-04 14:30:00,
2004-05-04 15:00:00, ...
If startDate has no time component (i.e. if it is a datetime.date),
then the first yielded time will be midnight (0:00:00) on that date.
If endDate has no time component (i.e. if it is a datetime.date),
then the last yielded time will be the last valid time before
midnight on that date.
For example:
minutes(datetime.datetime(2004, 5, 4), 15, datetime.datetime(2004, 5, 5))
yields the sequence: 2004-05-04 00:00:00, 2004-05-04 00:15:00,
2004-05-04 00:30:00, ...
... 2004-05-05 23:15:00,
2004-05-05 23:30:00, 2004-05-05 23:45:00.
"""
if not hasattr(startDate, u'time'):
startDate = datetime.datetime.combine(startDate, datetime.time(0))
while (endDate is None) or (startDate <= endDate):
yield startDate
startDate += datetime.timedelta(minutes=frequency)
def eachhour(startDate, minutes=0, seconds=0, endDate=None):
"""Yield the same time for each hour. Defaults to 00:00.
Yielded values are datetime.datetime objects.
For example:
eachhour(datetime.date(2004, 5, 4, 6), 15)
yields the sequence: 2004-05-04 06:15:00, 2004-05-04 07:15:00,
2004-05-04 08:15:00, ...
If startDate has no time component (i.e. if it is a datetime.date),
then the first yielded time will be the first valid time after
midnight (0:00:00) on that date.
If endDate has no time component (i.e. if it is a datetime.date),
then the last yielded time will be the last valid time before
midnight on that date.
"""
minutes = int(minutes)
seconds = int(seconds)
if hasattr(startDate, u'time'):
zerotime = datetime.time(startDate.hour, minutes, seconds)
if zerotime < startDate.time():
if zerotime.hour < 23:
zerotime = datetime.time(zerotime.hour + 1, minutes, seconds)
else:
zerotime = datetime.time(0, minutes, seconds)
startDate = sane_date(startDate.year, startDate.month,
startDate.day + 1)
else:
zerotime = datetime.time(0, minutes, seconds)
startDate = datetime.datetime.combine(startDate, zerotime)
while (endDate is None) or (startDate <= endDate):
yield startDate
startDate += datetime.timedelta(hours=1)
def hours(startDate, frequency=1, endDate=None):
"""Yield a sequence of datetimes, adding 'frequency' hours each time.
For example:
hours(datetime.datetime(2004, 5, 4, 14), 6)
yields the sequence: 2004-05-04 14:00:00, 2004-05-04 20:00:00,
2004-05-05 2:00:00, ...
If startDate has no time component (i.e. if it is a datetime.date),
then the first yielded time will be midnight (0:00:00) on that date.
If endDate has no time component (i.e. if it is a datetime.date),
then the last yielded time will be the last valid time before
midnight on that date.
For example:
hours(datetime.datetime(2004, 5, 4), 8, datetime.datetime(2004, 5, 5))
yields the sequence: 2004-05-04 00:00:00, 2004-05-04 08:00:00,
2004-05-04 16:00:00, 2004-05-05 00:00:00,
2004-05-05 08:00:00, 2004-05-05 16:00:00.
"""
if not hasattr(startDate, "time"):
startDate = datetime.datetime.combine(startDate, datetime.time(0))
if endDate and not hasattr(endDate, "time"):
endDate = datetime.datetime.combine(endDate, datetime.time(23, 59, 59))
while (endDate is None) or (startDate <= endDate):
yield startDate
startDate += datetime.timedelta(hours=frequency)
def time_from_str(timeofday):
atoms = timeofday.split(u":")
def pop_or_zero():
try:
return int(atoms.pop(0))
except TypeError:
raise ValueError("The supplied time '%s' could not be parsed."
% timeofday)
except IndexError:
return 0
hour = pop_or_zero()
minute = pop_or_zero()
second = pop_or_zero()
return datetime.time(hour, minute, second)
def eachday(startDate, timeofday=None, endDate=None):
"""Yield the same time-of-day for each day. Defaults to midnight.
Yielded values are datetime.datetime objects.
For example:
eachday(datetime.date(2004, 5, 4), datetime.time(14, 3, 0))
yields the sequence: 2004-05-04 14:03:00, 2004-05-05 14:03:00,
2004-05-06 14:03:00, ...
timeofday may be a datetime.time, as in the above example, or it
may be a string, of the form "hour:min:sec". Seconds and minutes
may be omitted if their colon ":" separator is also omitted. So
the example above could be rewritten:
eachday(datetime.date(2004, 5, 4), "14:03")
"""
if timeofday is None:
timeofday = datetime.time(0)
elif isinstance(timeofday, (str, unicode)):
timeofday = time_from_str(timeofday)
# If the timeofday is less than the time of startDate,
# don't include the startDate in the results.
try:
if timeofday < startDate.time():
startDate = sane_date(startDate.year, startDate.month,
startDate.day + 1)
except AttributeError:
# startDate is a datetime.date, and has no time() attribute
pass
startDate = datetime.datetime.combine(startDate, timeofday)
# Now that we've coerced our startDate to a datetime, we need to
# do the same thing to endDate so we can compare them.
if endDate and not hasattr(endDate, "time"):
endDate = datetime.datetime.combine(endDate, timeofday)
while (endDate is None) or (startDate <= endDate):
yield startDate
startDate += datetime.timedelta(1)
def eachweekday(startDate, weekday, timeofday=None, endDate=None):
"""Yield the same time-of-day each week for the given day. The time-of-day
defaults to midnight.
Yielded values are datetime.datetime objects.
For example:
eachweekday(datetime.date(2006, 8, 10), 3, datetime.time(14, 3, 0))
yields the sequence: 2006-08-11 14:03:00, 2006-08-18 14:03:00,
2006-08-25 14:03:00, ...
timeofday may be a datetime.time, as in the above example, or it
may be a string, of the form "hour:min:sec". Seconds and minutes
may be omitted if their colon ":" separator is also omitted. So
the example above could be rewritten:
eachday(datetime.date(2004, 5, 4), "14:03")
"""
if timeofday is None:
timeofday = datetime.time(0)
elif isinstance(timeofday, (str, unicode)):
timeofday = time_from_str(timeofday)
# get the given start time or datetime.time(0,0)
startTime = getattr(startDate, 'time', datetime.time)()
if startDate.weekday() > weekday or startTime > timeofday:
offset = (7 + weekday) - startDate.weekday()
while offset > 6:
offset -= 7
while offset <= 0:
offset += 7
startDate += datetime.timedelta(offset)
startDate = datetime.datetime.combine(startDate, timeofday)
# Now that we've coerced our startDate to a datetime, we need to
# do the same thing to endDate so we can compare them.
if endDate and not hasattr(endDate, "time"):
endDate = datetime.datetime.combine(endDate, timeofday)
end = getattr(endDate, 'date', lambda: None)()
day_iter = eachweek(startDate.date(), weekday, end)
startDate = datetime.datetime.combine(day_iter.next(), timeofday)
while (endDate is None) or (startDate <= endDate):
yield startDate
startDate = datetime.datetime.combine(day_iter.next(), timeofday)
def days(startDate, frequency=1, endDate=None):
"""Yield a sequence of dates, adding 'frequency' days each time.
For example:
days(datetime.date(2004, 5, 4), 7)
yields the sequence: 2004-5-4, 2004-5-11, 2004-5-18, ...
"""
while (endDate is None) or (startDate <= endDate):
yield startDate
startDate += datetime.timedelta(frequency)
def eachweek(startDate, weekday=0, endDate=None):
"""Yield the same day-of-the-week for each week. Defaults to Monday.
Yielded values are datetime.date objects.
Weekday follows the same days of the week as datetime.weekday().
For example:
mon, tue, wed, thu, fri, sat, sun = range(7)
eachweek(datetime.date(2004, 5, 4), thu)
yields the sequence: 2004-5-6, 2004-5-13, 2004-5-20, ...
If weekday is out of bounds (0-6), it will be brought in bounds.
"""
if hasattr(startDate, 'time'):
startDate = startDate.date()
weekday = int(weekday)
offset = (7 + weekday) - startDate.weekday()
while offset > 6:
offset -= 7
while offset < 0:
offset += 7
startDate += datetime.timedelta(offset)
return days(startDate, 7, endDate)
def weeks(startDate, frequency=1, endDate=None):
"""Yield a sequence of dates, adding 'frequency' weeks each time.
For example:
weeks(datetime.date(2004, 5, 4), 2)
yields the sequence: 2004-5-4, 2004-5-18, 2004-6-1, ...
"""
while (endDate is None) or (startDate <= endDate):
yield startDate
startDate += datetime.timedelta(frequency * 7)
def eachmonth(startDate, day=1, endDate=None):
"""Yield the same day of each month. Defaults to the first day.
Yielded values are datetime.date objects.
If day is a positive number, return that date for each month,
starting with startDate. For example:
eachmonth(datetime.date(2004, 5, 4), 15)
yields the sequence: 2004-5-15, 2004-6-15, 2004-7-15, ...
If day is zero or negative, return the same date counting
backwards from the end of the month. For example:
eachmonth(datetime.date(2004, 5, 4), -5)
yields the sequence: 2004-5-26, 2004-6-25, 2004-7-26, ...
If day specifies a day which does not appear in every month,
then the closest valid date within that month will be used instead.
For example:
eachmonth(datetime.date(2004, 5, 4), 31)
yields the sequence: 2004-5-31, 2004-6-30, 2004-7-31, ...
If startDate is greater than what would otherwise be the first date
in the sequence, that first item is not yielded; instead, the next
item becomes the first item yielded.
If endDate is less than what would otherwise be the last date in the
sequence, that last item is not yielded, and the sequence ends.
"""
if hasattr(startDate, 'time'):
startDate = startDate.date()
day = int(day)
highzero = (day < 1)
index = 0
while True:
firstDate = sane_date(startDate.year, startDate.month + index, day, highzero)
if firstDate >= startDate:
break
index += 1
startDate = firstDate
while (endDate is None) or (startDate <= endDate):
yield startDate
startDate = sane_date(startDate.year, startDate.month + 1, day, highzero)
def months(startDate, frequency=1, endDate=None):
"""Yield a sequence of dates, adding 'frequency' months each time.
For example:
months(datetime.date(2004, 5, 4), 3)
yields the sequence: 2004-5-4, 2004-8-4, 2004-11-4, ...
If the specified startDate contains a day which does not appear
in every month, then the corresponding day from the next month
will be used instead.
For example:
months(datetime.date(2004, 5, 31), 3)
yields the sequence: 2004-5-31, 2004-8-31, 2004-12-1, ...
If the frequency parameter is negative, the sequence descends.
"""
day = startDate.day
month = startDate.month
year = startDate.year
while True:
if endDate is not None:
if frequency < 0:
if startDate < endDate: break
else:
if startDate > endDate: break
yield startDate
month += frequency
startDate = sane_date(year, month, day)
def eachyear(startDate, month=1, day=1, endDate=None):
"""Yield the same day of the year for each year. Defaults to 1/1.
Yielded values are datetime.date objects.
If day and month are positive numbers, return that day/month for each
year, starting with startDate. For example:
eachyear(datetime.date(2004, 5, 4), 8, 15)
yields the sequence: 2004-8-15, 2005-8-15, 2006-8-15, ...
If month is zero or negative, return the same date counting months
backwards from the end of the year. For example:
eachyear(datetime.date(2004, 5, 4), -2, 15)
yields the sequence: 2004-10-15, 2005-10-15, 2006-10-15, ...
If day is zero or negative, return the same date counting days
backwards from the end of the month. For example:
eachyear(datetime.date(2004, 5, 4), -2, -1)
yields the sequence: 2004-10-30, 2005-10-30, 2006-10-30, ...
If day specifies a day which does not appear in the given month,
then the corresponding day from the next month will be used instead.
For example:
eachyear(datetime.date(2004, 5, 4), 5, 31)
yields the sequence: 2004-6-1, 2005-6-1, 2006-6-1, ...
If startDate is greater than what would otherwise be the first date
in the sequence, that first item is not yielded; instead, the next
item becomes the first item yielded.
If endDate is less than what would otherwise be the last date in the
sequence, that last item is not yielded, and the sequence ends.
"""
if hasattr(startDate, 'time'):
startDate = startDate.date()
month = int(month)
day = int(day)
index = 0
while True:
curDate = sane_date(startDate.year + index, month, day, True)
if curDate >= startDate:
break
index += 1
while (endDate is None) or (curDate <= endDate):
yield curDate
index += 1
curDate = sane_date(startDate.year + index, month, day, True)
def years(startDate, frequency=1, endDate=None):
"""Yield a sequence of dates, adding 'frequency' years each time.
For example:
years(datetime.date(2004, 5, 4), 3)
yields the sequence: 2004-5-4, 2007-5-4, 2010-5-4, ...
If the specified startDate contains a day which does not appear
in every year (i.e. leap years), then the corresponding day from
the next month will be used instead.
For example:
years(datetime.date(2004, 2, 29), 3)
yields the sequence: 2004-2-29, 2007-3-1, 2010-3-1, ...
If the frequency parameter is negative, the sequence descends.
"""
day = startDate.day
month = startDate.month
year = startDate.year
while True:
if endDate is not None:
if frequency < 0:
if startDate < endDate: break
else:
if startDate > endDate: break
yield startDate
year += frequency
startDate = sane_date(year, month, day)
def byunits(startDate, whichUnit, frequency=1, endDate=None):
"""Dispatch to the appropriate unit handler.
This really just exists to help out Locale series.
"""
frequency = int(frequency)
unithandler = (seconds, minutes, hours, days, weeks, months, years)
return unithandler[whichUnit](startDate, frequency, endDate)
def singledate(startDate, year, month=1, day=1, endDate=None):
"""Yield a single datetime.date if y/m/d occurs between start and end."""
year = int(year)
month = int(month)
day = int(day)
curDate = sane_date(year, month, day)
if curDate < startDate:
raise StopIteration
if (endDate is None) or (curDate <= endDate):
yield curDate
class Locale(object):
"""Language-specific expression matching.
To use a language other than English with Recurrence objects,
either subclass Locale and override the "patterns" dictionary,
or write some other callable that takes a description string
and returns a recurrence function and its "inner" args.
"""
patterns = {byunits: [r"([0-9]+) sec",
r"([0-9]+) min",
r"([0-9]+) hour",
r"([0-9]+) day",
r"([0-9]+) week",
r"([0-9]+) month",
r"([0-9]+) year",
],
# \S is any non-whitespace character.
eachday: r"([\S]+) (?:every|each) day",
eachweekday: [# don't match "month"
r"([\S]+) (?:every|each) mon(?!th)",
r"([\S]+) (?:every|each) tue",
r"([\S]+) (?:every|each) wed",
r"([\S]+) (?:every|each) thu",
r"([\S]+) (?:every|each) fri",
r"([\S]+) (?:every|each) sat",
r"([\S]+) (?:every|each) sun",
],
eachweek: [r"mon", r"tue", r"wed", r"thu", r"fri", r"sat", r"sun"],
eachmonth: r"(-?\d+) (?:every|each) month",
# Lookbehind for a digit and separator so we don't
# screw up singledate, below.
eachyear: [r"^(dummy entry to line up indexing)$",
r"(?= now:
self.nextrun = next
break
try:
next = self.recurrence.next()
except StopIteration:
# The recurrence series was exhausted.
self.nextrun = None
break
def start(self, secs=0):
"""Call self.run in a new thread."""
if self.active:
self.curthread = threading.Timer(secs, self.run)
self.curthread.start()
def run(self):
"""Prepare for work."""
if self.active:
self.work()
self.lastrun = datetime.datetime.now()
def work(self):
"""Perform the actual work. Must be overridden."""
raise NotImplementedError
def stop(self):
"""Stop work."""
self.active = False
if self.curthread:
self.curthread.cancel()
class Scheduler(object):
"""Collection of Workers governed by a single scheduler thread.
paused: a boolean flag indicating whether or not each Worker's start()
method should be executed at each interval. Notice that, even if
paused is True, the scheduler thread will still cycle, but Workers
will not be run at each interval.
terminated: a boolean flag indicating whether or not the Worker should
continue to cycle. If terminated is True, recurring Workers will
not schedule new threads.
"""
def __init__(self, workers=None):
if workers is None:
workers = {}
self.workers = workers
self.curthread = None
self.paused = False
self.terminated = False
def start(self):
"""Start a new recurring thread for all workers.
This sets self.terminated to False, but doesn't set self.paused.
"""
# Set nextrun for all workers
for worker in self.workers.values():
worker.advance()
self.terminated = False
self._cycle()
def _cycle(self):
"""Start a new Timer for the next worker."""
if self.terminated:
return
ivs = []
for w in self.workers.values():
next = w.nextrun
if next is not None:
ivs.append((w.interval(next), w))
if ivs:
ivs.sort()
iv, nextworker = ivs[0]
iv = (iv.days * 86400) + iv.seconds + (iv.microseconds / 1000000.0)
nextworker.advance()
self.curthread = threading.Timer(iv, self.run, (nextworker,))
self.curthread.start()
def run(self, worker):
"""Run the worker, then cycle again."""
if not self.paused and not self.terminated:
worker.start()
self._cycle()
def stop(self):
self.terminated = True
if self.curthread:
self.curthread.cancel()
for w in self.workers.values():
w.stop()