1738 lines
65 KiB
Python
1738 lines
65 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""
|
|
The rrule module offers a small, complete, and very fast, implementation of
|
|
the recurrence rules documented in the
|
|
`iCalendar RFC <https://tools.ietf.org/html/rfc5545>`_,
|
|
including support for caching of results.
|
|
"""
|
|
import calendar
|
|
import datetime
|
|
import heapq
|
|
import itertools
|
|
import re
|
|
import sys
|
|
from functools import wraps
|
|
# For warning about deprecation of until and count
|
|
from warnings import warn
|
|
|
|
from six import advance_iterator, integer_types
|
|
|
|
from six.moves import _thread, range
|
|
|
|
from ._common import weekday as weekdaybase
|
|
|
|
try:
|
|
from math import gcd
|
|
except ImportError:
|
|
from fractions import gcd
|
|
|
|
__all__ = ["rrule", "rruleset", "rrulestr",
|
|
"YEARLY", "MONTHLY", "WEEKLY", "DAILY",
|
|
"HOURLY", "MINUTELY", "SECONDLY",
|
|
"MO", "TU", "WE", "TH", "FR", "SA", "SU"]
|
|
|
|
# Every mask is 7 days longer to handle cross-year weekly periods.
|
|
M366MASK = tuple([1]*31+[2]*29+[3]*31+[4]*30+[5]*31+[6]*30 +
|
|
[7]*31+[8]*31+[9]*30+[10]*31+[11]*30+[12]*31+[1]*7)
|
|
M365MASK = list(M366MASK)
|
|
M29, M30, M31 = list(range(1, 30)), list(range(1, 31)), list(range(1, 32))
|
|
MDAY366MASK = tuple(M31+M29+M31+M30+M31+M30+M31+M31+M30+M31+M30+M31+M31[:7])
|
|
MDAY365MASK = list(MDAY366MASK)
|
|
M29, M30, M31 = list(range(-29, 0)), list(range(-30, 0)), list(range(-31, 0))
|
|
NMDAY366MASK = tuple(M31+M29+M31+M30+M31+M30+M31+M31+M30+M31+M30+M31+M31[:7])
|
|
NMDAY365MASK = list(NMDAY366MASK)
|
|
M366RANGE = (0, 31, 60, 91, 121, 152, 182, 213, 244, 274, 305, 335, 366)
|
|
M365RANGE = (0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334, 365)
|
|
WDAYMASK = [0, 1, 2, 3, 4, 5, 6]*55
|
|
del M29, M30, M31, M365MASK[59], MDAY365MASK[59], NMDAY365MASK[31]
|
|
MDAY365MASK = tuple(MDAY365MASK)
|
|
M365MASK = tuple(M365MASK)
|
|
|
|
FREQNAMES = ['YEARLY', 'MONTHLY', 'WEEKLY', 'DAILY', 'HOURLY', 'MINUTELY', 'SECONDLY']
|
|
|
|
(YEARLY,
|
|
MONTHLY,
|
|
WEEKLY,
|
|
DAILY,
|
|
HOURLY,
|
|
MINUTELY,
|
|
SECONDLY) = list(range(7))
|
|
|
|
# Imported on demand.
|
|
easter = None
|
|
parser = None
|
|
|
|
|
|
class weekday(weekdaybase):
|
|
"""
|
|
This version of weekday does not allow n = 0.
|
|
"""
|
|
def __init__(self, wkday, n=None):
|
|
if n == 0:
|
|
raise ValueError("Can't create weekday with n==0")
|
|
|
|
super(weekday, self).__init__(wkday, n)
|
|
|
|
|
|
MO, TU, WE, TH, FR, SA, SU = weekdays = tuple(weekday(x) for x in range(7))
|
|
|
|
|
|
def _invalidates_cache(f):
|
|
"""
|
|
Decorator for rruleset methods which may invalidate the
|
|
cached length.
|
|
"""
|
|
@wraps(f)
|
|
def inner_func(self, *args, **kwargs):
|
|
rv = f(self, *args, **kwargs)
|
|
self._invalidate_cache()
|
|
return rv
|
|
|
|
return inner_func
|
|
|
|
|
|
class rrulebase(object):
|
|
def __init__(self, cache=False):
|
|
if cache:
|
|
self._cache = []
|
|
self._cache_lock = _thread.allocate_lock()
|
|
self._invalidate_cache()
|
|
else:
|
|
self._cache = None
|
|
self._cache_complete = False
|
|
self._len = None
|
|
|
|
def __iter__(self):
|
|
if self._cache_complete:
|
|
return iter(self._cache)
|
|
elif self._cache is None:
|
|
return self._iter()
|
|
else:
|
|
return self._iter_cached()
|
|
|
|
def _invalidate_cache(self):
|
|
if self._cache is not None:
|
|
self._cache = []
|
|
self._cache_complete = False
|
|
self._cache_gen = self._iter()
|
|
|
|
if self._cache_lock.locked():
|
|
self._cache_lock.release()
|
|
|
|
self._len = None
|
|
|
|
def _iter_cached(self):
|
|
i = 0
|
|
gen = self._cache_gen
|
|
cache = self._cache
|
|
acquire = self._cache_lock.acquire
|
|
release = self._cache_lock.release
|
|
while gen:
|
|
if i == len(cache):
|
|
acquire()
|
|
if self._cache_complete:
|
|
break
|
|
try:
|
|
for j in range(10):
|
|
cache.append(advance_iterator(gen))
|
|
except StopIteration:
|
|
self._cache_gen = gen = None
|
|
self._cache_complete = True
|
|
break
|
|
release()
|
|
yield cache[i]
|
|
i += 1
|
|
while i < self._len:
|
|
yield cache[i]
|
|
i += 1
|
|
|
|
def __getitem__(self, item):
|
|
if self._cache_complete:
|
|
return self._cache[item]
|
|
elif isinstance(item, slice):
|
|
if item.step and item.step < 0:
|
|
return list(iter(self))[item]
|
|
else:
|
|
return list(itertools.islice(self,
|
|
item.start or 0,
|
|
item.stop or sys.maxsize,
|
|
item.step or 1))
|
|
elif item >= 0:
|
|
gen = iter(self)
|
|
try:
|
|
for i in range(item+1):
|
|
res = advance_iterator(gen)
|
|
except StopIteration:
|
|
raise IndexError
|
|
return res
|
|
else:
|
|
return list(iter(self))[item]
|
|
|
|
def __contains__(self, item):
|
|
if self._cache_complete:
|
|
return item in self._cache
|
|
else:
|
|
for i in self:
|
|
if i == item:
|
|
return True
|
|
elif i > item:
|
|
return False
|
|
return False
|
|
|
|
# __len__() introduces a large performance penalty.
|
|
def count(self):
|
|
""" Returns the number of recurrences in this set. It will have go
|
|
through the whole recurrence, if this hasn't been done before. """
|
|
if self._len is None:
|
|
for x in self:
|
|
pass
|
|
return self._len
|
|
|
|
def before(self, dt, inc=False):
|
|
""" Returns the last recurrence before the given datetime instance. The
|
|
inc keyword defines what happens if dt is an occurrence. With
|
|
inc=True, if dt itself is an occurrence, it will be returned. """
|
|
if self._cache_complete:
|
|
gen = self._cache
|
|
else:
|
|
gen = self
|
|
last = None
|
|
if inc:
|
|
for i in gen:
|
|
if i > dt:
|
|
break
|
|
last = i
|
|
else:
|
|
for i in gen:
|
|
if i >= dt:
|
|
break
|
|
last = i
|
|
return last
|
|
|
|
def after(self, dt, inc=False):
|
|
""" Returns the first recurrence after the given datetime instance. The
|
|
inc keyword defines what happens if dt is an occurrence. With
|
|
inc=True, if dt itself is an occurrence, it will be returned. """
|
|
if self._cache_complete:
|
|
gen = self._cache
|
|
else:
|
|
gen = self
|
|
if inc:
|
|
for i in gen:
|
|
if i >= dt:
|
|
return i
|
|
else:
|
|
for i in gen:
|
|
if i > dt:
|
|
return i
|
|
return None
|
|
|
|
def xafter(self, dt, count=None, inc=False):
|
|
"""
|
|
Generator which yields up to `count` recurrences after the given
|
|
datetime instance, equivalent to `after`.
|
|
|
|
:param dt:
|
|
The datetime at which to start generating recurrences.
|
|
|
|
:param count:
|
|
The maximum number of recurrences to generate. If `None` (default),
|
|
dates are generated until the recurrence rule is exhausted.
|
|
|
|
:param inc:
|
|
If `dt` is an instance of the rule and `inc` is `True`, it is
|
|
included in the output.
|
|
|
|
:yields: Yields a sequence of `datetime` objects.
|
|
"""
|
|
|
|
if self._cache_complete:
|
|
gen = self._cache
|
|
else:
|
|
gen = self
|
|
|
|
# Select the comparison function
|
|
if inc:
|
|
comp = lambda dc, dtc: dc >= dtc
|
|
else:
|
|
comp = lambda dc, dtc: dc > dtc
|
|
|
|
# Generate dates
|
|
n = 0
|
|
for d in gen:
|
|
if comp(d, dt):
|
|
if count is not None:
|
|
n += 1
|
|
if n > count:
|
|
break
|
|
|
|
yield d
|
|
|
|
def between(self, after, before, inc=False, count=1):
|
|
""" Returns all the occurrences of the rrule between after and before.
|
|
The inc keyword defines what happens if after and/or before are
|
|
themselves occurrences. With inc=True, they will be included in the
|
|
list, if they are found in the recurrence set. """
|
|
if self._cache_complete:
|
|
gen = self._cache
|
|
else:
|
|
gen = self
|
|
started = False
|
|
l = []
|
|
if inc:
|
|
for i in gen:
|
|
if i > before:
|
|
break
|
|
elif not started:
|
|
if i >= after:
|
|
started = True
|
|
l.append(i)
|
|
else:
|
|
l.append(i)
|
|
else:
|
|
for i in gen:
|
|
if i >= before:
|
|
break
|
|
elif not started:
|
|
if i > after:
|
|
started = True
|
|
l.append(i)
|
|
else:
|
|
l.append(i)
|
|
return l
|
|
|
|
|
|
class rrule(rrulebase):
|
|
"""
|
|
That's the base of the rrule operation. It accepts all the keywords
|
|
defined in the RFC as its constructor parameters (except byday,
|
|
which was renamed to byweekday) and more. The constructor prototype is::
|
|
|
|
rrule(freq)
|
|
|
|
Where freq must be one of YEARLY, MONTHLY, WEEKLY, DAILY, HOURLY, MINUTELY,
|
|
or SECONDLY.
|
|
|
|
.. note::
|
|
Per RFC section 3.3.10, recurrence instances falling on invalid dates
|
|
and times are ignored rather than coerced:
|
|
|
|
Recurrence rules may generate recurrence instances with an invalid
|
|
date (e.g., February 30) or nonexistent local time (e.g., 1:30 AM
|
|
on a day where the local time is moved forward by an hour at 1:00
|
|
AM). Such recurrence instances MUST be ignored and MUST NOT be
|
|
counted as part of the recurrence set.
|
|
|
|
This can lead to possibly surprising behavior when, for example, the
|
|
start date occurs at the end of the month:
|
|
|
|
>>> from dateutil.rrule import rrule, MONTHLY
|
|
>>> from datetime import datetime
|
|
>>> start_date = datetime(2014, 12, 31)
|
|
>>> list(rrule(freq=MONTHLY, count=4, dtstart=start_date))
|
|
... # doctest: +NORMALIZE_WHITESPACE
|
|
[datetime.datetime(2014, 12, 31, 0, 0),
|
|
datetime.datetime(2015, 1, 31, 0, 0),
|
|
datetime.datetime(2015, 3, 31, 0, 0),
|
|
datetime.datetime(2015, 5, 31, 0, 0)]
|
|
|
|
Additionally, it supports the following keyword arguments:
|
|
|
|
:param dtstart:
|
|
The recurrence start. Besides being the base for the recurrence,
|
|
missing parameters in the final recurrence instances will also be
|
|
extracted from this date. If not given, datetime.now() will be used
|
|
instead.
|
|
:param interval:
|
|
The interval between each freq iteration. For example, when using
|
|
YEARLY, an interval of 2 means once every two years, but with HOURLY,
|
|
it means once every two hours. The default interval is 1.
|
|
:param wkst:
|
|
The week start day. Must be one of the MO, TU, WE constants, or an
|
|
integer, specifying the first day of the week. This will affect
|
|
recurrences based on weekly periods. The default week start is got
|
|
from calendar.firstweekday(), and may be modified by
|
|
calendar.setfirstweekday().
|
|
:param count:
|
|
If given, this determines how many occurrences will be generated.
|
|
|
|
.. note::
|
|
As of version 2.5.0, the use of the keyword ``until`` in conjunction
|
|
with ``count`` is deprecated, to make sure ``dateutil`` is fully
|
|
compliant with `RFC-5545 Sec. 3.3.10 <https://tools.ietf.org/
|
|
html/rfc5545#section-3.3.10>`_. Therefore, ``until`` and ``count``
|
|
**must not** occur in the same call to ``rrule``.
|
|
:param until:
|
|
If given, this must be a datetime instance specifying the upper-bound
|
|
limit of the recurrence. The last recurrence in the rule is the greatest
|
|
datetime that is less than or equal to the value specified in the
|
|
``until`` parameter.
|
|
|
|
.. note::
|
|
As of version 2.5.0, the use of the keyword ``until`` in conjunction
|
|
with ``count`` is deprecated, to make sure ``dateutil`` is fully
|
|
compliant with `RFC-5545 Sec. 3.3.10 <https://tools.ietf.org/
|
|
html/rfc5545#section-3.3.10>`_. Therefore, ``until`` and ``count``
|
|
**must not** occur in the same call to ``rrule``.
|
|
:param bysetpos:
|
|
If given, it must be either an integer, or a sequence of integers,
|
|
positive or negative. Each given integer will specify an occurrence
|
|
number, corresponding to the nth occurrence of the rule inside the
|
|
frequency period. For example, a bysetpos of -1 if combined with a
|
|
MONTHLY frequency, and a byweekday of (MO, TU, WE, TH, FR), will
|
|
result in the last work day of every month.
|
|
:param bymonth:
|
|
If given, it must be either an integer, or a sequence of integers,
|
|
meaning the months to apply the recurrence to.
|
|
:param bymonthday:
|
|
If given, it must be either an integer, or a sequence of integers,
|
|
meaning the month days to apply the recurrence to.
|
|
:param byyearday:
|
|
If given, it must be either an integer, or a sequence of integers,
|
|
meaning the year days to apply the recurrence to.
|
|
:param byeaster:
|
|
If given, it must be either an integer, or a sequence of integers,
|
|
positive or negative. Each integer will define an offset from the
|
|
Easter Sunday. Passing the offset 0 to byeaster will yield the Easter
|
|
Sunday itself. This is an extension to the RFC specification.
|
|
:param byweekno:
|
|
If given, it must be either an integer, or a sequence of integers,
|
|
meaning the week numbers to apply the recurrence to. Week numbers
|
|
have the meaning described in ISO8601, that is, the first week of
|
|
the year is that containing at least four days of the new year.
|
|
:param byweekday:
|
|
If given, it must be either an integer (0 == MO), a sequence of
|
|
integers, one of the weekday constants (MO, TU, etc), or a sequence
|
|
of these constants. When given, these variables will define the
|
|
weekdays where the recurrence will be applied. It's also possible to
|
|
use an argument n for the weekday instances, which will mean the nth
|
|
occurrence of this weekday in the period. For example, with MONTHLY,
|
|
or with YEARLY and BYMONTH, using FR(+1) in byweekday will specify the
|
|
first friday of the month where the recurrence happens. Notice that in
|
|
the RFC documentation, this is specified as BYDAY, but was renamed to
|
|
avoid the ambiguity of that keyword.
|
|
:param byhour:
|
|
If given, it must be either an integer, or a sequence of integers,
|
|
meaning the hours to apply the recurrence to.
|
|
:param byminute:
|
|
If given, it must be either an integer, or a sequence of integers,
|
|
meaning the minutes to apply the recurrence to.
|
|
:param bysecond:
|
|
If given, it must be either an integer, or a sequence of integers,
|
|
meaning the seconds to apply the recurrence to.
|
|
:param cache:
|
|
If given, it must be a boolean value specifying to enable or disable
|
|
caching of results. If you will use the same rrule instance multiple
|
|
times, enabling caching will improve the performance considerably.
|
|
"""
|
|
def __init__(self, freq, dtstart=None,
|
|
interval=1, wkst=None, count=None, until=None, bysetpos=None,
|
|
bymonth=None, bymonthday=None, byyearday=None, byeaster=None,
|
|
byweekno=None, byweekday=None,
|
|
byhour=None, byminute=None, bysecond=None,
|
|
cache=False):
|
|
super(rrule, self).__init__(cache)
|
|
global easter
|
|
if not dtstart:
|
|
if until and until.tzinfo:
|
|
dtstart = datetime.datetime.now(tz=until.tzinfo).replace(microsecond=0)
|
|
else:
|
|
dtstart = datetime.datetime.now().replace(microsecond=0)
|
|
elif not isinstance(dtstart, datetime.datetime):
|
|
dtstart = datetime.datetime.fromordinal(dtstart.toordinal())
|
|
else:
|
|
dtstart = dtstart.replace(microsecond=0)
|
|
self._dtstart = dtstart
|
|
self._tzinfo = dtstart.tzinfo
|
|
self._freq = freq
|
|
self._interval = interval
|
|
self._count = count
|
|
|
|
# Cache the original byxxx rules, if they are provided, as the _byxxx
|
|
# attributes do not necessarily map to the inputs, and this can be
|
|
# a problem in generating the strings. Only store things if they've
|
|
# been supplied (the string retrieval will just use .get())
|
|
self._original_rule = {}
|
|
|
|
if until and not isinstance(until, datetime.datetime):
|
|
until = datetime.datetime.fromordinal(until.toordinal())
|
|
self._until = until
|
|
|
|
if self._dtstart and self._until:
|
|
if (self._dtstart.tzinfo is not None) != (self._until.tzinfo is not None):
|
|
# According to RFC5545 Section 3.3.10:
|
|
# https://tools.ietf.org/html/rfc5545#section-3.3.10
|
|
#
|
|
# > If the "DTSTART" property is specified as a date with UTC
|
|
# > time or a date with local time and time zone reference,
|
|
# > then the UNTIL rule part MUST be specified as a date with
|
|
# > UTC time.
|
|
raise ValueError(
|
|
'RRULE UNTIL values must be specified in UTC when DTSTART '
|
|
'is timezone-aware'
|
|
)
|
|
|
|
if count is not None and until:
|
|
warn("Using both 'count' and 'until' is inconsistent with RFC 5545"
|
|
" and has been deprecated in dateutil. Future versions will "
|
|
"raise an error.", DeprecationWarning)
|
|
|
|
if wkst is None:
|
|
self._wkst = calendar.firstweekday()
|
|
elif isinstance(wkst, integer_types):
|
|
self._wkst = wkst
|
|
else:
|
|
self._wkst = wkst.weekday
|
|
|
|
if bysetpos is None:
|
|
self._bysetpos = None
|
|
elif isinstance(bysetpos, integer_types):
|
|
if bysetpos == 0 or not (-366 <= bysetpos <= 366):
|
|
raise ValueError("bysetpos must be between 1 and 366, "
|
|
"or between -366 and -1")
|
|
self._bysetpos = (bysetpos,)
|
|
else:
|
|
self._bysetpos = tuple(bysetpos)
|
|
for pos in self._bysetpos:
|
|
if pos == 0 or not (-366 <= pos <= 366):
|
|
raise ValueError("bysetpos must be between 1 and 366, "
|
|
"or between -366 and -1")
|
|
|
|
if self._bysetpos:
|
|
self._original_rule['bysetpos'] = self._bysetpos
|
|
|
|
if (byweekno is None and byyearday is None and bymonthday is None and
|
|
byweekday is None and byeaster is None):
|
|
if freq == YEARLY:
|
|
if bymonth is None:
|
|
bymonth = dtstart.month
|
|
self._original_rule['bymonth'] = None
|
|
bymonthday = dtstart.day
|
|
self._original_rule['bymonthday'] = None
|
|
elif freq == MONTHLY:
|
|
bymonthday = dtstart.day
|
|
self._original_rule['bymonthday'] = None
|
|
elif freq == WEEKLY:
|
|
byweekday = dtstart.weekday()
|
|
self._original_rule['byweekday'] = None
|
|
|
|
# bymonth
|
|
if bymonth is None:
|
|
self._bymonth = None
|
|
else:
|
|
if isinstance(bymonth, integer_types):
|
|
bymonth = (bymonth,)
|
|
|
|
self._bymonth = tuple(sorted(set(bymonth)))
|
|
|
|
if 'bymonth' not in self._original_rule:
|
|
self._original_rule['bymonth'] = self._bymonth
|
|
|
|
# byyearday
|
|
if byyearday is None:
|
|
self._byyearday = None
|
|
else:
|
|
if isinstance(byyearday, integer_types):
|
|
byyearday = (byyearday,)
|
|
|
|
self._byyearday = tuple(sorted(set(byyearday)))
|
|
self._original_rule['byyearday'] = self._byyearday
|
|
|
|
# byeaster
|
|
if byeaster is not None:
|
|
if not easter:
|
|
from dateutil import easter
|
|
if isinstance(byeaster, integer_types):
|
|
self._byeaster = (byeaster,)
|
|
else:
|
|
self._byeaster = tuple(sorted(byeaster))
|
|
|
|
self._original_rule['byeaster'] = self._byeaster
|
|
else:
|
|
self._byeaster = None
|
|
|
|
# bymonthday
|
|
if bymonthday is None:
|
|
self._bymonthday = ()
|
|
self._bynmonthday = ()
|
|
else:
|
|
if isinstance(bymonthday, integer_types):
|
|
bymonthday = (bymonthday,)
|
|
|
|
bymonthday = set(bymonthday) # Ensure it's unique
|
|
|
|
self._bymonthday = tuple(sorted(x for x in bymonthday if x > 0))
|
|
self._bynmonthday = tuple(sorted(x for x in bymonthday if x < 0))
|
|
|
|
# Storing positive numbers first, then negative numbers
|
|
if 'bymonthday' not in self._original_rule:
|
|
self._original_rule['bymonthday'] = tuple(
|
|
itertools.chain(self._bymonthday, self._bynmonthday))
|
|
|
|
# byweekno
|
|
if byweekno is None:
|
|
self._byweekno = None
|
|
else:
|
|
if isinstance(byweekno, integer_types):
|
|
byweekno = (byweekno,)
|
|
|
|
self._byweekno = tuple(sorted(set(byweekno)))
|
|
|
|
self._original_rule['byweekno'] = self._byweekno
|
|
|
|
# byweekday / bynweekday
|
|
if byweekday is None:
|
|
self._byweekday = None
|
|
self._bynweekday = None
|
|
else:
|
|
# If it's one of the valid non-sequence types, convert to a
|
|
# single-element sequence before the iterator that builds the
|
|
# byweekday set.
|
|
if isinstance(byweekday, integer_types) or hasattr(byweekday, "n"):
|
|
byweekday = (byweekday,)
|
|
|
|
self._byweekday = set()
|
|
self._bynweekday = set()
|
|
for wday in byweekday:
|
|
if isinstance(wday, integer_types):
|
|
self._byweekday.add(wday)
|
|
elif not wday.n or freq > MONTHLY:
|
|
self._byweekday.add(wday.weekday)
|
|
else:
|
|
self._bynweekday.add((wday.weekday, wday.n))
|
|
|
|
if not self._byweekday:
|
|
self._byweekday = None
|
|
elif not self._bynweekday:
|
|
self._bynweekday = None
|
|
|
|
if self._byweekday is not None:
|
|
self._byweekday = tuple(sorted(self._byweekday))
|
|
orig_byweekday = [weekday(x) for x in self._byweekday]
|
|
else:
|
|
orig_byweekday = ()
|
|
|
|
if self._bynweekday is not None:
|
|
self._bynweekday = tuple(sorted(self._bynweekday))
|
|
orig_bynweekday = [weekday(*x) for x in self._bynweekday]
|
|
else:
|
|
orig_bynweekday = ()
|
|
|
|
if 'byweekday' not in self._original_rule:
|
|
self._original_rule['byweekday'] = tuple(itertools.chain(
|
|
orig_byweekday, orig_bynweekday))
|
|
|
|
# byhour
|
|
if byhour is None:
|
|
if freq < HOURLY:
|
|
self._byhour = {dtstart.hour}
|
|
else:
|
|
self._byhour = None
|
|
else:
|
|
if isinstance(byhour, integer_types):
|
|
byhour = (byhour,)
|
|
|
|
if freq == HOURLY:
|
|
self._byhour = self.__construct_byset(start=dtstart.hour,
|
|
byxxx=byhour,
|
|
base=24)
|
|
else:
|
|
self._byhour = set(byhour)
|
|
|
|
self._byhour = tuple(sorted(self._byhour))
|
|
self._original_rule['byhour'] = self._byhour
|
|
|
|
# byminute
|
|
if byminute is None:
|
|
if freq < MINUTELY:
|
|
self._byminute = {dtstart.minute}
|
|
else:
|
|
self._byminute = None
|
|
else:
|
|
if isinstance(byminute, integer_types):
|
|
byminute = (byminute,)
|
|
|
|
if freq == MINUTELY:
|
|
self._byminute = self.__construct_byset(start=dtstart.minute,
|
|
byxxx=byminute,
|
|
base=60)
|
|
else:
|
|
self._byminute = set(byminute)
|
|
|
|
self._byminute = tuple(sorted(self._byminute))
|
|
self._original_rule['byminute'] = self._byminute
|
|
|
|
# bysecond
|
|
if bysecond is None:
|
|
if freq < SECONDLY:
|
|
self._bysecond = ((dtstart.second,))
|
|
else:
|
|
self._bysecond = None
|
|
else:
|
|
if isinstance(bysecond, integer_types):
|
|
bysecond = (bysecond,)
|
|
|
|
self._bysecond = set(bysecond)
|
|
|
|
if freq == SECONDLY:
|
|
self._bysecond = self.__construct_byset(start=dtstart.second,
|
|
byxxx=bysecond,
|
|
base=60)
|
|
else:
|
|
self._bysecond = set(bysecond)
|
|
|
|
self._bysecond = tuple(sorted(self._bysecond))
|
|
self._original_rule['bysecond'] = self._bysecond
|
|
|
|
if self._freq >= HOURLY:
|
|
self._timeset = None
|
|
else:
|
|
self._timeset = []
|
|
for hour in self._byhour:
|
|
for minute in self._byminute:
|
|
for second in self._bysecond:
|
|
self._timeset.append(
|
|
datetime.time(hour, minute, second,
|
|
tzinfo=self._tzinfo))
|
|
self._timeset.sort()
|
|
self._timeset = tuple(self._timeset)
|
|
|
|
def __str__(self):
|
|
"""
|
|
Output a string that would generate this RRULE if passed to rrulestr.
|
|
This is mostly compatible with RFC5545, except for the
|
|
dateutil-specific extension BYEASTER.
|
|
"""
|
|
|
|
output = []
|
|
h, m, s = [None] * 3
|
|
if self._dtstart:
|
|
output.append(self._dtstart.strftime('DTSTART:%Y%m%dT%H%M%S'))
|
|
h, m, s = self._dtstart.timetuple()[3:6]
|
|
|
|
parts = ['FREQ=' + FREQNAMES[self._freq]]
|
|
if self._interval != 1:
|
|
parts.append('INTERVAL=' + str(self._interval))
|
|
|
|
if self._wkst:
|
|
parts.append('WKST=' + repr(weekday(self._wkst))[0:2])
|
|
|
|
if self._count is not None:
|
|
parts.append('COUNT=' + str(self._count))
|
|
|
|
if self._until:
|
|
parts.append(self._until.strftime('UNTIL=%Y%m%dT%H%M%S'))
|
|
|
|
if self._original_rule.get('byweekday') is not None:
|
|
# The str() method on weekday objects doesn't generate
|
|
# RFC5545-compliant strings, so we should modify that.
|
|
original_rule = dict(self._original_rule)
|
|
wday_strings = []
|
|
for wday in original_rule['byweekday']:
|
|
if wday.n:
|
|
wday_strings.append('{n:+d}{wday}'.format(
|
|
n=wday.n,
|
|
wday=repr(wday)[0:2]))
|
|
else:
|
|
wday_strings.append(repr(wday))
|
|
|
|
original_rule['byweekday'] = wday_strings
|
|
else:
|
|
original_rule = self._original_rule
|
|
|
|
partfmt = '{name}={vals}'
|
|
for name, key in [('BYSETPOS', 'bysetpos'),
|
|
('BYMONTH', 'bymonth'),
|
|
('BYMONTHDAY', 'bymonthday'),
|
|
('BYYEARDAY', 'byyearday'),
|
|
('BYWEEKNO', 'byweekno'),
|
|
('BYDAY', 'byweekday'),
|
|
('BYHOUR', 'byhour'),
|
|
('BYMINUTE', 'byminute'),
|
|
('BYSECOND', 'bysecond'),
|
|
('BYEASTER', 'byeaster')]:
|
|
value = original_rule.get(key)
|
|
if value:
|
|
parts.append(partfmt.format(name=name, vals=(','.join(str(v)
|
|
for v in value))))
|
|
|
|
output.append('RRULE:' + ';'.join(parts))
|
|
return '\n'.join(output)
|
|
|
|
def replace(self, **kwargs):
|
|
"""Return new rrule with same attributes except for those attributes given new
|
|
values by whichever keyword arguments are specified."""
|
|
new_kwargs = {"interval": self._interval,
|
|
"count": self._count,
|
|
"dtstart": self._dtstart,
|
|
"freq": self._freq,
|
|
"until": self._until,
|
|
"wkst": self._wkst,
|
|
"cache": False if self._cache is None else True }
|
|
new_kwargs.update(self._original_rule)
|
|
new_kwargs.update(kwargs)
|
|
return rrule(**new_kwargs)
|
|
|
|
def _iter(self):
|
|
year, month, day, hour, minute, second, weekday, yearday, _ = \
|
|
self._dtstart.timetuple()
|
|
|
|
# Some local variables to speed things up a bit
|
|
freq = self._freq
|
|
interval = self._interval
|
|
wkst = self._wkst
|
|
until = self._until
|
|
bymonth = self._bymonth
|
|
byweekno = self._byweekno
|
|
byyearday = self._byyearday
|
|
byweekday = self._byweekday
|
|
byeaster = self._byeaster
|
|
bymonthday = self._bymonthday
|
|
bynmonthday = self._bynmonthday
|
|
bysetpos = self._bysetpos
|
|
byhour = self._byhour
|
|
byminute = self._byminute
|
|
bysecond = self._bysecond
|
|
|
|
ii = _iterinfo(self)
|
|
ii.rebuild(year, month)
|
|
|
|
getdayset = {YEARLY: ii.ydayset,
|
|
MONTHLY: ii.mdayset,
|
|
WEEKLY: ii.wdayset,
|
|
DAILY: ii.ddayset,
|
|
HOURLY: ii.ddayset,
|
|
MINUTELY: ii.ddayset,
|
|
SECONDLY: ii.ddayset}[freq]
|
|
|
|
if freq < HOURLY:
|
|
timeset = self._timeset
|
|
else:
|
|
gettimeset = {HOURLY: ii.htimeset,
|
|
MINUTELY: ii.mtimeset,
|
|
SECONDLY: ii.stimeset}[freq]
|
|
if ((freq >= HOURLY and
|
|
self._byhour and hour not in self._byhour) or
|
|
(freq >= MINUTELY and
|
|
self._byminute and minute not in self._byminute) or
|
|
(freq >= SECONDLY and
|
|
self._bysecond and second not in self._bysecond)):
|
|
timeset = ()
|
|
else:
|
|
timeset = gettimeset(hour, minute, second)
|
|
|
|
total = 0
|
|
count = self._count
|
|
while True:
|
|
# Get dayset with the right frequency
|
|
dayset, start, end = getdayset(year, month, day)
|
|
|
|
# Do the "hard" work ;-)
|
|
filtered = False
|
|
for i in dayset[start:end]:
|
|
if ((bymonth and ii.mmask[i] not in bymonth) or
|
|
(byweekno and not ii.wnomask[i]) or
|
|
(byweekday and ii.wdaymask[i] not in byweekday) or
|
|
(ii.nwdaymask and not ii.nwdaymask[i]) or
|
|
(byeaster and not ii.eastermask[i]) or
|
|
((bymonthday or bynmonthday) and
|
|
ii.mdaymask[i] not in bymonthday and
|
|
ii.nmdaymask[i] not in bynmonthday) or
|
|
(byyearday and
|
|
((i < ii.yearlen and i+1 not in byyearday and
|
|
-ii.yearlen+i not in byyearday) or
|
|
(i >= ii.yearlen and i+1-ii.yearlen not in byyearday and
|
|
-ii.nextyearlen+i-ii.yearlen not in byyearday)))):
|
|
dayset[i] = None
|
|
filtered = True
|
|
|
|
# Output results
|
|
if bysetpos and timeset:
|
|
poslist = []
|
|
for pos in bysetpos:
|
|
if pos < 0:
|
|
daypos, timepos = divmod(pos, len(timeset))
|
|
else:
|
|
daypos, timepos = divmod(pos-1, len(timeset))
|
|
try:
|
|
i = [x for x in dayset[start:end]
|
|
if x is not None][daypos]
|
|
time = timeset[timepos]
|
|
except IndexError:
|
|
pass
|
|
else:
|
|
date = datetime.date.fromordinal(ii.yearordinal+i)
|
|
res = datetime.datetime.combine(date, time)
|
|
if res not in poslist:
|
|
poslist.append(res)
|
|
poslist.sort()
|
|
for res in poslist:
|
|
if until and res > until:
|
|
self._len = total
|
|
return
|
|
elif res >= self._dtstart:
|
|
if count is not None:
|
|
count -= 1
|
|
if count < 0:
|
|
self._len = total
|
|
return
|
|
total += 1
|
|
yield res
|
|
else:
|
|
for i in dayset[start:end]:
|
|
if i is not None:
|
|
date = datetime.date.fromordinal(ii.yearordinal + i)
|
|
for time in timeset:
|
|
res = datetime.datetime.combine(date, time)
|
|
if until and res > until:
|
|
self._len = total
|
|
return
|
|
elif res >= self._dtstart:
|
|
if count is not None:
|
|
count -= 1
|
|
if count < 0:
|
|
self._len = total
|
|
return
|
|
|
|
total += 1
|
|
yield res
|
|
|
|
# Handle frequency and interval
|
|
fixday = False
|
|
if freq == YEARLY:
|
|
year += interval
|
|
if year > datetime.MAXYEAR:
|
|
self._len = total
|
|
return
|
|
ii.rebuild(year, month)
|
|
elif freq == MONTHLY:
|
|
month += interval
|
|
if month > 12:
|
|
div, mod = divmod(month, 12)
|
|
month = mod
|
|
year += div
|
|
if month == 0:
|
|
month = 12
|
|
year -= 1
|
|
if year > datetime.MAXYEAR:
|
|
self._len = total
|
|
return
|
|
ii.rebuild(year, month)
|
|
elif freq == WEEKLY:
|
|
if wkst > weekday:
|
|
day += -(weekday+1+(6-wkst))+self._interval*7
|
|
else:
|
|
day += -(weekday-wkst)+self._interval*7
|
|
weekday = wkst
|
|
fixday = True
|
|
elif freq == DAILY:
|
|
day += interval
|
|
fixday = True
|
|
elif freq == HOURLY:
|
|
if filtered:
|
|
# Jump to one iteration before next day
|
|
hour += ((23-hour)//interval)*interval
|
|
|
|
if byhour:
|
|
ndays, hour = self.__mod_distance(value=hour,
|
|
byxxx=self._byhour,
|
|
base=24)
|
|
else:
|
|
ndays, hour = divmod(hour+interval, 24)
|
|
|
|
if ndays:
|
|
day += ndays
|
|
fixday = True
|
|
|
|
timeset = gettimeset(hour, minute, second)
|
|
elif freq == MINUTELY:
|
|
if filtered:
|
|
# Jump to one iteration before next day
|
|
minute += ((1439-(hour*60+minute))//interval)*interval
|
|
|
|
valid = False
|
|
rep_rate = (24*60)
|
|
for j in range(rep_rate // gcd(interval, rep_rate)):
|
|
if byminute:
|
|
nhours, minute = \
|
|
self.__mod_distance(value=minute,
|
|
byxxx=self._byminute,
|
|
base=60)
|
|
else:
|
|
nhours, minute = divmod(minute+interval, 60)
|
|
|
|
div, hour = divmod(hour+nhours, 24)
|
|
if div:
|
|
day += div
|
|
fixday = True
|
|
filtered = False
|
|
|
|
if not byhour or hour in byhour:
|
|
valid = True
|
|
break
|
|
|
|
if not valid:
|
|
raise ValueError('Invalid combination of interval and ' +
|
|
'byhour resulting in empty rule.')
|
|
|
|
timeset = gettimeset(hour, minute, second)
|
|
elif freq == SECONDLY:
|
|
if filtered:
|
|
# Jump to one iteration before next day
|
|
second += (((86399 - (hour * 3600 + minute * 60 + second))
|
|
// interval) * interval)
|
|
|
|
rep_rate = (24 * 3600)
|
|
valid = False
|
|
for j in range(0, rep_rate // gcd(interval, rep_rate)):
|
|
if bysecond:
|
|
nminutes, second = \
|
|
self.__mod_distance(value=second,
|
|
byxxx=self._bysecond,
|
|
base=60)
|
|
else:
|
|
nminutes, second = divmod(second+interval, 60)
|
|
|
|
div, minute = divmod(minute+nminutes, 60)
|
|
if div:
|
|
hour += div
|
|
div, hour = divmod(hour, 24)
|
|
if div:
|
|
day += div
|
|
fixday = True
|
|
|
|
if ((not byhour or hour in byhour) and
|
|
(not byminute or minute in byminute) and
|
|
(not bysecond or second in bysecond)):
|
|
valid = True
|
|
break
|
|
|
|
if not valid:
|
|
raise ValueError('Invalid combination of interval, ' +
|
|
'byhour and byminute resulting in empty' +
|
|
' rule.')
|
|
|
|
timeset = gettimeset(hour, minute, second)
|
|
|
|
if fixday and day > 28:
|
|
daysinmonth = calendar.monthrange(year, month)[1]
|
|
if day > daysinmonth:
|
|
while day > daysinmonth:
|
|
day -= daysinmonth
|
|
month += 1
|
|
if month == 13:
|
|
month = 1
|
|
year += 1
|
|
if year > datetime.MAXYEAR:
|
|
self._len = total
|
|
return
|
|
daysinmonth = calendar.monthrange(year, month)[1]
|
|
ii.rebuild(year, month)
|
|
|
|
def __construct_byset(self, start, byxxx, base):
|
|
"""
|
|
If a `BYXXX` sequence is passed to the constructor at the same level as
|
|
`FREQ` (e.g. `FREQ=HOURLY,BYHOUR={2,4,7},INTERVAL=3`), there are some
|
|
specifications which cannot be reached given some starting conditions.
|
|
|
|
This occurs whenever the interval is not coprime with the base of a
|
|
given unit and the difference between the starting position and the
|
|
ending position is not coprime with the greatest common denominator
|
|
between the interval and the base. For example, with a FREQ of hourly
|
|
starting at 17:00 and an interval of 4, the only valid values for
|
|
BYHOUR would be {21, 1, 5, 9, 13, 17}, because 4 and 24 are not
|
|
coprime.
|
|
|
|
:param start:
|
|
Specifies the starting position.
|
|
:param byxxx:
|
|
An iterable containing the list of allowed values.
|
|
:param base:
|
|
The largest allowable value for the specified frequency (e.g.
|
|
24 hours, 60 minutes).
|
|
|
|
This does not preserve the type of the iterable, returning a set, since
|
|
the values should be unique and the order is irrelevant, this will
|
|
speed up later lookups.
|
|
|
|
In the event of an empty set, raises a :exception:`ValueError`, as this
|
|
results in an empty rrule.
|
|
"""
|
|
|
|
cset = set()
|
|
|
|
# Support a single byxxx value.
|
|
if isinstance(byxxx, integer_types):
|
|
byxxx = (byxxx, )
|
|
|
|
for num in byxxx:
|
|
i_gcd = gcd(self._interval, base)
|
|
# Use divmod rather than % because we need to wrap negative nums.
|
|
if i_gcd == 1 or divmod(num - start, i_gcd)[1] == 0:
|
|
cset.add(num)
|
|
|
|
if len(cset) == 0:
|
|
raise ValueError("Invalid rrule byxxx generates an empty set.")
|
|
|
|
return cset
|
|
|
|
def __mod_distance(self, value, byxxx, base):
|
|
"""
|
|
Calculates the next value in a sequence where the `FREQ` parameter is
|
|
specified along with a `BYXXX` parameter at the same "level"
|
|
(e.g. `HOURLY` specified with `BYHOUR`).
|
|
|
|
:param value:
|
|
The old value of the component.
|
|
:param byxxx:
|
|
The `BYXXX` set, which should have been generated by
|
|
`rrule._construct_byset`, or something else which checks that a
|
|
valid rule is present.
|
|
:param base:
|
|
The largest allowable value for the specified frequency (e.g.
|
|
24 hours, 60 minutes).
|
|
|
|
If a valid value is not found after `base` iterations (the maximum
|
|
number before the sequence would start to repeat), this raises a
|
|
:exception:`ValueError`, as no valid values were found.
|
|
|
|
This returns a tuple of `divmod(n*interval, base)`, where `n` is the
|
|
smallest number of `interval` repetitions until the next specified
|
|
value in `byxxx` is found.
|
|
"""
|
|
accumulator = 0
|
|
for ii in range(1, base + 1):
|
|
# Using divmod() over % to account for negative intervals
|
|
div, value = divmod(value + self._interval, base)
|
|
accumulator += div
|
|
if value in byxxx:
|
|
return (accumulator, value)
|
|
|
|
|
|
class _iterinfo(object):
|
|
__slots__ = ["rrule", "lastyear", "lastmonth",
|
|
"yearlen", "nextyearlen", "yearordinal", "yearweekday",
|
|
"mmask", "mrange", "mdaymask", "nmdaymask",
|
|
"wdaymask", "wnomask", "nwdaymask", "eastermask"]
|
|
|
|
def __init__(self, rrule):
|
|
for attr in self.__slots__:
|
|
setattr(self, attr, None)
|
|
self.rrule = rrule
|
|
|
|
def rebuild(self, year, month):
|
|
# Every mask is 7 days longer to handle cross-year weekly periods.
|
|
rr = self.rrule
|
|
if year != self.lastyear:
|
|
self.yearlen = 365 + calendar.isleap(year)
|
|
self.nextyearlen = 365 + calendar.isleap(year + 1)
|
|
firstyday = datetime.date(year, 1, 1)
|
|
self.yearordinal = firstyday.toordinal()
|
|
self.yearweekday = firstyday.weekday()
|
|
|
|
wday = datetime.date(year, 1, 1).weekday()
|
|
if self.yearlen == 365:
|
|
self.mmask = M365MASK
|
|
self.mdaymask = MDAY365MASK
|
|
self.nmdaymask = NMDAY365MASK
|
|
self.wdaymask = WDAYMASK[wday:]
|
|
self.mrange = M365RANGE
|
|
else:
|
|
self.mmask = M366MASK
|
|
self.mdaymask = MDAY366MASK
|
|
self.nmdaymask = NMDAY366MASK
|
|
self.wdaymask = WDAYMASK[wday:]
|
|
self.mrange = M366RANGE
|
|
|
|
if not rr._byweekno:
|
|
self.wnomask = None
|
|
else:
|
|
self.wnomask = [0]*(self.yearlen+7)
|
|
# no1wkst = firstwkst = self.wdaymask.index(rr._wkst)
|
|
no1wkst = firstwkst = (7-self.yearweekday+rr._wkst) % 7
|
|
if no1wkst >= 4:
|
|
no1wkst = 0
|
|
# Number of days in the year, plus the days we got
|
|
# from last year.
|
|
wyearlen = self.yearlen+(self.yearweekday-rr._wkst) % 7
|
|
else:
|
|
# Number of days in the year, minus the days we
|
|
# left in last year.
|
|
wyearlen = self.yearlen-no1wkst
|
|
div, mod = divmod(wyearlen, 7)
|
|
numweeks = div+mod//4
|
|
for n in rr._byweekno:
|
|
if n < 0:
|
|
n += numweeks+1
|
|
if not (0 < n <= numweeks):
|
|
continue
|
|
if n > 1:
|
|
i = no1wkst+(n-1)*7
|
|
if no1wkst != firstwkst:
|
|
i -= 7-firstwkst
|
|
else:
|
|
i = no1wkst
|
|
for j in range(7):
|
|
self.wnomask[i] = 1
|
|
i += 1
|
|
if self.wdaymask[i] == rr._wkst:
|
|
break
|
|
if 1 in rr._byweekno:
|
|
# Check week number 1 of next year as well
|
|
# TODO: Check -numweeks for next year.
|
|
i = no1wkst+numweeks*7
|
|
if no1wkst != firstwkst:
|
|
i -= 7-firstwkst
|
|
if i < self.yearlen:
|
|
# If week starts in next year, we
|
|
# don't care about it.
|
|
for j in range(7):
|
|
self.wnomask[i] = 1
|
|
i += 1
|
|
if self.wdaymask[i] == rr._wkst:
|
|
break
|
|
if no1wkst:
|
|
# Check last week number of last year as
|
|
# well. If no1wkst is 0, either the year
|
|
# started on week start, or week number 1
|
|
# got days from last year, so there are no
|
|
# days from last year's last week number in
|
|
# this year.
|
|
if -1 not in rr._byweekno:
|
|
lyearweekday = datetime.date(year-1, 1, 1).weekday()
|
|
lno1wkst = (7-lyearweekday+rr._wkst) % 7
|
|
lyearlen = 365+calendar.isleap(year-1)
|
|
if lno1wkst >= 4:
|
|
lno1wkst = 0
|
|
lnumweeks = 52+(lyearlen +
|
|
(lyearweekday-rr._wkst) % 7) % 7//4
|
|
else:
|
|
lnumweeks = 52+(self.yearlen-no1wkst) % 7//4
|
|
else:
|
|
lnumweeks = -1
|
|
if lnumweeks in rr._byweekno:
|
|
for i in range(no1wkst):
|
|
self.wnomask[i] = 1
|
|
|
|
if (rr._bynweekday and (month != self.lastmonth or
|
|
year != self.lastyear)):
|
|
ranges = []
|
|
if rr._freq == YEARLY:
|
|
if rr._bymonth:
|
|
for month in rr._bymonth:
|
|
ranges.append(self.mrange[month-1:month+1])
|
|
else:
|
|
ranges = [(0, self.yearlen)]
|
|
elif rr._freq == MONTHLY:
|
|
ranges = [self.mrange[month-1:month+1]]
|
|
if ranges:
|
|
# Weekly frequency won't get here, so we may not
|
|
# care about cross-year weekly periods.
|
|
self.nwdaymask = [0]*self.yearlen
|
|
for first, last in ranges:
|
|
last -= 1
|
|
for wday, n in rr._bynweekday:
|
|
if n < 0:
|
|
i = last+(n+1)*7
|
|
i -= (self.wdaymask[i]-wday) % 7
|
|
else:
|
|
i = first+(n-1)*7
|
|
i += (7-self.wdaymask[i]+wday) % 7
|
|
if first <= i <= last:
|
|
self.nwdaymask[i] = 1
|
|
|
|
if rr._byeaster:
|
|
self.eastermask = [0]*(self.yearlen+7)
|
|
eyday = easter.easter(year).toordinal()-self.yearordinal
|
|
for offset in rr._byeaster:
|
|
self.eastermask[eyday+offset] = 1
|
|
|
|
self.lastyear = year
|
|
self.lastmonth = month
|
|
|
|
def ydayset(self, year, month, day):
|
|
return list(range(self.yearlen)), 0, self.yearlen
|
|
|
|
def mdayset(self, year, month, day):
|
|
dset = [None]*self.yearlen
|
|
start, end = self.mrange[month-1:month+1]
|
|
for i in range(start, end):
|
|
dset[i] = i
|
|
return dset, start, end
|
|
|
|
def wdayset(self, year, month, day):
|
|
# We need to handle cross-year weeks here.
|
|
dset = [None]*(self.yearlen+7)
|
|
i = datetime.date(year, month, day).toordinal()-self.yearordinal
|
|
start = i
|
|
for j in range(7):
|
|
dset[i] = i
|
|
i += 1
|
|
# if (not (0 <= i < self.yearlen) or
|
|
# self.wdaymask[i] == self.rrule._wkst):
|
|
# This will cross the year boundary, if necessary.
|
|
if self.wdaymask[i] == self.rrule._wkst:
|
|
break
|
|
return dset, start, i
|
|
|
|
def ddayset(self, year, month, day):
|
|
dset = [None] * self.yearlen
|
|
i = datetime.date(year, month, day).toordinal() - self.yearordinal
|
|
dset[i] = i
|
|
return dset, i, i + 1
|
|
|
|
def htimeset(self, hour, minute, second):
|
|
tset = []
|
|
rr = self.rrule
|
|
for minute in rr._byminute:
|
|
for second in rr._bysecond:
|
|
tset.append(datetime.time(hour, minute, second,
|
|
tzinfo=rr._tzinfo))
|
|
tset.sort()
|
|
return tset
|
|
|
|
def mtimeset(self, hour, minute, second):
|
|
tset = []
|
|
rr = self.rrule
|
|
for second in rr._bysecond:
|
|
tset.append(datetime.time(hour, minute, second, tzinfo=rr._tzinfo))
|
|
tset.sort()
|
|
return tset
|
|
|
|
def stimeset(self, hour, minute, second):
|
|
return (datetime.time(hour, minute, second,
|
|
tzinfo=self.rrule._tzinfo),)
|
|
|
|
|
|
class rruleset(rrulebase):
|
|
""" The rruleset type allows more complex recurrence setups, mixing
|
|
multiple rules, dates, exclusion rules, and exclusion dates. The type
|
|
constructor takes the following keyword arguments:
|
|
|
|
:param cache: If True, caching of results will be enabled, improving
|
|
performance of multiple queries considerably. """
|
|
|
|
class _genitem(object):
|
|
def __init__(self, genlist, gen):
|
|
try:
|
|
self.dt = advance_iterator(gen)
|
|
genlist.append(self)
|
|
except StopIteration:
|
|
pass
|
|
self.genlist = genlist
|
|
self.gen = gen
|
|
|
|
def __next__(self):
|
|
try:
|
|
self.dt = advance_iterator(self.gen)
|
|
except StopIteration:
|
|
if self.genlist[0] is self:
|
|
heapq.heappop(self.genlist)
|
|
else:
|
|
self.genlist.remove(self)
|
|
heapq.heapify(self.genlist)
|
|
|
|
next = __next__
|
|
|
|
def __lt__(self, other):
|
|
return self.dt < other.dt
|
|
|
|
def __gt__(self, other):
|
|
return self.dt > other.dt
|
|
|
|
def __eq__(self, other):
|
|
return self.dt == other.dt
|
|
|
|
def __ne__(self, other):
|
|
return self.dt != other.dt
|
|
|
|
def __init__(self, cache=False):
|
|
super(rruleset, self).__init__(cache)
|
|
self._rrule = []
|
|
self._rdate = []
|
|
self._exrule = []
|
|
self._exdate = []
|
|
|
|
@_invalidates_cache
|
|
def rrule(self, rrule):
|
|
""" Include the given :py:class:`rrule` instance in the recurrence set
|
|
generation. """
|
|
self._rrule.append(rrule)
|
|
|
|
@_invalidates_cache
|
|
def rdate(self, rdate):
|
|
""" Include the given :py:class:`datetime` instance in the recurrence
|
|
set generation. """
|
|
self._rdate.append(rdate)
|
|
|
|
@_invalidates_cache
|
|
def exrule(self, exrule):
|
|
""" Include the given rrule instance in the recurrence set exclusion
|
|
list. Dates which are part of the given recurrence rules will not
|
|
be generated, even if some inclusive rrule or rdate matches them.
|
|
"""
|
|
self._exrule.append(exrule)
|
|
|
|
@_invalidates_cache
|
|
def exdate(self, exdate):
|
|
""" Include the given datetime instance in the recurrence set
|
|
exclusion list. Dates included that way will not be generated,
|
|
even if some inclusive rrule or rdate matches them. """
|
|
self._exdate.append(exdate)
|
|
|
|
def _iter(self):
|
|
rlist = []
|
|
self._rdate.sort()
|
|
self._genitem(rlist, iter(self._rdate))
|
|
for gen in [iter(x) for x in self._rrule]:
|
|
self._genitem(rlist, gen)
|
|
exlist = []
|
|
self._exdate.sort()
|
|
self._genitem(exlist, iter(self._exdate))
|
|
for gen in [iter(x) for x in self._exrule]:
|
|
self._genitem(exlist, gen)
|
|
lastdt = None
|
|
total = 0
|
|
heapq.heapify(rlist)
|
|
heapq.heapify(exlist)
|
|
while rlist:
|
|
ritem = rlist[0]
|
|
if not lastdt or lastdt != ritem.dt:
|
|
while exlist and exlist[0] < ritem:
|
|
exitem = exlist[0]
|
|
advance_iterator(exitem)
|
|
if exlist and exlist[0] is exitem:
|
|
heapq.heapreplace(exlist, exitem)
|
|
if not exlist or ritem != exlist[0]:
|
|
total += 1
|
|
yield ritem.dt
|
|
lastdt = ritem.dt
|
|
advance_iterator(ritem)
|
|
if rlist and rlist[0] is ritem:
|
|
heapq.heapreplace(rlist, ritem)
|
|
self._len = total
|
|
|
|
|
|
|
|
|
|
class _rrulestr(object):
|
|
""" Parses a string representation of a recurrence rule or set of
|
|
recurrence rules.
|
|
|
|
:param s:
|
|
Required, a string defining one or more recurrence rules.
|
|
|
|
:param dtstart:
|
|
If given, used as the default recurrence start if not specified in the
|
|
rule string.
|
|
|
|
:param cache:
|
|
If set ``True`` caching of results will be enabled, improving
|
|
performance of multiple queries considerably.
|
|
|
|
:param unfold:
|
|
If set ``True`` indicates that a rule string is split over more
|
|
than one line and should be joined before processing.
|
|
|
|
:param forceset:
|
|
If set ``True`` forces a :class:`dateutil.rrule.rruleset` to
|
|
be returned.
|
|
|
|
:param compatible:
|
|
If set ``True`` forces ``unfold`` and ``forceset`` to be ``True``.
|
|
|
|
:param ignoretz:
|
|
If set ``True``, time zones in parsed strings are ignored and a naive
|
|
:class:`datetime.datetime` object is returned.
|
|
|
|
:param tzids:
|
|
If given, a callable or mapping used to retrieve a
|
|
:class:`datetime.tzinfo` from a string representation.
|
|
Defaults to :func:`dateutil.tz.gettz`.
|
|
|
|
:param tzinfos:
|
|
Additional time zone names / aliases which may be present in a string
|
|
representation. See :func:`dateutil.parser.parse` for more
|
|
information.
|
|
|
|
:return:
|
|
Returns a :class:`dateutil.rrule.rruleset` or
|
|
:class:`dateutil.rrule.rrule`
|
|
"""
|
|
|
|
_freq_map = {"YEARLY": YEARLY,
|
|
"MONTHLY": MONTHLY,
|
|
"WEEKLY": WEEKLY,
|
|
"DAILY": DAILY,
|
|
"HOURLY": HOURLY,
|
|
"MINUTELY": MINUTELY,
|
|
"SECONDLY": SECONDLY}
|
|
|
|
_weekday_map = {"MO": 0, "TU": 1, "WE": 2, "TH": 3,
|
|
"FR": 4, "SA": 5, "SU": 6}
|
|
|
|
def _handle_int(self, rrkwargs, name, value, **kwargs):
|
|
rrkwargs[name.lower()] = int(value)
|
|
|
|
def _handle_int_list(self, rrkwargs, name, value, **kwargs):
|
|
rrkwargs[name.lower()] = [int(x) for x in value.split(',')]
|
|
|
|
_handle_INTERVAL = _handle_int
|
|
_handle_COUNT = _handle_int
|
|
_handle_BYSETPOS = _handle_int_list
|
|
_handle_BYMONTH = _handle_int_list
|
|
_handle_BYMONTHDAY = _handle_int_list
|
|
_handle_BYYEARDAY = _handle_int_list
|
|
_handle_BYEASTER = _handle_int_list
|
|
_handle_BYWEEKNO = _handle_int_list
|
|
_handle_BYHOUR = _handle_int_list
|
|
_handle_BYMINUTE = _handle_int_list
|
|
_handle_BYSECOND = _handle_int_list
|
|
|
|
def _handle_FREQ(self, rrkwargs, name, value, **kwargs):
|
|
rrkwargs["freq"] = self._freq_map[value]
|
|
|
|
def _handle_UNTIL(self, rrkwargs, name, value, **kwargs):
|
|
global parser
|
|
if not parser:
|
|
from dateutil import parser
|
|
try:
|
|
rrkwargs["until"] = parser.parse(value,
|
|
ignoretz=kwargs.get("ignoretz"),
|
|
tzinfos=kwargs.get("tzinfos"))
|
|
except ValueError:
|
|
raise ValueError("invalid until date")
|
|
|
|
def _handle_WKST(self, rrkwargs, name, value, **kwargs):
|
|
rrkwargs["wkst"] = self._weekday_map[value]
|
|
|
|
def _handle_BYWEEKDAY(self, rrkwargs, name, value, **kwargs):
|
|
"""
|
|
Two ways to specify this: +1MO or MO(+1)
|
|
"""
|
|
l = []
|
|
for wday in value.split(','):
|
|
if '(' in wday:
|
|
# If it's of the form TH(+1), etc.
|
|
splt = wday.split('(')
|
|
w = splt[0]
|
|
n = int(splt[1][:-1])
|
|
elif len(wday):
|
|
# If it's of the form +1MO
|
|
for i in range(len(wday)):
|
|
if wday[i] not in '+-0123456789':
|
|
break
|
|
n = wday[:i] or None
|
|
w = wday[i:]
|
|
if n:
|
|
n = int(n)
|
|
else:
|
|
raise ValueError("Invalid (empty) BYDAY specification.")
|
|
|
|
l.append(weekdays[self._weekday_map[w]](n))
|
|
rrkwargs["byweekday"] = l
|
|
|
|
_handle_BYDAY = _handle_BYWEEKDAY
|
|
|
|
def _parse_rfc_rrule(self, line,
|
|
dtstart=None,
|
|
cache=False,
|
|
ignoretz=False,
|
|
tzinfos=None):
|
|
if line.find(':') != -1:
|
|
name, value = line.split(':')
|
|
if name != "RRULE":
|
|
raise ValueError("unknown parameter name")
|
|
else:
|
|
value = line
|
|
rrkwargs = {}
|
|
for pair in value.split(';'):
|
|
name, value = pair.split('=')
|
|
name = name.upper()
|
|
value = value.upper()
|
|
try:
|
|
getattr(self, "_handle_"+name)(rrkwargs, name, value,
|
|
ignoretz=ignoretz,
|
|
tzinfos=tzinfos)
|
|
except AttributeError:
|
|
raise ValueError("unknown parameter '%s'" % name)
|
|
except (KeyError, ValueError):
|
|
raise ValueError("invalid '%s': %s" % (name, value))
|
|
return rrule(dtstart=dtstart, cache=cache, **rrkwargs)
|
|
|
|
def _parse_date_value(self, date_value, parms, rule_tzids,
|
|
ignoretz, tzids, tzinfos):
|
|
global parser
|
|
if not parser:
|
|
from dateutil import parser
|
|
|
|
datevals = []
|
|
value_found = False
|
|
TZID = None
|
|
|
|
for parm in parms:
|
|
if parm.startswith("TZID="):
|
|
try:
|
|
tzkey = rule_tzids[parm.split('TZID=')[-1]]
|
|
except KeyError:
|
|
continue
|
|
if tzids is None:
|
|
from . import tz
|
|
tzlookup = tz.gettz
|
|
elif callable(tzids):
|
|
tzlookup = tzids
|
|
else:
|
|
tzlookup = getattr(tzids, 'get', None)
|
|
if tzlookup is None:
|
|
msg = ('tzids must be a callable, mapping, or None, '
|
|
'not %s' % tzids)
|
|
raise ValueError(msg)
|
|
|
|
TZID = tzlookup(tzkey)
|
|
continue
|
|
|
|
# RFC 5445 3.8.2.4: The VALUE parameter is optional, but may be found
|
|
# only once.
|
|
if parm not in {"VALUE=DATE-TIME", "VALUE=DATE"}:
|
|
raise ValueError("unsupported parm: " + parm)
|
|
else:
|
|
if value_found:
|
|
msg = ("Duplicate value parameter found in: " + parm)
|
|
raise ValueError(msg)
|
|
value_found = True
|
|
|
|
for datestr in date_value.split(','):
|
|
date = parser.parse(datestr, ignoretz=ignoretz, tzinfos=tzinfos)
|
|
if TZID is not None:
|
|
if date.tzinfo is None:
|
|
date = date.replace(tzinfo=TZID)
|
|
else:
|
|
raise ValueError('DTSTART/EXDATE specifies multiple timezone')
|
|
datevals.append(date)
|
|
|
|
return datevals
|
|
|
|
def _parse_rfc(self, s,
|
|
dtstart=None,
|
|
cache=False,
|
|
unfold=False,
|
|
forceset=False,
|
|
compatible=False,
|
|
ignoretz=False,
|
|
tzids=None,
|
|
tzinfos=None):
|
|
global parser
|
|
if compatible:
|
|
forceset = True
|
|
unfold = True
|
|
|
|
TZID_NAMES = dict(map(
|
|
lambda x: (x.upper(), x),
|
|
re.findall('TZID=(?P<name>[^:]+):', s)
|
|
))
|
|
s = s.upper()
|
|
if not s.strip():
|
|
raise ValueError("empty string")
|
|
if unfold:
|
|
lines = s.splitlines()
|
|
i = 0
|
|
while i < len(lines):
|
|
line = lines[i].rstrip()
|
|
if not line:
|
|
del lines[i]
|
|
elif i > 0 and line[0] == " ":
|
|
lines[i-1] += line[1:]
|
|
del lines[i]
|
|
else:
|
|
i += 1
|
|
else:
|
|
lines = s.split()
|
|
if (not forceset and len(lines) == 1 and (s.find(':') == -1 or
|
|
s.startswith('RRULE:'))):
|
|
return self._parse_rfc_rrule(lines[0], cache=cache,
|
|
dtstart=dtstart, ignoretz=ignoretz,
|
|
tzinfos=tzinfos)
|
|
else:
|
|
rrulevals = []
|
|
rdatevals = []
|
|
exrulevals = []
|
|
exdatevals = []
|
|
for line in lines:
|
|
if not line:
|
|
continue
|
|
if line.find(':') == -1:
|
|
name = "RRULE"
|
|
value = line
|
|
else:
|
|
name, value = line.split(':', 1)
|
|
parms = name.split(';')
|
|
if not parms:
|
|
raise ValueError("empty property name")
|
|
name = parms[0]
|
|
parms = parms[1:]
|
|
if name == "RRULE":
|
|
for parm in parms:
|
|
raise ValueError("unsupported RRULE parm: "+parm)
|
|
rrulevals.append(value)
|
|
elif name == "RDATE":
|
|
for parm in parms:
|
|
if parm != "VALUE=DATE-TIME":
|
|
raise ValueError("unsupported RDATE parm: "+parm)
|
|
rdatevals.append(value)
|
|
elif name == "EXRULE":
|
|
for parm in parms:
|
|
raise ValueError("unsupported EXRULE parm: "+parm)
|
|
exrulevals.append(value)
|
|
elif name == "EXDATE":
|
|
exdatevals.extend(
|
|
self._parse_date_value(value, parms,
|
|
TZID_NAMES, ignoretz,
|
|
tzids, tzinfos)
|
|
)
|
|
elif name == "DTSTART":
|
|
dtvals = self._parse_date_value(value, parms, TZID_NAMES,
|
|
ignoretz, tzids, tzinfos)
|
|
if len(dtvals) != 1:
|
|
raise ValueError("Multiple DTSTART values specified:" +
|
|
value)
|
|
dtstart = dtvals[0]
|
|
else:
|
|
raise ValueError("unsupported property: "+name)
|
|
if (forceset or len(rrulevals) > 1 or rdatevals
|
|
or exrulevals or exdatevals):
|
|
if not parser and (rdatevals or exdatevals):
|
|
from dateutil import parser
|
|
rset = rruleset(cache=cache)
|
|
for value in rrulevals:
|
|
rset.rrule(self._parse_rfc_rrule(value, dtstart=dtstart,
|
|
ignoretz=ignoretz,
|
|
tzinfos=tzinfos))
|
|
for value in rdatevals:
|
|
for datestr in value.split(','):
|
|
rset.rdate(parser.parse(datestr,
|
|
ignoretz=ignoretz,
|
|
tzinfos=tzinfos))
|
|
for value in exrulevals:
|
|
rset.exrule(self._parse_rfc_rrule(value, dtstart=dtstart,
|
|
ignoretz=ignoretz,
|
|
tzinfos=tzinfos))
|
|
for value in exdatevals:
|
|
rset.exdate(value)
|
|
if compatible and dtstart:
|
|
rset.rdate(dtstart)
|
|
return rset
|
|
else:
|
|
return self._parse_rfc_rrule(rrulevals[0],
|
|
dtstart=dtstart,
|
|
cache=cache,
|
|
ignoretz=ignoretz,
|
|
tzinfos=tzinfos)
|
|
|
|
def __call__(self, s, **kwargs):
|
|
return self._parse_rfc(s, **kwargs)
|
|
|
|
|
|
rrulestr = _rrulestr()
|
|
|
|
# vim:ts=4:sw=4:et
|