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