imip-agent

vRecurrence.py

1259:19a3de939b45
2017-09-13 Paul Boddie Fixed docstring.
     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 # Enumeration levels, employed by BY... qualifiers.    74     75 enum_levels = (    76     None,    77     "BYMONTH",    78     "BYWEEKNO",    79     "BYYEARDAY",    80     "BYMONTHDAY",    81     "BYDAY",    82     "BYHOUR",    83     "BYMINUTE",    84     "BYSECOND"    85     )    86     87 # Levels defining days.    88     89 daylevels = [2, 3, 4, 5]    90     91 # Map from levels to lengths of datetime tuples.    92     93 lengths = [1, 2, 3, 3, 3, 3, 4, 5, 6]    94 positions = [l-1 for l in lengths]    95     96 # Define the lowest values at each resolution (years, months, days... hours,    97 # minutes, seconds).    98     99 firstvalues = [0, 1, 1, 1, 1, 1, 0, 0, 0]   100    101 # Map from qualifiers to interval units. Here, weeks are defined as 7 days.   102    103 units = {"WEEKLY" : 7}   104    105 # Make dictionaries mapping qualifiers to levels.   106    107 freq = dict([(level, i) for (i, level) in enumerate(freq_levels) if level])   108 enum = dict([(level, i) for (i, level) in enumerate(enum_levels) if level])   109 weekdays = dict([(weekday, i+1) for (i, weekday) in enumerate(["MO", "TU", "WE", "TH", "FR", "SA", "SU"])])   110    111 # Functions for structuring the recurrences.   112    113 def get_next(it):   114     try:   115         return it.next()   116     except StopIteration:   117         return None   118    119 def get_parameters(values):   120    121     "Return parameters from the given list of 'values'."   122    123     d = {}   124     for value in values:   125         parts = value.split("=", 1)   126         if len(parts) < 2:   127             continue   128         key, value = parts   129         if key in ("COUNT", "BYSETPOS"):   130             d[key] = int(value)   131         else:   132             d[key] = value   133     return d   134    135 def get_qualifiers(values):   136    137     """   138     Process the list of 'values' of the form "key=value", returning a list of   139     qualifiers of the form (qualifier name, args).   140     """   141    142     qualifiers = []   143     frequency = None   144     interval = 1   145    146     for value in values:   147         parts = value.split("=", 1)   148         if len(parts) < 2:   149             continue   150         key, value = parts   151    152         # Accept frequency indicators as qualifiers.   153    154         if key == "FREQ" and freq.has_key(value):   155             qualifier = frequency = (value, {})   156    157         # Accept interval indicators for frequency qualifier parameterisation.   158    159         elif key == "INTERVAL":   160             interval = int(value)   161             continue   162    163         # Accept enumerators as qualifiers.   164    165         elif enum.has_key(key):   166             qualifier = (key, {"values" : get_qualifier_values(key, value)})   167    168         # Ignore other items.   169    170         else:   171             continue   172    173         qualifiers.append(qualifier)   174    175     # Parameterise any frequency qualifier with the interval.   176    177     if frequency:   178         frequency[1]["interval"] = interval   179    180     return qualifiers   181    182 def get_qualifier_values(qualifier, value):   183    184     """   185     For the given 'qualifier', process the 'value' string, returning a list of   186     suitable values.   187     """   188    189     if qualifier != "BYDAY":   190         return map(int, value.split(","))   191    192     values = []   193     for part in value.split(","):   194         weekday = weekdays.get(part[-2:])   195         if not weekday:   196             continue   197         index = part[:-2]   198         if index:   199             index = int(index)   200         else:   201             index = None   202         values.append((weekday, index))   203    204     return values   205    206 def order_qualifiers(qualifiers):   207    208     "Return the 'qualifiers' in order of increasing resolution."   209    210     l = []   211    212     for qualifier, args in qualifiers:   213    214         # Distinguish between enumerators, used to select particular periods,   215         # and frequencies, used to select repeating periods.   216    217         if enum.has_key(qualifier):   218             level = enum[qualifier]   219             if special_enum_levels.has_key(qualifier):   220                 args["interval"] = 1   221                 selector = special_enum_levels[qualifier]   222             else:   223                 selector = Enum   224         else:   225             level = freq[qualifier]   226             selector = Pattern   227    228         l.append(selector(level, args, qualifier))   229    230     l.sort(key=lambda x: x.level)   231     return l   232    233 def get_datetime_structure(datetime):   234    235     "Return the structure of 'datetime' for recurrence production."   236    237     l = []   238     offset = 0   239    240     for level, value in enumerate(datetime):   241    242         # At the day number, adjust the frequency level offset to reference   243         # days (and then hours, minutes, seconds).   244    245         if level == 2:   246             offset = 3   247    248         l.append(Enum(level + offset, {"values" : [value]}, "DT"))   249    250     return l   251    252 def combine_datetime_with_qualifiers(datetime, qualifiers):   253    254     """   255     Combine 'datetime' with 'qualifiers' to produce a structure for recurrence   256     production.   257    258     Initial datetime values appearing at broader resolutions than any qualifiers   259     are ignored, since their details will be used when materialising the   260     results.   261    262     Qualifiers are accumulated in order to define a selection. Datetime values   263     occurring between qualifiers or at the same resolution as qualifiers are   264     ignored.   265    266     Any remaining datetime values are introduced as enumerators, provided that   267     they do not conflict with qualifiers. For example, specific day values   268     conflict with day selectors and weekly qualifiers.   269    270     The purpose of the remaining datetime values is to define a result within   271     a period selected by the most precise qualifier. For example, the selection   272     of a day and month in a year recurrence.   273     """   274    275     iter_dt = iter(get_datetime_structure(datetime))   276     iter_q = iter(order_qualifiers(qualifiers))   277    278     l = []   279    280     from_dt = get_next(iter_dt)   281     from_q = get_next(iter_q)   282     have_q = False   283    284     # Consume from both lists, merging entries.   285    286     while from_dt and from_q:   287         _level = from_dt.level   288         level = from_q.level   289    290         # Datetime value at wider resolution.   291    292         if _level < level:   293             from_dt = get_next(iter_dt)   294    295         # Qualifier at wider or same resolution as datetime value.   296    297         else:   298             if not have_q:   299                 add_initial_qualifier(from_q, level, l)   300                 have_q = True   301    302             # Add the qualifier to the combined list.   303    304             l.append(from_q)   305    306             # Datetime value at same resolution.   307    308             if _level == level:   309                 from_dt = get_next(iter_dt)   310    311             # Get the next qualifier.   312    313             from_q = get_next(iter_q)   314    315     # Complete the list by adding remaining datetime enumerators.   316    317     while from_dt:   318    319         # Ignore datetime values that conflict with day-level qualifiers.   320    321         if not l or from_dt.level != freq["DAILY"] or l[-1].level not in daylevels:   322             l.append(from_dt)   323    324         from_dt = get_next(iter_dt)   325    326     # Complete the list by adding remaining qualifiers.   327    328     while from_q:   329         if not have_q:   330             add_initial_qualifier(from_q, level, l)   331             have_q = True   332    333         # Add the qualifier to the combined list.   334    335         l.append(from_q)   336    337         # Get the next qualifier.   338    339         from_q = get_next(iter_q)   340    341     return l   342    343 def add_initial_qualifier(from_q, level, l):   344    345     """   346     Take the first qualifier 'from_q' at the given resolution 'level', using it   347     to create an initial qualifier, adding it to the combined list 'l' if   348     required.   349     """   350    351     if isinstance(from_q, Enum) and level > 0:   352         repeat = Pattern(level - 1, {"interval" : 1}, None)   353         l.append(repeat)   354    355 # Datetime arithmetic.   356    357 def combine(t1, t2):   358    359     """   360     Combine tuples 't1' and 't2', returning a copy of 't1' filled with values   361     from 't2' in positions where 0 appeared in 't1'.   362     """   363    364     return tuple(map(lambda x, y: x or y, t1, t2))   365    366 def scale(interval, pos):   367    368     """   369     Scale the given 'interval' value to the indicated position 'pos', returning   370     a tuple with leading zero elements and 'interval' at the stated position.   371     """   372    373     return (0,) * pos + (interval,)   374    375 def get_seconds(t):   376    377     "Convert the sub-day part of 't' into seconds."   378    379     t = t + (0,) * (6 - len(t))   380     return (t[3] * 60 + t[4]) * 60 + t[5]   381    382 def update(t, step):   383    384     "Update 't' by 'step' at the resolution of 'step'."   385    386     i = len(step)   387    388     # Years only.   389    390     if i == 1:   391         return (t[0] + step[0],) + tuple(t[1:])   392    393     # Years and months.   394    395     elif i == 2:   396         month = t[1] + step[1]   397         return (t[0] + step[0] + (month - 1) / 12, (month - 1) % 12 + 1) + tuple(t[2:])   398    399     # Dates and datetimes.   400    401     else:   402         updated_for_months = update(t, step[:2])   403    404         # Dates only.   405    406         if i == 3:   407             d = datetime(*updated_for_months)   408             s = timedelta(step[2])   409    410         # Datetimes.   411    412         else:   413             d = datetime(*updated_for_months)   414             s = timedelta(step[2], get_seconds(step))   415    416         return to_tuple(d + s, len(t))   417    418 def to_tuple(d, n=None):   419    420     "Return 'd' as a tuple, optionally trimming the result to 'n' positions."   421    422     if not isinstance(d, date):   423         return d   424     if n is None:   425         if isinstance(d, datetime):   426             n = 6   427         else:   428             n = 3   429     return d.timetuple()[:n]   430    431 def get_first_day(first_day, weekday):   432    433     "Return the first occurrence at or after 'first_day' of 'weekday'."   434    435     first_day = date(*first_day)   436     first_weekday = first_day.isoweekday()   437     if first_weekday > weekday:   438         return first_day + timedelta(7 - first_weekday + weekday)   439     else:   440         return first_day + timedelta(weekday - first_weekday)   441    442 def get_last_day(last_day, weekday):   443    444     "Return the last occurrence at or before 'last_day' of 'weekday'."   445    446     last_day = date(*last_day)   447     last_weekday = last_day.isoweekday()   448     if last_weekday < weekday:   449         return last_day - timedelta(last_weekday + 7 - weekday)   450     else:   451         return last_day - timedelta(last_weekday - weekday)   452    453 # Classes for producing instances from recurrence structures.   454    455 class Selector:   456    457     "A generic selector."   458    459     def __init__(self, level, args, qualifier, selecting=None, first=False):   460    461         """   462         Initialise at the given 'level' a selector employing the given 'args'   463         defined in the interpretation of recurrence rule qualifiers, with the   464         'qualifier' being the name of the rule qualifier, and 'selecting' being   465         an optional selector used to find more specific instances from those   466         found by this selector.   467         """   468    469         self.level = level   470         self.args = args   471         self.qualifier = qualifier   472         self.selecting = selecting   473         self.first = first   474    475         # Define the index of values from datetimes involved with this selector.   476    477         self.pos = positions[level]   478    479     def __repr__(self):   480         return "%s(%r, %r, %r, %r)" % (self.__class__.__name__, self.level, self.args, self.qualifier, self.first)   481    482     def materialise(self, start, end, count=None, setpos=None, inclusive=False):   483    484         """   485         Starting at 'start', materialise instances up to but not including any   486         at 'end' or later, returning at most 'count' if specified, and returning   487         only the occurrences indicated by 'setpos' if specified. A list of   488         instances is returned.   489    490         If 'inclusive' is specified, the selection of instances will include the   491         end of the search period if present in the results.   492         """   493    494         start = to_tuple(start)   495         end = to_tuple(end)   496         counter = count and [0, count]   497         results = self.materialise_items(start, start, end, counter, setpos, inclusive)   498         results.sort()   499         return results[:count]   500    501     def materialise_item(self, current, earliest, next, counter, setpos=None, inclusive=False):   502    503         """   504         Given the 'current' instance, the 'earliest' acceptable instance, the   505         'next' instance, an instance 'counter', and the optional 'setpos'   506         criteria, return a list of result items. Where no selection within the   507         current instance occurs, the current instance will be returned as a   508         result if the same or later than the earliest acceptable instance.   509         """   510    511         if self.selecting:   512             return self.selecting.materialise_items(current, earliest, next, counter, setpos, inclusive)   513         elif earliest <= current:   514             return [current]   515         else:   516             return []   517    518     def convert_positions(self, setpos):   519    520         "Convert 'setpos' to 0-based indexes."   521    522         l = []   523         for pos in setpos:   524             lower = pos < 0 and pos or pos - 1   525             upper = pos > 0 and pos or pos < -1 and pos + 1 or None   526             l.append((lower, upper))   527         return l   528    529     def select_positions(self, results, setpos):   530    531         "Select in 'results' the 1-based positions given by 'setpos'."   532    533         results.sort()   534         l = []   535         for lower, upper in self.convert_positions(setpos):   536             l += results[lower:upper]   537         return l   538    539     def filter_by_period(self, results, start, end, inclusive):   540    541         """   542         Filter 'results' so that only those at or after 'start' and before 'end'   543         are returned.   544    545         If 'inclusive' is specified, the selection of instances will include the   546         end of the search period if present in the results.   547         """   548    549         l = []   550         for result in results:   551             if start <= result and (inclusive and result <= end or result < end):   552                 l.append(result)   553         return l   554    555 class Pattern(Selector):   556    557     "A selector of time periods according to a repeating pattern."   558    559     def materialise_items(self, context, start, end, counter, setpos=None, inclusive=False):   560    561         """   562         Bounded by the given 'context', return periods within 'start' to 'end',   563         updating the 'counter', selecting only the indexes specified by 'setpos'   564         (if given).   565    566         If 'inclusive' is specified, the selection of periods will include those   567         starting at the end of the search period, if present in the results.   568         """   569    570         # Define the step between result periods.   571    572         interval = self.args.get("interval", 1) * units.get(self.qualifier, 1)   573         step = scale(interval, self.pos)   574    575         # Define the scale of a single period.   576    577         unit_interval = units.get(self.qualifier, 1)   578         unit_step = scale(unit_interval, self.pos)   579    580         # Employ the context as the current period if this is the first   581         # qualifier in the selection chain.   582    583         if self.first:   584             current = context[:self.pos+1]   585    586         # Otherwise, obtain the first value at this resolution within the   587         # context period.   588    589         else:   590             first = scale(firstvalues[self.level], self.pos)   591             current = combine(context[:self.pos], first)   592    593         results = []   594    595         # Obtain periods before the end (and also at the end if inclusive),   596         # provided that any limit imposed by the counter has not been exceeded.   597    598         while (inclusive and current <= end or current < end) and \   599               (counter is None or counter[0] < counter[1]):   600    601             # Increment the current datetime by the step for the next period.   602    603             next = update(current, step)   604    605             # Determine the end point of the current period.   606    607             current_end = update(current, unit_step)   608    609             # Obtain any period or periods within the bounds defined by the   610             # current period and any contraining start and end points, plus   611             # counter, setpos and inclusive details.   612    613             interval_results = self.materialise_item(current, max(current, start), min(current_end, end), counter, setpos, inclusive)   614    615             # Update the counter with the number of identified results.   616    617             if counter is not None:   618                 counter[0] += len(interval_results)   619    620             # Accumulate the results.   621    622             results += interval_results   623    624             # Visit the next instance.   625    626             current = next   627    628         return results   629    630 class WeekDayFilter(Selector):   631    632     "A selector of instances specified in terms of day numbers."   633    634     def materialise_items(self, context, start, end, counter, setpos=None, inclusive=False):   635         step = scale(1, 2)   636         results = []   637    638         # Get weekdays in the year.   639    640         if len(context) == 1:   641             first_day = (context[0], 1, 1)   642             last_day = (context[0], 12, 31)   643    644         # Get weekdays in the month.   645    646         elif len(context) == 2:   647             first_day = (context[0], context[1], 1)   648             last_day = update((context[0], context[1], 1), (0, 1, 0))   649             last_day = update(last_day, (0, 0, -1))   650    651         # Get weekdays in the week.   652    653         else:   654             current = context   655             values = [value for (value, index) in self.args["values"]]   656    657             while (inclusive and current <= end or current < end):   658                 next = update(current, step)   659                 if date(*current).isoweekday() in values:   660                     results += self.materialise_item(current, max(current, start), min(next, end), counter, inclusive=inclusive)   661                 current = next   662    663             if setpos:   664                 return self.select_positions(results, setpos)   665             else:   666                 return results   667    668         # Find each of the given days.   669    670         for value, index in self.args["values"]:   671             if index is not None:   672                 offset = timedelta(7 * (abs(index) - 1))   673    674                 if index < 0:   675                     current = to_tuple(get_last_day(last_day, value) - offset, 3)   676                 else:   677                     current = to_tuple(get_first_day(first_day, value) + offset, 3)   678    679                 next = update(current, step)   680    681                 # To support setpos, only current and next bound the search, not   682                 # the period in addition.   683    684                 results += self.materialise_item(current, current, next, counter, inclusive=inclusive)   685    686             else:   687                 if index < 0:   688                     current = to_tuple(get_last_day(last_day, value), 3)   689                     direction = operator.sub   690                 else:   691                     current = to_tuple(get_first_day(first_day, value), 3)   692                     direction = operator.add   693    694                 while first_day <= current <= last_day:   695                     next = update(current, step)   696    697                     # To support setpos, only current and next bound the search, not   698                     # the period in addition.   699    700                     results += self.materialise_item(current, current, next, counter, inclusive=inclusive)   701                     current = to_tuple(direction(date(*current), timedelta(7)), 3)   702    703         # Extract selected positions and remove out-of-period instances.   704    705         if setpos:   706             results = self.select_positions(results, setpos)   707    708         return self.filter_by_period(results, start, end, inclusive)   709    710 class Enum(Selector):   711     def materialise_items(self, context, start, end, counter, setpos=None, inclusive=False):   712         step = scale(1, self.pos)   713         results = []   714         for value in self.args["values"]:   715             current = combine(context[:self.pos], scale(value, self.pos))   716             next = update(current, step)   717    718             # To support setpos, only current and next bound the search, not   719             # the period in addition.   720    721             results += self.materialise_item(current, current, next, counter, setpos, inclusive)   722    723         # Extract selected positions and remove out-of-period instances.   724    725         if setpos:   726             results = self.select_positions(results, setpos)   727    728         return self.filter_by_period(results, start, end, inclusive)   729    730 class MonthDayFilter(Enum):   731     def materialise_items(self, context, start, end, counter, setpos=None, inclusive=False):   732         last_day = monthrange(context[0], context[1])[1]   733         step = scale(1, self.pos)   734         results = []   735         for value in self.args["values"]:   736             if value < 0:   737                 value = last_day + 1 + value   738             current = combine(context, scale(value, self.pos))   739             next = update(current, step)   740    741             # To support setpos, only current and next bound the search, not   742             # the period in addition.   743    744             results += self.materialise_item(current, current, next, counter, inclusive=inclusive)   745    746         # Extract selected positions and remove out-of-period instances.   747    748         if setpos:   749             results = self.select_positions(results, setpos)   750    751         return self.filter_by_period(results, start, end, inclusive)   752    753 class YearDayFilter(Enum):   754     def materialise_items(self, context, start, end, counter, setpos=None, inclusive=False):   755         first_day = date(context[0], 1, 1)   756         next_first_day = date(context[0] + 1, 1, 1)   757         year_length = (next_first_day - first_day).days   758         step = scale(1, self.pos)   759         results = []   760         for value in self.args["values"]:   761             if value < 0:   762                 value = year_length + 1 + value   763             current = to_tuple(first_day + timedelta(value - 1), 3)   764             next = update(current, step)   765    766             # To support setpos, only current and next bound the search, not   767             # the period in addition.   768    769             results += self.materialise_item(current, current, next, counter, inclusive=inclusive)   770    771         # Extract selected positions and remove out-of-period instances.   772    773         if setpos:   774             results = self.select_positions(results, setpos)   775    776         return self.filter_by_period(results, start, end, inclusive)   777    778 special_enum_levels = {   779     "BYDAY" : WeekDayFilter,   780     "BYMONTHDAY" : MonthDayFilter,   781     "BYYEARDAY" : YearDayFilter,   782     }   783    784 # Public functions.   785    786 def connect_selectors(selectors):   787    788     """   789     Make the 'selectors' reference each other in a hierarchy so that   790     materialising the principal selector causes the more specific ones to be   791     employed in the operation.   792     """   793    794     current = selectors[0]   795     current.first = True   796     for selector in selectors[1:]:   797         current.selecting = selector   798         current = selector   799     return selectors[0]   800    801 def get_selector(dt, qualifiers):   802    803     """   804     Combine the initial datetime 'dt' with the given 'qualifiers', returning an   805     object that can be used to select recurrences described by the 'qualifiers'.   806     """   807    808     dt = to_tuple(dt)   809     return connect_selectors(combine_datetime_with_qualifiers(dt, qualifiers))   810    811 def get_rule(dt, rule):   812    813     """   814     Using the given initial datetime 'dt', interpret the 'rule' (a semicolon-   815     separated collection of "key=value" strings), and return the resulting   816     selector object.   817     """   818    819     if not isinstance(rule, tuple):   820         rule = rule.split(";")   821     qualifiers = get_qualifiers(rule)   822     return get_selector(dt, qualifiers)   823    824 # vim: tabstop=4 expandtab shiftwidth=4