imip-agent

Annotated vRecurrence.py

38:5e2b9a7ecb4f
2014-10-04 Paul Boddie Propagate context information through selectors for pattern iterators; prevent the introduction of enumerations from datetime information that will unnecessarily constrain patterns.
paul@33 1
#!/usr/bin/env python
paul@33 2
paul@33 3
"""
paul@33 4
Recurrence rule calculation.
paul@33 5
paul@33 6
Copyright (C) 2014 Paul Boddie <paul@boddie.org.uk>
paul@33 7
paul@33 8
This program is free software; you can redistribute it and/or modify it under
paul@33 9
the terms of the GNU General Public License as published by the Free Software
paul@33 10
Foundation; either version 3 of the License, or (at your option) any later
paul@33 11
version.
paul@33 12
paul@33 13
This program is distributed in the hope that it will be useful, but WITHOUT
paul@33 14
ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
paul@33 15
FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
paul@33 16
details.
paul@33 17
paul@33 18
You should have received a copy of the GNU General Public License along with
paul@33 19
this program.  If not, see <http://www.gnu.org/licenses/>.
paul@33 20
paul@33 21
----
paul@33 22
paul@33 23
References:
paul@33 24
paul@33 25
RFC 5545: Internet Calendaring and Scheduling Core Object Specification
paul@33 26
          (iCalendar)
paul@33 27
          http://tools.ietf.org/html/rfc5545
paul@33 28
paul@33 29
----
paul@33 30
paul@33 31
FREQ defines the selection resolution.
paul@33 32
DTSTART defines the start of the selection.
paul@33 33
INTERVAL defines the step of the selection.
paul@33 34
COUNT defines a number of instances; UNTIL defines a limit to the selection.
paul@33 35
paul@33 36
BY... qualifiers select instances within each outer selection instance according
paul@33 37
to the recurrence of instances of the next highest resolution. For example,
paul@33 38
BYDAY selects days in weeks. Thus, if no explicit week recurrence is indicated,
paul@33 39
all weeks are selected within the selection of the next highest explicitly
paul@33 40
specified resolution, whether this is months or years.
paul@33 41
paul@33 42
BYSETPOS in conjunction with BY... qualifiers permit the selection of specific
paul@33 43
instances.
paul@33 44
paul@33 45
Within the FREQ resolution, BY... qualifiers refine selected instances.
paul@33 46
paul@33 47
Outside the FREQ resolution, BY... qualifiers select instances at the resolution
paul@33 48
concerned.
paul@33 49
paul@33 50
Thus, FREQ and BY... qualifiers need to be ordered in terms of increasing
paul@33 51
resolution (or decreasing scope).
paul@33 52
"""
paul@33 53
paul@34 54
from calendar import monthrange
paul@33 55
from datetime import date, datetime, timedelta
paul@33 56
import operator
paul@33 57
paul@33 58
# Frequency levels, specified by FREQ in iCalendar.
paul@33 59
paul@33 60
freq_levels = (
paul@33 61
    "SECONDLY",
paul@33 62
    "MINUTELY",
paul@33 63
    "HOURLY",
paul@33 64
    "DAILY",
paul@33 65
    "WEEKLY",
paul@33 66
    "MONTHLY",
paul@33 67
    "YEARLY"
paul@33 68
    )
paul@33 69
paul@33 70
# Enumeration levels, employed by BY... qualifiers.
paul@33 71
paul@33 72
enum_levels = (
paul@33 73
    ("BYSECOND",),
paul@33 74
    ("BYMINUTE",),
paul@33 75
    ("BYHOUR",),
paul@33 76
    ("BYDAY", "BYMONTHDAY", "BYYEARDAY"),
paul@33 77
    ("BYWEEKNO",),
paul@33 78
    ("BYMONTH",)
paul@33 79
    )
paul@33 80
paul@33 81
# Map from levels to lengths of datetime tuples.
paul@33 82
paul@33 83
lengths = [6, 5, 4, 3, 3, 2, 1]
paul@33 84
positions = [l-1 for l in lengths]
paul@33 85
paul@33 86
# Map from qualifiers to interval units. Here, weeks are defined as 7 days.
paul@33 87
paul@33 88
units = {"WEEKLY" : 7}
paul@33 89
paul@33 90
# Map from tuple positions to base values at each resolution.
paul@33 91
paul@33 92
bases = [0, 1, 1, 0, 0, 0]
paul@33 93
paul@33 94
def basevalue(resolution):
paul@33 95
    return bases[positions[resolution]]
paul@33 96
paul@33 97
# Make dictionaries mapping qualifiers to levels.
paul@33 98
paul@33 99
freq = dict([(level, i) for (i, level) in enumerate(freq_levels)])
paul@33 100
enum = dict([(level, i) for (i, levels) in enumerate(enum_levels) for level in levels])
paul@33 101
paul@33 102
# Functions for structuring the recurrences.
paul@33 103
paul@33 104
def get_next(it):
paul@33 105
    try:
