# HG changeset patch # User Paul Boddie # Date 1512166161 -3600 # Node ID 592a4701a05f064bd3ba0464bfeb4a4d8b1e72b8 # Parent 3b7c6541fb43881d34be7e5d797f588be3b546b1 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 3b7c6541fb43 -r 592a4701a05f vRecurrence.py --- a/vRecurrence.py Fri Nov 24 17:09:36 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