1.1 --- a/vRecurrence.py Fri Nov 24 17:09:36 2017 +0100
1.2 +++ b/vRecurrence.py Fri Dec 01 23:09:21 2017 +0100
1.3 @@ -53,6 +53,7 @@
1.4 """
1.5
1.6 from calendar import monthrange
1.7 +from collections import OrderedDict
1.8 from datetime import date, datetime, timedelta
1.9 import operator
1.10
1.11 @@ -120,8 +121,10 @@
1.12
1.13 # Weekdays: name -> 1-based value
1.14
1.15 -weekdays = {}
1.16 -for i, weekday in enumerate(["MO", "TU", "WE", "TH", "FR", "SA", "SU"]):
1.17 +weekday_values = ["MO", "TU", "WE", "TH", "FR", "SA", "SU"]
1.18 +
1.19 +weekdays = OrderedDict()
1.20 +for i, weekday in enumerate(weekday_values):
1.21 weekdays[weekday] = i + 1
1.22
1.23 # Functions for structuring the recurrences.
1.24 @@ -140,6 +143,9 @@
1.25 "Return parameters from the given list of 'values'."
1.26
1.27 d = {}
1.28 + if not values:
1.29 + return d
1.30 +
1.31 for value in values:
1.32 try:
1.33 key, value = value.split("=", 1)
1.34 @@ -158,13 +164,24 @@
1.35 qualifiers = []
1.36 frequency = None
1.37 interval = 1
1.38 + keys = set()
1.39
1.40 for value in values:
1.41 +
1.42 + # Ignore qualifiers without values.
1.43 +
1.44 try:
1.45 key, value = value.split("=", 1)
1.46 except ValueError:
1.47 continue
1.48
1.49 + # Ignore duplicate qualifiers.
1.50 +
1.51 + if key in keys:
1.52 + continue
1.53 +
1.54 + keys.add(key)
1.55 +
1.56 # Accept frequency indicators as qualifiers.
1.57
1.58 if key == "FREQ" and freq.has_key(value):
1.59 @@ -202,7 +219,7 @@
1.60 suitable values.
1.61 """
1.62
1.63 - # For non-weekday selection, obtain a list of day numbers.
1.64 + # For non-weekday selection, obtain a list of numbers.
1.65
1.66 if qualifier != "BYDAY":
1.67 return map(int, value.split(","))
1.68 @@ -212,78 +229,185 @@
1.69 values = []
1.70
1.71 for part in value.split(","):
1.72 - weekday = weekdays.get(part[-2:])
1.73 - if not weekday:
1.74 + index, weekday = part[:-2], part[-2:]
1.75 +
1.76 + weekday_number = weekdays.get(weekday)
1.77 + if not weekday_number:
1.78 continue
1.79 - index = part[:-2]
1.80 +
1.81 if index:
1.82 index = int(index)
1.83 else:
1.84 index = None
1.85 - values.append((weekday, index))
1.86 +
1.87 + values.append((weekday_number, index))
1.88
1.89 return values
1.90
1.91 def order_qualifiers(qualifiers):
1.92
1.93 - "Return the 'qualifiers' in order of increasing resolution."
1.94 + """
1.95 + Obtain 'qualifiers' in order of increasing resolution, producing and
1.96 + returning selector objects corresponding to the qualifiers.
1.97 + """
1.98
1.99 l = []
1.100 - max_level = 0
1.101
1.102 - # Special qualifiers.
1.103 -
1.104 - setpos = None
1.105 - count = None
1.106 + # Obtain selectors for the qualifiers.
1.107
1.108 for qualifier, args in qualifiers:
1.109 + selector = new_selector(qualifier, args)
1.110 + l.append(selector)
1.111
1.112 - # Distinguish between enumerators, used to select particular periods,
1.113 - # and frequencies, used to select repeating periods.
1.114 + return sort_selectors(l)
1.115
1.116 - if enum.has_key(qualifier):
1.117 - level = enum[qualifier]
1.118 +def new_selector(qualifier, args=None):
1.119
1.120 - # Certain enumerators produce their values in a special way.
1.121 + "Return a selector for 'qualifier' and 'args'."
1.122 +
1.123 + # Distinguish between enumerators, used to select particular periods,
1.124 + # and frequencies, used to select repeating periods.
1.125
1.126 - if special_enum_levels.has_key(qualifier):
1.127 - args["interval"] = 1
1.128 - selector = special_enum_levels[qualifier]
1.129 - else:
1.130 - selector = Enum
1.131 + if enum.has_key(qualifier):
1.132 + selector = special_enum_levels.get(qualifier, Enum)
1.133 + return selector(enum[qualifier], args, qualifier)
1.134 +
1.135 + # Create a selector that must be updated with the maximum resolution.
1.136
1.137 - elif qualifier == "BYSETPOS":
1.138 - setpos = args
1.139 - continue
1.140 + elif qualifier == "BYSETPOS":
1.141 + return PositionSelector(None, args, "BYSETPOS")
1.142 +
1.143 + elif qualifier == "COUNT":
1.144 + return LimitSelector(0, args, "COUNT")
1.145
1.146 - elif qualifier == "COUNT":
1.147 - count = args
1.148 - continue
1.149 + else:
1.150 + return Pattern(freq[qualifier], args, qualifier)
1.151 +
1.152 +def sort_selectors(selectors):
1.153
1.154 - else:
1.155 - level = freq[qualifier]
1.156 - selector = Pattern
1.157 + "Sort 'selectors' in order of increasing resolution."
1.158 +
1.159 + if not selectors:
1.160 + return selectors
1.161 +
1.162 + max_level = max(map(lambda selector: selector.level or 0, selectors))
1.163
1.164 - l.append(selector(level, args, qualifier))
1.165 - max_level = max(level, max_level)
1.166 + # Update the result set selector at the maximum resolution.
1.167
1.168 - # Add the result set selector at the maximum level of enumeration.
1.169 + for selector in selectors:
1.170 + if isinstance(selector, PositionSelector):
1.171 + selector.level = max_level
1.172
1.173 - if setpos is not None:
1.174 - l.append(PositionSelector(max_level, setpos, "BYSETPOS"))
1.175 + selectors.sort(key=selector_sort_key)
1.176 + return selectors
1.177
1.178 - # Add the result set truncator at the top level.
1.179 +def selector_sort_key(selector):
1.180
1.181 - if count is not None:
1.182 - l.append(LimitSelector(0, count, "COUNT"))
1.183 + "Produce a sort key for 'selector'."
1.184
1.185 # Make BYSETPOS sort earlier than the enumeration it modifies.
1.186 # Other BY... qualifiers sort earlier than selectors at the same resolution
1.187 # even though such things as "FREQ=HOURLY;BYHOUR=10" do not make much sense.
1.188
1.189 - l.sort(key=lambda x: (x.level, not x.qualifier.startswith("BY") and 2 or
1.190 - x.qualifier != "BYSETPOS" and 1 or 0))
1.191 - return l
1.192 + return (selector.level, not selector.qualifier.startswith("BY") and 2 or
1.193 + selector.qualifier != "BYSETPOS" and 1 or 0)
1.194 +
1.195 +def get_value_ranges(qualifier):
1.196 +
1.197 + """
1.198 + Return value ranges for 'qualifier'. Each range is either given by a tuple
1.199 + indicating the inclusive start and end values or by a list enumerating the
1.200 + values.
1.201 + """
1.202 +
1.203 + # Provide ranges for the numeric value of each qualifier.
1.204 +
1.205 + if qualifier == "BYMONTH":
1.206 + return [(-12, -1), (1, 12)],
1.207 + elif qualifier == "BYWEEKNO":
1.208 + return [(-53, -1), (1, 53)],
1.209 + elif qualifier == "BYYEARDAY":
1.210 + return [(-366, -1), (1, 366)],
1.211 + elif qualifier == "BYMONTHDAY":
1.212 + return [(-31, -1), (1, 31)],
1.213 + elif qualifier == "BYHOUR":
1.214 + return [(0, 23)],
1.215 + elif qualifier == "BYMINUTE":
1.216 + return [(0, 59)],
1.217 + elif qualifier == "BYSECOND":
1.218 + return [(0, 60)],
1.219 +
1.220 + # Provide ranges for the weekday value and index.
1.221 +
1.222 + elif qualifier == "BYDAY":
1.223 + return [weekdays], [(-53, -1), (1, 53), None]
1.224 +
1.225 + return None
1.226 +
1.227 +def check_values(qualifier, values):
1.228 +
1.229 + """
1.230 + Check for 'qualifier' the given 'values', returning checked values that may
1.231 + be converted or updated.
1.232 + """
1.233 +
1.234 + ranges = get_value_ranges(qualifier)
1.235 +
1.236 + if not ranges:
1.237 + return None
1.238 +
1.239 + # Match each value against each range specification.
1.240 +
1.241 + checked = []
1.242 +
1.243 + for v, value_ranges in zip(values, ranges):
1.244 +
1.245 + # Return None if no match occurred for the value.
1.246 +
1.247 + try:
1.248 + checked.append(check_value_in_ranges(v, value_ranges))
1.249 + except ValueError:
1.250 + return None
1.251 +
1.252 + # Return the checked values.
1.253 +
1.254 + return checked
1.255 +
1.256 +def check_value_in_ranges(value, value_ranges):
1.257 +
1.258 + """
1.259 + Check the given 'value' against the given 'value_ranges'. Return the
1.260 + checked value, possibly converted or updated, or raise ValueError if the
1.261 + value was not suitable.
1.262 + """
1.263 +
1.264 + for value_range in value_ranges:
1.265 +
1.266 + # Test actual ranges.
1.267 +
1.268 + if isinstance(value_range, tuple):
1.269 + start, end = value_range
1.270 + if start <= value <= end:
1.271 + return value
1.272 +
1.273 + # Test enumerations.
1.274 +
1.275 + elif isinstance(value_range, list):
1.276 + if value in value_range:
1.277 + return value
1.278 +
1.279 + # Test mappings.
1.280 +
1.281 + elif isinstance(value_range, dict):
1.282 + if value_range.has_key(value):
1.283 + return value_range[value]
1.284 +
1.285 + # Test value matches.
1.286 +
1.287 + elif value == value_range:
1.288 + return value
1.289 +
1.290 + raise ValueError, value
1.291
1.292 def get_datetime_structure(datetime):
1.293
1.294 @@ -304,10 +428,10 @@
1.295
1.296 return l
1.297
1.298 -def combine_datetime_with_qualifiers(datetime, qualifiers):
1.299 +def combine_datetime_with_selectors(datetime, selectors):
1.300
1.301 """
1.302 - Combine 'datetime' with 'qualifiers' to produce a structure for recurrence
1.303 + Combine 'datetime' with 'selectors' to produce a structure for recurrence
1.304 production.
1.305
1.306 Initial datetime values appearing at broader resolutions than any qualifiers
1.307 @@ -328,19 +452,19 @@
1.308 """
1.309
1.310 iter_dt = iter(get_datetime_structure(datetime))
1.311 - iter_q = iter(order_qualifiers(qualifiers))
1.312 + iter_sel = iter(selectors)
1.313
1.314 l = []
1.315
1.316 from_dt = get_next(iter_dt)
1.317 - from_q = get_next(iter_q)
1.318 - have_q = False
1.319 + from_sel = get_next(iter_sel)
1.320 + have_sel = False
1.321
1.322 # Consume from both lists, merging entries.
1.323
1.324 - while from_dt and from_q:
1.325 + while from_dt and from_sel:
1.326 _level = from_dt.level
1.327 - level = from_q.level
1.328 + level = from_sel.level
1.329
1.330 # Datetime value at wider resolution.
1.331
1.332 @@ -350,13 +474,13 @@
1.333 # Qualifier at wider or same resolution as datetime value.
1.334
1.335 else:
1.336 - if not have_q:
1.337 - add_initial_qualifier(from_q, level, l)
1.338 - have_q = True
1.339 + if not have_sel:
1.340 + add_initial_selector(from_sel, level, l)
1.341 + have_sel = True
1.342
1.343 # Add the qualifier to the combined list.
1.344
1.345 - l.append(from_q)
1.346 + l.append(from_sel)
1.347
1.348 # Datetime value at same resolution.
1.349
1.350 @@ -365,7 +489,7 @@
1.351
1.352 # Get the next qualifier.
1.353
1.354 - from_q = get_next(iter_q)
1.355 + from_sel = get_next(iter_sel)
1.356
1.357 # Complete the list by adding remaining datetime enumerators.
1.358
1.359 @@ -382,30 +506,30 @@
1.360
1.361 # Complete the list by adding remaining qualifiers.
1.362
1.363 - while from_q:
1.364 - if not have_q:
1.365 - add_initial_qualifier(from_q, level, l)
1.366 - have_q = True
1.367 + while from_sel:
1.368 + if not have_sel:
1.369 + add_initial_selector(from_sel, level, l)
1.370 + have_sel = True
1.371
1.372 # Add the qualifier to the combined list.
1.373
1.374 - l.append(from_q)
1.375 + l.append(from_sel)
1.376
1.377 # Get the next qualifier.
1.378
1.379 - from_q = get_next(iter_q)
1.380 + from_sel = get_next(iter_sel)
1.381
1.382 return l
1.383
1.384 -def add_initial_qualifier(from_q, level, l):
1.385 +def add_initial_selector(from_sel, level, l):
1.386
1.387 """
1.388 - Take the first qualifier 'from_q' at the given resolution 'level', using it
1.389 - to create an initial qualifier, adding it to the combined list 'l' if
1.390 + Take the first selector 'from_sel' at the given resolution 'level', using it
1.391 + to create an initial selector, adding it to the combined list 'l' if
1.392 required.
1.393 """
1.394
1.395 - if isinstance(from_q, Enum) and level > 0:
1.396 + if isinstance(from_sel, Enum) and level > 0:
1.397 repeat = Pattern(level - 1, {"interval" : 1}, None)
1.398 l.append(repeat)
1.399
1.400 @@ -640,9 +764,9 @@
1.401
1.402 """
1.403 Return a sorted copy of the given 'values', each having the form (weekday
1.404 - number, instance number) using 'weekdays' to define the ordering of the
1.405 - weekday numbers and 'limit' to determine the positions of negative instance
1.406 - numbers.
1.407 + number, instance number), where 'first_day' indicates the start of the
1.408 + period in which these values apply, and where 'last_day' indicates the end
1.409 + of the period.
1.410 """
1.411
1.412 weekdays = get_ordered_weekdays(first_day)
1.413 @@ -704,7 +828,7 @@
1.414 """
1.415
1.416 self.level = level
1.417 - self.args = args
1.418 + self.args = args or {}
1.419 self.qualifier = qualifier
1.420 self.selecting = selecting
1.421 self.first = first
1.422 @@ -739,6 +863,9 @@
1.423
1.424 return list(self.select(start, end, inclusive))
1.425
1.426 + def set_values(self, values):
1.427 + self.args["values"] = values
1.428 +
1.429 class Pattern(Selector):
1.430
1.431 "A selector of time periods according to a repeating pattern."
1.432 @@ -747,7 +874,7 @@
1.433 Selector.__init__(self, level, args, qualifier, selecting, first)
1.434
1.435 multiple = get_multiple(self.qualifier)
1.436 - interval = self.args.get("interval", 1)
1.437 + interval = self.get_interval()
1.438
1.439 # Define the step between result periods.
1.440
1.441 @@ -781,6 +908,12 @@
1.442 return PatternIterator(self, current, start, end, inclusive,
1.443 self.step, self.unit_step)
1.444
1.445 + def get_interval(self):
1.446 + return self.args.get("interval", 1)
1.447 +
1.448 + def set_interval(self, interval):
1.449 + self.args["interval"] = interval
1.450 +
1.451 class WeekDayFilter(Selector):
1.452
1.453 "A selector of instances specified in terms of day numbers."
1.454 @@ -807,12 +940,31 @@
1.455
1.456 else:
1.457 current = context
1.458 - values = [value for (value, index) in self.args["values"]]
1.459 - return WeekDayIterator(self, current, start, end, inclusive, self.step, values)
1.460 + return WeekDayIterator(self, current, start, end, inclusive, self.step,
1.461 + self.get_weekdays())
1.462
1.463 current = first_day
1.464 values = sort_weekdays(self.args["values"], first_day, last_day)
1.465 - return WeekDayGeneralIterator(self, current, start, end, inclusive, self.step, values)
1.466 + return WeekDayGeneralIterator(self, current, start, end, inclusive,
1.467 + self.step, values)
1.468 +
1.469 + def get_values(self):
1.470 +
1.471 + """
1.472 + Return a sequence of (value, index) tuples selecting weekdays in the
1.473 + applicable period. Each value is a 1-based index representing a weekday.
1.474 + """
1.475 +
1.476 + return self.args["values"]
1.477 +
1.478 + def get_weekdays(self):
1.479 +
1.480 + "Return only the 1-based weekday indexes."
1.481 +
1.482 + values = []
1.483 + for value, index in self.args["values"]:
1.484 + values.append(value)
1.485 + return values
1.486
1.487 class Enum(Selector):
1.488
1.489 @@ -823,8 +975,11 @@
1.490 self.step = scale(1, level)
1.491
1.492 def materialise_items(self, context, start, end, inclusive=False):
1.493 - values = sort_values(self.args["values"])
1.494 - return EnumIterator(self, context, start, end, inclusive, self.step, values)
1.495 + return EnumIterator(self, context, start, end, inclusive, self.step,
1.496 + self.get_values())
1.497 +
1.498 + def get_values(self, limit=None):
1.499 + return sort_values(self.args["values"], limit)
1.500
1.501 class MonthDayFilter(Enum):
1.502
1.503 @@ -832,8 +987,8 @@
1.504
1.505 def materialise_items(self, context, start, end, inclusive=False):
1.506 last_day = end_of_month(context)[2]
1.507 - values = sort_values(self.args["values"], last_day)
1.508 - return EnumIterator(self, context, start, end, inclusive, self.step, values)
1.509 + return EnumIterator(self, context, start, end, inclusive, self.step,
1.510 + self.get_values(last_day))
1.511
1.512 class YearDayFilter(Enum):
1.513
1.514 @@ -842,22 +997,21 @@
1.515 def materialise_items(self, context, start, end, inclusive=False):
1.516 first_day = start_of_year(context)
1.517 year_length = get_year_length(context)
1.518 - values = sort_values(self.args["values"], year_length)
1.519 - return YearDayFilterIterator(self, first_day, start, end, inclusive, self.step, values)
1.520 -
1.521 -special_enum_levels = {
1.522 - "BYDAY" : WeekDayFilter,
1.523 - "BYMONTHDAY" : MonthDayFilter,
1.524 - "BYYEARDAY" : YearDayFilter,
1.525 - }
1.526 + return YearDayFilterIterator(self, first_day, start, end, inclusive, self.step,
1.527 + self.get_values(year_length))
1.528
1.529 class LimitSelector(Selector):
1.530
1.531 "A result set limit selector."
1.532
1.533 def materialise_items(self, context, start, end, inclusive=False):
1.534 - limit = self.args["values"][0]
1.535 - return LimitIterator(self, context, start, end, inclusive, limit)
1.536 + return LimitIterator(self, context, start, end, inclusive, self.get_limit())
1.537 +
1.538 + def get_limit(self):
1.539 + return self.args["values"][0]
1.540 +
1.541 + def set_limit(self, limit):
1.542 + self.args["values"] = [limit]
1.543
1.544 class PositionSelector(Selector):
1.545
1.546 @@ -868,8 +1022,20 @@
1.547 self.step = scale(1, level)
1.548
1.549 def materialise_items(self, context, start, end, inclusive=False):
1.550 - values = convert_positions(sort_values(self.args["values"]))
1.551 - return PositionIterator(self, context, start, end, inclusive, self.step, values)
1.552 + return PositionIterator(self, context, start, end, inclusive, self.step,
1.553 + self.get_positions())
1.554 +
1.555 + def get_positions(self):
1.556 + return convert_positions(sort_values(self.args["values"]))
1.557 +
1.558 + def set_positions(self, positions):
1.559 + self.args["values"] = positions
1.560 +
1.561 +special_enum_levels = {
1.562 + "BYDAY" : WeekDayFilter,
1.563 + "BYMONTHDAY" : MonthDayFilter,
1.564 + "BYYEARDAY" : YearDayFilter,
1.565 + }
1.566
1.567 # Iterator classes.
1.568
1.569 @@ -1207,8 +1373,6 @@
1.570 else:
1.571 raise
1.572
1.573 -# Public functions.
1.574 -
1.575 def connect_selectors(selectors):
1.576
1.577 """
1.578 @@ -1233,15 +1397,7 @@
1.579
1.580 return selectors[0]
1.581
1.582 -def get_selector(dt, qualifiers):
1.583 -
1.584 - """
1.585 - Combine the initial datetime 'dt' with the given 'qualifiers', returning an
1.586 - object that can be used to select recurrences described by the 'qualifiers'.
1.587 - """
1.588 -
1.589 - dt = to_tuple(dt)
1.590 - return connect_selectors(combine_datetime_with_qualifiers(dt, qualifiers))
1.591 +# Public functions.
1.592
1.593 def get_rule(dt, rule):
1.594
1.595 @@ -1251,9 +1407,28 @@
1.596 selector object.
1.597 """
1.598
1.599 + selectors = get_selectors_for_rule(rule)
1.600 + return get_selector(dt, selectors)
1.601 +
1.602 +def get_selector(dt, selectors):
1.603 +
1.604 + """
1.605 + Combine the initial datetime 'dt' with the given 'selectors', returning an
1.606 + object that can be used to select recurrences described by the 'selectors'.
1.607 + """
1.608 +
1.609 + dt = to_tuple(dt)
1.610 + return connect_selectors(combine_datetime_with_selectors(dt, selectors))
1.611 +
1.612 +def get_selectors_for_rule(rule):
1.613 +
1.614 + """
1.615 + Return a list of selectors implementing 'rule', useful for "explaining" how
1.616 + a rule works.
1.617 + """
1.618 +
1.619 if not isinstance(rule, tuple):
1.620 - rule = rule.split(";")
1.621 - qualifiers = get_qualifiers(rule)
1.622 - return get_selector(dt, qualifiers)
1.623 + rule = (rule or "").split(";")
1.624 + return order_qualifiers(get_qualifiers(rule))
1.625
1.626 # vim: tabstop=4 expandtab shiftwidth=4