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 def convert_positions(setpos): 649 650 "Convert 'setpos' to 0-based indexes." 651 652 l = [] 653 for pos in setpos: 654 index = pos < 0 and pos or pos - 1 655 l.append(index) 656 return l 657 658 # Classes for producing instances from recurrence structures. 659 660 class Selector: 661 662 "A generic selector." 663 664 def __init__(self, level, args, qualifier, selecting=None, first=False): 665 666 """ 667 Initialise at the given 'level' a selector employing the given 'args' 668 defined in the interpretation of recurrence rule qualifiers, with the 669 'qualifier' being the name of the rule qualifier, and 'selecting' being 670 an optional selector used to find more specific instances from those 671 found by this selector. 672 """ 673 674 self.level = level 675 self.args = args 676 self.qualifier = qualifier 677 self.selecting = selecting 678 self.first = first 679 680 def __repr__(self): 681 return "%s(%r, %r, %r, %r)" % (self.__class__.__name__, self.level, 682 self.args, self.qualifier, self.first) 683 684 def materialise(self, start, end, count=None, setpos=None, inclusive=False): 685 686 """ 687 Starting at 'start', materialise instances up to but not including any 688 at 'end' or later, returning at most 'count' if specified, and returning 689 only the occurrences indicated by 'setpos' if specified. A list of 690 instances is returned. 691 692 If 'inclusive' is specified, the selection of instances will include the 693 end of the search period if present in the results. 694 """ 695 696 start = to_tuple(start) 697 end = to_tuple(end) 698 counter = Counter(count) 699 results = self.materialise_items(start, start, end, counter, setpos, inclusive) 700 return results[:count] 701 702 def materialise_item(self, current, earliest, next, counter, setpos=None, inclusive=False): 703 704 """ 705 Given the 'current' instance, the 'earliest' acceptable instance, the 706 'next' instance, an instance 'counter', and the optional 'setpos' 707 criteria, return a list of result items. Where no selection within the 708 current instance occurs, the current instance will be returned as a 709 result if the same or later than the earliest acceptable instance. 710 """ 711 712 if self.selecting: 713 return self.selecting.materialise_items(current, earliest, next, 714 counter, setpos, inclusive) 715 else: 716 return [current] 717 718 def select_positions(self, results, setpos): 719 720 "Select in 'results' the 1-based positions given by 'setpos'." 721 722 l = [] 723 for index in convert_positions(setpos): 724 l.append(results[index]) 725 return l 726 727 def filter_by_period(self, result, start, end, inclusive): 728 729 "Return whether 'result' occurs at or after 'start' and before 'end'." 730 731 return start <= result and (inclusive and result <= end or result < end) 732 733 class Pattern(Selector): 734 735 "A selector of time periods according to a repeating pattern." 736 737 def materialise_items(self, context, start, end, counter, setpos=None, inclusive=False): 738 739 """ 740 Bounded by the given 'context', return periods within 'start' to 'end', 741 updating the 'counter', selecting only the indexes specified by 'setpos' 742 (if given). 743 744 If 'inclusive' is specified, the selection of periods will include those 745 starting at the end of the search period, if present in the results. 746 """ 747 748 # Define the step between result periods. 749 750 multiple = get_multiple(self.qualifier) 751 interval = self.args.get("interval", 1) * multiple 752 step = scale(interval, self.level) 753 754 # Define the scale of a single period. 755 756 unit_step = scale(multiple, self.level) 757 758 # Employ the context as the current period if this is the first 759 # qualifier in the selection chain. 760 761 if self.first: 762 current = precision(context, self.level) 763 764 # Otherwise, obtain the first value at this resolution within the 765 # context period. 766 767 else: 768 current = precision(context, self.level, firstvalues[self.level]) 769 770 results = [] 771 772 # Obtain periods before the end (and also at the end if inclusive), 773 # provided that any limit imposed by the counter has not been exceeded. 774 775 while (inclusive and current <= end or current < end) and \ 776 not counter.at_limit(): 777 778 # Increment the current datetime by the step for the next period. 779 780 next = update(current, step) 781 782 # Determine the end point of the current period. 783 784 current_end = update(current, unit_step) 785 786 # Obtain any period or periods within the bounds defined by the 787 # current period and any contraining start and end points, plus 788 # counter, setpos and inclusive details. 789 790 interval_results = self.materialise_item(current, 791 max(current, start), min(current_end, end), 792 counter, setpos, inclusive) 793 794 # Update the counter with the number of identified results. 795 796 counter += len(interval_results) 797 798 # Accumulate the results. 799 800 results += interval_results 801 802 # Visit the next instance. 803 804 current = next 805 806 return results 807 808 class WeekDayFilter(Selector): 809 810 "A selector of instances specified in terms of day numbers." 811 812 def materialise_items(self, context, start, end, counter, setpos=None, inclusive=False): 813 step = scale(1, WEEKS) 814 results = [] 815 816 # Get weekdays in the year. 817 818 if is_year_only(context): 819 first_day = start_of_year(context) 820 last_day = end_of_year(context) 821 822 # Get weekdays in the month. 823 824 elif is_month_only(context): 825 first_day = start_of_month(context) 826 last_day = end_of_month(context) 827 828 # Get weekdays in the week. 829 830 else: 831 current = context 832 values = [value for (value, index) in self.args["values"]] 833 834 while (inclusive and current <= end or current < end): 835 next = update(current, step) 836 837 if date(*current).isoweekday() in values: 838 results += self.materialise_item(current, 839 max(current, start), min(next, end), 840 counter, inclusive=inclusive) 841 current = next 842 843 if setpos: 844 return self.select_positions(results, setpos) 845 else: 846 return results 847 848 # Find each of the given days. 849 850 for value, index in sort_weekdays(self.args["values"], first_day, last_day): 851 offset = timedelta(7 * (index - 1)) 852 853 current = precision(to_tuple(get_first_day(first_day, value) + offset), DAYS) 854 next = update(current, step) 855 856 # To support setpos, only current and next bound the search, not 857 # the period in addition. 858 859 results += self.materialise_item(current, current, next, counter, 860 inclusive=inclusive) 861 862 # Extract selected positions and remove out-of-period instances. 863 864 if setpos: 865 results = self.select_positions(results, setpos) 866 867 return filter(lambda result: 868 self.filter_by_period(result, start, end, inclusive), 869 results) 870 871 class Enum(Selector): 872 873 "A generic value selector." 874 875 def materialise_items(self, context, start, end, counter, setpos=None, inclusive=False): 876 step = scale(1, self.level) 877 results = [] 878 879 # Select each value at the current resolution. 880 881 for value in sort_values(self.args["values"]): 882 current = precision(context, self.level, value) 883 next = update(current, step) 884 885 # To support setpos, only current and next bound the search, not 886 # the period in addition. 887 888 results += self.materialise_item(current, current, next, counter, 889 setpos, inclusive) 890 891 # Extract selected positions and remove out-of-period instances. 892 893 if setpos: 894 results = self.select_positions(results, setpos) 895 896 return filter(lambda result: 897 self.filter_by_period(result, start, end, inclusive), 898 results) 899 900 class MonthDayFilter(Enum): 901 902 "A selector of month days." 903 904 def materialise_items(self, context, start, end, counter, setpos=None, inclusive=False): 905 step = scale(1, self.level) 906 results = [] 907 908 last_day = end_of_month(context)[2] 909 910 for value in sort_values(self.args["values"], last_day): 911 current = precision(context, self.level, value) 912 next = update(current, step) 913 914 # To support setpos, only current and next bound the search, not 915 # the period in addition. 916 917 results += self.materialise_item(current, current, next, counter, 918 inclusive=inclusive) 919 920 # Extract selected positions and remove out-of-period instances. 921 922 if setpos: 923 results = self.select_positions(results, setpos) 924 925 return filter(lambda result: 926 self.filter_by_period(result, start, end, inclusive), 927 results) 928 929 class YearDayFilter(Enum): 930 931 "A selector of days in years." 932 933 def materialise_items(self, context, start, end, counter, setpos=None, inclusive=False): 934 step = scale(1, self.level) 935 results = [] 936 937 year_length = get_year_length(context) 938 939 for value in sort_values(self.args["values"], year_length): 940 current = day_in_year(context, value) 941 next = update(current, step) 942 943 # To support setpos, only current and next bound the search, not 944 # the period in addition. 945 946 results += self.materialise_item(current, current, next, counter, 947 inclusive=inclusive) 948 949 # Extract selected positions and remove out-of-period instances. 950 951 if setpos: 952 results = self.select_positions(results, setpos) 953 954 return filter(lambda result: 955 self.filter_by_period(result, start, end, inclusive), 956 results) 957 958 special_enum_levels = { 959 "BYDAY" : WeekDayFilter, 960 "BYMONTHDAY" : MonthDayFilter, 961 "BYYEARDAY" : YearDayFilter, 962 } 963 964 class Counter: 965 966 "A counter to track instance quantities." 967 968 def __init__(self, limit): 969 self.current = 0 970 self.limit = limit 971 972 def __iadd__(self, n): 973 self.current += n 974 return self 975 976 def at_limit(self): 977 return self.limit is not None and self.current >= self.limit 978 979 # Public functions. 980 981 def connect_selectors(selectors): 982 983 """ 984 Make the 'selectors' reference each other in a hierarchy so that 985 materialising the principal selector causes the more specific ones to be 986 employed in the operation. 987 """ 988 989 current = selectors[0] 990 current.first = True 991 for selector in selectors[1:]: 992 current.selecting = selector 993 current = selector 994 return selectors[0] 995 996 def get_selector(dt, qualifiers): 997 998 """ 999 Combine the initial datetime 'dt' with the given 'qualifiers', returning an 1000 object that can be used to select recurrences described by the 'qualifiers'. 1001 """ 1002 1003 dt = to_tuple(dt) 1004 return connect_selectors(combine_datetime_with_qualifiers(dt, qualifiers)) 1005 1006 def get_rule(dt, rule): 1007 1008 """ 1009 Using the given initial datetime 'dt', interpret the 'rule' (a semicolon- 1010 separated collection of "key=value" strings), and return the resulting 1011 selector object. 1012 """ 1013 1014 if not isinstance(rule, tuple): 1015 rule = rule.split(";") 1016 qualifiers = get_qualifiers(rule) 1017 return get_selector(dt, qualifiers) 1018 1019 # vim: tabstop=4 expandtab shiftwidth=4