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