• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1# -*- coding: utf-8 -*-
2"""
3The rrule module offers a small, complete, and very fast, implementation of
4the recurrence rules documented in the
5`iCalendar RFC <https://tools.ietf.org/html/rfc5545>`_,
6including support for caching of results.
7"""
8import itertools
9import datetime
10import calendar
11import re
12import sys
13
14try:
15    from math import gcd
16except ImportError:
17    from fractions import gcd
18
19from six import advance_iterator, integer_types
20from six.moves import _thread, range
21import heapq
22
23from ._common import weekday as weekdaybase
24from .tz import tzutc, tzlocal
25
26# For warning about deprecation of until and count
27from warnings import warn
28
29__all__ = ["rrule", "rruleset", "rrulestr",
30           "YEARLY", "MONTHLY", "WEEKLY", "DAILY",
31           "HOURLY", "MINUTELY", "SECONDLY",
32           "MO", "TU", "WE", "TH", "FR", "SA", "SU"]
33
34# Every mask is 7 days longer to handle cross-year weekly periods.
35M366MASK = tuple([1]*31+[2]*29+[3]*31+[4]*30+[5]*31+[6]*30 +
36                 [7]*31+[8]*31+[9]*30+[10]*31+[11]*30+[12]*31+[1]*7)
37M365MASK = list(M366MASK)
38M29, M30, M31 = list(range(1, 30)), list(range(1, 31)), list(range(1, 32))
39MDAY366MASK = tuple(M31+M29+M31+M30+M31+M30+M31+M31+M30+M31+M30+M31+M31[:7])
40MDAY365MASK = list(MDAY366MASK)
41M29, M30, M31 = list(range(-29, 0)), list(range(-30, 0)), list(range(-31, 0))
42NMDAY366MASK = tuple(M31+M29+M31+M30+M31+M30+M31+M31+M30+M31+M30+M31+M31[:7])
43NMDAY365MASK = list(NMDAY366MASK)
44M366RANGE = (0, 31, 60, 91, 121, 152, 182, 213, 244, 274, 305, 335, 366)
45M365RANGE = (0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334, 365)
46WDAYMASK = [0, 1, 2, 3, 4, 5, 6]*55
47del M29, M30, M31, M365MASK[59], MDAY365MASK[59], NMDAY365MASK[31]
48MDAY365MASK = tuple(MDAY365MASK)
49M365MASK = tuple(M365MASK)
50
51FREQNAMES = ['YEARLY', 'MONTHLY', 'WEEKLY', 'DAILY', 'HOURLY', 'MINUTELY', 'SECONDLY']
52
53(YEARLY,
54 MONTHLY,
55 WEEKLY,
56 DAILY,
57 HOURLY,
58 MINUTELY,
59 SECONDLY) = list(range(7))
60
61# Imported on demand.
62easter = None
63parser = None
64
65
66class weekday(weekdaybase):
67    """
68    This version of weekday does not allow n = 0.
69    """
70    def __init__(self, wkday, n=None):
71        if n == 0:
72            raise ValueError("Can't create weekday with n==0")
73
74        super(weekday, self).__init__(wkday, n)
75
76
77MO, TU, WE, TH, FR, SA, SU = weekdays = tuple(weekday(x) for x in range(7))
78
79
80def _invalidates_cache(f):
81    """
82    Decorator for rruleset methods which may invalidate the
83    cached length.
84    """
85    def inner_func(self, *args, **kwargs):
86        rv = f(self, *args, **kwargs)
87        self._invalidate_cache()
88        return rv
89
90    return inner_func
91
92
93class rrulebase(object):
94    def __init__(self, cache=False):
95        if cache:
96            self._cache = []
97            self._cache_lock = _thread.allocate_lock()
98            self._invalidate_cache()
99        else:
100            self._cache = None
101            self._cache_complete = False
102            self._len = None
103
104    def __iter__(self):
105        if self._cache_complete:
106            return iter(self._cache)
107        elif self._cache is None:
108            return self._iter()
109        else:
110            return self._iter_cached()
111
112    def _invalidate_cache(self):
113        if self._cache is not None:
114            self._cache = []
115            self._cache_complete = False
116            self._cache_gen = self._iter()
117
118            if self._cache_lock.locked():
119                self._cache_lock.release()
120
121        self._len = None
122
123    def _iter_cached(self):
124        i = 0
125        gen = self._cache_gen
126        cache = self._cache
127        acquire = self._cache_lock.acquire
128        release = self._cache_lock.release
129        while gen:
130            if i == len(cache):
131                acquire()
132                if self._cache_complete:
133                    break
134                try:
135                    for j in range(10):
136                        cache.append(advance_iterator(gen))
137                except StopIteration:
138                    self._cache_gen = gen = None
139                    self._cache_complete = True
140                    break
141                release()
142            yield cache[i]
143            i += 1
144        while i < self._len:
145            yield cache[i]
146            i += 1
147
148    def __getitem__(self, item):
149        if self._cache_complete:
150            return self._cache[item]
151        elif isinstance(item, slice):
152            if item.step and item.step < 0:
153                return list(iter(self))[item]
154            else:
155                return list(itertools.islice(self,
156                                             item.start or 0,
157                                             item.stop or sys.maxsize,
158                                             item.step or 1))
159        elif item >= 0:
160            gen = iter(self)
161            try:
162                for i in range(item+1):
163                    res = advance_iterator(gen)
164            except StopIteration:
165                raise IndexError
166            return res
167        else:
168            return list(iter(self))[item]
169
170    def __contains__(self, item):
171        if self._cache_complete:
172            return item in self._cache
173        else:
174            for i in self:
175                if i == item:
176                    return True
177                elif i > item:
178                    return False
179        return False
180
181    # __len__() introduces a large performance penality.
182    def count(self):
183        """ Returns the number of recurrences in this set. It will have go
184            trough the whole recurrence, if this hasn't been done before. """
185        if self._len is None:
186            for x in self:
187                pass
188        return self._len
189
190    def before(self, dt, inc=False):
191        """ Returns the last recurrence before the given datetime instance. The
192            inc keyword defines what happens if dt is an occurrence. With
193            inc=True, if dt itself is an occurrence, it will be returned. """
194        if self._cache_complete:
195            gen = self._cache
196        else:
197            gen = self
198        last = None
199        if inc:
200            for i in gen:
201                if i > dt:
202                    break
203                last = i
204        else:
205            for i in gen:
206                if i >= dt:
207                    break
208                last = i
209        return last
210
211    def after(self, dt, inc=False):
212        """ Returns the first recurrence after the given datetime instance. The
213            inc keyword defines what happens if dt is an occurrence. With
214            inc=True, if dt itself is an occurrence, it will be returned.  """
215        if self._cache_complete:
216            gen = self._cache
217        else:
218            gen = self
219        if inc:
220            for i in gen:
221                if i >= dt:
222                    return i
223        else:
224            for i in gen:
225                if i > dt:
226                    return i
227        return None
228
229    def xafter(self, dt, count=None, inc=False):
230        """
231        Generator which yields up to `count` recurrences after the given
232        datetime instance, equivalent to `after`.
233
234        :param dt:
235            The datetime at which to start generating recurrences.
236
237        :param count:
238            The maximum number of recurrences to generate. If `None` (default),
239            dates are generated until the recurrence rule is exhausted.
240
241        :param inc:
242            If `dt` is an instance of the rule and `inc` is `True`, it is
243            included in the output.
244
245        :yields: Yields a sequence of `datetime` objects.
246        """
247
248        if self._cache_complete:
249            gen = self._cache
250        else:
251            gen = self
252
253        # Select the comparison function
254        if inc:
255            comp = lambda dc, dtc: dc >= dtc
256        else:
257            comp = lambda dc, dtc: dc > dtc
258
259        # Generate dates
260        n = 0
261        for d in gen:
262            if comp(d, dt):
263                if count is not None:
264                    n += 1
265                    if n > count:
266                        break
267
268                yield d
269
270    def between(self, after, before, inc=False, count=1):
271        """ Returns all the occurrences of the rrule between after and before.
272        The inc keyword defines what happens if after and/or before are
273        themselves occurrences. With inc=True, they will be included in the
274        list, if they are found in the recurrence set. """
275        if self._cache_complete:
276            gen = self._cache
277        else:
278            gen = self
279        started = False
280        l = []
281        if inc:
282            for i in gen:
283                if i > before:
284                    break
285                elif not started:
286                    if i >= after:
287                        started = True
288                        l.append(i)
289                else:
290                    l.append(i)
291        else:
292            for i in gen:
293                if i >= before:
294                    break
295                elif not started:
296                    if i > after:
297                        started = True
298                        l.append(i)
299                else:
300                    l.append(i)
301        return l
302
303
304class rrule(rrulebase):
305    """
306    That's the base of the rrule operation. It accepts all the keywords
307    defined in the RFC as its constructor parameters (except byday,
308    which was renamed to byweekday) and more. The constructor prototype is::
309
310            rrule(freq)
311
312    Where freq must be one of YEARLY, MONTHLY, WEEKLY, DAILY, HOURLY, MINUTELY,
313    or SECONDLY.
314
315    .. note::
316        Per RFC section 3.3.10, recurrence instances falling on invalid dates
317        and times are ignored rather than coerced:
318
319            Recurrence rules may generate recurrence instances with an invalid
320            date (e.g., February 30) or nonexistent local time (e.g., 1:30 AM
321            on a day where the local time is moved forward by an hour at 1:00
322            AM).  Such recurrence instances MUST be ignored and MUST NOT be
323            counted as part of the recurrence set.
324
325        This can lead to possibly surprising behavior when, for example, the
326        start date occurs at the end of the month:
327
328        >>> from dateutil.rrule import rrule, MONTHLY
329        >>> from datetime import datetime
330        >>> start_date = datetime(2014, 12, 31)
331        >>> list(rrule(freq=MONTHLY, count=4, dtstart=start_date))
332        ... # doctest: +NORMALIZE_WHITESPACE
333        [datetime.datetime(2014, 12, 31, 0, 0),
334         datetime.datetime(2015, 1, 31, 0, 0),
335         datetime.datetime(2015, 3, 31, 0, 0),
336         datetime.datetime(2015, 5, 31, 0, 0)]
337
338    Additionally, it supports the following keyword arguments:
339
340    :param dtstart:
341        The recurrence start. Besides being the base for the recurrence,
342        missing parameters in the final recurrence instances will also be
343        extracted from this date. If not given, datetime.now() will be used
344        instead.
345    :param interval:
346        The interval between each freq iteration. For example, when using
347        YEARLY, an interval of 2 means once every two years, but with HOURLY,
348        it means once every two hours. The default interval is 1.
349    :param wkst:
350        The week start day. Must be one of the MO, TU, WE constants, or an
351        integer, specifying the first day of the week. This will affect
352        recurrences based on weekly periods. The default week start is got
353        from calendar.firstweekday(), and may be modified by
354        calendar.setfirstweekday().
355    :param count:
356        If given, this determines how many occurrences will be generated.
357
358        .. note::
359            As of version 2.5.0, the use of the keyword ``until`` in conjunction
360            with ``count`` is deprecated, to make sure ``dateutil`` is fully
361            compliant with `RFC-5545 Sec. 3.3.10 <https://tools.ietf.org/
362            html/rfc5545#section-3.3.10>`_. Therefore, ``until`` and ``count``
363            **must not** occur in the same call to ``rrule``.
364    :param until:
365        If given, this must be a datetime instance specifying the upper-bound
366        limit of the recurrence. The last recurrence in the rule is the greatest
367        datetime that is less than or equal to the value specified in the
368        ``until`` parameter.
369
370        .. note::
371            As of version 2.5.0, the use of the keyword ``until`` in conjunction
372            with ``count`` is deprecated, to make sure ``dateutil`` is fully
373            compliant with `RFC-5545 Sec. 3.3.10 <https://tools.ietf.org/
374            html/rfc5545#section-3.3.10>`_. Therefore, ``until`` and ``count``
375            **must not** occur in the same call to ``rrule``.
376    :param bysetpos:
377        If given, it must be either an integer, or a sequence of integers,
378        positive or negative. Each given integer will specify an occurrence
379        number, corresponding to the nth occurrence of the rule inside the
380        frequency period. For example, a bysetpos of -1 if combined with a
381        MONTHLY frequency, and a byweekday of (MO, TU, WE, TH, FR), will
382        result in the last work day of every month.
383    :param bymonth:
384        If given, it must be either an integer, or a sequence of integers,
385        meaning the months to apply the recurrence to.
386    :param bymonthday:
387        If given, it must be either an integer, or a sequence of integers,
388        meaning the month days to apply the recurrence to.
389    :param byyearday:
390        If given, it must be either an integer, or a sequence of integers,
391        meaning the year days to apply the recurrence to.
392    :param byeaster:
393        If given, it must be either an integer, or a sequence of integers,
394        positive or negative. Each integer will define an offset from the
395        Easter Sunday. Passing the offset 0 to byeaster will yield the Easter
396        Sunday itself. This is an extension to the RFC specification.
397    :param byweekno:
398        If given, it must be either an integer, or a sequence of integers,
399        meaning the week numbers to apply the recurrence to. Week numbers
400        have the meaning described in ISO8601, that is, the first week of
401        the year is that containing at least four days of the new year.
402    :param byweekday:
403        If given, it must be either an integer (0 == MO), a sequence of
404        integers, one of the weekday constants (MO, TU, etc), or a sequence
405        of these constants. When given, these variables will define the
406        weekdays where the recurrence will be applied. It's also possible to
407        use an argument n for the weekday instances, which will mean the nth
408        occurrence of this weekday in the period. For example, with MONTHLY,
409        or with YEARLY and BYMONTH, using FR(+1) in byweekday will specify the
410        first friday of the month where the recurrence happens. Notice that in
411        the RFC documentation, this is specified as BYDAY, but was renamed to
412        avoid the ambiguity of that keyword.
413    :param byhour:
414        If given, it must be either an integer, or a sequence of integers,
415        meaning the hours to apply the recurrence to.
416    :param byminute:
417        If given, it must be either an integer, or a sequence of integers,
418        meaning the minutes to apply the recurrence to.
419    :param bysecond:
420        If given, it must be either an integer, or a sequence of integers,
421        meaning the seconds to apply the recurrence to.
422    :param cache:
423        If given, it must be a boolean value specifying to enable or disable
424        caching of results. If you will use the same rrule instance multiple
425        times, enabling caching will improve the performance considerably.
426     """
427    def __init__(self, freq, dtstart=None,
428                 interval=1, wkst=None, count=None, until=None, bysetpos=None,
429                 bymonth=None, bymonthday=None, byyearday=None, byeaster=None,
430                 byweekno=None, byweekday=None,
431                 byhour=None, byminute=None, bysecond=None,
432                 cache=False):
433        super(rrule, self).__init__(cache)
434        global easter
435        if not dtstart:
436            if until and until.tzinfo:
437                dtstart = datetime.datetime.now(tz=until.tzinfo).replace(microsecond=0)
438            else:
439                dtstart = datetime.datetime.now().replace(microsecond=0)
440        elif not isinstance(dtstart, datetime.datetime):
441            dtstart = datetime.datetime.fromordinal(dtstart.toordinal())
442        else:
443            dtstart = dtstart.replace(microsecond=0)
444        self._dtstart = dtstart
445        self._tzinfo = dtstart.tzinfo
446        self._freq = freq
447        self._interval = interval
448        self._count = count
449
450        # Cache the original byxxx rules, if they are provided, as the _byxxx
451        # attributes do not necessarily map to the inputs, and this can be
452        # a problem in generating the strings. Only store things if they've
453        # been supplied (the string retrieval will just use .get())
454        self._original_rule = {}
455
456        if until and not isinstance(until, datetime.datetime):
457            until = datetime.datetime.fromordinal(until.toordinal())
458        self._until = until
459
460        if self._dtstart and self._until:
461            if (self._dtstart.tzinfo is not None) != (self._until.tzinfo is not None):
462                # According to RFC5545 Section 3.3.10:
463                # https://tools.ietf.org/html/rfc5545#section-3.3.10
464                #
465                # > If the "DTSTART" property is specified as a date with UTC
466                # > time or a date with local time and time zone reference,
467                # > then the UNTIL rule part MUST be specified as a date with
468                # > UTC time.
469                raise ValueError(
470                    'RRULE UNTIL values must be specified in UTC when DTSTART '
471                    'is timezone-aware'
472                )
473
474        if count is not None and until:
475            warn("Using both 'count' and 'until' is inconsistent with RFC 5545"
476                 " and has been deprecated in dateutil. Future versions will "
477                 "raise an error.", DeprecationWarning)
478
479        if wkst is None:
480            self._wkst = calendar.firstweekday()
481        elif isinstance(wkst, integer_types):
482            self._wkst = wkst
483        else:
484            self._wkst = wkst.weekday
485
486        if bysetpos is None:
487            self._bysetpos = None
488        elif isinstance(bysetpos, integer_types):
489            if bysetpos == 0 or not (-366 <= bysetpos <= 366):
490                raise ValueError("bysetpos must be between 1 and 366, "
491                                 "or between -366 and -1")
492            self._bysetpos = (bysetpos,)
493        else:
494            self._bysetpos = tuple(bysetpos)
495            for pos in self._bysetpos:
496                if pos == 0 or not (-366 <= pos <= 366):
497                    raise ValueError("bysetpos must be between 1 and 366, "
498                                     "or between -366 and -1")
499
500        if self._bysetpos:
501            self._original_rule['bysetpos'] = self._bysetpos
502
503        if (byweekno is None and byyearday is None and bymonthday is None and
504                byweekday is None and byeaster is None):
505            if freq == YEARLY:
506                if bymonth is None:
507                    bymonth = dtstart.month
508                    self._original_rule['bymonth'] = None
509                bymonthday = dtstart.day
510                self._original_rule['bymonthday'] = None
511            elif freq == MONTHLY:
512                bymonthday = dtstart.day
513                self._original_rule['bymonthday'] = None
514            elif freq == WEEKLY:
515                byweekday = dtstart.weekday()
516                self._original_rule['byweekday'] = None
517
518        # bymonth
519        if bymonth is None:
520            self._bymonth = None
521        else:
522            if isinstance(bymonth, integer_types):
523                bymonth = (bymonth,)
524
525            self._bymonth = tuple(sorted(set(bymonth)))
526
527            if 'bymonth' not in self._original_rule:
528                self._original_rule['bymonth'] = self._bymonth
529
530        # byyearday
531        if byyearday is None:
532            self._byyearday = None
533        else:
534            if isinstance(byyearday, integer_types):
535                byyearday = (byyearday,)
536
537            self._byyearday = tuple(sorted(set(byyearday)))
538            self._original_rule['byyearday'] = self._byyearday
539
540        # byeaster
541        if byeaster is not None:
542            if not easter:
543                from dateutil import easter
544            if isinstance(byeaster, integer_types):
545                self._byeaster = (byeaster,)
546            else:
547                self._byeaster = tuple(sorted(byeaster))
548
549            self._original_rule['byeaster'] = self._byeaster
550        else:
551            self._byeaster = None
552
553        # bymonthday
554        if bymonthday is None:
555            self._bymonthday = ()
556            self._bynmonthday = ()
557        else:
558            if isinstance(bymonthday, integer_types):
559                bymonthday = (bymonthday,)
560
561            bymonthday = set(bymonthday)            # Ensure it's unique
562
563            self._bymonthday = tuple(sorted(x for x in bymonthday if x > 0))
564            self._bynmonthday = tuple(sorted(x for x in bymonthday if x < 0))
565
566            # Storing positive numbers first, then negative numbers
567            if 'bymonthday' not in self._original_rule:
568                self._original_rule['bymonthday'] = tuple(
569                    itertools.chain(self._bymonthday, self._bynmonthday))
570
571        # byweekno
572        if byweekno is None:
573            self._byweekno = None
574        else:
575            if isinstance(byweekno, integer_types):
576                byweekno = (byweekno,)
577
578            self._byweekno = tuple(sorted(set(byweekno)))
579
580            self._original_rule['byweekno'] = self._byweekno
581
582        # byweekday / bynweekday
583        if byweekday is None:
584            self._byweekday = None
585            self._bynweekday = None
586        else:
587            # If it's one of the valid non-sequence types, convert to a
588            # single-element sequence before the iterator that builds the
589            # byweekday set.
590            if isinstance(byweekday, integer_types) or hasattr(byweekday, "n"):
591                byweekday = (byweekday,)
592
593            self._byweekday = set()
594            self._bynweekday = set()
595            for wday in byweekday:
596                if isinstance(wday, integer_types):
597                    self._byweekday.add(wday)
598                elif not wday.n or freq > MONTHLY:
599                    self._byweekday.add(wday.weekday)
600                else:
601                    self._bynweekday.add((wday.weekday, wday.n))
602
603            if not self._byweekday:
604                self._byweekday = None
605            elif not self._bynweekday:
606                self._bynweekday = None
607
608            if self._byweekday is not None:
609                self._byweekday = tuple(sorted(self._byweekday))
610                orig_byweekday = [weekday(x) for x in self._byweekday]
611            else:
612                orig_byweekday = ()
613
614            if self._bynweekday is not None:
615                self._bynweekday = tuple(sorted(self._bynweekday))
616                orig_bynweekday = [weekday(*x) for x in self._bynweekday]
617            else:
618                orig_bynweekday = ()
619
620            if 'byweekday' not in self._original_rule:
621                self._original_rule['byweekday'] = tuple(itertools.chain(
622                    orig_byweekday, orig_bynweekday))
623
624        # byhour
625        if byhour is None:
626            if freq < HOURLY:
627                self._byhour = {dtstart.hour}
628            else:
629                self._byhour = None
630        else:
631            if isinstance(byhour, integer_types):
632                byhour = (byhour,)
633
634            if freq == HOURLY:
635                self._byhour = self.__construct_byset(start=dtstart.hour,
636                                                      byxxx=byhour,
637                                                      base=24)
638            else:
639                self._byhour = set(byhour)
640
641            self._byhour = tuple(sorted(self._byhour))
642            self._original_rule['byhour'] = self._byhour
643
644        # byminute
645        if byminute is None:
646            if freq < MINUTELY:
647                self._byminute = {dtstart.minute}
648            else:
649                self._byminute = None
650        else:
651            if isinstance(byminute, integer_types):
652                byminute = (byminute,)
653
654            if freq == MINUTELY:
655                self._byminute = self.__construct_byset(start=dtstart.minute,
656                                                        byxxx=byminute,
657                                                        base=60)
658            else:
659                self._byminute = set(byminute)
660
661            self._byminute = tuple(sorted(self._byminute))
662            self._original_rule['byminute'] = self._byminute
663
664        # bysecond
665        if bysecond is None:
666            if freq < SECONDLY:
667                self._bysecond = ((dtstart.second,))
668            else:
669                self._bysecond = None
670        else:
671            if isinstance(bysecond, integer_types):
672                bysecond = (bysecond,)
673
674            self._bysecond = set(bysecond)
675
676            if freq == SECONDLY:
677                self._bysecond = self.__construct_byset(start=dtstart.second,
678                                                        byxxx=bysecond,
679                                                        base=60)
680            else:
681                self._bysecond = set(bysecond)
682
683            self._bysecond = tuple(sorted(self._bysecond))
684            self._original_rule['bysecond'] = self._bysecond
685
686        if self._freq >= HOURLY:
687            self._timeset = None
688        else:
689            self._timeset = []
690            for hour in self._byhour:
691                for minute in self._byminute:
692                    for second in self._bysecond:
693                        self._timeset.append(
694                            datetime.time(hour, minute, second,
695                                          tzinfo=self._tzinfo))
696            self._timeset.sort()
697            self._timeset = tuple(self._timeset)
698
699    def __str__(self):
700        """
701        Output a string that would generate this RRULE if passed to rrulestr.
702        This is mostly compatible with RFC5545, except for the
703        dateutil-specific extension BYEASTER.
704        """
705
706        output = []
707        h, m, s = [None] * 3
708        if self._dtstart:
709            output.append(self._dtstart.strftime('DTSTART:%Y%m%dT%H%M%S'))
710            h, m, s = self._dtstart.timetuple()[3:6]
711
712        parts = ['FREQ=' + FREQNAMES[self._freq]]
713        if self._interval != 1:
714            parts.append('INTERVAL=' + str(self._interval))
715
716        if self._wkst:
717            parts.append('WKST=' + repr(weekday(self._wkst))[0:2])
718
719        if self._count is not None:
720            parts.append('COUNT=' + str(self._count))
721
722        if self._until:
723            parts.append(self._until.strftime('UNTIL=%Y%m%dT%H%M%S'))
724
725        if self._original_rule.get('byweekday') is not None:
726            # The str() method on weekday objects doesn't generate
727            # RFC5545-compliant strings, so we should modify that.
728            original_rule = dict(self._original_rule)
729            wday_strings = []
730            for wday in original_rule['byweekday']:
731                if wday.n:
732                    wday_strings.append('{n:+d}{wday}'.format(
733                        n=wday.n,
734                        wday=repr(wday)[0:2]))
735                else:
736                    wday_strings.append(repr(wday))
737
738            original_rule['byweekday'] = wday_strings
739        else:
740            original_rule = self._original_rule
741
742        partfmt = '{name}={vals}'
743        for name, key in [('BYSETPOS', 'bysetpos'),
744                          ('BYMONTH', 'bymonth'),
745                          ('BYMONTHDAY', 'bymonthday'),
746                          ('BYYEARDAY', 'byyearday'),
747                          ('BYWEEKNO', 'byweekno'),
748                          ('BYDAY', 'byweekday'),
749                          ('BYHOUR', 'byhour'),
750                          ('BYMINUTE', 'byminute'),
751                          ('BYSECOND', 'bysecond'),
752                          ('BYEASTER', 'byeaster')]:
753            value = original_rule.get(key)
754            if value:
755                parts.append(partfmt.format(name=name, vals=(','.join(str(v)
756                                                             for v in value))))
757
758        output.append('RRULE:' + ';'.join(parts))
759        return '\n'.join(output)
760
761    def replace(self, **kwargs):
762        """Return new rrule with same attributes except for those attributes given new
763           values by whichever keyword arguments are specified."""
764        new_kwargs = {"interval": self._interval,
765                      "count": self._count,
766                      "dtstart": self._dtstart,
767                      "freq": self._freq,
768                      "until": self._until,
769                      "wkst": self._wkst,
770                      "cache": False if self._cache is None else True }
771        new_kwargs.update(self._original_rule)
772        new_kwargs.update(kwargs)
773        return rrule(**new_kwargs)
774
775    def _iter(self):
776        year, month, day, hour, minute, second, weekday, yearday, _ = \
777            self._dtstart.timetuple()
778
779        # Some local variables to speed things up a bit
780        freq = self._freq
781        interval = self._interval
782        wkst = self._wkst
783        until = self._until
784        bymonth = self._bymonth
785        byweekno = self._byweekno
786        byyearday = self._byyearday
787        byweekday = self._byweekday
788        byeaster = self._byeaster
789        bymonthday = self._bymonthday
790        bynmonthday = self._bynmonthday
791        bysetpos = self._bysetpos
792        byhour = self._byhour
793        byminute = self._byminute
794        bysecond = self._bysecond
795
796        ii = _iterinfo(self)
797        ii.rebuild(year, month)
798
799        getdayset = {YEARLY: ii.ydayset,
800                     MONTHLY: ii.mdayset,
801                     WEEKLY: ii.wdayset,
802                     DAILY: ii.ddayset,
803                     HOURLY: ii.ddayset,
804                     MINUTELY: ii.ddayset,
805                     SECONDLY: ii.ddayset}[freq]
806
807        if freq < HOURLY:
808            timeset = self._timeset
809        else:
810            gettimeset = {HOURLY: ii.htimeset,
811                          MINUTELY: ii.mtimeset,
812                          SECONDLY: ii.stimeset}[freq]
813            if ((freq >= HOURLY and
814                 self._byhour and hour not in self._byhour) or
815                (freq >= MINUTELY and
816                 self._byminute and minute not in self._byminute) or
817                (freq >= SECONDLY and
818                 self._bysecond and second not in self._bysecond)):
819                timeset = ()
820            else:
821                timeset = gettimeset(hour, minute, second)
822
823        total = 0
824        count = self._count
825        while True:
826            # Get dayset with the right frequency
827            dayset, start, end = getdayset(year, month, day)
828
829            # Do the "hard" work ;-)
830            filtered = False
831            for i in dayset[start:end]:
832                if ((bymonth and ii.mmask[i] not in bymonth) or
833                    (byweekno and not ii.wnomask[i]) or
834                    (byweekday and ii.wdaymask[i] not in byweekday) or
835                    (ii.nwdaymask and not ii.nwdaymask[i]) or
836                    (byeaster and not ii.eastermask[i]) or
837                    ((bymonthday or bynmonthday) and
838                     ii.mdaymask[i] not in bymonthday and
839                     ii.nmdaymask[i] not in bynmonthday) or
840                    (byyearday and
841                     ((i < ii.yearlen and i+1 not in byyearday and
842                       -ii.yearlen+i not in byyearday) or
843                      (i >= ii.yearlen and i+1-ii.yearlen not in byyearday and
844                       -ii.nextyearlen+i-ii.yearlen not in byyearday)))):
845                    dayset[i] = None
846                    filtered = True
847
848            # Output results
849            if bysetpos and timeset:
850                poslist = []
851                for pos in bysetpos:
852                    if pos < 0:
853                        daypos, timepos = divmod(pos, len(timeset))
854                    else:
855                        daypos, timepos = divmod(pos-1, len(timeset))
856                    try:
857                        i = [x for x in dayset[start:end]
858                             if x is not None][daypos]
859                        time = timeset[timepos]
860                    except IndexError:
861                        pass
862                    else:
863                        date = datetime.date.fromordinal(ii.yearordinal+i)
864                        res = datetime.datetime.combine(date, time)
865                        if res not in poslist:
866                            poslist.append(res)
867                poslist.sort()
868                for res in poslist:
869                    if until and res > until:
870                        self._len = total
871                        return
872                    elif res >= self._dtstart:
873                        if count is not None:
874                            count -= 1
875                            if count < 0:
876                                self._len = total
877                                return
878                        total += 1
879                        yield res
880            else:
881                for i in dayset[start:end]:
882                    if i is not None:
883                        date = datetime.date.fromordinal(ii.yearordinal + i)
884                        for time in timeset:
885                            res = datetime.datetime.combine(date, time)
886                            if until and res > until:
887                                self._len = total
888                                return
889                            elif res >= self._dtstart:
890                                if count is not None:
891                                    count -= 1
892                                    if count < 0:
893                                        self._len = total
894                                        return
895
896                                total += 1
897                                yield res
898
899            # Handle frequency and interval
900            fixday = False
901            if freq == YEARLY:
902                year += interval
903                if year > datetime.MAXYEAR:
904                    self._len = total
905                    return
906                ii.rebuild(year, month)
907            elif freq == MONTHLY:
908                month += interval
909                if month > 12:
910                    div, mod = divmod(month, 12)
911                    month = mod
912                    year += div
913                    if month == 0:
914                        month = 12
915                        year -= 1
916                    if year > datetime.MAXYEAR:
917                        self._len = total
918                        return
919                ii.rebuild(year, month)
920            elif freq == WEEKLY:
921                if wkst > weekday:
922                    day += -(weekday+1+(6-wkst))+self._interval*7
923                else:
924                    day += -(weekday-wkst)+self._interval*7
925                weekday = wkst
926                fixday = True
927            elif freq == DAILY:
928                day += interval
929                fixday = True
930            elif freq == HOURLY:
931                if filtered:
932                    # Jump to one iteration before next day
933                    hour += ((23-hour)//interval)*interval
934
935                if byhour:
936                    ndays, hour = self.__mod_distance(value=hour,
937                                                      byxxx=self._byhour,
938                                                      base=24)
939                else:
940                    ndays, hour = divmod(hour+interval, 24)
941
942                if ndays:
943                    day += ndays
944                    fixday = True
945
946                timeset = gettimeset(hour, minute, second)
947            elif freq == MINUTELY:
948                if filtered:
949                    # Jump to one iteration before next day
950                    minute += ((1439-(hour*60+minute))//interval)*interval
951
952                valid = False
953                rep_rate = (24*60)
954                for j in range(rep_rate // gcd(interval, rep_rate)):
955                    if byminute:
956                        nhours, minute = \
957                            self.__mod_distance(value=minute,
958                                                byxxx=self._byminute,
959                                                base=60)
960                    else:
961                        nhours, minute = divmod(minute+interval, 60)
962
963                    div, hour = divmod(hour+nhours, 24)
964                    if div:
965                        day += div
966                        fixday = True
967                        filtered = False
968
969                    if not byhour or hour in byhour:
970                        valid = True
971                        break
972
973                if not valid:
974                    raise ValueError('Invalid combination of interval and ' +
975                                     'byhour resulting in empty rule.')
976
977                timeset = gettimeset(hour, minute, second)
978            elif freq == SECONDLY:
979                if filtered:
980                    # Jump to one iteration before next day
981                    second += (((86399 - (hour * 3600 + minute * 60 + second))
982                                // interval) * interval)
983
984                rep_rate = (24 * 3600)
985                valid = False
986                for j in range(0, rep_rate // gcd(interval, rep_rate)):
987                    if bysecond:
988                        nminutes, second = \
989                            self.__mod_distance(value=second,
990                                                byxxx=self._bysecond,
991                                                base=60)
992                    else:
993                        nminutes, second = divmod(second+interval, 60)
994
995                    div, minute = divmod(minute+nminutes, 60)
996                    if div:
997                        hour += div
998                        div, hour = divmod(hour, 24)
999                        if div:
1000                            day += div
1001                            fixday = True
1002
1003                    if ((not byhour or hour in byhour) and
1004                            (not byminute or minute in byminute) and
1005                            (not bysecond or second in bysecond)):
1006                        valid = True
1007                        break
1008
1009                if not valid:
1010                    raise ValueError('Invalid combination of interval, ' +
1011                                     'byhour and byminute resulting in empty' +
1012                                     ' rule.')
1013
1014                timeset = gettimeset(hour, minute, second)
1015
1016            if fixday and day > 28:
1017                daysinmonth = calendar.monthrange(year, month)[1]
1018                if day > daysinmonth:
1019                    while day > daysinmonth:
1020                        day -= daysinmonth
1021                        month += 1
1022                        if month == 13:
1023                            month = 1
1024                            year += 1
1025                            if year > datetime.MAXYEAR:
1026                                self._len = total
1027                                return
1028                        daysinmonth = calendar.monthrange(year, month)[1]
1029                    ii.rebuild(year, month)
1030
1031    def __construct_byset(self, start, byxxx, base):
1032        """
1033        If a `BYXXX` sequence is passed to the constructor at the same level as
1034        `FREQ` (e.g. `FREQ=HOURLY,BYHOUR={2,4,7},INTERVAL=3`), there are some
1035        specifications which cannot be reached given some starting conditions.
1036
1037        This occurs whenever the interval is not coprime with the base of a
1038        given unit and the difference between the starting position and the
1039        ending position is not coprime with the greatest common denominator
1040        between the interval and the base. For example, with a FREQ of hourly
1041        starting at 17:00 and an interval of 4, the only valid values for
1042        BYHOUR would be {21, 1, 5, 9, 13, 17}, because 4 and 24 are not
1043        coprime.
1044
1045        :param start:
1046            Specifies the starting position.
1047        :param byxxx:
1048            An iterable containing the list of allowed values.
1049        :param base:
1050            The largest allowable value for the specified frequency (e.g.
1051            24 hours, 60 minutes).
1052
1053        This does not preserve the type of the iterable, returning a set, since
1054        the values should be unique and the order is irrelevant, this will
1055        speed up later lookups.
1056
1057        In the event of an empty set, raises a :exception:`ValueError`, as this
1058        results in an empty rrule.
1059        """
1060
1061        cset = set()
1062
1063        # Support a single byxxx value.
1064        if isinstance(byxxx, integer_types):
1065            byxxx = (byxxx, )
1066
1067        for num in byxxx:
1068            i_gcd = gcd(self._interval, base)
1069            # Use divmod rather than % because we need to wrap negative nums.
1070            if i_gcd == 1 or divmod(num - start, i_gcd)[1] == 0:
1071                cset.add(num)
1072
1073        if len(cset) == 0:
1074            raise ValueError("Invalid rrule byxxx generates an empty set.")
1075
1076        return cset
1077
1078    def __mod_distance(self, value, byxxx, base):
1079        """
1080        Calculates the next value in a sequence where the `FREQ` parameter is
1081        specified along with a `BYXXX` parameter at the same "level"
1082        (e.g. `HOURLY` specified with `BYHOUR`).
1083
1084        :param value:
1085            The old value of the component.
1086        :param byxxx:
1087            The `BYXXX` set, which should have been generated by
1088            `rrule._construct_byset`, or something else which checks that a
1089            valid rule is present.
1090        :param base:
1091            The largest allowable value for the specified frequency (e.g.
1092            24 hours, 60 minutes).
1093
1094        If a valid value is not found after `base` iterations (the maximum
1095        number before the sequence would start to repeat), this raises a
1096        :exception:`ValueError`, as no valid values were found.
1097
1098        This returns a tuple of `divmod(n*interval, base)`, where `n` is the
1099        smallest number of `interval` repetitions until the next specified
1100        value in `byxxx` is found.
1101        """
1102        accumulator = 0
1103        for ii in range(1, base + 1):
1104            # Using divmod() over % to account for negative intervals
1105            div, value = divmod(value + self._interval, base)
1106            accumulator += div
1107            if value in byxxx:
1108                return (accumulator, value)
1109
1110
1111class _iterinfo(object):
1112    __slots__ = ["rrule", "lastyear", "lastmonth",
1113                 "yearlen", "nextyearlen", "yearordinal", "yearweekday",
1114                 "mmask", "mrange", "mdaymask", "nmdaymask",
1115                 "wdaymask", "wnomask", "nwdaymask", "eastermask"]
1116
1117    def __init__(self, rrule):
1118        for attr in self.__slots__:
1119            setattr(self, attr, None)
1120        self.rrule = rrule
1121
1122    def rebuild(self, year, month):
1123        # Every mask is 7 days longer to handle cross-year weekly periods.
1124        rr = self.rrule
1125        if year != self.lastyear:
1126            self.yearlen = 365 + calendar.isleap(year)
1127            self.nextyearlen = 365 + calendar.isleap(year + 1)
1128            firstyday = datetime.date(year, 1, 1)
1129            self.yearordinal = firstyday.toordinal()
1130            self.yearweekday = firstyday.weekday()
1131
1132            wday = datetime.date(year, 1, 1).weekday()
1133            if self.yearlen == 365:
1134                self.mmask = M365MASK
1135                self.mdaymask = MDAY365MASK
1136                self.nmdaymask = NMDAY365MASK
1137                self.wdaymask = WDAYMASK[wday:]
1138                self.mrange = M365RANGE
1139            else:
1140                self.mmask = M366MASK
1141                self.mdaymask = MDAY366MASK
1142                self.nmdaymask = NMDAY366MASK
1143                self.wdaymask = WDAYMASK[wday:]
1144                self.mrange = M366RANGE
1145
1146            if not rr._byweekno:
1147                self.wnomask = None
1148            else:
1149                self.wnomask = [0]*(self.yearlen+7)
1150                # no1wkst = firstwkst = self.wdaymask.index(rr._wkst)
1151                no1wkst = firstwkst = (7-self.yearweekday+rr._wkst) % 7
1152                if no1wkst >= 4:
1153                    no1wkst = 0
1154                    # Number of days in the year, plus the days we got
1155                    # from last year.
1156                    wyearlen = self.yearlen+(self.yearweekday-rr._wkst) % 7
1157                else:
1158                    # Number of days in the year, minus the days we
1159                    # left in last year.
1160                    wyearlen = self.yearlen-no1wkst
1161                div, mod = divmod(wyearlen, 7)
1162                numweeks = div+mod//4
1163                for n in rr._byweekno:
1164                    if n < 0:
1165                        n += numweeks+1
1166                    if not (0 < n <= numweeks):
1167                        continue
1168                    if n > 1:
1169                        i = no1wkst+(n-1)*7
1170                        if no1wkst != firstwkst:
1171                            i -= 7-firstwkst
1172                    else:
1173                        i = no1wkst
1174                    for j in range(7):
1175                        self.wnomask[i] = 1
1176                        i += 1
1177                        if self.wdaymask[i] == rr._wkst:
1178                            break
1179                if 1 in rr._byweekno:
1180                    # Check week number 1 of next year as well
1181                    # TODO: Check -numweeks for next year.
1182                    i = no1wkst+numweeks*7
1183                    if no1wkst != firstwkst:
1184                        i -= 7-firstwkst
1185                    if i < self.yearlen:
1186                        # If week starts in next year, we
1187                        # don't care about it.
1188                        for j in range(7):
1189                            self.wnomask[i] = 1
1190                            i += 1
1191                            if self.wdaymask[i] == rr._wkst:
1192                                break
1193                if no1wkst:
1194                    # Check last week number of last year as
1195                    # well. If no1wkst is 0, either the year
1196                    # started on week start, or week number 1
1197                    # got days from last year, so there are no
1198                    # days from last year's last week number in
1199                    # this year.
1200                    if -1 not in rr._byweekno:
1201                        lyearweekday = datetime.date(year-1, 1, 1).weekday()
1202                        lno1wkst = (7-lyearweekday+rr._wkst) % 7
1203                        lyearlen = 365+calendar.isleap(year-1)
1204                        if lno1wkst >= 4:
1205                            lno1wkst = 0
1206                            lnumweeks = 52+(lyearlen +
1207                                            (lyearweekday-rr._wkst) % 7) % 7//4
1208                        else:
1209                            lnumweeks = 52+(self.yearlen-no1wkst) % 7//4
1210                    else:
1211                        lnumweeks = -1
1212                    if lnumweeks in rr._byweekno:
1213                        for i in range(no1wkst):
1214                            self.wnomask[i] = 1
1215
1216        if (rr._bynweekday and (month != self.lastmonth or
1217                                year != self.lastyear)):
1218            ranges = []
1219            if rr._freq == YEARLY:
1220                if rr._bymonth:
1221                    for month in rr._bymonth:
1222                        ranges.append(self.mrange[month-1:month+1])
1223                else:
1224                    ranges = [(0, self.yearlen)]
1225            elif rr._freq == MONTHLY:
1226                ranges = [self.mrange[month-1:month+1]]
1227            if ranges:
1228                # Weekly frequency won't get here, so we may not
1229                # care about cross-year weekly periods.
1230                self.nwdaymask = [0]*self.yearlen
1231                for first, last in ranges:
1232                    last -= 1
1233                    for wday, n in rr._bynweekday:
1234                        if n < 0:
1235                            i = last+(n+1)*7
1236                            i -= (self.wdaymask[i]-wday) % 7
1237                        else:
1238                            i = first+(n-1)*7
1239                            i += (7-self.wdaymask[i]+wday) % 7
1240                        if first <= i <= last:
1241                            self.nwdaymask[i] = 1
1242
1243        if rr._byeaster:
1244            self.eastermask = [0]*(self.yearlen+7)
1245            eyday = easter.easter(year).toordinal()-self.yearordinal
1246            for offset in rr._byeaster:
1247                self.eastermask[eyday+offset] = 1
1248
1249        self.lastyear = year
1250        self.lastmonth = month
1251
1252    def ydayset(self, year, month, day):
1253        return list(range(self.yearlen)), 0, self.yearlen
1254
1255    def mdayset(self, year, month, day):
1256        dset = [None]*self.yearlen
1257        start, end = self.mrange[month-1:month+1]
1258        for i in range(start, end):
1259            dset[i] = i
1260        return dset, start, end
1261
1262    def wdayset(self, year, month, day):
1263        # We need to handle cross-year weeks here.
1264        dset = [None]*(self.yearlen+7)
1265        i = datetime.date(year, month, day).toordinal()-self.yearordinal
1266        start = i
1267        for j in range(7):
1268            dset[i] = i
1269            i += 1
1270            # if (not (0 <= i < self.yearlen) or
1271            #    self.wdaymask[i] == self.rrule._wkst):
1272            # This will cross the year boundary, if necessary.
1273            if self.wdaymask[i] == self.rrule._wkst:
1274                break
1275        return dset, start, i
1276
1277    def ddayset(self, year, month, day):
1278        dset = [None] * self.yearlen
1279        i = datetime.date(year, month, day).toordinal() - self.yearordinal
1280        dset[i] = i
1281        return dset, i, i + 1
1282
1283    def htimeset(self, hour, minute, second):
1284        tset = []
1285        rr = self.rrule
1286        for minute in rr._byminute:
1287            for second in rr._bysecond:
1288                tset.append(datetime.time(hour, minute, second,
1289                                          tzinfo=rr._tzinfo))
1290        tset.sort()
1291        return tset
1292
1293    def mtimeset(self, hour, minute, second):
1294        tset = []
1295        rr = self.rrule
1296        for second in rr._bysecond:
1297            tset.append(datetime.time(hour, minute, second, tzinfo=rr._tzinfo))
1298        tset.sort()
1299        return tset
1300
1301    def stimeset(self, hour, minute, second):
1302        return (datetime.time(hour, minute, second,
1303                tzinfo=self.rrule._tzinfo),)
1304
1305
1306class rruleset(rrulebase):
1307    """ The rruleset type allows more complex recurrence setups, mixing
1308    multiple rules, dates, exclusion rules, and exclusion dates. The type
1309    constructor takes the following keyword arguments:
1310
1311    :param cache: If True, caching of results will be enabled, improving
1312                  performance of multiple queries considerably. """
1313
1314    class _genitem(object):
1315        def __init__(self, genlist, gen):
1316            try:
1317                self.dt = advance_iterator(gen)
1318                genlist.append(self)
1319            except StopIteration:
1320                pass
1321            self.genlist = genlist
1322            self.gen = gen
1323
1324        def __next__(self):
1325            try:
1326                self.dt = advance_iterator(self.gen)
1327            except StopIteration:
1328                if self.genlist[0] is self:
1329                    heapq.heappop(self.genlist)
1330                else:
1331                    self.genlist.remove(self)
1332                    heapq.heapify(self.genlist)
1333
1334        next = __next__
1335
1336        def __lt__(self, other):
1337            return self.dt < other.dt
1338
1339        def __gt__(self, other):
1340            return self.dt > other.dt
1341
1342        def __eq__(self, other):
1343            return self.dt == other.dt
1344
1345        def __ne__(self, other):
1346            return self.dt != other.dt
1347
1348    def __init__(self, cache=False):
1349        super(rruleset, self).__init__(cache)
1350        self._rrule = []
1351        self._rdate = []
1352        self._exrule = []
1353        self._exdate = []
1354
1355    @_invalidates_cache
1356    def rrule(self, rrule):
1357        """ Include the given :py:class:`rrule` instance in the recurrence set
1358            generation. """
1359        self._rrule.append(rrule)
1360
1361    @_invalidates_cache
1362    def rdate(self, rdate):
1363        """ Include the given :py:class:`datetime` instance in the recurrence
1364            set generation. """
1365        self._rdate.append(rdate)
1366
1367    @_invalidates_cache
1368    def exrule(self, exrule):
1369        """ Include the given rrule instance in the recurrence set exclusion
1370            list. Dates which are part of the given recurrence rules will not
1371            be generated, even if some inclusive rrule or rdate matches them.
1372        """
1373        self._exrule.append(exrule)
1374
1375    @_invalidates_cache
1376    def exdate(self, exdate):
1377        """ Include the given datetime instance in the recurrence set
1378            exclusion list. Dates included that way will not be generated,
1379            even if some inclusive rrule or rdate matches them. """
1380        self._exdate.append(exdate)
1381
1382    def _iter(self):
1383        rlist = []
1384        self._rdate.sort()
1385        self._genitem(rlist, iter(self._rdate))
1386        for gen in [iter(x) for x in self._rrule]:
1387            self._genitem(rlist, gen)
1388        exlist = []
1389        self._exdate.sort()
1390        self._genitem(exlist, iter(self._exdate))
1391        for gen in [iter(x) for x in self._exrule]:
1392            self._genitem(exlist, gen)
1393        lastdt = None
1394        total = 0
1395        heapq.heapify(rlist)
1396        heapq.heapify(exlist)
1397        while rlist:
1398            ritem = rlist[0]
1399            if not lastdt or lastdt != ritem.dt:
1400                while exlist and exlist[0] < ritem:
1401                    exitem = exlist[0]
1402                    advance_iterator(exitem)
1403                    if exlist and exlist[0] is exitem:
1404                        heapq.heapreplace(exlist, exitem)
1405                if not exlist or ritem != exlist[0]:
1406                    total += 1
1407                    yield ritem.dt
1408                lastdt = ritem.dt
1409            advance_iterator(ritem)
1410            if rlist and rlist[0] is ritem:
1411                heapq.heapreplace(rlist, ritem)
1412        self._len = total
1413
1414
1415
1416
1417class _rrulestr(object):
1418    """ Parses a string representation of a recurrence rule or set of
1419    recurrence rules.
1420
1421    :param s:
1422        Required, a string defining one or more recurrence rules.
1423
1424    :param dtstart:
1425        If given, used as the default recurrence start if not specified in the
1426        rule string.
1427
1428    :param cache:
1429        If set ``True`` caching of results will be enabled, improving
1430        performance of multiple queries considerably.
1431
1432    :param unfold:
1433        If set ``True`` indicates that a rule string is split over more
1434        than one line and should be joined before processing.
1435
1436    :param forceset:
1437        If set ``True`` forces a :class:`dateutil.rrule.rruleset` to
1438        be returned.
1439
1440    :param compatible:
1441        If set ``True`` forces ``unfold`` and ``forceset`` to be ``True``.
1442
1443    :param ignoretz:
1444        If set ``True``, time zones in parsed strings are ignored and a naive
1445        :class:`datetime.datetime` object is returned.
1446
1447    :param tzids:
1448        If given, a callable or mapping used to retrieve a
1449        :class:`datetime.tzinfo` from a string representation.
1450        Defaults to :func:`dateutil.tz.gettz`.
1451
1452    :param tzinfos:
1453        Additional time zone names / aliases which may be present in a string
1454        representation.  See :func:`dateutil.parser.parse` for more
1455        information.
1456
1457    :return:
1458        Returns a :class:`dateutil.rrule.rruleset` or
1459        :class:`dateutil.rrule.rrule`
1460    """
1461
1462    _freq_map = {"YEARLY": YEARLY,
1463                 "MONTHLY": MONTHLY,
1464                 "WEEKLY": WEEKLY,
1465                 "DAILY": DAILY,
1466                 "HOURLY": HOURLY,
1467                 "MINUTELY": MINUTELY,
1468                 "SECONDLY": SECONDLY}
1469
1470    _weekday_map = {"MO": 0, "TU": 1, "WE": 2, "TH": 3,
1471                    "FR": 4, "SA": 5, "SU": 6}
1472
1473    def _handle_int(self, rrkwargs, name, value, **kwargs):
1474        rrkwargs[name.lower()] = int(value)
1475
1476    def _handle_int_list(self, rrkwargs, name, value, **kwargs):
1477        rrkwargs[name.lower()] = [int(x) for x in value.split(',')]
1478
1479    _handle_INTERVAL = _handle_int
1480    _handle_COUNT = _handle_int
1481    _handle_BYSETPOS = _handle_int_list
1482    _handle_BYMONTH = _handle_int_list
1483    _handle_BYMONTHDAY = _handle_int_list
1484    _handle_BYYEARDAY = _handle_int_list
1485    _handle_BYEASTER = _handle_int_list
1486    _handle_BYWEEKNO = _handle_int_list
1487    _handle_BYHOUR = _handle_int_list
1488    _handle_BYMINUTE = _handle_int_list
1489    _handle_BYSECOND = _handle_int_list
1490
1491    def _handle_FREQ(self, rrkwargs, name, value, **kwargs):
1492        rrkwargs["freq"] = self._freq_map[value]
1493
1494    def _handle_UNTIL(self, rrkwargs, name, value, **kwargs):
1495        global parser
1496        if not parser:
1497            from dateutil import parser
1498        try:
1499            rrkwargs["until"] = parser.parse(value,
1500                                             ignoretz=kwargs.get("ignoretz"),
1501                                             tzinfos=kwargs.get("tzinfos"))
1502        except ValueError:
1503            raise ValueError("invalid until date")
1504
1505    def _handle_WKST(self, rrkwargs, name, value, **kwargs):
1506        rrkwargs["wkst"] = self._weekday_map[value]
1507
1508    def _handle_BYWEEKDAY(self, rrkwargs, name, value, **kwargs):
1509        """
1510        Two ways to specify this: +1MO or MO(+1)
1511        """
1512        l = []
1513        for wday in value.split(','):
1514            if '(' in wday:
1515                # If it's of the form TH(+1), etc.
1516                splt = wday.split('(')
1517                w = splt[0]
1518                n = int(splt[1][:-1])
1519            elif len(wday):
1520                # If it's of the form +1MO
1521                for i in range(len(wday)):
1522                    if wday[i] not in '+-0123456789':
1523                        break
1524                n = wday[:i] or None
1525                w = wday[i:]
1526                if n:
1527                    n = int(n)
1528            else:
1529                raise ValueError("Invalid (empty) BYDAY specification.")
1530
1531            l.append(weekdays[self._weekday_map[w]](n))
1532        rrkwargs["byweekday"] = l
1533
1534    _handle_BYDAY = _handle_BYWEEKDAY
1535
1536    def _parse_rfc_rrule(self, line,
1537                         dtstart=None,
1538                         cache=False,
1539                         ignoretz=False,
1540                         tzinfos=None):
1541        if line.find(':') != -1:
1542            name, value = line.split(':')
1543            if name != "RRULE":
1544                raise ValueError("unknown parameter name")
1545        else:
1546            value = line
1547        rrkwargs = {}
1548        for pair in value.split(';'):
1549            name, value = pair.split('=')
1550            name = name.upper()
1551            value = value.upper()
1552            try:
1553                getattr(self, "_handle_"+name)(rrkwargs, name, value,
1554                                               ignoretz=ignoretz,
1555                                               tzinfos=tzinfos)
1556            except AttributeError:
1557                raise ValueError("unknown parameter '%s'" % name)
1558            except (KeyError, ValueError):
1559                raise ValueError("invalid '%s': %s" % (name, value))
1560        return rrule(dtstart=dtstart, cache=cache, **rrkwargs)
1561
1562    def _parse_date_value(self, date_value, parms, rule_tzids,
1563                          ignoretz, tzids, tzinfos):
1564        global parser
1565        if not parser:
1566            from dateutil import parser
1567
1568        datevals = []
1569        value_found = False
1570        TZID = None
1571
1572        for parm in parms:
1573            if parm.startswith("TZID="):
1574                try:
1575                    tzkey = rule_tzids[parm.split('TZID=')[-1]]
1576                except KeyError:
1577                    continue
1578                if tzids is None:
1579                    from . import tz
1580                    tzlookup = tz.gettz
1581                elif callable(tzids):
1582                    tzlookup = tzids
1583                else:
1584                    tzlookup = getattr(tzids, 'get', None)
1585                    if tzlookup is None:
1586                        msg = ('tzids must be a callable, mapping, or None, '
1587                               'not %s' % tzids)
1588                        raise ValueError(msg)
1589
1590                TZID = tzlookup(tzkey)
1591                continue
1592
1593            # RFC 5445 3.8.2.4: The VALUE parameter is optional, but may be found
1594            # only once.
1595            if parm not in {"VALUE=DATE-TIME", "VALUE=DATE"}:
1596                raise ValueError("unsupported parm: " + parm)
1597            else:
1598                if value_found:
1599                    msg = ("Duplicate value parameter found in: " + parm)
1600                    raise ValueError(msg)
1601                value_found = True
1602
1603        for datestr in date_value.split(','):
1604            date = parser.parse(datestr, ignoretz=ignoretz, tzinfos=tzinfos)
1605            if TZID is not None:
1606                if date.tzinfo is None:
1607                    date = date.replace(tzinfo=TZID)
1608                else:
1609                    raise ValueError('DTSTART/EXDATE specifies multiple timezone')
1610            datevals.append(date)
1611
1612        return datevals
1613
1614    def _parse_rfc(self, s,
1615                   dtstart=None,
1616                   cache=False,
1617                   unfold=False,
1618                   forceset=False,
1619                   compatible=False,
1620                   ignoretz=False,
1621                   tzids=None,
1622                   tzinfos=None):
1623        global parser
1624        if compatible:
1625            forceset = True
1626            unfold = True
1627
1628        TZID_NAMES = dict(map(
1629            lambda x: (x.upper(), x),
1630            re.findall('TZID=(?P<name>[^:]+):', s)
1631        ))
1632        s = s.upper()
1633        if not s.strip():
1634            raise ValueError("empty string")
1635        if unfold:
1636            lines = s.splitlines()
1637            i = 0
1638            while i < len(lines):
1639                line = lines[i].rstrip()
1640                if not line:
1641                    del lines[i]
1642                elif i > 0 and line[0] == " ":
1643                    lines[i-1] += line[1:]
1644                    del lines[i]
1645                else:
1646                    i += 1
1647        else:
1648            lines = s.split()
1649        if (not forceset and len(lines) == 1 and (s.find(':') == -1 or
1650                                                  s.startswith('RRULE:'))):
1651            return self._parse_rfc_rrule(lines[0], cache=cache,
1652                                         dtstart=dtstart, ignoretz=ignoretz,
1653                                         tzinfos=tzinfos)
1654        else:
1655            rrulevals = []
1656            rdatevals = []
1657            exrulevals = []
1658            exdatevals = []
1659            for line in lines:
1660                if not line:
1661                    continue
1662                if line.find(':') == -1:
1663                    name = "RRULE"
1664                    value = line
1665                else:
1666                    name, value = line.split(':', 1)
1667                parms = name.split(';')
1668                if not parms:
1669                    raise ValueError("empty property name")
1670                name = parms[0]
1671                parms = parms[1:]
1672                if name == "RRULE":
1673                    for parm in parms:
1674                        raise ValueError("unsupported RRULE parm: "+parm)
1675                    rrulevals.append(value)
1676                elif name == "RDATE":
1677                    for parm in parms:
1678                        if parm != "VALUE=DATE-TIME":
1679                            raise ValueError("unsupported RDATE parm: "+parm)
1680                    rdatevals.append(value)
1681                elif name == "EXRULE":
1682                    for parm in parms:
1683                        raise ValueError("unsupported EXRULE parm: "+parm)
1684                    exrulevals.append(value)
1685                elif name == "EXDATE":
1686                    exdatevals.extend(
1687                        self._parse_date_value(value, parms,
1688                                               TZID_NAMES, ignoretz,
1689                                               tzids, tzinfos)
1690                    )
1691                elif name == "DTSTART":
1692                    dtvals = self._parse_date_value(value, parms, TZID_NAMES,
1693                                                    ignoretz, tzids, tzinfos)
1694                    if len(dtvals) != 1:
1695                        raise ValueError("Multiple DTSTART values specified:" +
1696                                         value)
1697                    dtstart = dtvals[0]
1698                else:
1699                    raise ValueError("unsupported property: "+name)
1700            if (forceset or len(rrulevals) > 1 or rdatevals
1701                    or exrulevals or exdatevals):
1702                if not parser and (rdatevals or exdatevals):
1703                    from dateutil import parser
1704                rset = rruleset(cache=cache)
1705                for value in rrulevals:
1706                    rset.rrule(self._parse_rfc_rrule(value, dtstart=dtstart,
1707                                                     ignoretz=ignoretz,
1708                                                     tzinfos=tzinfos))
1709                for value in rdatevals:
1710                    for datestr in value.split(','):
1711                        rset.rdate(parser.parse(datestr,
1712                                                ignoretz=ignoretz,
1713                                                tzinfos=tzinfos))
1714                for value in exrulevals:
1715                    rset.exrule(self._parse_rfc_rrule(value, dtstart=dtstart,
1716                                                      ignoretz=ignoretz,
1717                                                      tzinfos=tzinfos))
1718                for value in exdatevals:
1719                    rset.exdate(value)
1720                if compatible and dtstart:
1721                    rset.rdate(dtstart)
1722                return rset
1723            else:
1724                return self._parse_rfc_rrule(rrulevals[0],
1725                                             dtstart=dtstart,
1726                                             cache=cache,
1727                                             ignoretz=ignoretz,
1728                                             tzinfos=tzinfos)
1729
1730    def __call__(self, s, **kwargs):
1731        return self._parse_rfc(s, **kwargs)
1732
1733
1734rrulestr = _rrulestr()
1735
1736# vim:ts=4:sw=4:et
1737