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 select(self, start, end, inclusive=False): 714 715 """ 716 Return an iterator over instances starting at 'start' and continuing up 717 to but not including any at 'end' or later. 718 719 If 'inclusive' is specified, the selection of instances will include the 720 end of the search period if present in the results. 721 """ 722 723 start = to_tuple(start) 724 end = to_tuple(end) 725 return self.materialise_items(start, start, end, inclusive) 726 727 def materialise(self, start, end, inclusive=False): 728 729 """ 730 Starting at 'start', materialise instances up to but not including any 731 at 'end' or later. A list of instances is returned. 732 733 If 'inclusive' is specified, the selection of instances will include the 734 end of the search period if present in the results. 735 """ 736 737 return list(self.select(start, end, inclusive)) 738 739 class Pattern(Selector): 740 741 "A selector of time periods according to a repeating pattern." 742 743 def __init__(self, level, args, qualifier, selecting=None, first=False): 744 Selector.__init__(self, level, args, qualifier, selecting, first) 745 746 multiple = get_multiple(self.qualifier) 747 interval = self.args.get("interval", 1) 748 749 # Define the step between result periods. 750 751 self.step = scale(interval * multiple, level) 752 753 # Define the scale of a single period. 754 755 self.unit_step = scale(multiple, level) 756 757 def materialise_items(self, context, start, end, inclusive=False): 758 759 """ 760 Bounded by the given 'context', return periods within 'start' to 'end'. 761 762 If 'inclusive' is specified, the selection of periods will include those 763 starting at the end of the search period, if present in the results. 764 """ 765 766 # Employ the context as the current period if this is the first 767 # qualifier in the selection chain. 768 769 if self.first: 770 current = precision(context, self.level) 771 772 # Otherwise, obtain the first value at this resolution within the 773 # context period. 774 775 else: 776 current = precision(context, self.level, firstvalues[self.level]) 777 778 return PatternIterator(self, current, start, end, inclusive, 779 self.step, self.unit_step) 780 781 class WeekDayFilter(Selector): 782 783 "A selector of instances specified in terms of day numbers." 784 785 def __init__(self, level, args, qualifier, selecting=None, first=False): 786 Selector.__init__(self, level, args, qualifier, selecting, first) 787 self.step = scale(1, WEEKS) 788 789 def materialise_items(self, context, start, end, inclusive=False): 790 791 # Get weekdays in the year. 792 793 if is_year_only(context): 794 first_day = start_of_year(context) 795 last_day = end_of_year(context) 796 797 # Get weekdays in the month. 798 799 elif is_month_only(context): 800 first_day = start_of_month(context) 801 last_day = end_of_month(context) 802 803 # Get weekdays in the week. 804 805 else: 806 current = context 807 values = [value for (value, index) in self.args["values"]] 808 return WeekDayIterator(self, current, start, end, inclusive, self.step, values) 809 810 current = first_day 811 values = sort_weekdays(self.args["values"], first_day, last_day) 812 return WeekDayGeneralIterator(self, current, start, end, inclusive, self.step, values) 813 814 class Enum(Selector): 815 816 "A generic value selector." 817 818 def __init__(self, level, args, qualifier, selecting=None, first=False): 819 Selector.__init__(self, level, args, qualifier, selecting, first) 820 self.step = scale(1, level) 821 822 def materialise_items(self, context, start, end, inclusive=False): 823 values = sort_values(self.args["values"]) 824 return EnumIterator(self, context, start, end, inclusive, self.step, values) 825 826 class MonthDayFilter(Enum): 827 828 "A selector of month days." 829 830 def materialise_items(self, context, start, end, inclusive=False): 831 last_day = end_of_month(context)[2] 832 values = sort_values(self.args["values"], last_day) 833 return EnumIterator(self, context, start, end, inclusive, self.step, values) 834 835 class YearDayFilter(Enum): 836 837 "A selector of days in years." 838 839 def materialise_items(self, context, start, end, inclusive=False): 840 first_day = start_of_year(context) 841 year_length = get_year_length(context) 842 values = sort_values(self.args["values"], year_length) 843 return YearDayFilterIterator(self, first_day, start, end, inclusive, self.step, values) 844 845 special_enum_levels = { 846 "BYDAY" : WeekDayFilter, 847 "BYMONTHDAY" : MonthDayFilter, 848 "BYYEARDAY" : YearDayFilter, 849 } 850 851 class LimitSelector(Selector): 852 853 "A result set limit selector." 854 855 def materialise_items(self, context, start, end, inclusive=False): 856 limit = self.args["values"][0] 857 return LimitIterator(self, context, start, end, inclusive, limit) 858 859 class PositionSelector(Selector): 860 861 "A result set position selector." 862 863 def __init__(self, level, args, qualifier, selecting=None, first=False): 864 Selector.__init__(self, level, args, qualifier, selecting, first) 865 self.step = scale(1, level) 866 867 def materialise_items(self, context, start, end, inclusive=False): 868 values = convert_positions(sort_values(self.args["values"])) 869 return PositionIterator(self, context, start, end, inclusive, self.step, values) 870 871 # Iterator classes. 872 873 class SelectorIterator: 874 875 "An iterator over selected data." 876 877 def __init__(self, selector, current, start, end, inclusive): 878 879 """ 880 Define an iterator having the 'current' point in time, 'start' and 'end' 881 limits, and whether the selection is 'inclusive'. 882 """ 883 884 self.selector = selector 885 self.current = current 886 self.start = start 887 self.end = end 888 self.inclusive = inclusive 889 890 # Iterator over selections within this selection. 891 892 self.iterator = None 893 894 def __iter__(self): 895 return self 896 897 def next_item(self, earliest, next): 898 899 """ 900 Given the 'earliest' acceptable instance and the 'next' instance, return 901 a list of result items. 902 903 Where no selection within the current instance occurs, the current 904 instance will be returned as a result if the same or later than the 905 earliest acceptable instance. 906 """ 907 908 # Obtain an item from a selector operating within this selection. 909 910 selecting = self.selector.selecting 911 912 if selecting: 913 914 # Obtain an iterator for any selector within the current period. 915 916 if not self.iterator: 917 self.iterator = selecting.materialise_items(self.current, 918 earliest, next, self.inclusive) 919 920 # Attempt to obtain and return a value. 921 922 return self.iterator.next() 923 924 # Return items within this selection. 925 926 else: 927 return self.current 928 929 def filter_by_period(self, result): 930 931 "Return whether 'result' occurs within the selection period." 932 933 return (self.inclusive and result <= self.end or result < self.end) and \ 934 self.start <= result 935 936 def at_limit(self): 937 938 "Obtain periods before the end (and also at the end if inclusive)." 939 940 return not self.inclusive and self.current == self.end or \ 941 self.current > self.end 942 943 class PatternIterator(SelectorIterator): 944 945 "An iterator for a general selection pattern." 946 947 def __init__(self, selector, current, start, end, inclusive, step, unit_step): 948 SelectorIterator.__init__(self, selector, current, start, end, inclusive) 949 self.step = step 950 self.unit_step = unit_step 951 952 def next(self): 953 954 "Return the next value." 955 956 while not self.at_limit(): 957 958 # Increment the current datetime by the step for the next period. 959 960 next = update(self.current, self.step) 961 962 # Determine the end point of the current period. 963 964 current_end = update(self.current, self.unit_step) 965 966 # Obtain any period or periods within the bounds defined by the 967 # current period and any contraining start and end points. 968 969 try: 970 result = self.next_item(max(self.current, self.start), 971 min(current_end, self.end)) 972 973 # Obtain the next period if not selecting within this period. 974 975 if not self.selector.selecting: 976 self.current = next 977 978 # Filter out periods. 979 980 if self.filter_by_period(result): 981 return result 982 983 # Move on to the next instance. 984 985 except StopIteration: 986 self.current = next 987 self.iterator = None 988 989 raise StopIteration 990 991 class WeekDayIterator(SelectorIterator): 992 993 "An iterator over weekday selections in week periods." 994 995 def __init__(self, selector, current, start, end, inclusive, step, values): 996 SelectorIterator.__init__(self, selector, current, start, end, inclusive) 997 self.step = step 998 self.values = values 999 1000 def next(self): 1001 1002 "Return the next value." 1003 1004 while not self.at_limit(): 1005 1006 # Increment the current datetime by the step for the next period. 1007 1008 next = update(self.current, self.step) 1009 1010 # Determine whether the day is one chosen. 1011 1012 if date(*self.current).isoweekday() in self.values: 1013 try: 1014 result = self.next_item(max(self.current, self.start), 1015 min(next, self.end)) 1016 1017 # Obtain the next period if not selecting within this period. 1018 1019 if not self.selector.selecting: 1020 self.current = next 1021 1022 return result 1023 1024 # Move on to the next instance. 1025 1026 except StopIteration: 1027 self.current = next 1028 self.iterator = None 1029 1030 else: 1031 self.current = next 1032 self.iterator = None 1033 1034 raise StopIteration 1035 1036 class EnumIterator(SelectorIterator): 1037 1038 "An iterator over specific value selections." 1039 1040 def __init__(self, selector, current, start, end, inclusive, step, values): 1041 SelectorIterator.__init__(self, selector, current, start, end, inclusive) 1042 self.step = step 1043 1044 # Derive selected periods from a base and the indicated values. 1045 1046 self.base = current 1047 1048 # Iterate over the indicated period values. 1049 1050 self.values = iter(values) 1051 self.value = None 1052 1053 def get_selected_period(self): 1054 1055 "Return the period indicated by the current value." 1056 1057 return precision(self.base, self.selector.level, self.value) 1058 1059 def next(self): 1060 1061 "Return the next value." 1062 1063 while True: 1064 1065 # Find each of the given selected values. 1066 1067 if not self.selector.selecting or self.value is None: 1068 self.value = self.values.next() 1069 1070 # Select a period for each value at the current resolution. 1071 1072 self.current = self.get_selected_period() 1073 next = update(self.current, self.step) 1074 1075 # To support setpos, only current and next bound the search, not 1076 # the period in addition. 1077 1078 try: 1079 return self.next_item(self.current, next) 1080 1081 # Move on to the next instance. 1082 1083 except StopIteration: 1084 self.value = None 1085 self.iterator = None 1086 1087 raise StopIteration 1088 1089 class WeekDayGeneralIterator(EnumIterator): 1090 1091 "An iterator over weekday selections in month and year periods." 1092 1093 def get_selected_period(self): 1094 1095 "Return the day indicated by the current day value." 1096 1097 value, index = self.value 1098 offset = timedelta(7 * (index - 1)) 1099 weekday0 = get_first_day(self.base, value) 1100 return precision(to_tuple(weekday0 + offset), DAYS) 1101 1102 class YearDayFilterIterator(EnumIterator): 1103 1104 "An iterator over day-in-year selections." 1105 1106 def get_selected_period(self): 1107 1108 "Return the day indicated by the current day value." 1109 1110 offset = timedelta(self.value - 1) 1111 return precision(to_tuple(date(*self.base) + offset), DAYS) 1112 1113 class LimitIterator(SelectorIterator): 1114 1115 "A result set limiting iterator." 1116 1117 def __init__(self, selector, context, start, end, inclusive, limit): 1118 SelectorIterator.__init__(self, selector, context, start, end, inclusive) 1119 self.limit = limit 1120 self.count = 0 1121 1122 def next(self): 1123 1124 "Return the next value." 1125 1126 if self.count < self.limit: 1127 self.count += 1 1128 result = self.next_item(self.start, self.end) 1129 return result 1130 1131 raise StopIteration 1132 1133 class PositionIterator(SelectorIterator): 1134 1135 "An iterator over results, selecting positions." 1136 1137 def __init__(self, selector, context, start, end, inclusive, step, positions): 1138 SelectorIterator.__init__(self, selector, context, start, end, inclusive) 1139 self.step = step 1140 1141 # Positions to select. 1142 1143 self.positions = positions 1144 1145 # Queue to hold values matching the negative position values. 1146 1147 self.queue_length = positions and positions[0] < 0 and abs(positions[0]) or 0 1148 self.queue = [] 1149 1150 # Result position. 1151 1152 self.resultpos = 0 1153 1154 def next(self): 1155 1156 "Return the next value." 1157 1158 while True: 1159 try: 1160 result = self.next_item(self.start, self.end) 1161 1162 # Positive positions can have their values released immediately. 1163 1164 selected = self.resultpos in self.positions 1165 self.resultpos += 1 1166 1167 if selected: 1168 return result 1169 1170 # Negative positions must be held until this iterator completes and 1171 # then be released. 1172 1173 if self.queue_length: 1174 self.queue.append(result) 1175 if len(self.queue) > self.queue_length: 1176 del self.queue[0] 1177 1178 except StopIteration: 1179 1180 # With a queue and positions, attempt to find the referenced 1181 # positions. 1182 1183 if self.queue and self.positions: 1184 index = self.positions[0] 1185 del self.positions[0] 1186 1187 # Only negative positions are used at this point. 1188 1189 if index < 0: 1190 try: 1191 return self.queue[index] 1192 except IndexError: 1193 pass 1194 1195 # With only positive positions remaining, signal the end of 1196 # the collection. 1197 1198 else: 1199 raise 1200 1201 # With no queue or positions remaining, signal the end of the 1202 # collection. 1203 1204 else: 1205 raise 1206 1207 # Public functions. 1208 1209 def connect_selectors(selectors): 1210 1211 """ 1212 Make the 'selectors' reference each other in a hierarchy so that 1213 materialising the principal selector causes the more specific ones to be 1214 employed in the operation. 1215 """ 1216 1217 current = selectors[0] 1218 current.first = first = True 1219 1220 for selector in selectors[1:]: 1221 current.selecting = selector 1222 1223 # Allow selectors within the limit selector to act as if they are first 1224 # in the chain and will operate using the supplied datetime context. 1225 1226 first = isinstance(current, LimitSelector) 1227 1228 current = selector 1229 current.first = first 1230 1231 return selectors[0] 1232 1233 def get_selector(dt, qualifiers): 1234 1235 """ 1236 Combine the initial datetime 'dt' with the given 'qualifiers', returning an 1237 object that can be used to select recurrences described by the 'qualifiers'. 1238 """ 1239 1240 dt = to_tuple(dt) 1241 return connect_selectors(combine_datetime_with_qualifiers(dt, qualifiers)) 1242 1243 def get_rule(dt, rule): 1244 1245 """ 1246 Using the given initial datetime 'dt', interpret the 'rule' (a semicolon- 1247 separated collection of "key=value" strings), and return the resulting 1248 selector object. 1249 """ 1250 1251 if not isinstance(rule, tuple): 1252 rule = rule.split(";") 1253 qualifiers = get_qualifiers(rule) 1254 return get_selector(dt, qualifiers) 1255 1256 # vim: tabstop=4 expandtab shiftwidth=4