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 parts = value.split("=", 1) 145 if len(parts) < 2: 146 continue 147 key, value = parts 148 if key in ("COUNT", "BYSETPOS"): 149 d[key] = int(value) 150 else: 151 d[key] = value 152 return d 153 154 def get_qualifiers(values): 155 156 """ 157 Process the list of 'values' of the form "key=value", returning a list of 158 qualifiers of the form (qualifier name, args). 159 """ 160 161 qualifiers = [] 162 frequency = None 163 interval = 1 164 165 for value in values: 166 parts = value.split("=", 1) 167 if len(parts) < 2: 168 continue 169 key, value = parts 170 171 # Accept frequency indicators as qualifiers. 172 173 if key == "FREQ" and freq.has_key(value): 174 qualifier = frequency = (value, {}) 175 176 # Accept interval indicators for frequency qualifier parameterisation. 177 178 elif key == "INTERVAL": 179 interval = int(value) 180 continue 181 182 # Accept enumerators as qualifiers. 183 184 elif enum.has_key(key): 185 qualifier = (key, {"values" : get_qualifier_values(key, value)}) 186 187 # Ignore other items. 188 189 else: 190 continue 191 192 qualifiers.append(qualifier) 193 194 # Parameterise any frequency qualifier with the interval. 195 196 if frequency: 197 frequency[1]["interval"] = interval 198 199 return qualifiers 200 201 def get_qualifier_values(qualifier, value): 202 203 """ 204 For the given 'qualifier', process the 'value' string, returning a list of 205 suitable values. 206 """ 207 208 # For non-weekday selection, obtain a list of day numbers. 209 210 if qualifier != "BYDAY": 211 return map(int, value.split(",")) 212 213 # For weekday selection, obtain the weekday number and instance number. 214 215 values = [] 216 217 for part in value.split(","): 218 weekday = weekdays.get(part[-2:]) 219 if not weekday: 220 continue 221 index = part[:-2] 222 if index: 223 index = int(index) 224 else: 225 index = None 226 values.append((weekday, index)) 227 228 return values 229 230 def order_qualifiers(qualifiers): 231 232 "Return the 'qualifiers' in order of increasing resolution." 233 234 l = [] 235 236 for qualifier, args in qualifiers: 237 238 # Distinguish between enumerators, used to select particular periods, 239 # and frequencies, used to select repeating periods. 240 241 if enum.has_key(qualifier): 242 level = enum[qualifier] 243 if special_enum_levels.has_key(qualifier): 244 args["interval"] = 1 245 selector = special_enum_levels[qualifier] 246 else: 247 selector = Enum 248 else: 249 level = freq[qualifier] 250 selector = Pattern 251 252 l.append(selector(level, args, qualifier)) 253 254 l.sort(key=lambda x: x.level) 255 return l 256 257 def get_datetime_structure(datetime): 258 259 "Return the structure of 'datetime' for recurrence production." 260 261 l = [] 262 offset = 0 263 264 for pos, value in enumerate(datetime): 265 266 # At the day number, adjust the frequency level offset to reference 267 # days (and then hours, minutes, seconds). 268 269 if pos == 2: 270 offset = 3 271 272 l.append(Enum(pos + offset, {"values" : [value]}, "DT")) 273 274 return l 275 276 def combine_datetime_with_qualifiers(datetime, qualifiers): 277 278 """ 279 Combine 'datetime' with 'qualifiers' to produce a structure for recurrence 280 production. 281 282 Initial datetime values appearing at broader resolutions than any qualifiers 283 are ignored, since their details will be used when materialising the 284 results. 285 286 Qualifiers are accumulated in order to define a selection. Datetime values 287 occurring between qualifiers or at the same resolution as qualifiers are 288 ignored. 289 290 Any remaining datetime values are introduced as enumerators, provided that 291 they do not conflict with qualifiers. For example, specific day values 292 conflict with day selectors and weekly qualifiers. 293 294 The purpose of the remaining datetime values is to define a result within 295 a period selected by the most precise qualifier. For example, the selection 296 of a day and month in a year recurrence. 297 """ 298 299 iter_dt = iter(get_datetime_structure(datetime)) 300 iter_q = iter(order_qualifiers(qualifiers)) 301 302 l = [] 303 304 from_dt = get_next(iter_dt) 305 from_q = get_next(iter_q) 306 have_q = False 307 308 # Consume from both lists, merging entries. 309 310 while from_dt and from_q: 311 _level = from_dt.level 312 level = from_q.level 313 314 # Datetime value at wider resolution. 315 316 if _level < level: 317 from_dt = get_next(iter_dt) 318 319 # Qualifier at wider or same resolution as datetime value. 320 321 else: 322 if not have_q: 323 add_initial_qualifier(from_q, level, l) 324 have_q = True 325 326 # Add the qualifier to the combined list. 327 328 l.append(from_q) 329 330 # Datetime value at same resolution. 331 332 if _level == level: 333 from_dt = get_next(iter_dt) 334 335 # Get the next qualifier. 336 337 from_q = get_next(iter_q) 338 339 # Complete the list by adding remaining datetime enumerators. 340 341 while from_dt: 342 343 # Ignore datetime values that conflict with day-level qualifiers. 344 345 if not l or from_dt.level != freq["DAILY"] or \ 346 l[-1].level not in daylevels: 347 348 l.append(from_dt) 349 350 from_dt = get_next(iter_dt) 351 352 # Complete the list by adding remaining qualifiers. 353 354 while from_q: 355 if not have_q: 356 add_initial_qualifier(from_q, level, l) 357 have_q = True 358 359 # Add the qualifier to the combined list. 360 361 l.append(from_q) 362 363 # Get the next qualifier. 364 365 from_q = get_next(iter_q) 366 367 return l 368 369 def add_initial_qualifier(from_q, level, l): 370 371 """ 372 Take the first qualifier 'from_q' at the given resolution 'level', using it 373 to create an initial qualifier, adding it to the combined list 'l' if 374 required. 375 """ 376 377 if isinstance(from_q, Enum) and level > 0: 378 repeat = Pattern(level - 1, {"interval" : 1}, None) 379 l.append(repeat) 380 381 def get_multiple(qualifier): 382 383 "Return the time unit multiple for 'qualifier'." 384 385 return multiples.get(qualifier, 1) 386 387 # Datetime arithmetic. 388 389 def is_year_only(t): 390 391 "Return if 't' describes a year." 392 393 return len(t) == lengths[YEARS] 394 395 def is_month_only(t): 396 397 "Return if 't' describes a month within a year." 398 399 return len(t) == lengths[MONTHS] 400 401 def start_of_year(t): 402 403 "Return the start of the year referenced by 't'." 404 405 return (t[0], 1, 1) 406 407 def end_of_year(t): 408 409 "Return the end of the year referenced by 't'." 410 411 return (t[0], 12, 31) 412 413 def start_of_month(t): 414 415 "Return the start of the month referenced by 't'." 416 417 year, month = t[:2] 418 return (year, month, 1) 419 420 def end_of_month(t): 421 422 "Return the end of the month referenced by 't'." 423 424 year, month = t[:2] 425 return update(update((year, month, 1), (0, 1, 0)), (0, 0, -1)) 426 427 def day_in_year(t, number): 428 429 "Return the day in the year referenced by 't' with the given 'number'." 430 431 return to_tuple(date(*start_of_year(t)) + timedelta(number - 1)) 432 433 def get_year_length(t): 434 435 "Return the length of the year referenced by 't'." 436 437 first_day = date(*start_of_year(t)) 438 last_day = date(*end_of_year(t)) 439 return (last_day - first_day).days + 1 440 441 def get_weekday(t): 442 443 "Return the 1-based weekday for the month referenced by 't'." 444 445 year, month = t[:2] 446 return monthrange(year, month)[0] + 1 447 448 def get_ordered_weekdays(t): 449 450 """ 451 Return the 1-based weekday sequence describing the first week of the month 452 referenced by 't'. 453 """ 454 455 first = get_weekday(t) 456 return range(first, 8) + range(1, first) 457 458 def get_last_weekday_instance(weekday, first_day, last_day): 459 460 """ 461 Return the last instance number for 'weekday' within the period from 462 'first_day' to 'last_day' inclusive. 463 464 Here, 'weekday' is 1-based (starting on Monday), the returned limit is 465 1-based. 466 """ 467 468 weekday0 = get_first_day(first_day, weekday) 469 days = (date(*last_day) - weekday0).days 470 return days / 7 + 1 471 472 def precision(t, level, value=None): 473 474 """ 475 Return 't' trimmed in resolution to the indicated resolution 'level', 476 setting 'value' at the given resolution if indicated. 477 """ 478 479 pos = positions[level] 480 481 if value is None: 482 return t[:pos + 1] 483 else: 484 return t[:pos] + (value,) 485 486 def scale(interval, level): 487 488 """ 489 Scale the given 'interval' value in resolution to the indicated resolution 490 'level', returning a tuple with leading zero elements and 'interval' at the 491 stated position. 492 """ 493 494 pos = positions[level] 495 return (0,) * pos + (interval,) 496 497 def get_seconds(t): 498 499 "Convert the sub-day part of 't' into seconds." 500 501 t = t + (0,) * (6 - len(t)) 502 return (t[3] * 60 + t[4]) * 60 + t[5] 503 504 def update(t, step): 505 506 "Update 't' by 'step' at the resolution of 'step'." 507 508 i = len(step) 509 510 # Years only. 511 512 if i == 1: 513 return (t[0] + step[0],) + tuple(t[1:]) 514 515 # Years and months. 516 517 elif i == 2: 518 month = t[1] + step[1] 519 return (t[0] + step[0] + (month - 1) / 12, (month - 1) % 12 + 1) + tuple(t[2:]) 520 521 # Dates and datetimes. 522 523 else: 524 updated_for_months = update(t, step[:2]) 525 526 # Dates only. 527 528 if i == 3: 529 d = datetime(*updated_for_months) 530 s = timedelta(step[2]) 531 532 # Datetimes. 533 534 else: 535 d = datetime(*updated_for_months) 536 s = timedelta(step[2], get_seconds(step)) 537 538 return to_tuple(d + s)[:len(t)] 539 540 def to_tuple(d): 541 542 "Return date or datetime 'd' as a tuple." 543 544 if not isinstance(d, date): 545 return d 546 if isinstance(d, datetime): 547 n = 6 548 else: 549 n = 3 550 return d.timetuple()[:n] 551 552 def get_first_day(first_day, weekday): 553 554 """ 555 Return the first occurrence at or after 'first_day' of 'weekday' as a date 556 instance. 557 """ 558 559 first_day = date(*first_day) 560 first_weekday = first_day.isoweekday() 561 if first_weekday > weekday: 562 return first_day + timedelta(7 - first_weekday + weekday) 563 else: 564 return first_day + timedelta(weekday - first_weekday) 565 566 def get_last_day(last_day, weekday): 567 568 """ 569 Return the last occurrence at or before 'last_day' of 'weekday' as a date 570 instance. 571 """ 572 573 last_day = date(*last_day) 574 last_weekday = last_day.isoweekday() 575 if last_weekday < weekday: 576 return last_day - timedelta(last_weekday + 7 - weekday) 577 else: 578 return last_day - timedelta(last_weekday - weekday) 579 580 # Value expansion and sorting. 581 582 def sort_values(values, limit=None): 583 584 """ 585 Sort the given 'values' using 'limit' to determine the positions of negative 586 values. 587 """ 588 589 # Convert negative values to positive values according to the value limit. 590 591 if limit is not None: 592 l = map(lambda x, limit=limit: x < 0 and x + 1 + limit or x, values) 593 else: 594 l = values[:] 595 596 l.sort() 597 return l 598 599 def compare_weekday_selectors(x, y, weekdays): 600 601 """ 602 Compare 'x' and 'y' values of the form (weekday number, instance number) 603 using 'weekdays' to define an ordering of the weekday numbers. 604 """ 605 606 return cmp((x[1], weekdays.index(x[0])), (y[1], weekdays.index(y[0]))) 607 608 def sort_weekdays(values, first_day, last_day): 609 610 """ 611 Return a sorted copy of the given 'values', each having the form (weekday 612 number, instance number) using 'weekdays' to define the ordering of the 613 weekday numbers and 'limit' to determine the positions of negative instance 614 numbers. 615 """ 616 617 weekdays = get_ordered_weekdays(first_day) 618 619 # Expand the values to incorporate specific weekday instances. 620 621 l = [] 622 623 for weekday, index in values: 624 625 # Obtain the last instance number of the weekday in the period. 626 627 limit = get_last_weekday_instance(weekday, first_day, last_day) 628 629 # For specific instances, convert negative selections. 630 631 if index is not None: 632 l.append((weekday, index < 0 and index + 1 + limit or index)) 633 634 # For None, introduce selections for all instances of the weekday. 635 636 else: 637 index = 1 638 while index <= limit: 639 l.append((weekday, index)) 640 index += 1 641 642 # Sort the values so that the resulting dates are ordered. 643 644 fn = lambda x, y, weekdays=weekdays: compare_weekday_selectors(x, y, weekdays) 645 l.sort(cmp=fn) 646 return l 647 648 # Classes for producing instances from recurrence structures. 649 650 class Selector: 651 652 "A generic selector." 653 654 def __init__(self, level, args, qualifier, selecting=None, first=False): 655 656 """ 657 Initialise at the given 'level' a selector employing the given 'args' 658 defined in the interpretation of recurrence rule qualifiers, with the 659 'qualifier' being the name of the rule qualifier, and 'selecting' being 660 an optional selector used to find more specific instances from those 661 found by this selector. 662 """ 663 664 self.level = level 665 self.args = args 666 self.qualifier = qualifier 667 self.selecting = selecting 668 self.first = first 669 670 def __repr__(self): 671 return "%s(%r, %r, %r, %r)" % (self.__class__.__name__, self.level, 672 self.args, self.qualifier, self.first) 673 674 def materialise(self, start, end, count=None, setpos=None, inclusive=False): 675 676 """ 677 Starting at 'start', materialise instances up to but not including any 678 at 'end' or later, returning at most 'count' if specified, and returning 679 only the occurrences indicated by 'setpos' if specified. A list of 680 instances is returned. 681 682 If 'inclusive' is specified, the selection of instances will include the 683 end of the search period if present in the results. 684 """ 685 686 start = to_tuple(start) 687 end = to_tuple(end) 688 counter = Counter(count) 689 results = self.materialise_items(start, start, end, counter, setpos, inclusive) 690 return results[:count] 691 692 def materialise_item(self, current, earliest, next, counter, setpos=None, inclusive=False): 693 694 """ 695 Given the 'current' instance, the 'earliest' acceptable instance, the 696 'next' instance, an instance 'counter', and the optional 'setpos' 697 criteria, return a list of result items. Where no selection within the 698 current instance occurs, the current instance will be returned as a 699 result if the same or later than the earliest acceptable instance. 700 """ 701 702 if self.selecting: 703 return self.selecting.materialise_items(current, earliest, next, 704 counter, setpos, inclusive) 705 elif earliest <= current: 706 return [current] 707 else: 708 return [] 709 710 def convert_positions(self, setpos): 711 712 "Convert 'setpos' to 0-based indexes." 713 714 l = [] 715 for pos in setpos: 716 index = pos < 0 and pos or pos - 1 717 l.append(index) 718 return l 719 720 def select_positions(self, results, setpos): 721 722 "Select in 'results' the 1-based positions given by 'setpos'." 723 724 results.sort() 725 l = [] 726 for index in self.convert_positions(setpos): 727 l.append(results[index]) 728 return l 729 730 def filter_by_period(self, result, start, end, inclusive): 731 732 "Return whether 'result' occurs at or after 'start' and before 'end'." 733 734 return start <= result and (inclusive and result <= end or result < end) 735 736 class Pattern(Selector): 737 738 "A selector of time periods according to a repeating pattern." 739 740 def materialise_items(self, context, start, end, counter, setpos=None, inclusive=False): 741 742 """ 743 Bounded by the given 'context', return periods within 'start' to 'end', 744 updating the 'counter', selecting only the indexes specified by 'setpos' 745 (if given). 746 747 If 'inclusive' is specified, the selection of periods will include those 748 starting at the end of the search period, if present in the results. 749 """ 750 751 # Define the step between result periods. 752 753 multiple = get_multiple(self.qualifier) 754 interval = self.args.get("interval", 1) * multiple 755 step = scale(interval, self.level) 756 757 # Define the scale of a single period. 758 759 unit_step = scale(multiple, self.level) 760 761 # Employ the context as the current period if this is the first 762 # qualifier in the selection chain. 763 764 if self.first: 765 current = precision(context, self.level) 766 767 # Otherwise, obtain the first value at this resolution within the 768 # context period. 769 770 else: 771 current = precision(context, self.level, firstvalues[self.level]) 772 773 results = [] 774 775 # Obtain periods before the end (and also at the end if inclusive), 776 # provided that any limit imposed by the counter has not been exceeded. 777 778 while (inclusive and current <= end or current < end) and \ 779 not counter.at_limit(): 780 781 # Increment the current datetime by the step for the next period. 782 783 next = update(current, step) 784 785 # Determine the end point of the current period. 786 787 current_end = update(current, unit_step) 788 789 # Obtain any period or periods within the bounds defined by the 790 # current period and any contraining start and end points, plus 791 # counter, setpos and inclusive details. 792 793 interval_results = self.materialise_item(current, 794 max(current, start), min(current_end, end), 795 counter, setpos, inclusive) 796 797 # Update the counter with the number of identified results. 798 799 counter += len(interval_results) 800 801 # Accumulate the results. 802 803 results += interval_results 804 805 # Visit the next instance. 806 807 current = next 808 809 return results 810 811 class WeekDayFilter(Selector): 812 813 "A selector of instances specified in terms of day numbers." 814 815 def materialise_items(self, context, start, end, counter, setpos=None, inclusive=False): 816 step = scale(1, WEEKS) 817 results = [] 818 819 # Get weekdays in the year. 820 821 if is_year_only(context): 822 first_day = start_of_year(context) 823 last_day = end_of_year(context) 824 825 # Get weekdays in the month. 826 827 elif is_month_only(context): 828 first_day = start_of_month(context) 829 last_day = end_of_month(context) 830 831 # Get weekdays in the week. 832 833 else: 834 current = context 835 values = [value for (value, index) in self.args["values"]] 836 837 while (inclusive and current <= end or current < end): 838 next = update(current, step) 839 840 if date(*current).isoweekday() in values: 841 results += self.materialise_item(current, 842 max(current, start), min(next, end), 843 counter, inclusive=inclusive) 844 current = next 845 846 if setpos: 847 return self.select_positions(results, setpos) 848 else: 849 return results 850 851 # Find each of the given days. 852 853 for value, index in sort_weekdays(self.args["values"], first_day, last_day): 854 offset = timedelta(7 * (abs(index) - 1)) 855 856 current = precision(to_tuple(get_first_day(first_day, value) + offset), DAYS) 857 next = update(current, step) 858 859 # To support setpos, only current and next bound the search, not 860 # the period in addition. 861 862 results += self.materialise_item(current, current, next, counter, 863 inclusive=inclusive) 864 865 # Extract selected positions and remove out-of-period instances. 866 867 if setpos: 868 results = self.select_positions(results, setpos) 869 870 return filter(lambda result: 871 self.filter_by_period(result, start, end, inclusive), 872 results) 873 874 class Enum(Selector): 875 876 "A generic value selector." 877 878 def materialise_items(self, context, start, end, counter, setpos=None, inclusive=False): 879 step = scale(1, self.level) 880 results = [] 881 882 # Select each value at the current resolution. 883 884 for value in sort_values(self.args["values"]): 885 current = precision(context, self.level, value) 886 next = update(current, step) 887 888 # To support setpos, only current and next bound the search, not 889 # the period in addition. 890 891 results += self.materialise_item(current, current, next, counter, 892 setpos, inclusive) 893 894 # Extract selected positions and remove out-of-period instances. 895 896 if setpos: 897 results = self.select_positions(results, setpos) 898 899 return filter(lambda result: 900 self.filter_by_period(result, start, end, inclusive), 901 results) 902 903 class MonthDayFilter(Enum): 904 905 "A selector of month days." 906 907 def materialise_items(self, context, start, end, counter, setpos=None, inclusive=False): 908 step = scale(1, self.level) 909 results = [] 910 911 last_day = end_of_month(context)[2] 912 913 for value in sort_values(self.args["values"], last_day): 914 current = precision(context, self.level, value) 915 next = update(current, step) 916 917 # To support setpos, only current and next bound the search, not 918 # the period in addition. 919 920 results += self.materialise_item(current, current, next, counter, 921 inclusive=inclusive) 922 923 # Extract selected positions and remove out-of-period instances. 924 925 if setpos: 926 results = self.select_positions(results, setpos) 927 928 return filter(lambda result: 929 self.filter_by_period(result, start, end, inclusive), 930 results) 931 932 class YearDayFilter(Enum): 933 934 "A selector of days in years." 935 936 def materialise_items(self, context, start, end, counter, setpos=None, inclusive=False): 937 step = scale(1, self.level) 938 results = [] 939 940 year_length = get_year_length(context) 941 942 for value in sort_values(self.args["values"], year_length): 943 current = day_in_year(context, value) 944 next = update(current, step) 945 946 # To support setpos, only current and next bound the search, not 947 # the period in addition. 948 949 results += self.materialise_item(current, current, next, counter, 950 inclusive=inclusive) 951 952 # Extract selected positions and remove out-of-period instances. 953 954 if setpos: 955 results = self.select_positions(results, setpos) 956 957 return filter(lambda result: 958 self.filter_by_period(result, start, end, inclusive), 959 results) 960 961 special_enum_levels = { 962 "BYDAY" : WeekDayFilter, 963 "BYMONTHDAY" : MonthDayFilter, 964 "BYYEARDAY" : YearDayFilter, 965 } 966 967 class Counter: 968 969 "A counter to track instance quantities." 970 971 def __init__(self, limit): 972 self.current = 0 973 self.limit = limit 974 975 def __iadd__(self, n): 976 self.current += n 977 return self 978 979 def at_limit(self): 980 return self.limit is not None and self.current >= self.limit 981 982 # Public functions. 983 984 def connect_selectors(selectors): 985 986 """ 987 Make the 'selectors' reference each other in a hierarchy so that 988 materialising the principal selector causes the more specific ones to be 989 employed in the operation. 990 """ 991 992 current = selectors[0] 993 current.first = True 994 for selector in selectors[1:]: 995 current.selecting = selector 996 current = selector 997 return selectors[0] 998 999 def get_selector(dt, qualifiers): 1000 1001 """ 1002 Combine the initial datetime 'dt' with the given 'qualifiers', returning an 1003 object that can be used to select recurrences described by the 'qualifiers'. 1004 """ 1005 1006 dt = to_tuple(dt) 1007 return connect_selectors(combine_datetime_with_qualifiers(dt, qualifiers)) 1008 1009 def get_rule(dt, rule): 1010 1011 """ 1012 Using the given initial datetime 'dt', interpret the 'rule' (a semicolon- 1013 separated collection of "key=value" strings), and return the resulting 1014 selector object. 1015 """ 1016 1017 if not isinstance(rule, tuple): 1018 rule = rule.split(";") 1019 qualifiers = get_qualifiers(rule) 1020 return get_selector(dt, qualifiers) 1021 1022 # vim: tabstop=4 expandtab shiftwidth=4