# HG changeset patch # User Paul Boddie # Date 1512166161 -3600 # Node ID 5525d39ba8ed51dd43ab6c9928172e6c22a0a7f9 # Parent d956c95cff89d4a21bd160cddb92e82d997c66ea Added various functions and methods to facilitate rule selector editing. Reorganised rule period generation to permit modified selectors to be used instead of existing rule property values. diff -r d956c95cff89 -r 5525d39ba8ed imiptools/data.py --- a/imiptools/data.py Sat Nov 25 00:11:38 2017 +0100 +++ b/imiptools/data.py Fri Dec 01 23:09:21 2017 +0100 @@ -33,7 +33,7 @@ MergingIterator, RulePeriodCollection from itertools import ifilter from vCalendar import iterwrite, parse, ParseError, to_dict, to_node -from vRecurrence import get_parameters +from vRecurrence import get_parameters, get_rule import email.utils try: @@ -1217,31 +1217,32 @@ will be included. """ - tzid = obj.get_tzid() rrule = obj.get_value("RRULE") - # Use localised datetimes. - - main_period = obj.get_main_period() - - if not rrule: - rule_periods = iter([main_period]) - # Recurrence rules create multiple instances to be checked. # Conflicts may only be assessed within a period defined by policy # for the agent, with instances outside that period being considered # unchecked. - elif end or rule_has_end(rrule): + if not end and not rule_has_end(rrule): + return iter([]) - # Filter periods using a start point. The end will be handled in the - # materialisation process. + tzid = obj.get_tzid() + main_period = obj.get_main_period() + + start = main_period.get_start() + selector = get_rule(start, rrule) - rule_periods = ifilter(Period(start, None).wraps, - RulePeriodCollection(rrule, main_period, tzid, - end, inclusive)) - else: - rule_periods = iter([]) + if not selector: + return iter([main_period]) + + parameters = get_parameters(rrule) + until = parameters and parameters.has_key("UNTIL") or None + until = until and to_timezone(get_datetime(until), tzid) + + rule_periods = get_periods_using_selector(selector, until, + main_period, tzid, start, end, + inclusive) # Add recurrence dates. @@ -1259,6 +1260,17 @@ return filter(lambda p, excluded=exdates: p not in excluded, periods) +def get_periods_using_selector(selector, until, main_period, tzid, start, end, + inclusive=False): + + # Filter periods using a start point. The period from the given start + # until the end of time must wrap each period for that period to be + # included. The end will be handled in the materialisation process. + + return ifilter(Period(start, None).wraps, + RulePeriodCollection(selector, until, main_period, tzid, + end, inclusive)) + def get_main_period(periods): "Return the main period from 'periods' using origin information." diff -r d956c95cff89 -r 5525d39ba8ed imiptools/period.py --- a/imiptools/period.py Sat Nov 25 00:11:38 2017 +0100 +++ b/imiptools/period.py Fri Dec 01 23:09:21 2017 +0100 @@ -28,7 +28,7 @@ get_start_of_day, \ get_tzid, \ to_timezone, to_utc_datetime -from vRecurrence import get_parameters, get_rule +from vRecurrence import get_selector def ifnone(x, y): if x is None: return y @@ -422,11 +422,11 @@ "A collection of rule periods." - def __init__(self, rrule, main_period, tzid, end, inclusive=False): + def __init__(self, selector, until, main_period, tzid, end, inclusive=False): """ - Initialise a period collection for the given 'rrule', employing the - 'main_period' and 'tzid'. + Initialise a period collection for the given 'selectors', limited by any + 'until' datetime, employing the 'main_period' and 'tzid'. The specified 'end' datetime indicates the end of the window for which periods shall be computed. @@ -435,19 +435,14 @@ will be included. """ - self.rrule = rrule + self.selector = selector self.main_period = main_period self.tzid = tzid - parameters = rrule and get_parameters(rrule) - until = parameters.get("UNTIL") - # Any UNTIL qualifier changes the nature of the end of the collection. if until: - attr = main_period.get_start_attr() - until_dt = to_timezone(get_datetime(until, attr), tzid) - self.end = end and min(until_dt, end) or until_dt + self.end = end and min(until, end) or until self.inclusive = True else: self.end = end @@ -462,10 +457,9 @@ """ start = self.main_period.get_start() - selector = get_rule(start, self.rrule) return RulePeriodIterator(self.main_period, self.tzid, - selector.select(start, self.end, self.inclusive)) + self.selector.select(start, self.end, self.inclusive)) class RulePeriodIterator: diff -r d956c95cff89 -r 5525d39ba8ed vRecurrence.py --- a/vRecurrence.py Sat Nov 25 00:11:38 2017 +0100 +++ b/vRecurrence.py Fri Dec 01 23:09:21 2017 +0100 @@ -53,6 +53,7 @@ """ from calendar import monthrange +from collections import OrderedDict from datetime import date, datetime, timedelta import operator @@ -120,8 +121,10 @@ # Weekdays: name -> 1-based value -weekdays = {} -for i, weekday in enumerate(["MO", "TU", "WE", "TH", "FR", "SA", "SU"]): +weekday_values = ["MO", "TU", "WE", "TH", "FR", "SA", "SU"] + +weekdays = OrderedDict() +for i, weekday in enumerate(weekday_values): weekdays[weekday] = i + 1 # Functions for structuring the recurrences. @@ -140,6 +143,9 @@ "Return parameters from the given list of 'values'." d = {} + if not values: + return d + for value in values: try: key, value = value.split("=", 1) @@ -158,13 +164,24 @@ qualifiers = [] frequency = None interval = 1 + keys = set() for value in values: + + # Ignore qualifiers without values. + try: key, value = value.split("=", 1) except ValueError: continue + # Ignore duplicate qualifiers. + + if key in keys: + continue + + keys.add(key) + # Accept frequency indicators as qualifiers. if key == "FREQ" and freq.has_key(value): @@ -202,7 +219,7 @@ suitable values. """ - # For non-weekday selection, obtain a list of day numbers. + # For non-weekday selection, obtain a list of numbers. if qualifier != "BYDAY": return map(int, value.split(",")) @@ -212,78 +229,185 @@ values = [] for part in value.split(","): - weekday = weekdays.get(part[-2:]) - if not weekday: + index, weekday = part[:-2], part[-2:] + + weekday_number = weekdays.get(weekday) + if not weekday_number: continue - index = part[:-2] + if index: index = int(index) else: index = None - values.append((weekday, index)) + + values.append((weekday_number, index)) return values def order_qualifiers(qualifiers): - "Return the 'qualifiers' in order of increasing resolution." + """ + Obtain 'qualifiers' in order of increasing resolution, producing and + returning selector objects corresponding to the qualifiers. + """ l = [] - max_level = 0 - # Special qualifiers. - - setpos = None - count = None + # Obtain selectors for the qualifiers. for qualifier, args in qualifiers: + selector = new_selector(qualifier, args) + l.append(selector) - # Distinguish between enumerators, used to select particular periods, - # and frequencies, used to select repeating periods. + return sort_selectors(l) - if enum.has_key(qualifier): - level = enum[qualifier] +def new_selector(qualifier, args=None): - # Certain enumerators produce their values in a special way. + "Return a selector for 'qualifier' and 'args'." + + # Distinguish between enumerators, used to select particular periods, + # and frequencies, used to select repeating periods. - if special_enum_levels.has_key(qualifier): - args["interval"] = 1 - selector = special_enum_levels[qualifier] - else: - selector = Enum + if enum.has_key(qualifier): + selector = special_enum_levels.get(qualifier, Enum) + return selector(enum[qualifier], args, qualifier) + + # Create a selector that must be updated with the maximum resolution. - elif qualifier == "BYSETPOS": - setpos = args - continue + elif qualifier == "BYSETPOS": + return PositionSelector(None, args, "BYSETPOS") + + elif qualifier == "COUNT": + return LimitSelector(0, args, "COUNT") - elif qualifier == "COUNT": - count = args - continue + else: + return Pattern(freq[qualifier], args, qualifier) + +def sort_selectors(selectors): - else: - level = freq[qualifier] - selector = Pattern + "Sort 'selectors' in order of increasing resolution." + + if not selectors: + return selectors + + max_level = max(map(lambda selector: selector.level or 0, selectors)) - l.append(selector(level, args, qualifier)) - max_level = max(level, max_level) + # Update the result set selector at the maximum resolution. - # Add the result set selector at the maximum level of enumeration. + for selector in selectors: + if isinstance(selector, PositionSelector): + selector.level = max_level - if setpos is not None: - l.append(PositionSelector(max_level, setpos, "BYSETPOS")) + selectors.sort(key=selector_sort_key) + return selectors - # Add the result set truncator at the top level. +def selector_sort_key(selector): - if count is not None: - l.append(LimitSelector(0, count, "COUNT")) + "Produce a sort key for 'selector'." # Make BYSETPOS sort earlier than the enumeration it modifies. # Other BY... qualifiers sort earlier than selectors at the same resolution # even though such things as "FREQ=HOURLY;BYHOUR=10" do not make much sense. - l.sort(key=lambda x: (x.level, not x.qualifier.startswith("BY") and 2 or - x.qualifier != "BYSETPOS" and 1 or 0)) - return l + return (selector.level, not selector.qualifier.startswith("BY") and 2 or + selector.qualifier != "BYSETPOS" and 1 or 0) + +def get_value_ranges(qualifier): + + """ + Return value ranges for 'qualifier'. Each range is either given by a tuple + indicating the inclusive start and end values or by a list enumerating the + values. + """ + + # Provide ranges for the numeric value of each qualifier. + + if qualifier == "BYMONTH": + return [(-12, -1), (1, 12)], + elif qualifier == "BYWEEKNO": + return [(-53, -1), (1, 53)], + elif qualifier == "BYYEARDAY": + return [(-366, -1), (1, 366)], + elif qualifier == "BYMONTHDAY": + return [(-31, -1), (1, 31)], + elif qualifier == "BYHOUR": + return [(0, 23)], + elif qualifier == "BYMINUTE": + return [(0, 59)], + elif qualifier == "BYSECOND": + return [(0, 60)], + + # Provide ranges for the weekday value and index. + + elif qualifier == "BYDAY": + return [weekdays], [(-53, -1), (1, 53), None] + + return None + +def check_values(qualifier, values): + + """ + Check for 'qualifier' the given 'values', returning checked values that may + be converted or updated. + """ + + ranges = get_value_ranges(qualifier) + + if not ranges: + return None + + # Match each value against each range specification. + + checked = [] + + for v, value_ranges in zip(values, ranges): + + # Return None if no match occurred for the value. + + try: + checked.append(check_value_in_ranges(v, value_ranges)) + except ValueError: + return None + + # Return the checked values. + + return checked + +def check_value_in_ranges(value, value_ranges): + + """ + Check the given 'value' against the given 'value_ranges'. Return the + checked value, possibly converted or updated, or raise ValueError if the + value was not suitable. + """ + + for value_range in value_ranges: + + # Test actual ranges. + + if isinstance(value_range, tuple): + start, end = value_range + if start <= value <= end: + return value + + # Test enumerations. + + elif isinstance(value_range, list): + if value in value_range: + return value + + # Test mappings. + + elif isinstance(value_range, dict): + if value_range.has_key(value): + return value_range[value] + + # Test value matches. + + elif value == value_range: + return value + + raise ValueError, value def get_datetime_structure(datetime): @@ -304,10 +428,10 @@ return l -def combine_datetime_with_qualifiers(datetime, qualifiers): +def combine_datetime_with_selectors(datetime, selectors): """ - Combine 'datetime' with 'qualifiers' to produce a structure for recurrence + Combine 'datetime' with 'selectors' to produce a structure for recurrence production. Initial datetime values appearing at broader resolutions than any qualifiers @@ -328,19 +452,19 @@ """ iter_dt = iter(get_datetime_structure(datetime)) - iter_q = iter(order_qualifiers(qualifiers)) + iter_sel = iter(selectors) l = [] from_dt = get_next(iter_dt) - from_q = get_next(iter_q) - have_q = False + from_sel = get_next(iter_sel) + have_sel = False # Consume from both lists, merging entries. - while from_dt and from_q: + while from_dt and from_sel: _level = from_dt.level - level = from_q.level + level = from_sel.level # Datetime value at wider resolution. @@ -350,13 +474,13 @@ # Qualifier at wider or same resolution as datetime value. else: - if not have_q: - add_initial_qualifier(from_q, level, l) - have_q = True + if not have_sel: + add_initial_selector(from_sel, level, l) + have_sel = True # Add the qualifier to the combined list. - l.append(from_q) + l.append(from_sel) # Datetime value at same resolution. @@ -365,7 +489,7 @@ # Get the next qualifier. - from_q = get_next(iter_q) + from_sel = get_next(iter_sel) # Complete the list by adding remaining datetime enumerators. @@ -382,30 +506,30 @@ # Complete the list by adding remaining qualifiers. - while from_q: - if not have_q: - add_initial_qualifier(from_q, level, l) - have_q = True + while from_sel: + if not have_sel: + add_initial_selector(from_sel, level, l) + have_sel = True # Add the qualifier to the combined list. - l.append(from_q) + l.append(from_sel) # Get the next qualifier. - from_q = get_next(iter_q) + from_sel = get_next(iter_sel) return l -def add_initial_qualifier(from_q, level, l): +def add_initial_selector(from_sel, level, l): """ - Take the first qualifier 'from_q' at the given resolution 'level', using it - to create an initial qualifier, adding it to the combined list 'l' if + Take the first selector 'from_sel' at the given resolution 'level', using it + to create an initial selector, adding it to the combined list 'l' if required. """ - if isinstance(from_q, Enum) and level > 0: + if isinstance(from_sel, Enum) and level > 0: repeat = Pattern(level - 1, {"interval" : 1}, None) l.append(repeat) @@ -640,9 +764,9 @@ """ Return a sorted copy of the given 'values', each having the form (weekday - number, instance number) using 'weekdays' to define the ordering of the - weekday numbers and 'limit' to determine the positions of negative instance - numbers. + number, instance number), where 'first_day' indicates the start of the + period in which these values apply, and where 'last_day' indicates the end + of the period. """ weekdays = get_ordered_weekdays(first_day) @@ -704,7 +828,7 @@ """ self.level = level - self.args = args + self.args = args or {} self.qualifier = qualifier self.selecting = selecting self.first = first @@ -739,6 +863,9 @@ return list(self.select(start, end, inclusive)) + def set_values(self, values): + self.args["values"] = values + class Pattern(Selector): "A selector of time periods according to a repeating pattern." @@ -747,7 +874,7 @@ Selector.__init__(self, level, args, qualifier, selecting, first) multiple = get_multiple(self.qualifier) - interval = self.args.get("interval", 1) + interval = self.get_interval() # Define the step between result periods. @@ -781,6 +908,12 @@ return PatternIterator(self, current, start, end, inclusive, self.step, self.unit_step) + def get_interval(self): + return self.args.get("interval", 1) + + def set_interval(self, interval): + self.args["interval"] = interval + class WeekDayFilter(Selector): "A selector of instances specified in terms of day numbers." @@ -807,12 +940,31 @@ else: current = context - values = [value for (value, index) in self.args["values"]] - return WeekDayIterator(self, current, start, end, inclusive, self.step, values) + return WeekDayIterator(self, current, start, end, inclusive, self.step, + self.get_weekdays()) current = first_day values = sort_weekdays(self.args["values"], first_day, last_day) - return WeekDayGeneralIterator(self, current, start, end, inclusive, self.step, values) + return WeekDayGeneralIterator(self, current, start, end, inclusive, + self.step, values) + + def get_values(self): + + """ + Return a sequence of (value, index) tuples selecting weekdays in the + applicable period. Each value is a 1-based index representing a weekday. + """ + + return self.args["values"] + + def get_weekdays(self): + + "Return only the 1-based weekday indexes." + + values = [] + for value, index in self.args["values"]: + values.append(value) + return values class Enum(Selector): @@ -823,8 +975,11 @@ self.step = scale(1, level) def materialise_items(self, context, start, end, inclusive=False): - values = sort_values(self.args["values"]) - return EnumIterator(self, context, start, end, inclusive, self.step, values) + return EnumIterator(self, context, start, end, inclusive, self.step, + self.get_values()) + + def get_values(self, limit=None): + return sort_values(self.args["values"], limit) class MonthDayFilter(Enum): @@ -832,8 +987,8 @@ def materialise_items(self, context, start, end, inclusive=False): last_day = end_of_month(context)[2] - values = sort_values(self.args["values"], last_day) - return EnumIterator(self, context, start, end, inclusive, self.step, values) + return EnumIterator(self, context, start, end, inclusive, self.step, + self.get_values(last_day)) class YearDayFilter(Enum): @@ -842,22 +997,21 @@ def materialise_items(self, context, start, end, inclusive=False): first_day = start_of_year(context) year_length = get_year_length(context) - values = sort_values(self.args["values"], year_length) - return YearDayFilterIterator(self, first_day, start, end, inclusive, self.step, values) - -special_enum_levels = { - "BYDAY" : WeekDayFilter, - "BYMONTHDAY" : MonthDayFilter, - "BYYEARDAY" : YearDayFilter, - } + return YearDayFilterIterator(self, first_day, start, end, inclusive, self.step, + self.get_values(year_length)) class LimitSelector(Selector): "A result set limit selector." def materialise_items(self, context, start, end, inclusive=False): - limit = self.args["values"][0] - return LimitIterator(self, context, start, end, inclusive, limit) + return LimitIterator(self, context, start, end, inclusive, self.get_limit()) + + def get_limit(self): + return self.args["values"][0] + + def set_limit(self, limit): + self.args["values"] = [limit] class PositionSelector(Selector): @@ -868,8 +1022,20 @@ self.step = scale(1, level) def materialise_items(self, context, start, end, inclusive=False): - values = convert_positions(sort_values(self.args["values"])) - return PositionIterator(self, context, start, end, inclusive, self.step, values) + return PositionIterator(self, context, start, end, inclusive, self.step, + self.get_positions()) + + def get_positions(self): + return convert_positions(sort_values(self.args["values"])) + + def set_positions(self, positions): + self.args["values"] = positions + +special_enum_levels = { + "BYDAY" : WeekDayFilter, + "BYMONTHDAY" : MonthDayFilter, + "BYYEARDAY" : YearDayFilter, + } # Iterator classes. @@ -1207,8 +1373,6 @@ else: raise -# Public functions. - def connect_selectors(selectors): """ @@ -1233,15 +1397,7 @@ return selectors[0] -def get_selector(dt, qualifiers): - - """ - Combine the initial datetime 'dt' with the given 'qualifiers', returning an - object that can be used to select recurrences described by the 'qualifiers'. - """ - - dt = to_tuple(dt) - return connect_selectors(combine_datetime_with_qualifiers(dt, qualifiers)) +# Public functions. def get_rule(dt, rule): @@ -1251,9 +1407,28 @@ selector object. """ + selectors = get_selectors_for_rule(rule) + return get_selector(dt, selectors) + +def get_selector(dt, selectors): + + """ + Combine the initial datetime 'dt' with the given 'selectors', returning an + object that can be used to select recurrences described by the 'selectors'. + """ + + dt = to_tuple(dt) + return connect_selectors(combine_datetime_with_selectors(dt, selectors)) + +def get_selectors_for_rule(rule): + + """ + Return a list of selectors implementing 'rule', useful for "explaining" how + a rule works. + """ + if not isinstance(rule, tuple): - rule = rule.split(";") - qualifiers = get_qualifiers(rule) - return get_selector(dt, qualifiers) + rule = (rule or "").split(";") + return order_qualifiers(get_qualifiers(rule)) # vim: tabstop=4 expandtab shiftwidth=4