paul@33 106
        return it.next()
paul@33 107
    except StopIteration:
paul@33 108
        return None
paul@33 109
paul@33 110
def order_qualifiers(qualifiers):
paul@33 111
paul@33 112
    "Return the 'qualifiers' in order of increasing resolution."
paul@33 113
paul@33 114
    l = []
paul@33 115
paul@33 116
    for qualifier, args in qualifiers:
paul@33 117
        if enum.has_key(qualifier):
paul@33 118
            level = enum[qualifier]
paul@35 119
            if special_enum_levels.has_key(qualifier):
paul@33 120
                args["interval"] = 1
paul@35 121
                selector = special_enum_levels[qualifier]
paul@33 122
            else:
paul@33 123
                selector = Enum
paul@33 124
        else:
paul@33 125
            level = freq[qualifier]
paul@33 126
            selector = Pattern
paul@33 127
paul@33 128
        pos = positions[level]
paul@33 129
        l.append(selector(pos, args, qualifier))
paul@33 130
paul@33 131
    l.sort(key=lambda x: x.pos)
paul@33 132
    return l
paul@33 133
paul@33 134
def get_datetime_structure(datetime):
paul@33 135
paul@33 136
    "Return the structure of 'datetime' for recurrence production."
paul@33 137
paul@33 138
    l = []
paul@33 139
    for pos, value in enumerate(datetime):
paul@33 140
        l.append(Enum(pos, {"values" : [value]}, "DT"))
paul@33 141
    return l
paul@33 142
paul@33 143
def combine_datetime_with_qualifiers(datetime, qualifiers):
paul@33 144
paul@33 145
    """
paul@33 146
    Combine 'datetime' with 'qualifiers' to produce a structure for recurrence
paul@33 147
    production.
paul@33 148
    """
paul@33 149
paul@33 150
    iter_dt = iter(get_datetime_structure(datetime))
paul@33 151
    iter_q = iter(order_qualifiers(qualifiers))
paul@33 152
paul@33 153
    l = []
paul@33 154
paul@33 155
    from_dt = get_next(iter_dt)
paul@33 156
    from_q = get_next(iter_q)
paul@33 157
paul@33 158
    have_q = False
paul@33 159
    context = []
paul@33 160
paul@33 161
    # Consume from both lists, merging entries.
paul@33 162
paul@33 163
    while from_dt and from_q:
paul@33 164
        _pos = from_dt.pos
paul@33 165
        pos = from_q.pos
paul@33 166
paul@33 167
        # Datetime value at wider resolution.
paul@33 168
paul@33 169
        if _pos < pos:
paul@38 170
            context.append(from_dt.args["values"][0])
paul@33 171
            from_dt = get_next(iter_dt)
paul@33 172
paul@33 173
        # Qualifier at wider or same resolution as datetime value.
paul@33 174
paul@33 175
        else:
paul@33 176
            if not have_q:
paul@33 177
                if isinstance(from_q, Enum) and pos > 0:
paul@38 178
                    repeat = Pattern(pos - 1, {"interval" : 1}, "REPEAT")
paul@38 179
                    repeat.context = tuple(context)
paul@33 180
                    l.append(repeat)
paul@33 181
                else:
paul@33 182
                    from_q.context = tuple(context)
paul@33 183
                have_q = True
paul@33 184
paul@33 185
            # Either introduce the qualifier first.
paul@33 186
paul@33 187
            if _pos > pos:
paul@33 188
                l.append(from_q)
paul@33 189
paul@33 190
            # Or combine the qualifier and value details.
paul@33 191
paul@33 192
            else:
paul@38 193
                context.append(from_dt.args["values"][0])
paul@38 194
                l.append(combine_context_with_qualifier(context, from_q))
paul@33 195
                from_dt = get_next(iter_dt)
paul@33 196
paul@33 197
            from_q = get_next(iter_q)
paul@33 198
paul@33 199
    # Complete the list.
paul@33 200
paul@33 201
    while from_dt:
paul@33 202
        l.append(from_dt)
paul@33 203
        from_dt = get_next(iter_dt)
paul@33 204
paul@33 205
    while from_q:
paul@33 206
        if not have_q:
paul@33 207
            if isinstance(from_q, Enum) and pos > 0:
paul@38 208
                repeat = Pattern(pos - 1, {"interval" : 1}, "REPEAT")
paul@38 209
                repeat.context = tuple(context)
paul@33 210
                l.append(repeat)
paul@33 211
            else:
paul@33 212
                from_q.context = tuple(context)
