3.1 --- a/vRecurrence.py Sat Nov 25 00:11:38 2017 +0100
3.2 +++ b/vRecurrence.py Fri Dec 01 23:09:21 2017 +0100
3.3 @@ -53,6 +53,7 @@
3.4 """
3.5
3.6 from calendar import monthrange
3.7 +from collections import OrderedDict
3.8 from datetime import date, datetime, timedelta
3.9 import operator
3.10
3.11 @@ -120,8 +121,10 @@
3.12
3.13 # Weekdays: name -> 1-based value
3.14
3.15 -weekdays = {}
3.16 -for i, weekday in enumerate(["MO", "TU", "WE", "TH", "FR", "SA", "SU"]):
3.17 +weekday_values = ["MO", "TU", "WE", "TH", "FR", "SA", "SU"]
3.18 +
3.19 +weekdays = OrderedDict()
3.20 +for i, weekday in enumerate(weekday_values):
3.21 weekdays[weekday] = i + 1
3.22
3.23 # Functions for structuring the recurrences.
3.24 @@ -140,6 +143,9 @@
3.25 "Return parameters from the given list of 'values'."
3.26
3.27 d = {}
3.28 + if not values:
3.29 + return d
3.30 +
3.31 for value in values:
3.32 try:
3.33 key, value = value.split("=", 1)
3.34 @@ -158,13 +164,24 @@
3.35 qualifiers = []
3.36 frequency = None
3.37 interval = 1
3.38 + keys = set()
3.39
3.40 for value in values:
3.41 +
3.42 + # Ignore qualifiers without values.
3.43 +
3.44 try:
3.45 key, value = value.split("=", 1)
3.46 except ValueError:
3.47 continue
3.48
3.49 + # Ignore duplicate qualifiers.
3.50 +
3.51 + if key in keys:
3.52 + continue
3.53 +
3.54 + keys.add(key)
3.55 +
3.56 # Accept frequency indicators as qualifiers.
3.57
3.58 if key == "FREQ" and freq.has_key(value):
3.59 @@ -202,7 +219,7 @@
3.60 suitable values.
3.61 """
3.62
3.63 - # For non-weekday selection, obtain a list of day numbers.
3.64 + # For non-weekday selection, obtain a list of numbers.
3.65
3.66 if qualifier != "BYDAY":
3.67 return map(int, value.split(","))
3.68 @@ -212,78 +229,185 @@
3.69 values = []
3.70
3.71 for part in value.split(","):
3.72 - weekday = weekdays.get(part[-2:])
3.73 - if not weekday:
3.74 + index, weekday = part[:-2], part[-2:]
3.75 +
3.76 + weekday_number = weekdays.get(weekday)
3.77 + if not weekday_number:
3.78 continue
3.79 - index = part[:-2]
3.80 +
3.81 if index:
3.82 index = int(index)
3.83 else:
3.84 index = None
3.85 - values.append((weekday, index))
3.86 +
3.87 + values.append((weekday_number, index))
3.88
3.89 return values
3.90
3.91 def order_qualifiers(qualifiers):
3.92
3.93 - "Return the 'qualifiers' in order of increasing resolution."
3.94 + """
3.95 + Obtain 'qualifiers' in order of increasing resolution, producing and
3.96 + returning selector objects corresponding to the qualifiers.
3.97 + """
3.98
3.99 l = []
3.100 - max_level = 0
3.101
3.102 - # Special qualifiers.
3.103 -
3.104 - setpos = None
3.105 - count = None
3.106 + # Obtain selectors for the qualifiers.
3.107
3.108 for qualifier, args in qualifiers:
3.109 + selector = new_selector(qualifier, args)
3.110 + l.append(selector)
3.111
3.112 - # Distinguish between enumerators, used to select particular periods,
3.113 - # and frequencies, used to select repeating periods.
3.114 + return sort_selectors(l)
3.115
3.116 - if enum.has_key(qualifier):
3.117 - level = enum[qualifier]
3.118 +def new_selector(qualifier, args=None):
3.119
3.120 - # Certain enumerators produce their values in a special way.
3.121 + "Return a selector for 'qualifier' and 'args'."
3.122 +
3.123 + # Distinguish between enumerators, used to select particular periods,
3.124 + # and frequencies, used to select repeating periods.
3.125
3.126 - if special_enum_levels.has_key(qualifier):
3.127 - args["interval"] = 1
3.128 - selector = special_enum_levels[qualifier]
3.129 - else:
3.130 - selector = Enum
3.131 + if enum.has_key(qualifier):
3.132 + selector = special_enum_levels.get(qualifier, Enum)
3.133 + return selector(enum[qualifier], args, qualifier)
3.134 +
3.135 + # Create a selector that must be updated with the maximum resolution.
3.136
3.137 - elif qualifier == "BYSETPOS":
3.138 - setpos = args
3.139 - continue
3.140 + elif qualifier == "BYSETPOS":
3.141 + return PositionSelector(None, args, "BYSETPOS")
3.142 +
3.143 + elif qualifier == "COUNT":
3.144 + return LimitSelector(0, args, "COUNT")
3.145
3.146 - elif qualifier == "COUNT":
3.147 - count = args
3.148 - continue
3.149 + else:
3.150 + return Pattern(freq[qualifier], args, qualifier)
3.151 +
3.152 +def sort_selectors(selectors):
3.153
3.154 - else:
3.155 - level = freq[qualifier]
3.156 - selector = Pattern
3.157 + "Sort 'selectors' in order of increasing resolution."
3.158 +
3.159 + if not selectors:
3.160 + return selectors
3.161 +
3.162 + max_level = max(map(lambda selector: selector.level or 0, selectors))
3.163
3.164 - l.append(selector(level, args, qualifier))
3.165 - max_level = max(level, max_level)
3.166 + # Update the result set selector at the maximum resolution.
3.167
3.168 - # Add the result set selector at the maximum level of enumeration.
3.169 + for selector in selectors:
3.170 + if isinstance(selector, PositionSelector):
3.171 + selector.level = max_level
3.172
3.173 - if setpos is not None:
3.174 - l.append(PositionSelector(max_level, setpos, "BYSETPOS"))
3.175 + selectors.sort(key=selector_sort_key)
3.176 + return selectors
3.177
3.178 - # Add the result set truncator at the top level.
3.179 +def selector_sort_key(selector):
3.180
3.181 - if count is not None:
3.182 - l.append(LimitSelector(0, count, "COUNT"))
3.183 + "Produce a sort key for 'selector'."
3.184
3.185 # Make BYSETPOS sort earlier than the enumeration it modifies.
3.186 # Other BY... qualifiers sort earlier than selectors at the same resolution
3.187 # even though such things as "FREQ=HOURLY;BYHOUR=10" do not make much sense.
3.188
3.189 - l.sort(key=lambda x: (x.level, not x.qualifier.startswith("BY") and 2 or
3.190 - x.qualifier != "BYSETPOS" and 1 or 0))
3.191 - return l
3.192 + return (selector.level, not selector.qualifier.startswith("BY") and 2 or
3.193 + selector.qualifier != "BYSETPOS" and 1 or 0)
3.194 +
3.195 +def get_value_ranges(qualifier):
3.196 +
3.197 + """
3.198 + Return value ranges for 'qualifier'. Each range is either given by a tuple
3.199 + indicating the inclusive start and end values or by a list enumerating the
3.200 + values.
3.201 + """
3.202 +
3.203 + # Provide ranges for the numeric value of each qualifier.
3.204 +
3.205 + if qualifier == "BYMONTH":
3.206 + return [(-12, -1), (1, 12)],
3.207 + elif qualifier == "BYWEEKNO":
3.208 + return [(-53, -1), (1, 53)],
3.209 + elif qualifier == "BYYEARDAY":
3.210 + return [(-366, -1), (1, 366)],
3.211 + elif qualifier == "BYMONTHDAY":
3.212 + return [(-31, -1), (1, 31)],
3.213 + elif qualifier == "BYHOUR":
3.214 + return [(0, 23)],
3.215 + elif qualifier == "BYMINUTE":
3.216 + return [(0, 59)],
3.217 + elif qualifier == "BYSECOND":
3.218 + return [(0, 60)],
3.219 +
3.220 + # Provide ranges for the weekday value and index.
3.221 +
3.222 + elif qualifier == "BYDAY":
3.223 + return [weekdays], [(-53, -1), (1, 53), None]
3.224 +
3.225 + return None
3.226 +
3.227 +def check_values(qualifier, values):
3.228 +
3.229 + """
3.230 + Check for 'qualifier' the given 'values', returning checked values that may
3.231 + be converted or updated.
3.232 + """
3.233 +
3.234 + ranges = get_value_ranges(qualifier)
3.235 +
3.236 + if not ranges:
3.237 + return None
3.238 +
3.239 + # Match each value against each range specification.
3.240 +
3.241 + checked = []
3.242 +
3.243 + for v, value_ranges in zip(values, ranges):
3.244 +
3.245 + # Return None if no match occurred for the value.
3.246 +
3.247 + try:
3.248 + checked.append(check_value_in_ranges(v, value_ranges))
3.249 + except ValueError:
3.250 + return None
3.251 +
3.252 + # Return the checked values.
3.253 +
3.254 + return checked
3.255 +
3.256 +def check_value_in_ranges(value, value_ranges):
3.257 +
3.258 + """
3.259 + Check the given 'value' against the given 'value_ranges'. Return the
3.260 + checked value, possibly converted or updated, or raise ValueError if the
3.261 + value was not suitable.
3.262 + """
3.263 +
3.264 + for value_range in value_ranges:
3.265 +
3.266 + # Test actual ranges.
3.267 +
3.268 + if isinstance(value_range, tuple):
3.269 + start, end = value_range
3.270 + if start <= value <= end:
3.271 + return value
3.272 +
3.273 + # Test enumerations.
3.274 +
3.275 + elif isinstance(value_range, list):
3.276 + if value in value_range:
3.277 + return value
3.278 +
3.279 + # Test mappings.
3.280 +
3.281 + elif isinstance(value_range, dict):
3.282 + if value_range.has_key(value):
3.283 + return value_range[value]
3.284 +
3.285 + # Test value matches.
3.286 +
3.287 + elif value == value_range:
3.288 + return value
3.289 +
3.290 + raise ValueError, value
3.291
3.292 def get_datetime_structure(datetime):
3.293
3.294 @@ -304,10 +428,10 @@
3.295
3.296 return l
3.297
3.298 -def combine_datetime_with_qualifiers(datetime, qualifiers):
3.299 +def combine_datetime_with_selectors(datetime, selectors):
3.300
3.301 """
3.302 - Combine 'datetime' with 'qualifiers' to produce a structure for recurrence
3.303 + Combine 'datetime' with 'selectors' to produce a structure for recurrence
3.304 production.
3.305
3.306 Initial datetime values appearing at broader resolutions than any qualifiers
3.307 @@ -328,19 +452,19 @@
3.308 """
3.309
3.310 iter_dt = iter(get_datetime_structure(datetime))
3.311 - iter_q = iter(order_qualifiers(qualifiers))
3.312 + iter_sel = iter(selectors)
3.313
3.314 l = []
3.315
3.316 from_dt = get_next(iter_dt)
3.317 - from_q = get_next(iter_q)
3.318 - have_q = False
3.319 + from_sel = get_next(iter_sel)
3.320 + have_sel = False
3.321
3.322 # Consume from both lists, merging entries.
3.323
3.324 - while from_dt and from_q:
3.325 + while from_dt and from_sel:
3.326 _level = from_dt.level
3.327 - level = from_q.level
3.328 + level = from_sel.level
3.329
3.330 # Datetime value at wider resolution.
3.331
3.332 @@ -350,13 +474,13 @@
3.333 # Qualifier at wider or same resolution as datetime value.
3.334
3.335 else:
3.336 - if not have_q:
3.337 - add_initial_qualifier(from_q, level, l)
3.338 - have_q = True
3.339 + if not have_sel:
3.340 + add_initial_selector(from_sel, level, l)
3.341 + have_sel = True
3.342
3.343 # Add the qualifier to the combined list.
3.344
3.345 - l.append(from_q)
3.346 + l.append(from_sel)
3.347
3.348 # Datetime value at same resolution.
3.349
3.350 @@ -365,7 +489,7 @@
3.351
3.352 # Get the next qualifier.
3.353
3.354 - from_q = get_next(iter_q)
3.355 + from_sel = get_next(iter_sel)
3.356
3.357 # Complete the list by adding remaining datetime enumerators.
3.358
3.359 @@ -382,30 +506,30 @@
3.360
3.361 # Complete the list by adding remaining qualifiers.
3.362
3.363 - while from_q:
3.364 - if not have_q:
3.365 - add_initial_qualifier(from_q, level, l)
3.366 - have_q = True
3.367 + while from_sel:
3.368 + if not have_sel:
3.369 + add_initial_selector(from_sel, level, l)
3.370 + have_sel = True
3.371
3.372 # Add the qualifier to the combined list.
3.373
3.374 - l.append(from_q)
3.375 + l.append(from_sel)
3.376
3.377 # Get the next qualifier.
3.378
3.379 - from_q = get_next(iter_q)
3.380 + from_sel = get_next(iter_sel)
3.381
3.382 return l
3.383
3.384 -def add_initial_qualifier(from_q, level, l):
3.385 +def add_initial_selector(from_sel, level, l):
3.386
3.387 """
3.388 - Take the first qualifier 'from_q' at the given resolution 'level', using it
3.389 - to create an initial qualifier, adding it to the combined list 'l' if
3.390 + Take the first selector 'from_sel' at the given resolution 'level', using it
3.391 + to create an initial selector, adding it to the combined list 'l' if
3.392 required.
3.393 """
3.394
3.395 - if isinstance(from_q, Enum) and level > 0:
3.396 + if isinstance(from_sel, Enum) and level > 0:
3.397 repeat = Pattern(level - 1, {"interval" : 1}, None)
3.398 l.append(repeat)
3.399
3.400 @@ -640,9 +764,9 @@
3.401
3.402 """
3.403 Return a sorted copy of the given 'values', each having the form (weekday
3.404 - number, instance number) using 'weekdays' to define the ordering of the
3.405 - weekday numbers and 'limit' to determine the positions of negative instance
3.406 - numbers.
3.407 + number, instance number), where 'first_day' indicates the start of the
3.408 + period in which these values apply, and where 'last_day' indicates the end
3.409 + of the period.
3.410 """
3.411
3.412 weekdays = get_ordered_weekdays(first_day)
3.413 @@ -704,7 +828,7 @@
3.414 """
3.415
3.416 self.level = level
3.417 - self.args = args
3.418 + self.args = args or {}
3.419 self.qualifier = qualifier
3.420 self.selecting = selecting
3.421 self.first = first
3.422 @@ -739,6 +863,9 @@
3.423
3.424 return list(self.select(start, end, inclusive))
3.425
3.426 + def set_values(self, values):
3.427 + self.args["values"] = values
3.428 +
3.429 class Pattern(Selector):
3.430
3.431 "A selector of time periods according to a repeating pattern."
3.432 @@ -747,7 +874,7 @@
3.433 Selector.__init__(self, level, args, qualifier, selecting, first)
3.434
3.435 multiple = get_multiple(self.qualifier)
3.436 - interval = self.args.get("interval", 1)
3.437 + interval = self.get_interval()
3.438
3.439 # Define the step between result periods.
3.440
3.441 @@ -781,6 +908,12 @@
3.442 return PatternIterator(self, current, start, end, inclusive,
3.443 self.step, self.unit_step)
3.444
3.445 + def get_interval(self):
3.446 + return self.args.get("interval", 1)
3.447 +
3.448 + def set_interval(self, interval):
3.449 + self.args["interval"] = interval
3.450 +
3.451 class WeekDayFilter(Selector):
3.452
3.453 "A selector of instances specified in terms of day numbers."
3.454 @@ -807,12 +940,31 @@
3.455
3.456 else:
3.457 current = context
3.458 - values = [value for (value, index) in self.args["values"]]
3.459 - return WeekDayIterator(self, current, start, end, inclusive, self.step, values)
3.460 + return WeekDayIterator(self, current, start, end, inclusive, self.step,
3.461 + self.get_weekdays())
3.462
3.463 current = first_day
3.464 values = sort_weekdays(self.args["values"], first_day, last_day)
3.465 - return WeekDayGeneralIterator(self, current, start, end, inclusive, self.step, values)
3.466 + return WeekDayGeneralIterator(self, current, start, end, inclusive,
3.467 + self.step, values)
3.468 +
3.469 + def get_values(self):
3.470 +
3.471 + """
3.472 + Return a sequence of (value, index) tuples selecting weekdays in the
3.473 + applicable period. Each value is a 1-based index representing a weekday.
3.474 + """
3.475 +
3.476 + return self.args["values"]
3.477 +
3.478 + def get_weekdays(self):
3.479 +
3.480 + "Return only the 1-based weekday indexes."
3.481 +
3.482 + values = []
3.483 + for value, index in self.args["values"]:
3.484 + values.append(value)
3.485 + return values
3.486
3.487 class Enum(Selector):
3.488
3.489 @@ -823,8 +975,11 @@
3.490 self.step = scale(1, level)
3.491
3.492 def materialise_items(self, context, start, end, inclusive=False):
3.493 - values = sort_values(self.args["values"])
3.494 - return EnumIterator(self, context, start, end, inclusive, self.step, values)
3.495 + return EnumIterator(self, context, start, end, inclusive, self.step,
3.496 + self.get_values())
3.497 +
3.498 + def get_values(self, limit=None):
3.499 + return sort_values(self.args["values"], limit)
3.500
3.501 class MonthDayFilter(Enum):
3.502
3.503 @@ -832,8 +987,8 @@
3.504
3.505 def materialise_items(self, context, start, end, inclusive=False):
3.506 last_day = end_of_month(context)[2]
3.507 - values = sort_values(self.args["values"], last_day)
3.508 - return EnumIterator(self, context, start, end, inclusive, self.step, values)
3.509 + return EnumIterator(self, context, start, end, inclusive, self.step,
3.510 + self.get_values(last_day))
3.511
3.512 class YearDayFilter(Enum):
3.513
3.514 @@ -842,22 +997,21 @@
3.515 def materialise_items(self, context, start, end, inclusive=False):
3.516 first_day = start_of_year(context)
3.517 year_length = get_year_length(context)
3.518 - values = sort_values(self.args["values"], year_length)
3.519 - return YearDayFilterIterator(self, first_day, start, end, inclusive, self.step, values)
3.520 -
3.521 -special_enum_levels = {
3.522 - "BYDAY" : WeekDayFilter,
3.523 - "BYMONTHDAY" : MonthDayFilter,
3.524 - "BYYEARDAY" : YearDayFilter,
3.525 - }
3.526 + return YearDayFilterIterator(self, first_day, start, end, inclusive, self.step,
3.527 + self.get_values(year_length))
3.528
3.529 class LimitSelector(Selector):
3.530
3.531 "A result set limit selector."
3.532
3.533 def materialise_items(self, context, start, end, inclusive=False):
3.534 - limit = self.args["values"][0]
3.535 - return LimitIterator(self, context, start, end, inclusive, limit)
3.536 + return LimitIterator(self, context, start, end, inclusive, self.get_limit())
3.537 +
3.538 + def get_limit(self):
3.539 + return self.args["values"][0]
3.540 +
3.541 + def set_limit(self, limit):
3.542 + self.args["values"] = [limit]
3.543
3.544 class PositionSelector(Selector):
3.545
3.546 @@ -868,8 +1022,20 @@
3.547 self.step = scale(1, level)
3.548
3.549 def materialise_items(self, context, start, end, inclusive=False):
3.550 - values = convert_positions(sort_values(self.args["values"]))
3.551 - return PositionIterator(self, context, start, end, inclusive, self.step, values)
3.552 + return PositionIterator(self, context, start, end, inclusive, self.step,
3.553 + self.get_positions())
3.554 +
3.555 + def get_positions(self):
3.556 + return convert_positions(sort_values(self.args["values"]))
3.557 +
3.558 + def set_positions(self, positions):
3.559 + self.args["values"] = positions
3.560 +
3.561 +special_enum_levels = {
3.562 + "BYDAY" : WeekDayFilter,
3.563 + "BYMONTHDAY" : MonthDayFilter,
3.564 + "BYYEARDAY" : YearDayFilter,
3.565 + }
3.566
3.567 # Iterator classes.
3.568
3.569 @@ -1207,8 +1373,6 @@
3.570 else:
3.571 raise
3.572
3.573 -# Public functions.
3.574 -
3.575 def connect_selectors(selectors):
3.576
3.577 """
3.578 @@ -1233,15 +1397,7 @@
3.579
3.580 return selectors[0]
3.581
3.582 -def get_selector(dt, qualifiers):
3.583 -
3.584 - """
3.585 - Combine the initial datetime 'dt' with the given 'qualifiers', returning an
3.586 - object that can be used to select recurrences described by the 'qualifiers'.
3.587 - """
3.588 -
3.589 - dt = to_tuple(dt)
3.590 - return connect_selectors(combine_datetime_with_qualifiers(dt, qualifiers))
3.591 +# Public functions.
3.592
3.593 def get_rule(dt, rule):
3.594
3.595 @@ -1251,9 +1407,28 @@
3.596 selector object.
3.597 """
3.598
3.599 + selectors = get_selectors_for_rule(rule)
3.600 + return get_selector(dt, selectors)
3.601 +
3.602 +def get_selector(dt, selectors):
3.603 +
3.604 + """
3.605 + Combine the initial datetime 'dt' with the given 'selectors', returning an
3.606 + object that can be used to select recurrences described by the 'selectors'.
3.607 + """
3.608 +
3.609 + dt = to_tuple(dt)
3.610 + return connect_selectors(combine_datetime_with_selectors(dt, selectors))
3.611 +
3.612 +def get_selectors_for_rule(rule):
3.613 +
3.614 + """
3.615 + Return a list of selectors implementing 'rule', useful for "explaining" how
3.616 + a rule works.
3.617 + """
3.618 +
3.619 if not isinstance(rule, tuple):
3.620 - rule = rule.split(";")
3.621 - qualifiers = get_qualifiers(rule)
3.622 - return get_selector(dt, qualifiers)
3.623 + rule = (rule or "").split(";")
3.624 + return order_qualifiers(get_qualifiers(rule))
3.625
3.626 # vim: tabstop=4 expandtab shiftwidth=4