• 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 cache:
341        If given, it must be a boolean value specifying to enable or disable
342        caching of results. If you will use the same rrule instance multiple
343        times, enabling caching will improve the performance considerably.
344    :param dtstart:
345        The recurrence start. Besides being the base for the recurrence,
346        missing parameters in the final recurrence instances will also be
347        extracted from this date. If not given, datetime.now() will be used
348        instead.
349    :param interval:
350        The interval between each freq iteration. For example, when using
351        YEARLY, an interval of 2 means once every two years, but with HOURLY,
352        it means once every two hours. The default interval is 1.
353    :param wkst:
354        The week start day. Must be one of the MO, TU, WE constants, or an
355        integer, specifying the first day of the week. This will affect
356        recurrences based on weekly periods. The default week start is got
357        from calendar.firstweekday(), and may be modified by
358        calendar.setfirstweekday().
359    :param count:
360        How many occurrences will be generated.
361
362        .. note::
363            As of version 2.5.0, the use of the ``until`` keyword together
364            with the ``count`` keyword is deprecated per RFC-5545 Sec. 3.3.10.
365    :param until:
366        If given, this must be a datetime instance, that will specify the
367        limit of the recurrence. The last recurrence in the rule is the greatest
368        datetime that is less than or equal to the value specified in the
369        ``until`` parameter.
370
371        .. note::
372            As of version 2.5.0, the use of the ``until`` keyword together
373            with the ``count`` keyword is deprecated per RFC-5545 Sec. 3.3.10.
374    :param bysetpos:
375        If given, it must be either an integer, or a sequence of integers,
376        positive or negative. Each given integer will specify an occurrence
377        number, corresponding to the nth occurrence of the rule inside the
378        frequency period. For example, a bysetpos of -1 if combined with a
379        MONTHLY frequency, and a byweekday of (MO, TU, WE, TH, FR), will
380        result in the last work day of every month.
381    :param bymonth:
382        If given, it must be either an integer, or a sequence of integers,
383        meaning the months to apply the recurrence to.
384    :param bymonthday:
385        If given, it must be either an integer, or a sequence of integers,
386        meaning the month days to apply the recurrence to.
387    :param byyearday:
388        If given, it must be either an integer, or a sequence of integers,
389        meaning the year days to apply the recurrence to.
390    :param byweekno:
391        If given, it must be either an integer, or a sequence of integers,
392        meaning the week numbers to apply the recurrence to. Week numbers
393        have the meaning described in ISO8601, that is, the first week of
394        the year is that containing at least four days of the new year.
395    :param byweekday:
396        If given, it must be either an integer (0 == MO), a sequence of
397        integers, one of the weekday constants (MO, TU, etc), or a sequence
398        of these constants. When given, these variables will define the
399        weekdays where the recurrence will be applied. It's also possible to
400        use an argument n for the weekday instances, which will mean the nth
401        occurrence of this weekday in the period. For example, with MONTHLY,
402        or with YEARLY and BYMONTH, using FR(+1) in byweekday will specify the
403        first friday of the month where the recurrence happens. Notice that in
404        the RFC documentation, this is specified as BYDAY, but was renamed to
405        avoid the ambiguity of that keyword.
406    :param byhour:
407        If given, it must be either an integer, or a sequence of integers,
408        meaning the hours to apply the recurrence to.
409    :param byminute:
410        If given, it must be either an integer, or a sequence of integers,
411        meaning the minutes to apply the recurrence to.
412    :param bysecond:
413        If given, it must be either an integer, or a sequence of integers,
414        meaning the seconds to apply the recurrence to.
415    :param byeaster:
416        If given, it must be either an integer, or a sequence of integers,
417        positive or negative. Each integer will define an offset from the
418        Easter Sunday. Passing the offset 0 to byeaster will yield the Easter
419        Sunday itself. This is an extension to the RFC specification.
420     """
421    def __init__(self, freq, dtstart=None,
422                 interval=1, wkst=None, count=None, until=None, bysetpos=None,
423                 bymonth=None, bymonthday=None, byyearday=None, byeaster=None,
424                 byweekno=None, byweekday=None,
425                 byhour=None, byminute=None, bysecond=None,
426                 cache=False):
427        super(rrule, self).__init__(cache)
428        global easter
429        if not dtstart:
430            dtstart = datetime.datetime.now().replace(microsecond=0)
431        elif not isinstance(dtstart, datetime.datetime):
432            dtstart = datetime.datetime.fromordinal(dtstart.toordinal())
433        else:
434            dtstart = dtstart.replace(microsecond=0)
435        self._dtstart = dtstart
436        self._tzinfo = dtstart.tzinfo
437        self._freq = freq
438        self._interval = interval
439        self._count = count
440
441        # Cache the original byxxx rules, if they are provided, as the _byxxx
442        # attributes do not necessarily map to the inputs, and this can be
443        # a problem in generating the strings. Only store things if they've
444        # been supplied (the string retrieval will just use .get())
445        self._original_rule = {}
446
447        if until and not isinstance(until, datetime.datetime):
448            until = datetime.datetime.fromordinal(until.toordinal())
449        self._until = until
450
451        if self._dtstart and self._until:
452            if (self._dtstart.tzinfo is not None) != (self._until.tzinfo is not None):
453                # According to RFC5545 Section 3.3.10:
454                # https://tools.ietf.org/html/rfc5545#section-3.3.10
455                #
456                # > If the "DTSTART" property is specified as a date with UTC
457                # > time or a date with local time and time zone reference,
458                # > then the UNTIL rule part MUST be specified as a date with
459                # > UTC time.
460                raise ValueError(
461                    'RRULE UNTIL values must be specified in UTC when DTSTART '
462                    'is timezone-aware'
463                )
464
465        if count is not None and until:
466            warn("Using both 'count' and 'until' is inconsistent with RFC 5545"
467                 " and has been deprecated in dateutil. Future versions will "
468                 "raise an error.", DeprecationWarning)
469
470        if wkst is None:
471            self._wkst = calendar.firstweekday()
472        elif isinstance(wkst, integer_types):
473            self._wkst = wkst
474        else:
475            self._wkst = wkst.weekday
476
477        if bysetpos is None:
478            self._bysetpos = None
479        elif isinstance(bysetpos, integer_types):
480            if bysetpos == 0 or not (-366 <= bysetpos <= 366):
481                raise ValueError("bysetpos must be between 1 and 366, "
482                                 "or between -366 and -1")
483            self._bysetpos = (bysetpos,)
484        else:
485            self._bysetpos = tuple(bysetpos)
486            for pos in self._bysetpos:
487                if pos == 0 or not (-366 <= pos <= 366):
488                    raise ValueError("bysetpos must be between 1 and 366, "
489                                     "or between -366 and -1")
490
491        if self._bysetpos:
492            self._original_rule['bysetpos'] = self._bysetpos
493
494        if (byweekno is None and byyearday is None and bymonthday is None and
495                byweekday is None and byeaster is None):
496            if freq == YEARLY:
497                if bymonth is None:
498                    bymonth = dtstart.month
499                    self._original_rule['bymonth'] = None
500                bymonthday = dtstart.day
501                self._original_rule['bymonthday'] = None
502            elif freq == MONTHLY:
503                bymonthday = dtstart.day
504                self._original_rule['bymonthday'] = None
505            elif freq == WEEKLY:
506                byweekday = dtstart.weekday()
507                self._original_rule['byweekday'] = None
508
509        # bymonth
510        if bymonth is None:
511            self._bymonth = None
512        else:
513            if isinstance(bymonth, integer_types):
514                bymonth = (bymonth,)
515
516            self._bymonth = tuple(sorted(set(bymonth)))
517
518            if 'bymonth' not in self._original_rule:
519                self._original_rule['bymonth'] = self._bymonth
520
521        # byyearday
522        if byyearday is None:
523            self._byyearday = None
524        else:
525            if isinstance(byyearday, integer_types):
526                byyearday = (byyearday,)
527
528            self._byyearday = tuple(sorted(set(byyearday)))
529            self._original_rule['byyearday'] = self._byyearday
530
531        # byeaster
532        if byeaster is not None:
533            if not easter:
534                from dateutil import easter
535            if isinstance(byeaster, integer_types):
536                self._byeaster = (byeaster,)
537            else:
538                self._byeaster = tuple(sorted(byeaster))
539
540            self._original_rule['byeaster'] = self._byeaster
541        else:
542            self._byeaster = None
543
544        # bymonthday
545        if bymonthday is None:
546            self._bymonthday = ()
547            self._bynmonthday = ()
548        else:
549            if isinstance(bymonthday, integer_types):
550                bymonthday = (bymonthday,)
551
552            bymonthday = set(bymonthday)            # Ensure it's unique
553
554            self._bymonthday = tuple(sorted(x for x in bymonthday if x > 0))
555            self._bynmonthday = tuple(sorted(x for x in bymonthday if x < 0))
556
557            # Storing positive numbers first, then negative numbers
558            if 'bymonthday' not in self._original_rule:
559                self._original_rule['bymonthday'] = tuple(
560                    itertools.chain(self._bymonthday, self._bynmonthday))
561
562        # byweekno
563        if byweekno is None:
564            self._byweekno = None
565        else:
566            if isinstance(byweekno, integer_types):
567                byweekno = (byweekno,)
568
569            self._byweekno = tuple(sorted(set(byweekno)))
570
571            self._original_rule['byweekno'] = self._byweekno
572
573        # byweekday / bynweekday
574        if byweekday is None:
575            self._byweekday = None
576            self._bynweekday = None
577        else:
578            # If it's one of the valid non-sequence types, convert to a
579            # single-element sequence before the iterator that builds the
580            # byweekday set.
581            if isinstance(byweekday, integer_types) or hasattr(byweekday, "n"):
582                byweekday = (byweekday,)
583
584            self._byweekday = set()
585            self._bynweekday = set()
586            for wday in byweekday:
587                if isinstance(wday, integer_types):
588                    self._byweekday.add(wday)
589                elif not wday.n or freq > MONTHLY:
590                    self._byweekday.add(wday.weekday)
591                else:
592                    self._bynweekday.add((wday.weekday, wday.n))
593
594            if not self._byweekday:
595                self._byweekday = None
596            elif not self._bynweekday:
597                self._bynweekday = None
598
599            if self._byweekday is not None:
600                self._byweekday = tuple(sorted(self._byweekday))
601                orig_byweekday = [weekday(x) for x in self._byweekday]
602            else:
603                orig_byweekday = ()
604
605            if self._bynweekday is not None:
606                self._bynweekday = tuple(sorted(self._bynweekday))
607                orig_bynweekday = [weekday(*x) for x in self._bynweekday]
608            else:
609                orig_bynweekday = ()
610
611            if 'byweekday' not in self._original_rule:
612                self._original_rule['byweekday'] = tuple(itertools.chain(
613                    orig_byweekday, orig_bynweekday))
614
615        # byhour
616        if byhour is None:
617            if freq < HOURLY:
618                self._byhour = {dtstart.hour}
619            else:
620                self._byhour = None
621        else:
622            if isinstance(byhour, integer_types):
623                byhour = (byhour,)
624
625            if freq == HOURLY:
626                self._byhour = self.__construct_byset(start=dtstart.hour,
627                                                      byxxx=byhour,
628                                                      base=24)
629            else:
630                self._byhour = set(byhour)
631
632            self._byhour = tuple(sorted(self._byhour))
633            self._original_rule['byhour'] = self._byhour
634
635        # byminute
636        if byminute is None:
637            if freq < MINUTELY:
638                self._byminute = {dtstart.minute}
639            else:
640                self._byminute = None
641        else:
642            if isinstance(byminute, integer_types):
643                byminute = (byminute,)
644
645            if freq == MINUTELY:
646                self._byminute = self.__construct_byset(start=dtstart.minute,
647                                                        byxxx=byminute,
648                                                        base=60)
649            else:
650                self._byminute = set(byminute)
651
652            self._byminute = tuple(sorted(self._byminute))
653            self._original_rule['byminute'] = self._byminute
654
655        # bysecond
656        if bysecond is None:
657            if freq < SECONDLY:
658                self._bysecond = ((dtstart.second,))
659            else:
660                self._bysecond = None
661        else:
662            if isinstance(bysecond, integer_types):
663                bysecond = (bysecond,)
664
665            self._bysecond = set(bysecond)
666
667            if freq == SECONDLY:
668                self._bysecond = self.__construct_byset(start=dtstart.second,
669                                                        byxxx=bysecond,
670                                                        base=60)
671            else:
672                self._bysecond = set(bysecond)
673
674            self._bysecond = tuple(sorted(self._bysecond))
675            self._original_rule['bysecond'] = self._bysecond
676
677        if self._freq >= HOURLY:
678            self._timeset = None
679        else:
680            self._timeset = []
681            for hour in self._byhour:
682                for minute in self._byminute:
683                    for second in self._bysecond:
684                        self._timeset.append(
685                            datetime.time(hour, minute, second,
686                                          tzinfo=self._tzinfo))
687            self._timeset.sort()
688            self._timeset = tuple(self._timeset)
689
690    def __str__(self):
691        """
692        Output a string that would generate this RRULE if passed to rrulestr.
693        This is mostly compatible with RFC5545, except for the
694        dateutil-specific extension BYEASTER.
695        """
696
697        output = []
698        h, m, s = [None] * 3
699        if self._dtstart:
700            output.append(self._dtstart.strftime('DTSTART:%Y%m%dT%H%M%S'))
701            h, m, s = self._dtstart.timetuple()[3:6]
702
703        parts = ['FREQ=' + FREQNAMES[self._freq]]
704        if self._interval != 1:
705            parts.append('INTERVAL=' + str(self._interval))
706
707        if self._wkst:
708            parts.append('WKST=' + repr(weekday(self._wkst))[0:2])
709
710        if self._count is not None:
711            parts.append('COUNT=' + str(self._count))
712
713        if self._until:
714            parts.append(self._until.strftime('UNTIL=%Y%m%dT%H%M%S'))
715
716        if self._original_rule.get('byweekday') is not None:
717            # The str() method on weekday objects doesn't generate
718            # RFC5545-compliant strings, so we should modify that.
719            original_rule = dict(self._original_rule)
720            wday_strings = []
721            for wday in original_rule['byweekday']:
722                if wday.n:
723                    wday_strings.append('{n:+d}{wday}'.format(
724                        n=wday.n,
725                        wday=repr(wday)[0:2]))
726                else:
727                    wday_strings.append(repr(wday))
728
729            original_rule['byweekday'] = wday_strings
730        else:
731            original_rule = self._original_rule
732
733        partfmt = '{name}={vals}'
734        for name, key in [('BYSETPOS', 'bysetpos'),
735                          ('BYMONTH', 'bymonth'),
736                          ('BYMONTHDAY', 'bymonthday'),
737                          ('BYYEARDAY', 'byyearday'),
738                          ('BYWEEKNO', 'byweekno'),
739                          ('BYDAY', 'byweekday'),
740                          ('BYHOUR', 'byhour'),
741                          ('BYMINUTE', 'byminute'),
742                          ('BYSECOND', 'bysecond'),
743                          ('BYEASTER', 'byeaster')]:
744            value = original_rule.get(key)
745            if value:
746                parts.append(partfmt.format(name=name, vals=(','.join(str(v)
747                                                             for v in value))))
748
749        output.append('RRULE:' + ';'.join(parts))
750        return '\n'.join(output)
751
752    def replace(self, **kwargs):
753        """Return new rrule with same attributes except for those attributes given new
754           values by whichever keyword arguments are specified."""
755        new_kwargs = {"interval": self._interval,
756                      "count": self._count,
757                      "dtstart": self._dtstart,
758                      "freq": self._freq,
759                      "until": self._until,
760                      "wkst": self._wkst,
761                      "cache": False if self._cache is None else True }
762        new_kwargs.update(self._original_rule)
763        new_kwargs.update(kwargs)
764        return rrule(**new_kwargs)
765
766    def _iter(self):
767        year, month, day, hour, minute, second, weekday, yearday, _ = \
768            self._dtstart.timetuple()
769
770        # Some local variables to speed things up a bit
771        freq = self._freq
772        interval = self._interval
773        wkst = self._wkst
774        until = self._until
775        bymonth = self._bymonth
776        byweekno = self._byweekno
777        byyearday = self._byyearday
778        byweekday = self._byweekday
779        byeaster = self._byeaster
780        bymonthday = self._bymonthday
781        bynmonthday = self._bynmonthday
782        bysetpos = self._bysetpos
783        byhour = self._byhour
784        byminute = self._byminute
785        bysecond = self._bysecond
786
787        ii = _iterinfo(self)
788        ii.rebuild(year, month)
789
790        getdayset = {YEARLY: ii.ydayset,
791                     MONTHLY: ii.mdayset,
792                     WEEKLY: ii.wdayset,
793                     DAILY: ii.ddayset,
794                     HOURLY: ii.ddayset,
795                     MINUTELY: ii.ddayset,
796                     SECONDLY: ii.ddayset}[freq]
797
798        if freq < HOURLY:
799            timeset = self._timeset
800        else:
801            gettimeset = {HOURLY: ii.htimeset,
802                          MINUTELY: ii.mtimeset,
803                          SECONDLY: ii.stimeset}[freq]
804            if ((freq >= HOURLY and
805                 self._byhour and hour not in self._byhour) or
806                (freq >= MINUTELY and
807                 self._byminute and minute not in self._byminute) or
808                (freq >= SECONDLY and
809                 self._bysecond and second not in self._bysecond)):
810                timeset = ()
811            else:
812                timeset = gettimeset(hour, minute, second)
813
814        total = 0
815        count = self._count
816        while True:
817            # Get dayset with the right frequency
818            dayset, start, end = getdayset(year, month, day)
819
820            # Do the "hard" work ;-)
821            filtered = False
822            for i in dayset[start:end]:
823                if ((bymonth and ii.mmask[i] not in bymonth) or
824                    (byweekno and not ii.wnomask[i]) or
825                    (byweekday and ii.wdaymask[i] not in byweekday) or
826                    (ii.nwdaymask and not ii.nwdaymask[i]) or
827                    (byeaster and not ii.eastermask[i]) or
828                    ((bymonthday or bynmonthday) and
829                     ii.mdaymask[i] not in bymonthday and
830                     ii.nmdaymask[i] not in bynmonthday) or
831                    (byyearday and
832                     ((i < ii.yearlen and i+1 not in byyearday and
833                       -ii.yearlen+i not in byyearday) or
834                      (i >= ii.yearlen and i+1-ii.yearlen not in byyearday and
835                       -ii.nextyearlen+i-ii.yearlen not in byyearday)))):
836                    dayset[i] = None
837                    filtered = True
838
839            # Output results
840            if bysetpos and timeset:
841                poslist = []
842                for pos in bysetpos:
843                    if pos < 0:
844                        daypos, timepos = divmod(pos, len(timeset))
845                    else:
846                        daypos, timepos = divmod(pos-1, len(timeset))
847                    try:
848                        i = [x for x in dayset[start:end]
849                             if x is not None][daypos]
850                        time = timeset[timepos]
851                    except IndexError:
852                        pass
853                    else:
854                        date = datetime.date.fromordinal(ii.yearordinal+i)
855                        res = datetime.datetime.combine(date, time)
856                        if res not in poslist:
857                            poslist.append(res)
858                poslist.sort()
859                for res in poslist:
860                    if until and res > until:
861                        self._len = total
862                        return
863                    elif res >= self._dtstart:
864                        if count is not None:
865                            count -= 1
866                            if count < 0:
867                                self._len = total
868                                return
869                        total += 1
870                        yield res
871            else:
872                for i in dayset[start:end]:
873                    if i is not None:
874                        date = datetime.date.fromordinal(ii.yearordinal + i)
875                        for time in timeset:
876                            res = datetime.datetime.combine(date, time)
877                            if until and res > until:
878                                self._len = total
879                                return
880                            elif res >= self._dtstart:
881                                if count is not None:
882                                    count -= 1
883                                    if count < 0:
884                                        self._len = total
885                                        return
886
887                                total += 1
888                                yield res
889
890            # Handle frequency and interval
891            fixday = False
892            if freq == YEARLY:
893                year += interval
894                if year > datetime.MAXYEAR:
895                    self._len = total
896                    return
897                ii.rebuild(year, month)
898            elif freq == MONTHLY:
899                month += interval
900                if month > 12:
901                    div, mod = divmod(month, 12)
902                    month = mod
903                    year += div
904                    if month == 0:
905                        month = 12
906                        year -= 1
907                    if year > datetime.MAXYEAR:
908                        self._len = total
909                        return
910                ii.rebuild(year, month)
911            elif freq == WEEKLY:
912                if wkst > weekday:
913                    day += -(weekday+1+(6-wkst))+self._interval*7
914                else:
915                    day += -(weekday-wkst)+self._interval*7
916                weekday = wkst
917                fixday = True
918            elif freq == DAILY:
919                day += interval
920                fixday = True
921            elif freq == HOURLY:
922                if filtered:
923                    # Jump to one iteration before next day
924                    hour += ((23-hour)//interval)*interval
925
926                if byhour:
927                    ndays, hour = self.__mod_distance(value=hour,
928                                                      byxxx=self._byhour,
929                                                      base=24)
930                else:
931                    ndays, hour = divmod(hour+interval, 24)
932
933                if ndays:
934                    day += ndays
935                    fixday = True
936
937                timeset = gettimeset(hour, minute, second)
938            elif freq == MINUTELY:
939                if filtered:
940                    # Jump to one iteration before next day
941                    minute += ((1439-(hour*60+minute))//interval)*interval
942
943                valid = False
944                rep_rate = (24*60)
945                for j in range(rep_rate // gcd(interval, rep_rate)):
946                    if byminute:
947                        nhours, minute = \
948                            self.__mod_distance(value=minute,
949                                                byxxx=self._byminute,
950                                                base=60)
951                    else:
952                        nhours, minute = divmod(minute+interval, 60)
953
954                    div, hour = divmod(hour+nhours, 24)
955                    if div:
956                        day += div
957                        fixday = True
958                        filtered = False
959
960                    if not byhour or hour in byhour:
961                        valid = True
962                        break
963
964                if not valid:
965                    raise ValueError('Invalid combination of interval and ' +
966                                     'byhour resulting in empty rule.')
967
968                timeset = gettimeset(hour, minute, second)
969            elif freq == SECONDLY:
970                if filtered:
971                    # Jump to one iteration before next day
972                    second += (((86399 - (hour * 3600 + minute * 60 + second))
973                                // interval) * interval)
974
975                rep_rate = (24 * 3600)
976                valid = False
977                for j in range(0, rep_rate // gcd(interval, rep_rate)):
978                    if bysecond:
979                        nminutes, second = \
980                            self.__mod_distance(value=second,
981                                                byxxx=self._bysecond,
982                                                base=60)
983                    else:
984                        nminutes, second = divmod(second+interval, 60)
985
986                    div, minute = divmod(minute+nminutes, 60)
987                    if div:
988                        hour += div
989                        div, hour = divmod(hour, 24)
990                        if div:
991                            day += div
992                            fixday = True
993
994                    if ((not byhour or hour in byhour) and
995                            (not byminute or minute in byminute) and
996                            (not bysecond or second in bysecond)):
997                        valid = True
998                        break
999
1000                if not valid:
1001                    raise ValueError('Invalid combination of interval, ' +
1002                                     'byhour and byminute resulting in empty' +
1003                                     ' rule.')
1004
1005                timeset = gettimeset(hour, minute, second)
1006
1007            if fixday and day > 28:
1008                daysinmonth = calendar.monthrange(year, month)[1]
1009                if day > daysinmonth:
1010                    while day > daysinmonth:
1011                        day -= daysinmonth
1012                        month += 1
1013                        if month == 13:
1014                            month = 1
1015                            year += 1
1016                            if year > datetime.MAXYEAR:
1017                                self._len = total
1018                                return
1019                        daysinmonth = calendar.monthrange(year, month)[1]
1020                    ii.rebuild(year, month)
1021
1022    def __construct_byset(self, start, byxxx, base):
1023        """
1024        If a `BYXXX` sequence is passed to the constructor at the same level as
1025        `FREQ` (e.g. `FREQ=HOURLY,BYHOUR={2,4,7},INTERVAL=3`), there are some
1026        specifications which cannot be reached given some starting conditions.
1027
1028        This occurs whenever the interval is not coprime with the base of a
1029        given unit and the difference between the starting position and the
1030        ending position is not coprime with the greatest common denominator
1031        between the interval and the base. For example, with a FREQ of hourly
1032        starting at 17:00 and an interval of 4, the only valid values for
1033        BYHOUR would be {21, 1, 5, 9, 13, 17}, because 4 and 24 are not
1034        coprime.
1035
1036        :param start:
1037            Specifies the starting position.
1038        :param byxxx:
1039            An iterable containing the list of allowed values.
1040        :param base:
1041            The largest allowable value for the specified frequency (e.g.
1042            24 hours, 60 minutes).
1043
1044        This does not preserve the type of the iterable, returning a set, since
1045        the values should be unique and the order is irrelevant, this will
1046        speed up later lookups.
1047
1048        In the event of an empty set, raises a :exception:`ValueError`, as this
1049        results in an empty rrule.
1050        """
1051
1052        cset = set()
1053
1054        # Support a single byxxx value.
1055        if isinstance(byxxx, integer_types):
1056            byxxx = (byxxx, )
1057
1058        for num in byxxx:
1059            i_gcd = gcd(self._interval, base)
1060            # Use divmod rather than % because we need to wrap negative nums.
1061            if i_gcd == 1 or divmod(num - start, i_gcd)[1] == 0:
1062                cset.add(num)
1063
1064        if len(cset) == 0:
1065            raise ValueError("Invalid rrule byxxx generates an empty set.")
1066
1067        return cset
1068
1069    def __mod_distance(self, value, byxxx, base):
1070        """
1071        Calculates the next value in a sequence where the `FREQ` parameter is
1072        specified along with a `BYXXX` parameter at the same "level"
1073        (e.g. `HOURLY` specified with `BYHOUR`).
1074
1075        :param value:
1076            The old value of the component.
1077        :param byxxx:
1078            The `BYXXX` set, which should have been generated by
1079            `rrule._construct_byset`, or something else which checks that a
1080            valid rule is present.
1081        :param base:
1082            The largest allowable value for the specified frequency (e.g.
1083            24 hours, 60 minutes).
1084
1085        If a valid value is not found after `base` iterations (the maximum
1086        number before the sequence would start to repeat), this raises a
1087        :exception:`ValueError`, as no valid values were found.
1088
1089        This returns a tuple of `divmod(n*interval, base)`, where `n` is the
1090        smallest number of `interval` repetitions until the next specified
1091        value in `byxxx` is found.
1092        """
1093        accumulator = 0
1094        for ii in range(1, base + 1):
1095            # Using divmod() over % to account for negative intervals
1096            div, value = divmod(value + self._interval, base)
1097            accumulator += div
1098            if value in byxxx:
1099                return (accumulator, value)
1100
1101
1102class _iterinfo(object):
1103    __slots__ = ["rrule", "lastyear", "lastmonth",
1104                 "yearlen", "nextyearlen", "yearordinal", "yearweekday",
1105                 "mmask", "mrange", "mdaymask", "nmdaymask",
1106                 "wdaymask", "wnomask", "nwdaymask", "eastermask"]
1107
1108    def __init__(self, rrule):
1109        for attr in self.__slots__:
1110            setattr(self, attr, None)
1111        self.rrule = rrule
1112
1113    def rebuild(self, year, month):
1114        # Every mask is 7 days longer to handle cross-year weekly periods.
1115        rr = self.rrule
1116        if year != self.lastyear:
1117            self.yearlen = 365 + calendar.isleap(year)
1118            self.nextyearlen = 365 + calendar.isleap(year + 1)
1119            firstyday = datetime.date(year, 1, 1)
1120            self.yearordinal = firstyday.toordinal()
1121            self.yearweekday = firstyday.weekday()
1122
1123            wday = datetime.date(year, 1, 1).weekday()
1124            if self.yearlen == 365:
1125                self.mmask = M365MASK
1126                self.mdaymask = MDAY365MASK
1127                self.nmdaymask = NMDAY365MASK
1128                self.wdaymask = WDAYMASK[wday:]
1129                self.mrange = M365RANGE
1130            else:
1131                self.mmask = M366MASK
1132                self.mdaymask = MDAY366MASK
1133                self.nmdaymask = NMDAY366MASK
1134                self.wdaymask = WDAYMASK[wday:]
1135                self.mrange = M366RANGE
1136
1137            if not rr._byweekno:
1138                self.wnomask = None
1139            else:
1140                self.wnomask = [0]*(self.yearlen+7)
1141                # no1wkst = firstwkst = self.wdaymask.index(rr._wkst)
1142                no1wkst = firstwkst = (7-self.yearweekday+rr._wkst) % 7
1143                if no1wkst >= 4:
1144                    no1wkst = 0
1145                    # Number of days in the year, plus the days we got
1146                    # from last year.
1147                    wyearlen = self.yearlen+(self.yearweekday-rr._wkst) % 7
1148                else:
1149                    # Number of days in the year, minus the days we
1150                    # left in last year.
1151                    wyearlen = self.yearlen-no1wkst
1152                div, mod = divmod(wyearlen, 7)
1153                numweeks = div+mod//4
1154                for n in rr._byweekno:
1155                    if n < 0:
1156                        n += numweeks+1
1157                    if not (0 < n <= numweeks):
1158                        continue
1159                    if n > 1:
1160                        i = no1wkst+(n-1)*7
1161                        if no1wkst != firstwkst:
1162                            i -= 7-firstwkst
1163                    else:
1164                        i = no1wkst
1165                    for j in range(7):
1166                        self.wnomask[i] = 1
1167                        i += 1
1168                        if self.wdaymask[i] == rr._wkst:
1169                            break
1170                if 1 in rr._byweekno:
1171                    # Check week number 1 of next year as well
1172                    # TODO: Check -numweeks for next year.
1173                    i = no1wkst+numweeks*7
1174                    if no1wkst != firstwkst:
1175                        i -= 7-firstwkst
1176                    if i < self.yearlen:
1177                        # If week starts in next year, we
1178                        # don't care about it.
1179                        for j in range(7):
1180                            self.wnomask[i] = 1
1181                            i += 1
1182                            if self.wdaymask[i] == rr._wkst:
1183                                break
1184                if no1wkst:
1185                    # Check last week number of last year as
1186                    # well. If no1wkst is 0, either the year
1187                    # started on week start, or week number 1
1188                    # got days from last year, so there are no
1189                    # days from last year's last week number in
1190                    # this year.
1191                    if -1 not in rr._byweekno:
1192                        lyearweekday = datetime.date(year-1, 1, 1).weekday()
1193                        lno1wkst = (7-lyearweekday+rr._wkst) % 7
1194                        lyearlen = 365+calendar.isleap(year-1)
1195                        if lno1wkst >= 4:
1196                            lno1wkst = 0
1197                            lnumweeks = 52+(lyearlen +
1198                                            (lyearweekday-rr._wkst) % 7) % 7//4
1199                        else:
1200                            lnumweeks = 52+(self.yearlen-no1wkst) % 7//4
1201                    else:
1202                        lnumweeks = -1
1203                    if lnumweeks in rr._byweekno:
1204                        for i in range(no1wkst):
1205                            self.wnomask[i] = 1
1206
1207        if (rr._bynweekday and (month != self.lastmonth or
1208                                year != self.lastyear)):
1209            ranges = []
1210            if rr._freq == YEARLY:
1211                if rr._bymonth:
1212                    for month in rr._bymonth:
1213                        ranges.append(self.mrange[month-1:month+1])
1214                else:
1215                    ranges = [(0, self.yearlen)]
1216            elif rr._freq == MONTHLY:
1217                ranges = [self.mrange[month-1:month+1]]
1218            if ranges:
1219                # Weekly frequency won't get here, so we may not
1220                # care about cross-year weekly periods.
1221                self.nwdaymask = [0]*self.yearlen
1222                for first, last in ranges:
1223                    last -= 1
1224                    for wday, n in rr._bynweekday:
1225                        if n < 0:
1226                            i = last+(n+1)*7
1227                            i -= (self.wdaymask[i]-wday) % 7
1228                        else:
1229                            i = first+(n-1)*7
1230                            i += (7-self.wdaymask[i]+wday) % 7
1231                        if first <= i <= last:
1232                            self.nwdaymask[i] = 1
1233
1234        if rr._byeaster:
1235            self.eastermask = [0]*(self.yearlen+7)
1236            eyday = easter.easter(year).toordinal()-self.yearordinal
1237            for offset in rr._byeaster:
1238                self.eastermask[eyday+offset] = 1
1239
1240        self.lastyear = year
1241        self.lastmonth = month
1242
1243    def ydayset(self, year, month, day):
1244        return list(range(self.yearlen)), 0, self.yearlen
1245
1246    def mdayset(self, year, month, day):
1247        dset = [None]*self.yearlen
1248        start, end = self.mrange[month-1:month+1]
1249        for i in range(start, end):
1250            dset[i] = i
1251        return dset, start, end
1252
1253    def wdayset(self, year, month, day):
1254        # We need to handle cross-year weeks here.
1255        dset = [None]*(self.yearlen+7)
1256        i = datetime.date(year, month, day).toordinal()-self.yearordinal
1257        start = i
1258        for j in range(7):
1259            dset[i] = i
1260            i += 1
1261            # if (not (0 <= i < self.yearlen) or
1262            #    self.wdaymask[i] == self.rrule._wkst):
1263            # This will cross the year boundary, if necessary.
1264            if self.wdaymask[i] == self.rrule._wkst:
1265                break
1266        return dset, start, i
1267
1268    def ddayset(self, year, month, day):
1269        dset = [None] * self.yearlen
1270        i = datetime.date(year, month, day).toordinal() - self.yearordinal
1271        dset[i] = i
1272        return dset, i, i + 1
1273
1274    def htimeset(self, hour, minute, second):
1275        tset = []
1276        rr = self.rrule
1277        for minute in rr._byminute:
1278            for second in rr._bysecond:
1279                tset.append(datetime.time(hour, minute, second,
1280                                          tzinfo=rr._tzinfo))
1281        tset.sort()
1282        return tset
1283
1284    def mtimeset(self, hour, minute, second):
1285        tset = []
1286        rr = self.rrule
1287        for second in rr._bysecond:
1288            tset.append(datetime.time(hour, minute, second, tzinfo=rr._tzinfo))
1289        tset.sort()
1290        return tset
1291
1292    def stimeset(self, hour, minute, second):
1293        return (datetime.time(hour, minute, second,
1294                tzinfo=self.rrule._tzinfo),)
1295
1296
1297class rruleset(rrulebase):
1298    """ The rruleset type allows more complex recurrence setups, mixing
1299    multiple rules, dates, exclusion rules, and exclusion dates. The type
1300    constructor takes the following keyword arguments:
1301
1302    :param cache: If True, caching of results will be enabled, improving
1303                  performance of multiple queries considerably. """
1304
1305    class _genitem(object):
1306        def __init__(self, genlist, gen):
1307            try:
1308                self.dt = advance_iterator(gen)
1309                genlist.append(self)
1310            except StopIteration:
1311                pass
1312            self.genlist = genlist
1313            self.gen = gen
1314
1315        def __next__(self):
1316            try:
1317                self.dt = advance_iterator(self.gen)
1318            except StopIteration:
1319                if self.genlist[0] is self:
1320                    heapq.heappop(self.genlist)
1321                else:
1322                    self.genlist.remove(self)
1323                    heapq.heapify(self.genlist)
1324
1325        next = __next__
1326
1327        def __lt__(self, other):
1328            return self.dt < other.dt
1329
1330        def __gt__(self, other):
1331            return self.dt > other.dt
1332
1333        def __eq__(self, other):
1334            return self.dt == other.dt
1335
1336        def __ne__(self, other):
1337            return self.dt != other.dt
1338
1339    def __init__(self, cache=False):
1340        super(rruleset, self).__init__(cache)
1341        self._rrule = []
1342        self._rdate = []
1343        self._exrule = []
1344        self._exdate = []
1345
1346    @_invalidates_cache
1347    def rrule(self, rrule):
1348        """ Include the given :py:class:`rrule` instance in the recurrence set
1349            generation. """
1350        self._rrule.append(rrule)
1351
1352    @_invalidates_cache
1353    def rdate(self, rdate):
1354        """ Include the given :py:class:`datetime` instance in the recurrence
1355            set generation. """
1356        self._rdate.append(rdate)
1357
1358    @_invalidates_cache
1359    def exrule(self, exrule):
1360        """ Include the given rrule instance in the recurrence set exclusion
1361            list. Dates which are part of the given recurrence rules will not
1362            be generated, even if some inclusive rrule or rdate matches them.
1363        """
1364        self._exrule.append(exrule)
1365
1366    @_invalidates_cache
1367    def exdate(self, exdate):
1368        """ Include the given datetime instance in the recurrence set
1369            exclusion list. Dates included that way will not be generated,
1370            even if some inclusive rrule or rdate matches them. """
1371        self._exdate.append(exdate)
1372
1373    def _iter(self):
1374        rlist = []
1375        self._rdate.sort()
1376        self._genitem(rlist, iter(self._rdate))
1377        for gen in [iter(x) for x in self._rrule]:
1378            self._genitem(rlist, gen)
1379        exlist = []
1380        self._exdate.sort()
1381        self._genitem(exlist, iter(self._exdate))
1382        for gen in [iter(x) for x in self._exrule]:
1383            self._genitem(exlist, gen)
1384        lastdt = None
1385        total = 0
1386        heapq.heapify(rlist)
1387        heapq.heapify(exlist)
1388        while rlist:
1389            ritem = rlist[0]
1390            if not lastdt or lastdt != ritem.dt:
1391                while exlist and exlist[0] < ritem:
1392                    exitem = exlist[0]
1393                    advance_iterator(exitem)
1394                    if exlist and exlist[0] is exitem:
1395                        heapq.heapreplace(exlist, exitem)
1396                if not exlist or ritem != exlist[0]:
1397                    total += 1
1398                    yield ritem.dt
1399                lastdt = ritem.dt
1400            advance_iterator(ritem)
1401            if rlist and rlist[0] is ritem:
1402                heapq.heapreplace(rlist, ritem)
1403        self._len = total
1404
1405
1406class _rrulestr(object):
1407
1408    _freq_map = {"YEARLY": YEARLY,
1409                 "MONTHLY": MONTHLY,
1410                 "WEEKLY": WEEKLY,
1411                 "DAILY": DAILY,
1412                 "HOURLY": HOURLY,
1413                 "MINUTELY": MINUTELY,
1414                 "SECONDLY": SECONDLY}
1415
1416    _weekday_map = {"MO": 0, "TU": 1, "WE": 2, "TH": 3,
1417                    "FR": 4, "SA": 5, "SU": 6}
1418
1419    def _handle_int(self, rrkwargs, name, value, **kwargs):
1420        rrkwargs[name.lower()] = int(value)
1421
1422    def _handle_int_list(self, rrkwargs, name, value, **kwargs):
1423        rrkwargs[name.lower()] = [int(x) for x in value.split(',')]
1424
1425    _handle_INTERVAL = _handle_int
1426    _handle_COUNT = _handle_int
1427    _handle_BYSETPOS = _handle_int_list
1428    _handle_BYMONTH = _handle_int_list
1429    _handle_BYMONTHDAY = _handle_int_list
1430    _handle_BYYEARDAY = _handle_int_list
1431    _handle_BYEASTER = _handle_int_list
1432    _handle_BYWEEKNO = _handle_int_list
1433    _handle_BYHOUR = _handle_int_list
1434    _handle_BYMINUTE = _handle_int_list
1435    _handle_BYSECOND = _handle_int_list
1436
1437    def _handle_FREQ(self, rrkwargs, name, value, **kwargs):
1438        rrkwargs["freq"] = self._freq_map[value]
1439
1440    def _handle_UNTIL(self, rrkwargs, name, value, **kwargs):
1441        global parser
1442        if not parser:
1443            from dateutil import parser
1444        try:
1445            rrkwargs["until"] = parser.parse(value,
1446                                             ignoretz=kwargs.get("ignoretz"),
1447                                             tzinfos=kwargs.get("tzinfos"))
1448        except ValueError:
1449            raise ValueError("invalid until date")
1450
1451    def _handle_WKST(self, rrkwargs, name, value, **kwargs):
1452        rrkwargs["wkst"] = self._weekday_map[value]
1453
1454    def _handle_BYWEEKDAY(self, rrkwargs, name, value, **kwargs):
1455        """
1456        Two ways to specify this: +1MO or MO(+1)
1457        """
1458        l = []
1459        for wday in value.split(','):
1460            if '(' in wday:
1461                # If it's of the form TH(+1), etc.
1462                splt = wday.split('(')
1463                w = splt[0]
1464                n = int(splt[1][:-1])
1465            elif len(wday):
1466                # If it's of the form +1MO
1467                for i in range(len(wday)):
1468                    if wday[i] not in '+-0123456789':
1469                        break
1470                n = wday[:i] or None
1471                w = wday[i:]
1472                if n:
1473                    n = int(n)
1474            else:
1475                raise ValueError("Invalid (empty) BYDAY specification.")
1476
1477            l.append(weekdays[self._weekday_map[w]](n))
1478        rrkwargs["byweekday"] = l
1479
1480    _handle_BYDAY = _handle_BYWEEKDAY
1481
1482    def _parse_rfc_rrule(self, line,
1483                         dtstart=None,
1484                         cache=False,
1485                         ignoretz=False,
1486                         tzinfos=None):
1487        if line.find(':') != -1:
1488            name, value = line.split(':')
1489            if name != "RRULE":
1490                raise ValueError("unknown parameter name")
1491        else:
1492            value = line
1493        rrkwargs = {}
1494        for pair in value.split(';'):
1495            name, value = pair.split('=')
1496            name = name.upper()
1497            value = value.upper()
1498            try:
1499                getattr(self, "_handle_"+name)(rrkwargs, name, value,
1500                                               ignoretz=ignoretz,
1501                                               tzinfos=tzinfos)
1502            except AttributeError:
1503                raise ValueError("unknown parameter '%s'" % name)
1504            except (KeyError, ValueError):
1505                raise ValueError("invalid '%s': %s" % (name, value))
1506        return rrule(dtstart=dtstart, cache=cache, **rrkwargs)
1507
1508    def _parse_rfc(self, s,
1509                   dtstart=None,
1510                   cache=False,
1511                   unfold=False,
1512                   forceset=False,
1513                   compatible=False,
1514                   ignoretz=False,
1515                   tzids=None,
1516                   tzinfos=None):
1517        global parser
1518        if compatible:
1519            forceset = True
1520            unfold = True
1521
1522        TZID_NAMES = dict(map(
1523            lambda x: (x.upper(), x),
1524            re.findall('TZID=(?P<name>[^:]+):', s)
1525        ))
1526        s = s.upper()
1527        if not s.strip():
1528            raise ValueError("empty string")
1529        if unfold:
1530            lines = s.splitlines()
1531            i = 0
1532            while i < len(lines):
1533                line = lines[i].rstrip()
1534                if not line:
1535                    del lines[i]
1536                elif i > 0 and line[0] == " ":
1537                    lines[i-1] += line[1:]
1538                    del lines[i]
1539                else:
1540                    i += 1
1541        else:
1542            lines = s.split()
1543        if (not forceset and len(lines) == 1 and (s.find(':') == -1 or
1544                                                  s.startswith('RRULE:'))):
1545            return self._parse_rfc_rrule(lines[0], cache=cache,
1546                                         dtstart=dtstart, ignoretz=ignoretz,
1547                                         tzinfos=tzinfos)
1548        else:
1549            rrulevals = []
1550            rdatevals = []
1551            exrulevals = []
1552            exdatevals = []
1553            for line in lines:
1554                if not line:
1555                    continue
1556                if line.find(':') == -1:
1557                    name = "RRULE"
1558                    value = line
1559                else:
1560                    name, value = line.split(':', 1)
1561                parms = name.split(';')
1562                if not parms:
1563                    raise ValueError("empty property name")
1564                name = parms[0]
1565                parms = parms[1:]
1566                if name == "RRULE":
1567                    for parm in parms:
1568                        raise ValueError("unsupported RRULE parm: "+parm)
1569                    rrulevals.append(value)
1570                elif name == "RDATE":
1571                    for parm in parms:
1572                        if parm != "VALUE=DATE-TIME":
1573                            raise ValueError("unsupported RDATE parm: "+parm)
1574                    rdatevals.append(value)
1575                elif name == "EXRULE":
1576                    for parm in parms:
1577                        raise ValueError("unsupported EXRULE parm: "+parm)
1578                    exrulevals.append(value)
1579                elif name == "EXDATE":
1580                    for parm in parms:
1581                        if parm != "VALUE=DATE-TIME":
1582                            raise ValueError("unsupported EXDATE parm: "+parm)
1583                    exdatevals.append(value)
1584                elif name == "DTSTART":
1585                    # RFC 5445 3.8.2.4: The VALUE parameter is optional, but
1586                    # may be found only once.
1587                    value_found = False
1588                    TZID = None
1589                    valid_values = {"VALUE=DATE-TIME", "VALUE=DATE"}
1590                    for parm in parms:
1591                        if parm.startswith("TZID="):
1592                            try:
1593                                tzkey = TZID_NAMES[parm.split('TZID=')[-1]]
1594                            except KeyError:
1595                                continue
1596                            if tzids is None:
1597                                from . import tz
1598                                tzlookup = tz.gettz
1599                            elif callable(tzids):
1600                                tzlookup = tzids
1601                            else:
1602                                tzlookup = getattr(tzids, 'get', None)
1603                                if tzlookup is None:
1604                                    msg = ('tzids must be a callable, ' +
1605                                           'mapping, or None, ' +
1606                                           'not %s' % tzids)
1607                                    raise ValueError(msg)
1608
1609                            TZID = tzlookup(tzkey)
1610                            continue
1611                        if parm not in valid_values:
1612                            raise ValueError("unsupported DTSTART parm: "+parm)
1613                        else:
1614                            if value_found:
1615                                msg = ("Duplicate value parameter found in " +
1616                                       "DTSTART: " + parm)
1617                                raise ValueError(msg)
1618                            value_found = True
1619                    if not parser:
1620                        from dateutil import parser
1621                    dtstart = parser.parse(value, ignoretz=ignoretz,
1622                                           tzinfos=tzinfos)
1623                    if TZID is not None:
1624                        if dtstart.tzinfo is None:
1625                            dtstart = dtstart.replace(tzinfo=TZID)
1626                        else:
1627                            raise ValueError('DTSTART specifies multiple timezones')
1628                else:
1629                    raise ValueError("unsupported property: "+name)
1630            if (forceset or len(rrulevals) > 1 or rdatevals
1631                    or exrulevals or exdatevals):
1632                if not parser and (rdatevals or exdatevals):
1633                    from dateutil import parser
1634                rset = rruleset(cache=cache)
1635                for value in rrulevals:
1636                    rset.rrule(self._parse_rfc_rrule(value, dtstart=dtstart,
1637                                                     ignoretz=ignoretz,
1638                                                     tzinfos=tzinfos))
1639                for value in rdatevals:
1640                    for datestr in value.split(','):
1641                        rset.rdate(parser.parse(datestr,
1642                                                ignoretz=ignoretz,
1643                                                tzinfos=tzinfos))
1644                for value in exrulevals:
1645                    rset.exrule(self._parse_rfc_rrule(value, dtstart=dtstart,
1646                                                      ignoretz=ignoretz,
1647                                                      tzinfos=tzinfos))
1648                for value in exdatevals:
1649                    for datestr in value.split(','):
1650                        rset.exdate(parser.parse(datestr,
1651                                                 ignoretz=ignoretz,
1652                                                 tzinfos=tzinfos))
1653                if compatible and dtstart:
1654                    rset.rdate(dtstart)
1655                return rset
1656            else:
1657                return self._parse_rfc_rrule(rrulevals[0],
1658                                             dtstart=dtstart,
1659                                             cache=cache,
1660                                             ignoretz=ignoretz,
1661                                             tzinfos=tzinfos)
1662
1663    def __call__(self, s, **kwargs):
1664        return self._parse_rfc(s, **kwargs)
1665
1666
1667rrulestr = _rrulestr()
1668
1669# vim:ts=4:sw=4:et
1670