paul@33 213
            have_q = True
paul@33 214
        l.append(from_q)
paul@33 215
        from_q = get_next(iter_q)
paul@33 216
paul@33 217
    return l
paul@33 218
paul@38 219
def combine_context_with_qualifier(context, from_q):
paul@33 220
paul@33 221
    """
paul@38 222
    Combine 'context' information (a datetime) and 'from_q' (a qualifier),
paul@38 223
    imposing the datetime value information on any qualifiers.
paul@33 224
    """
paul@33 225
paul@38 226
    if not from_q.args.has_key("values"):
paul@38 227
        from_q.context = tuple(context)
paul@33 228
    return from_q
paul@33 229
paul@33 230
# Datetime arithmetic.
paul@33 231
paul@33 232
def combine(t1, t2):
paul@33 233
    return tuple(map(lambda x, y: x or y, t1, t2))
paul@33 234
paul@33 235
def scale(interval, pos):
paul@33 236
    return (0,) * pos + (interval,)
paul@33 237
paul@33 238
def get_seconds(t):
paul@33 239
paul@33 240
    "Convert the sub-day part of 't' into seconds."
paul@33 241
paul@33 242
    t = t + (0,) * (6 - len(t))
paul@33 243
    return (t[3] * 60 + t[4]) * 60 + t[5]
paul@33 244
paul@33 245
def update(t, step):
paul@33 246
paul@33 247
    "Update 't' by 'step' at the resolution of 'step'."
paul@33 248
paul@33 249
    i = len(step)
paul@33 250
paul@33 251
    # Years only.
paul@33 252
paul@33 253
    if i == 1:
paul@33 254
        return (t[0] + step[0],) + tuple(t[1:])
paul@33 255
paul@33 256
    # Years and months.
paul@33 257
paul@33 258
    elif i == 2:
paul@33 259
        month = t[1] + step[1]
paul@33 260
        return (t[0] + step[0] + (month - 1) / 12, (month - 1) % 12 + 1) + tuple(t[2:])
paul@33 261
paul@33 262
    # Dates and datetimes.
paul@33 263
paul@33 264
    else:
paul@33 265
        updated_for_months = update(t, step[:2])
paul@33 266
paul@33 267
        # Dates only.
paul@33 268
paul@33 269
        if i == 3:
paul@33 270
            d = datetime(*updated_for_months)
paul@33 271
            s = timedelta(step[2])
paul@33 272
paul@33 273
        # Datetimes.
paul@33 274
paul@33 275
        else:
paul@33 276
            d = datetime(*updated_for_months)
paul@33 277
            s = timedelta(step[2], get_seconds(step))
paul@33 278
paul@33 279
        return (d + s).timetuple()[:len(t)]
paul@33 280
paul@33 281
# Classes for producing instances from recurrence structures.
paul@33 282
paul@33 283
class Selector:
paul@33 284
    def __init__(self, pos, args, qualifier, selecting=None):
paul@33 285
        self.pos = pos
paul@33 286
        self.args = args
paul@33 287
        self.qualifier = qualifier
paul@33 288
        self.context = ()
paul@33 289
        self.selecting = selecting
paul@33 290
paul@33 291
    def __repr__(self):
paul@33 292
        return "%s(%r, %r, %r, %r)" % (self.__class__.__name__, self.pos, self.args, self.qualifier, self.context)
paul@33 293
paul@35 294
    def materialise(self, start, end, count=None):
paul@33 295
        counter = count and [0, count]
paul@35 296
        return self.materialise_items(self.context, start, end, counter)
paul@33 297
paul@35 298
    def materialise_item(self, current, last, next, counter):
paul@34 299
        if counter is None or counter[0] < counter[1]:
paul@34 300
            if self.selecting:
paul@35 301
                return self.selecting.materialise_items(current, last, next, counter)
paul@35 302
            elif last <= current:
paul@34 303
                if counter is not None:
paul@34 304
                    counter[0] += 1
paul@34 305
                return [current]
paul@35 306
        return []
paul@33 307
paul@33 308
class Pattern(Selector):
paul@35 309
    def materialise_items(self, context, start, end, counter):
paul@38 310
        first = scale(self.context[self.pos], self.pos)
paul@34 311
paul@34 312
        # Define the step between items.
paul@34 313
paul@33 314
        interval = self.args.get("interval", 1) * units.get(self.qualifier, 1)
paul@33 315
        step = scale(interval, self.pos)
paul@34 316
paul@34 317
        # Define the scale of a single item.
paul@34 318
paul@33 319
        unit_interval = units.get(self.qualifier, 1)
paul@33 320
        unit_step = scale(unit_interval, self.pos)
paul@34 321
paul@34 322
        current = combine(context, first)
paul@33 323
        results = []
