1 #!/usr/bin/env python 2 3 """ 4 Recurrence rule calculation. 5 6 Copyright (C) 2014, 2015, 2017 Paul Boddie <paul@boddie.org.uk> 7 8 This program is free software; you can redistribute it and/or modify it under 9 the terms of the GNU General Public License as published by the Free Software 10 Foundation; either version 3 of the License, or (at your option) any later 11 version. 12 13 This program is distributed in the hope that it will be useful, but WITHOUT 14 ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS 15 FOR A PARTICULAR PURPOSE. See the GNU General Public License for more 16 details. 17 18 You should have received a copy of the GNU General Public License along with 19 this program. If not, see <http://www.gnu.org/licenses/>. 20 21 ---- 22 23 References: 24 25 RFC 5545: Internet Calendaring and Scheduling Core Object Specification 26 (iCalendar) 27 http://tools.ietf.org/html/rfc5545 28 29 ---- 30 31 FREQ defines the selection resolution. 32 DTSTART defines the start of the selection. 33 INTERVAL defines the step of the selection. 34 COUNT defines a number of instances 35 UNTIL defines a limit to the selection. 36 37 BY... qualifiers select instances within each outer selection instance according 38 to the recurrence of instances of the next highest resolution. For example, 39 BYDAY selects days in weeks. Thus, if no explicit week recurrence is indicated, 40 all weeks are selected within the selection of the next highest explicitly 41 specified resolution, whether this is months or years. 42 43 BYSETPOS in conjunction with BY... qualifiers permit the selection of specific 44 instances. 45 46 Within the FREQ resolution, BY... qualifiers refine selected instances. 47 48 Outside the FREQ resolution, BY... qualifiers select instances at the resolution 49 concerned. 50 51 Thus, FREQ and BY... qualifiers need to be ordered in terms of increasing 52 resolution (or decreasing scope). 53 """ 54 55 from calendar import monthrange 56 from datetime import date, datetime, timedelta 57 import operator 58 59 # Frequency levels, specified by FREQ in iCalendar. 60 61 freq_levels = ( 62 "YEARLY", 63 "MONTHLY", 64 "WEEKLY", 65 None, 66 None, 67 "DAILY", 68 "HOURLY", 69 "MINUTELY", 70 "SECONDLY" 71 ) 72 73 # Symbols corresponding to resolution levels. 74 75 YEARS, MONTHS, WEEKS, DAYS, HOURS, MINUTES, SECONDS = 0, 1, 2, 5, 6, 7, 8 76 77 # Enumeration levels, employed by BY... qualifiers. 78 79 enum_levels = ( 80 None, 81 "BYMONTH", 82 "BYWEEKNO", 83 "BYYEARDAY", 84 "BYMONTHDAY", 85 "BYDAY", 86 "BYHOUR", 87 "BYMINUTE", 88 "BYSECOND" 89 ) 90 91 # Levels defining days. 92 93 daylevels = [2, 3, 4, 5] 94 95 # Map from levels to lengths of datetime tuples. 96 97 lengths = [1, 2, 3, 3, 3, 3, 4, 5, 6] 98 positions = [l-1 for l in lengths] 99 100 # Define the lowest values at each resolution (years, months, days... hours, 101 # minutes, seconds). 102 103 firstvalues = [0, 1, 1, 1, 1, 1, 0, 0, 0] 104 105 # Map from qualifiers to interval multiples. Here, weeks are defined as 7 days. 106 107 multiples = {"WEEKLY" : 7} 108 109 # Make dictionaries mapping qualifiers to levels. 110 111 freq = {} 112 for i, level in enumerate(freq_levels): 113 if level: 114 freq[level] = i 115 116 enum = {} 117 for i, level in enumerate(enum_levels): 118 if level: 119 enum[level] = i 120 121 # Weekdays: name -> 1-based value 122 123 weekdays = {} 124 for i, weekday in enumerate(["MO", "TU", "WE", "TH", "FR", "SA", "SU"]): 125 weekdays[weekday] = i + 1 126 127 # Functions for structuring the recurrences. 128 129 def get_next(it): 130 131 "Return the next value from iterator 'it' or None if no more values exist." 132 133 try: 134 return it.next() 135 except StopIteration: 136 return None 137 138 def get_parameters(values): 139 140 "Return parameters from the given list of 'values'." 141 142 d = {} 143 for value in values: 144 try: 145 key, value = value.split("=", 1) 146 d[key] = value 147 except ValueError: 148 continue 149 return d 150 151 def get_qualifiers(values): 152 153 """ 154 Process the list of 'values' of the form "key=value", returning a list of 155 qualifiers of the form (qualifier name, args). 156 """ 157 158 qualifiers = [] 159 frequency = None 160 interval = 1 161 162 for value in values: 163 try: 164 key, value = value.split("=", 1) 165 except ValueError: 166 continue 167 168 # Accept frequency indicators as qualifiers. 169 170 if key == "FREQ" and freq.has_key(value): 171 qualifier = frequency = (value, {}) 172 173 # Accept interval indicators for frequency qualifier parameterisation. 174 175 elif key == "INTERVAL": 176 interval = int(value) 177 continue 178 179 # Accept result set selection, truncation and enumerators as qualifiers. 180 181 elif key in ("BYSETPOS", "COUNT") or enum.has_key(key): 182 qualifier = (key, {"values" : get_qualifier_values(key, value)}) 183 184 # Ignore other items. 185 186 else: 187 continue 188 189 qualifiers.append(qualifier) 190 191 # Parameterise any frequency qualifier with the interval. 192 193 if frequency: 194 frequency[1]["interval"] = interval 195 196 return qualifiers 197 198 def get_qualifier_values(qualifier, value): 199 200 """ 201 For the given 'qualifier', process the 'value' string, returning a list of 202 suitable values. 203 """ 204 205 # For non-weekday selection, obtain a list of day numbers. 206 207 if qualifier != "BYDAY": 208 return map(int, value.split(",")) 209 210 # For weekday selection, obtain the weekday number and instance number. 211 212 values = [] 213 214 for part in value.split(","): 215 weekday = weekdays.get(part[-2:]) 216 if not weekday: 217 continue 218 index = part[:-2] 219 if index: 220 index = int(index) 221 else: 222 index = None 223 values.append((weekday, index)) 224 225 return values 226 227 def order_qualifiers(qualifiers): 228 229 "Return the 'qualifiers' in order of increasing resolution." 230 231 l = [] 232 max_level = 0 233 234 # Special qualifiers. 235 236 setpos = None 237 count = None 238 239 for qualifier, args in qualifiers: 240 241 # Distinguish between enumerators, used to select particular periods, 242 # and frequencies, used to select repeating periods. 243 244 if enum.has_key(qualifier): 245 level = enum[qualifier] 246 247 # Certain enumerators produce their values in a special way. 248 249 if special_enum_levels.has_key(qualifier): 250 args["interval"] = 1 251 selector = special_enum_levels[qualifier] 252 else: 253 selector = Enum 254 255 elif qualifier == "BYSETPOS": 256 setpos = args 257 continue 258 259 elif qualifier == "COUNT": 260 count = args 261 continue 262 263 else: 264 level = freq[qualifier] 265 selector = Pattern 266 267 l.append(selector(level, args, qualifier)) 268 max_level = max(level, max_level) 269 270 # Add the result set selector at the maximum level of enumeration. 271 272 if setpos is not None: 273 l.append(PositionSelector(max_level, setpos, "BYSETPOS")) 274 275 # Add the result set truncator at the top level. 276 277 if count is not None: 278 l.append(LimitSelector(0, count, "COUNT")) 279 280 # Make BYSETPOS sort earlier than the enumeration it modifies. 281 282 l.sort(key=lambda x: (x.level, x.qualifier != "BYSETPOS" and 1 or 0)) 283 return l 284 285 def get_datetime_structure(datetime): 286 287 "Return the structure of 'datetime' for recurrence production." 288 289 l = [] 290 offset = 0 291 292 for pos, value in enumerate(datetime): 293 294 # At the day number, adjust the frequency level offset to reference 295 # days (and then hours, minutes, seconds). 296 297 if pos == 2: 298 offset = 3 299 300 l.append(Enum(pos + offset, {"values" : [value]}, "DT")) 301 302 return l 303 304 def combine_datetime_with_qualifiers(datetime, qualifiers): 305 306 """ 307 Combine 'datetime' with 'qualifiers' to produce a structure for recurrence 308 production. 309 310 Initial datetime values appearing at broader resolutions than any qualifiers 311 are ignored, since their details will be used when materialising the 312 results. 313 314 Qualifiers are accumulated in order to define a selection. Datetime values 315 occurring between qualifiers or at the same resolution as qualifiers are 316 ignored. 317 318 Any remaining datetime values are introduced as enumerators, provided that 319 they do not conflict with qualifiers. For example, specific day values 320 conflict with day selectors and weekly qualifiers. 321 322 The purpose of the remaining datetime values is to define a result within 323 a period selected by the most precise qualifier. For example, the selection 324 of a day and month in a year recurrence. 325 """ 326 327 iter_dt = iter(get_datetime_structure(datetime)) 328 iter_q = iter(order_qualifiers(qualifiers)) 329 330 l = [] 331 332 from_dt = get_next(iter_dt) 333 from_q = get_next(iter_q) 334 have_q = False 335 336 # Consume from both lists, merging entries. 337 338 while from_dt and from_q: 339 _level = from_dt.level 340 level = from_q.level 341 342 # Datetime value at wider resolution. 343 344 if _level < level: 345 from_dt = get_next(iter_dt) 346 347 # Qualifier at wider or same resolution as datetime value. 348 349 else: 350 if not have_q: 351 add_initial_qualifier(from_q, level, l) 352 have_q = True 353 354 # Add the qualifier to the combined list. 355 356 l.append(from_q) 357 358 # Datetime value at same resolution. 359 360 if _level == level: 361 from_dt = get_next(iter_dt) 362 363 # Get the next qualifier. 364 365 from_q = get_next(iter_q) 366 367 # Complete the list by adding remaining datetime enumerators. 368 369 while from_dt: 370 371 # Ignore datetime values that conflict with day-level qualifiers. 372 373 if not l or from_dt.level != freq["DAILY"] or \ 374 l[-1].level not in daylevels: 375 376 l.append(from_dt) 377 378 from_dt = get_next(iter_dt) 379 380 # Complete the list by adding remaining qualifiers. 381 382 while from_q: 383 if not have_q: 384 add_initial_qualifier(from_q, level, l) 385 have_q = True 386 387 # Add the qualifier to the combined list. 388 389 l.append(from_q) 390 391 # Get the next qualifier. 392 393 from_q = get_next(iter_q) 394 395 return l 396 397 def add_initial_qualifier(from_q, level, l): 398 399 """ 400 Take the first qualifier 'from_q' at the given resolution 'level', using it 401 to create an initial qualifier, adding it to the combined list 'l' if 402 required. 403 """ 404 405 if isinstance(from_q, Enum) and level > 0: 406 repeat = Pattern(level - 1, {"interval" : 1}, None) 407 l.append(repeat) 408 409 def get_multiple(qualifier): 410 411 "Return the time unit multiple for 'qualifier'." 412 413 return multiples.get(qualifier, 1) 414 415 # Datetime arithmetic. 416 417 def is_year_only(t): 418 419 "Return if 't' describes a year." 420 421 return len(t) == lengths[YEARS] 422 423 def is_month_only(t): 424 425 "Return if 't' describes a month within a year." 426 427 return len(t) == lengths[MONTHS] 428 429 def start_of_year(t): 430 431 "Return the start of the year referenced by 't'." 432 433 return (t[0], 1, 1) 434 435 def end_of_year(t): 436 437 "Return the end of the year referenced by 't'." 438 439 return (t[0], 12, 31) 440 441 def start_of_month(t): 442 443 "Return the start of the month referenced by 't'." 444 445 year, month = t[:2] 446 return (year, month, 1) 447 448 def end_of_month(t): 449 450 "Return the end of the month referenced by 't'." 451 452 year, month = t[:2] 453 return update(update((year, month, 1), (0, 1, 0)), (0, 0, -1)) 454 455 def day_in_year(t, number): 456 457 "Return the day in the year referenced by 't' with the given 'number'." 458 459 return to_tuple(date(*start_of_year(t)) + timedelta(number - 1)) 460 461 def get_year_length(t): 462 463 "Return the length of the year referenced by 't'." 464 465 first_day = date(*start_of_year(t)) 466 last_day = date(*end_of_year(t)) 467 return (last_day - first_day).days + 1 468 469 def get_weekday(t): 470 471 "Return the 1-based weekday for the month referenced by 't'." 472 473 year, month = t[:2] 474 return monthrange(year, month)[0] + 1 475 476 def get_ordered_weekdays(t): 477 478 """ 479 Return the 1-based weekday sequence describing the first week of the month 480 referenced by 't'. 481 """ 482 483 first = get_weekday(t) 484 return range(first, 8) + range(1, first) 485 486 def get_last_weekday_instance(weekday, first_day, last_day): 487 488 """ 489 Return the last instance number for 'weekday' within the period from 490 'first_day' to 'last_day' inclusive. 491 492 Here, 'weekday' is 1-based (starting on Monday), the returned limit is 493 1-based. 494 """ 495 496 weekday0 = get_first_day(first_day, weekday) 497 days = (date(*last_day) - weekday0).days 498 return days / 7 + 1 499 500 def precision(t, level, value=None): 501 502 """ 503 Return 't' trimmed in resolution to the indicated resolution 'level', 504 setting 'value' at the given resolution if indicated. 505 """ 506 507 pos = positions[level] 508 509 if value is None: 510 return t[:pos + 1] 511 else: 512 return t[:pos] + (value,) 513 514 def scale(interval, level): 515 516 """ 517 Scale the given 'interval' value in resolution to the indicated resolution 518 'level', returning a tuple with leading zero elements and 'interval' at the 519 stated position. 520 """ 521 522 pos = positions[level] 523 return (0,) * pos + (interval,) 524 525 def get_seconds(t): 526 527 "Convert the sub-day part of 't' into seconds." 528 529 t = t + (0,) * (6 - len(t)) 530 return (t[3] * 60 + t[4]) * 60 + t[5] 531 532 def update(t, step): 533 534 "Update 't' by 'step' at the resolution of 'step'." 535 536 i = len(step) 537 538 # Years only. 539 540 if i == 1: 541 return (t[0] + step[0],) + tuple(t[1:]) 542 543 # Years and months. 544 545 elif i == 2: 546 month = t[1] + step[1] 547 return (t[0] + step[0] + (month - 1) / 12, (month - 1) % 12 + 1) + tuple(t[2:]) 548 549 # Dates and datetimes. 550 551 else: 552 updated_for_months = update(t, step[:2]) 553 554 # Dates only. 555 556 if i == 3: 557 d = datetime(*updated_for_months) 558 s = timedelta(step[2]) 559 560 # Datetimes. 561 562 else: 563 d = datetime(*updated_for_months) 564 s = timedelta(step[2], get_seconds(step)) 565 566 return to_tuple(d + s)[:len(t)] 567 568 def to_tuple(d): 569 570 "Return date or datetime 'd' as a tuple." 571 572 if not isinstance(d, date): 573 return d 574 if isinstance(d, datetime): 575 n = 6 576 else: 577 n = 3 578 return d.timetuple()[:n] 579 580 def get_first_day(first_day, weekday): 581 582 """ 583 Return the first occurrence at or after 'first_day' of 'weekday' as a date 584 instance. 585 """ 586 587 first_day = date(*first_day) 588 first_weekday = first_day.isoweekday() 589 if first_weekday > weekday: 590 return first_day + timedelta(7 - first_weekday + weekday) 591 else: 592 return first_day + timedelta(weekday - first_weekday) 593 594 def get_last_day(last_day, weekday): 595 596 """ 597 Return the last occurrence at or before 'last_day' of 'weekday' as a date 598 instance. 599 """ 600 601 last_day = date(*last_day) 602 last_weekday = last_day.isoweekday() 603 if last_weekday < weekday: 604 return last_day - timedelta(last_weekday + 7 - weekday) 605 else: 606 return last_day - timedelta(last_weekday - weekday) 607 608 # Value expansion and sorting. 609 610 def sort_values(values, limit=None): 611 612 """ 613 Sort the given 'values' using 'limit' to determine the positions of negative 614 values. 615 """ 616 617 # Convert negative values to positive values according to the value limit. 618 619 if limit is not None: 620 l = map(lambda x, limit=limit: x < 0 and x + 1 + limit or x, values) 621 else: 622 l = values[:] 623 624 l.sort() 625 return l 626 627 def compare_weekday_selectors(x, y, weekdays): 628 629 """ 630 Compare 'x' and 'y' values of the form (weekday number, instance number) 631 using 'weekdays' to define an ordering of the weekday numbers. 632 """ 633 634 return cmp((x[1], weekdays.index(x[0])), (y[1], weekdays.index(y[0]))) 635 636 def sort_weekdays(values, first_day, last_day): 637 638 """ 639 Return a sorted copy of the given 'values', each having the form (weekday 640 number, instance number) using 'weekdays' to define the ordering of the 641 weekday numbers and 'limit' to determine the positions of negative instance 642 numbers. 643 """ 644 645 weekdays = get_ordered_weekdays(first_day) 646 647 # Expand the values to incorporate specific weekday instances. 648 649 l = [] 650 651 for weekday, index in values: 652 653 # Obtain the last instance number of the weekday in the period. 654 655 limit = get_last_weekday_instance(weekday, first_day, last_day) 656 657 # For specific instances, convert negative selections. 658 659 if index is not None: 660 l.append((weekday, index < 0 and index + 1 + limit or index)) 661 662 # For None, introduce selections for all instances of the weekday. 663 664 else: 665 index = 1 666 while index <= limit: 667 l.append((weekday, index)) 668 index += 1 669 670 # Sort the values so that the resulting dates are ordered. 671 672 fn = lambda x, y, weekdays=weekdays: compare_weekday_selectors(x, y, weekdays) 673 l.sort(cmp=fn) 674 return l 675 676 def convert_positions(setpos): 677 678 "Convert 'setpos' to 0-based indexes." 679 680 l = [] 681 if setpos: 682 for pos in setpos: 683 index = pos < 0 and pos or pos - 1 684 l.append(index) 685 return l 686 687 # Classes for producing instances from recurrence structures. 688 689 class Selector: 690 691 "A generic selector." 692 693 def __init__(self, level, args, qualifier, selecting=None, first=False): 694 695 """ 696 Initialise at the given 'level' a selector employing the given 'args' 697 defined in the interpretation of recurrence rule qualifiers, with the 698 'qualifier' being the name of the rule qualifier, and 'selecting' being 699 an optional selector used to find more specific instances from those 700 found by this selector. 701 """ 702 703 self.level = level 704 self.args = args 705 self.qualifier = qualifier 706 self.selecting = selecting 707 self.first = first 708 709 def __repr__(self): 710 return "%s(%r, %r, %r, %r)" % (self.__class__.__name__, self.level, 711 self.args, self.qualifier, self.first) 712 713 def materialise(self, start, end, count=None, inclusive=False): 714 715 """ 716 Starting at 'start', materialise instances up to but not including any 717 at 'end' or later, returning at most 'count' if specified. A list of 718 instances is returned. 719 720 If 'inclusive' is specified, the selection of instances will include the 721 end of the search period if present in the results. 722 """ 723 724 start = to_tuple(start) 725 end = to_tuple(end) 726 results = self.materialise_items(start, start, end, inclusive) 727 return list(results)[:count] 728 729 class Pattern(Selector): 730 731 "A selector of time periods according to a repeating pattern." 732 733 def __init__(self, level, args, qualifier, selecting=None, first=False): 734 Selector.__init__(self, level, args, qualifier, selecting, first) 735 736 multiple = get_multiple(self.qualifier) 737 interval = self.args.get("interval", 1) 738 739 # Define the step between result periods. 740 741 self.step = scale(interval * multiple, level) 742 743 # Define the scale of a single period. 744 745 self.unit_step = scale(multiple, level) 746 747 def materialise_items(self, context, start, end, inclusive=False): 748 749 """ 750 Bounded by the given 'context', return periods within 'start' to 'end'. 751 752 If 'inclusive' is specified, the selection of periods will include those 753 starting at the end of the search period, if present in the results. 754 """ 755 756 # Employ the context as the current period if this is the first 757 # qualifier in the selection chain. 758 759 if self.first: 760 current = precision(context, self.level) 761 762 # Otherwise, obtain the first value at this resolution within the 763 # context period. 764 765 else: 766 current = precision(context, self.level, firstvalues[self.level]) 767 768 return PatternIterator(self, current, start, end, inclusive, 769 self.step, self.unit_step) 770 771 class WeekDayFilter(Selector): 772 773 "A selector of instances specified in terms of day numbers." 774 775 def __init__(self, level, args, qualifier, selecting=None, first=False): 776 Selector.__init__(self, level, args, qualifier, selecting, first) 777 self.step = scale(1, WEEKS) 778 779 def materialise_items(self, context, start, end, inclusive=False): 780 781 # Get weekdays in the year. 782 783 if is_year_only(context): 784 first_day = start_of_year(context) 785 last_day = end_of_year(context) 786 787 # Get weekdays in the month. 788 789 elif is_month_only(context): 790 first_day = start_of_month(context) 791 last_day = end_of_month(context) 792 793 # Get weekdays in the week. 794 795 else: 796 current = context 797 values = [value for (value, index) in self.args["values"]] 798 return WeekDayIterator(self, current, start, end, inclusive, self.step, values) 799 800 current = first_day 801 values = sort_weekdays(self.args["values"], first_day, last_day) 802 return WeekDayGeneralIterator(self, current, start, end, inclusive, self.step, values) 803 804 class Enum(Selector): 805 806 "A generic value selector." 807 808 def __init__(self, level, args, qualifier, selecting=None, first=False): 809 Selector.__init__(self, level, args, qualifier, selecting, first) 810 self.step = scale(1, level) 811 812 def materialise_items(self, context, start, end, inclusive=False): 813 values = sort_values(self.args["values"]) 814 return EnumIterator(self, context, start, end, inclusive, self.step, values) 815 816 class MonthDayFilter(Enum): 817 818 "A selector of month days." 819 820 def materialise_items(self, context, start, end, inclusive=False): 821 last_day = end_of_month(context)[2] 822 values = sort_values(self.args["values"], last_day) 823 return EnumIterator(self, context, start, end, inclusive, self.step, values) 824 825 class YearDayFilter(Enum): 826 827 "A selector of days in years." 828 829 def materialise_items(self, context, start, end, inclusive=False): 830 first_day = start_of_year(context) 831 year_length = get_year_length(context) 832 values = sort_values(self.args["values"], year_length) 833 return YearDayFilterIterator(self, first_day, start, end, inclusive, self.step, values) 834 835 special_enum_levels = { 836 "BYDAY" : WeekDayFilter, 837 "BYMONTHDAY" : MonthDayFilter, 838 "BYYEARDAY" : YearDayFilter, 839 } 840 841 class LimitSelector(Selector): 842 843 "A result set limit selector." 844 845 def materialise_items(self, context, start, end, inclusive=False): 846 limit = self.args["values"][0] 847 return LimitIterator(self, context, start, end, inclusive, limit) 848 849 class PositionSelector(Selector): 850 851 "A result set position selector." 852 853 def __init__(self, level, args, qualifier, selecting=None, first=False): 854 Selector.__init__(self, level, args, qualifier, selecting, first) 855 self.step = scale(1, level) 856 857 def materialise_items(self, context, start, end, inclusive=False): 858 values = convert_positions(sort_values(self.args["values"])) 859 return PositionIterator(self, context, start, end, inclusive, self.step, values) 860 861 # Iterator classes. 862 863 class SelectorIterator: 864 865 "An iterator over selected data." 866 867 def __init__(self, selector, current, start, end, inclusive): 868 869 """ 870 Define an iterator having the 'current' point in time, 'start' and 'end' 871 limits, and whether the selection is 'inclusive'. 872 """ 873 874 self.selector = selector 875 self.current = current 876 self.start = start 877 self.end = end 878 self.inclusive = inclusive 879 880 # Iterator over selections within this selection. 881 882 self.iterator = None 883 884 def __iter__(self): 885 return self 886 887 def next_item(self, earliest, next): 888 889 """ 890 Given the 'earliest' acceptable instance and the 'next' instance, return 891 a list of result items. 892 893 Where no selection within the current instance occurs, the current 894 instance will be returned as a result if the same or later than the 895 earliest acceptable instance. 896 """ 897 898 # Obtain an item from a selector operating within this selection. 899 900 selecting = self.selector.selecting 901 902 if selecting: 903 904 # Obtain an iterator for any selector within the current period. 905 906 if not self.iterator: 907 self.iterator = selecting.materialise_items(self.current, 908 earliest, next, self.inclusive) 909 910 # Attempt to obtain and return a value. 911 912 return self.iterator.next() 913 914 # Return items within this selection. 915 916 else: 917 return self.current 918 919 def filter_by_period(self, result): 920 921 "Return whether 'result' occurs within the selection period." 922 923 return (self.inclusive and result <= self.end or result < self.end) and \ 924 self.start <= result 925 926 def at_limit(self): 927 928 "Obtain periods before the end (and also at the end if inclusive)." 929 930 return not self.inclusive and self.current == self.end or \ 931 self.current > self.end 932 933 class PatternIterator(SelectorIterator): 934 935 "An iterator for a general selection pattern." 936 937 def __init__(self, selector, current, start, end, inclusive, step, unit_step): 938 SelectorIterator.__init__(self, selector, current, start, end, inclusive) 939 self.step = step 940 self.unit_step = unit_step 941 942 def next(self): 943 944 "Return the next value." 945 946 while not self.at_limit(): 947 948 # Increment the current datetime by the step for the next period. 949 950 next = update(self.current, self.step) 951 952 # Determine the end point of the current period. 953 954 current_end = update(self.current, self.unit_step) 955 956 # Obtain any period or periods within the bounds defined by the 957 # current period and any contraining start and end points. 958 959 try: 960 result = self.next_item(max(self.current, self.start), 961 min(current_end, self.end)) 962 963 # Obtain the next period if not selecting within this period. 964 965 if not self.selector.selecting: 966 self.current = next 967 968 # Filter out periods. 969 970 if self.filter_by_period(result): 971 return result 972 973 # Move on to the next instance. 974 975 except StopIteration: 976 self.current = next 977 self.iterator = None 978 979 raise StopIteration 980 981 class WeekDayIterator(SelectorIterator): 982 983 "An iterator over weekday selections in week periods." 984 985 def __init__(self, selector, current, start, end, inclusive, step, values): 986 SelectorIterator.__init__(self, selector, current, start, end, inclusive) 987 self.step = step 988 self.values = values 989 990 def next(self): 991 992 "Return the next value." 993 994 while not self.at_limit(): 995 996 # Increment the current datetime by the step for the next period. 997 998 next = update(self.current, self.step) 999 1000 # Determine whether the day is one chosen. 1001 1002 if date(*self.current).isoweekday() in self.values: 1003 try: 1004 result = self.next_item(max(self.current, self.start), 1005 min(next, self.end)) 1006 1007 # Obtain the next period if not selecting within this period. 1008 1009 if not self.selector.selecting: 1010 self.current = next 1011 1012 return result 1013 1014 # Move on to the next instance. 1015 1016 except StopIteration: 1017 self.current = next 1018 self.iterator = None 1019 1020 else: 1021 self.current = next 1022 self.iterator = None 1023 1024 raise StopIteration 1025 1026 class EnumIterator(SelectorIterator): 1027 1028 "An iterator over specific value selections." 1029 1030 def __init__(self, selector, current, start, end, inclusive, step, values): 1031 SelectorIterator.__init__(self, selector, current, start, end, inclusive) 1032 self.step = step 1033 1034 # Derive selected periods from a base and the indicated values. 1035 1036 self.base = current 1037 1038 # Iterate over the indicated period values. 1039 1040 self.values = iter(values) 1041 self.value = None 1042 1043 def get_selected_period(self): 1044 1045 "Return the period indicated by the current value." 1046 1047 return precision(self.base, self.selector.level, self.value) 1048 1049 def next(self): 1050 1051 "Return the next value." 1052 1053 while True: 1054 1055 # Find each of the given selected values. 1056 1057 if not self.selector.selecting or self.value is None: 1058 self.value = self.values.next() 1059 1060 # Select a period for each value at the current resolution. 1061 1062 self.current = self.get_selected_period() 1063 next = update(self.current, self.step) 1064 1065 # To support setpos, only current and next bound the search, not 1066 # the period in addition. 1067 1068 try: 1069 return self.next_item(self.current, next) 1070 1071 # Move on to the next instance. 1072 1073 except StopIteration: 1074 self.value = None 1075 self.iterator = None 1076 1077 raise StopIteration 1078 1079 class WeekDayGeneralIterator(EnumIterator): 1080 1081 "An iterator over weekday selections in month and year periods." 1082 1083 def get_selected_period(self): 1084 1085 "Return the day indicated by the current day value." 1086 1087 value, index = self.value 1088 offset = timedelta(7 * (index - 1)) 1089 weekday0 = get_first_day(self.base, value) 1090 return precision(to_tuple(weekday0 + offset), DAYS) 1091 1092 class YearDayFilterIterator(EnumIterator): 1093 1094 "An iterator over day-in-year selections." 1095 1096 def get_selected_period(self): 1097 1098 "Return the day indicated by the current day value." 1099 1100 offset = timedelta(self.value - 1) 1101 return precision(to_tuple(date(*self.base) + offset), DAYS) 1102 1103 class LimitIterator(SelectorIterator): 1104 1105 "A result set limiting iterator." 1106 1107 def __init__(self, selector, context, start, end, inclusive, limit): 1108 SelectorIterator.__init__(self, selector, context, start, end, inclusive) 1109 self.limit = limit 1110 self.count = 0 1111 1112 def next(self): 1113 1114 "Return the next value." 1115 1116 if self.count < self.limit: 1117 self.count += 1 1118 result = self.next_item(self.start, self.end) 1119 return result 1120 1121 raise StopIteration 1122 1123 class PositionIterator(SelectorIterator): 1124 1125 "An iterator over results, selecting positions." 1126 1127 def __init__(self, selector, context, start, end, inclusive, step, positions): 1128 SelectorIterator.__init__(self, selector, context, start, end, inclusive) 1129 self.step = step 1130 1131 # Positions to select. 1132 1133 self.positions = positions 1134 1135 # Queue to hold values matching the negative position values. 1136 1137 self.queue_length = positions and positions[0] < 0 and abs(positions[0]) or 0 1138 self.queue = [] 1139 1140 # Result position. 1141 1142 self.resultpos = 0 1143 1144 def next(self): 1145 1146 "Return the next value." 1147 1148 while True: 1149 try: 1150 result = self.next_item(self.start, self.end) 1151 1152 # Positive positions can have their values released immediately. 1153 1154 selected = self.resultpos in self.positions 1155 self.resultpos += 1 1156 1157 if selected: 1158 return result 1159 1160 # Negative positions must be held until this iterator completes and 1161 # then be released. 1162 1163 if self.queue_length: 1164 self.queue.append(result) 1165 if len(self.queue) > self.queue_length: 1166 del self.queue[0] 1167 1168 except StopIteration: 1169 1170 # With a queue and positions, attempt to find the referenced 1171 # positions. 1172 1173 if self.queue and self.positions: 1174 index = self.positions[0] 1175 del self.positions[0] 1176 1177 # Only negative positions are used at this point. 1178 1179 if index < 0: 1180 try: 1181 return self.queue[index] 1182 except IndexError: 1183 pass 1184 1185 # With only positive positions remaining, signal the end of 1186 # the collection. 1187 1188 else: 1189 raise 1190 1191 # With no queue or positions remaining, signal the end of the 1192 # collection. 1193 1194 else: 1195 raise 1196 1197 # Public functions. 1198 1199 def connect_selectors(selectors): 1200 1201 """ 1202 Make the 'selectors' reference each other in a hierarchy so that 1203 materialising the principal selector causes the more specific ones to be 1204 employed in the operation. 1205 """ 1206 1207 current = selectors[0] 1208 current.first = first = True 1209 1210 for selector in selectors[1:]: 1211 current.selecting = selector 1212 1213 # Allow selectors within the limit selector to act as if they are first 1214 # in the chain and will operate using the supplied datetime context. 1215 1216 first = isinstance(current, LimitSelector) 1217 1218 current = selector 1219 current.first = first 1220 1221 return selectors[0] 1222 1223 def get_selector(dt, qualifiers): 1224 1225 """ 1226 Combine the initial datetime 'dt' with the given 'qualifiers', returning an 1227 object that can be used to select recurrences described by the 'qualifiers'. 1228 """ 1229 1230 dt = to_tuple(dt) 1231 return connect_selectors(combine_datetime_with_qualifiers(dt, qualifiers)) 1232 1233 def get_rule(dt, rule): 1234 1235 """ 1236 Using the given initial datetime 'dt', interpret the 'rule' (a semicolon- 1237 separated collection of "key=value" strings), and return the resulting 1238 selector object. 1239 """ 1240 1241 if not isinstance(rule, tuple): 1242 rule = rule.split(";") 1243 qualifiers = get_qualifiers(rule) 1244 return get_selector(dt, qualifiers) 1245 1246 # vim: tabstop=4 expandtab shiftwidth=4