paul@34 324
paul@33 325
        while current < end and (counter is None or counter[0] < counter[1]):
paul@33 326
            next = update(current, step)
paul@33 327
            current_end = update(current, unit_step)
paul@35 328
            results += self.materialise_item(current, max(current, start), min(current_end, end), counter)
paul@33 329
            current = next
paul@34 330
paul@33 331
        return results
paul@33 332
paul@35 333
class WeekDayFilter(Selector):
paul@35 334
    def materialise_items(self, context, start, end, counter):
paul@34 335
        first = scale(bases[self.pos], self.pos)
paul@34 336
paul@34 337
        # Define the step between items to be tested.
paul@34 338
paul@33 339
        step = scale(1, self.pos)
paul@34 340
paul@34 341
        current = combine(context, first)
paul@33 342
        results = []
paul@34 343
paul@33 344
        while current < end and (counter is None or counter[0] < counter[1]):
paul@33 345
            next = update(current, step)
paul@35 346
            results += self.materialise_item(current, max(current, start), min(next, end), counter)
paul@33 347
            current = next
paul@34 348
paul@33 349
        return results
paul@33 350
paul@35 351
    def materialise_item(self, current, last, next, counter):
paul@33 352
        values = self.args["values"]
paul@34 353
paul@34 354
        # Select by day in week, also by occurrence in month.
paul@34 355
paul@35 356
        for value, index in values:
paul@35 357
            if datetime(*current).isoweekday() == value:
paul@35 358
                last_day = monthrange(current[0], current[1])[1]
paul@35 359
                index_from_start = (current[2] - 1) / 7
paul@35 360
                index_from_end = -(1 + (last_day - current[2]) / 7)
paul@35 361
                if index is None or index in (index_from_start, index_from_end):
paul@35 362
                    return Selector.materialise_item(self, current, last, next, counter)
paul@34 363
paul@33 364
        return []
paul@33 365
paul@33 366
class Enum(Selector):
paul@35 367
    def materialise_items(self, context, start, end, counter):
paul@33 368
        step = scale(1, self.pos)
paul@33 369
        results = []
paul@33 370
        for value in self.args["values"]:
paul@33 371
            current = combine(context, scale(value, self.pos))
paul@33 372
            if current < end and (counter is None or counter[0] < counter[1]):
paul@33 373
                next = update(current, step)
paul@35 374
                results += self.materialise_item(current, max(current, start), min(next, end), counter)
paul@35 375
        return results
paul@35 376
paul@35 377
class MonthDayFilter(Enum):
paul@35 378
    def materialise_items(self, context, start, end, counter):
paul@35 379
        last_day = monthrange(context[0], context[1])[1]
paul@35 380
        step = scale(1, self.pos)
paul@35 381
        results = []
paul@35 382
        for value in self.args["values"]:
paul@35 383
            if value < 0:
paul@35 384
                value = last_day + 1 + value
paul@35 385
            current = combine(context, scale(value, self.pos))
paul@35 386
            if current < end and (counter is None or counter[0] < counter[1]):
paul@35 387
                next = update(current, step)
paul@35 388
                results += self.materialise_item(current, max(current, start), min(next, end), counter)
paul@33 389
        return results
paul@33 390
paul@37 391
class YearDayFilter(Enum):
paul@37 392
    def materialise_items(self, context, start, end, counter):
paul@37 393
        first_day = date(context[0], 1, 1)
paul@37 394
        next_first_day = date(context[0] + 1, 1, 1)
paul@37 395
        year_length = (next_first_day - first_day).days
paul@37 396
        step = scale(1, self.pos)
paul@37 397
        results = []
paul@37 398
        for value in self.args["values"]:
paul@37 399
            if value < 0:
paul@37 400
                value = year_length + 1 + value
paul@37 401
            current = (first_day + timedelta(value - 1)).timetuple()[:3]
paul@37 402
            if current < end and (counter is None or counter[0] < counter[1]):
paul@37 403
                next = update(current, step)
paul@37 404
                results += self.materialise_item(current, max(current, start), min(next, end), counter)
paul@37 405
        return results
paul@37 406
paul@33 407
def process(selectors):
paul@33 408
    current = selectors[0]
paul@33 409
    for selector in selectors[1:]:
paul@33 410
        current.selecting = selector
paul@33 411
        current = selector
paul@33 412
    return selectors[0]
paul@33 413
paul@35 414
special_enum_levels = {
paul@35 415
    "BYDAY" : WeekDayFilter,
paul@35 416
    "BYMONTHDAY" : MonthDayFilter,
paul@37 417
    "BYYEARDAY" : YearDayFilter,
paul@35 418
    }
paul@35 419
paul@33 420
# vim: tabstop=4 expandtab shiftwidth=4