imip-agent

Annotated vRecurrence.py

1037:e55229d8df6d
2016-01-31 Paul Boddie Added explanatory diagrams for scheduling functions to the resources page.
paul@33 1
#!/usr/bin/env python
paul@33 2
paul@33 3
"""
paul@33 4
Recurrence rule calculation.
paul@33 5
paul@358 6
Copyright (C) 2014, 2015 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@521 34
COUNT defines a number of instances
paul@521 35
UNTIL defines a limit to the selection.
paul@33 36
paul@33 37
BY... qualifiers select instances within each outer selection instance according
paul@33 38
to the recurrence of instances of the next highest resolution. For example,
paul@33 39
BYDAY selects days in weeks. Thus, if no explicit week recurrence is indicated,
paul@33 40
all weeks are selected within the selection of the next highest explicitly
paul@33 41
specified resolution, whether this is months or years.
paul@33 42
paul@33 43
BYSETPOS in conjunction with BY... qualifiers permit the selection of specific
paul@33 44
instances.
paul@33 45
paul@33 46
Within the FREQ resolution, BY... qualifiers refine selected instances.
paul@33 47
paul@33 48
Outside the FREQ resolution, BY... qualifiers select instances at the resolution
paul@33 49
concerned.
paul@33 50
paul@33 51
Thus, FREQ and BY... qualifiers need to be ordered in terms of increasing
paul@33 52
resolution (or decreasing scope).
paul@33 53
"""
paul@33 54
paul@34 55
from calendar import monthrange
paul@33 56
from datetime import date, datetime, timedelta
paul@33 57
import operator
paul@33 58
paul@33 59
# Frequency levels, specified by FREQ in iCalendar.
paul@33 60
paul@33 61
freq_levels = (
paul@42 62
    "YEARLY",
paul@42 63
    "MONTHLY",
paul@42 64
    "WEEKLY",
paul@44 65
    None,
paul@44 66
    None,
paul@33 67
    "DAILY",
paul@42 68
    "HOURLY",
paul@42 69
    "MINUTELY",
paul@42 70
    "SECONDLY"
paul@33 71
    )
paul@33 72
paul@33 73
# Enumeration levels, employed by BY... qualifiers.
paul@33 74
paul@33 75
enum_levels = (
paul@42 76
    None,
paul@44 77
    "BYMONTH",
paul@44 78
    "BYWEEKNO",
paul@44 79
    "BYYEARDAY",
paul@44 80
    "BYMONTHDAY",
paul@44 81
    "BYDAY",
paul@44 82
    "BYHOUR",
paul@44 83
    "BYMINUTE",
paul@44 84
    "BYSECOND"
paul@33 85
    )
paul@33 86
paul@33 87
# Map from levels to lengths of datetime tuples.
paul@33 88
paul@44 89
lengths = [1, 2, 3, 3, 3, 3, 4, 5, 6]
paul@33 90
positions = [l-1 for l in lengths]
paul@33 91
paul@33 92
# Map from qualifiers to interval units. Here, weeks are defined as 7 days.
paul@33 93
paul@33 94
units = {"WEEKLY" : 7}
paul@33 95
paul@33 96
# Make dictionaries mapping qualifiers to levels.
paul@33 97
paul@44 98
freq = dict([(level, i) for (i, level) in enumerate(freq_levels) if level])
paul@44 99
enum = dict([(level, i) for (i, level) in enumerate(enum_levels) if level])
paul@46 100
weekdays = dict([(weekday, i+1) for (i, weekday) in enumerate(["MO", "TU", "WE", "TH", "FR", "SA", "SU"])])
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@46 110
def get_parameters(values):
paul@46 111
paul@46 112
    "Return parameters from the given list of 'values'."
paul@46 113
paul@46 114
    d = {}
paul@46 115
    for value in values:
paul@46 116
        parts = value.split("=", 1)
paul@46 117
        if len(parts) < 2:
paul@46 118
            continue
paul@46 119
        key, value = parts
paul@46 120
        if key in ("COUNT", "BYSETPOS"):
paul@46 121
            d[key] = int(value)
paul@521 122
        else:
paul@521 123
            d[key] = value
paul@46 124
    return d
paul@46 125
paul@46 126
def get_qualifiers(values):
paul@46 127
paul@46 128
    """
paul@46 129
    Process the list of 'values' of the form "key=value", returning a list of
paul@358 130
    qualifiers of the form (qualifier name, args).
paul@46 131
    """
paul@46 132
paul@46 133
    qualifiers = []
paul@46 134
    frequency = None
paul@46 135
    interval = 1
paul@46 136
paul@46 137
    for value in values:
paul@46 138
        parts = value.split("=", 1)
paul@46 139
        if len(parts) < 2:
paul@46 140
            continue
paul@46 141
        key, value = parts
paul@46 142
        if key == "FREQ" and freq.has_key(value):
paul@46 143
            qualifier = frequency = (value, {})
paul@46 144
        elif key == "INTERVAL":
paul@46 145
            interval = int(value)
paul@46 146
            continue
paul@46 147
        elif enum.has_key(key):
paul@46 148
            qualifier = (key, {"values" : get_qualifier_values(key, value)})
paul@46 149
        else:
paul@46 150
            continue
paul@46 151
paul@46 152
        qualifiers.append(qualifier)
paul@46 153
paul@46 154
    if frequency:
paul@46 155
        frequency[1]["interval"] = interval
paul@46 156
paul@46 157
    return qualifiers
paul@46 158
paul@46 159
def get_qualifier_values(qualifier, value):
paul@46 160
paul@46 161
    """
paul@46 162
    For the given 'qualifier', process the 'value' string, returning a list of
paul@46 163
    suitable values.
paul@46 164
    """
paul@46 165
paul@46 166
    if qualifier != "BYDAY":
paul@46 167
        return map(int, value.split(","))
paul@46 168
paul@46 169
    values = []
paul@46 170
    for part in value.split(","):
paul@46 171
        weekday = weekdays.get(part[-2:])
paul@46 172
        if not weekday:
paul@46 173
            continue
paul@46 174
        index = part[:-2]
paul@46 175
        if index:
paul@46 176
            index = int(index)
paul@46 177
        else:
paul@46 178
            index = None
paul@46 179
        values.append((weekday, index))
paul@46 180
paul@46 181
    return values
paul@46 182
paul@33 183
def order_qualifiers(qualifiers):
paul@33 184
paul@33 185
    "Return the 'qualifiers' in order of increasing resolution."
paul@33 186
paul@33 187
    l = []
paul@33 188
paul@33 189
    for qualifier, args in qualifiers:
paul@33 190
        if enum.has_key(qualifier):
paul@33 191
            level = enum[qualifier]
paul@35 192
            if special_enum_levels.has_key(qualifier):
paul@33 193
                args["interval"] = 1
paul@35 194
                selector = special_enum_levels[qualifier]
paul@33 195
            else:
paul@33 196
                selector = Enum
paul@33 197
        else:
paul@33 198
            level = freq[qualifier]
paul@33 199
            selector = Pattern
paul@33 200
paul@42 201
        l.append(selector(level, args, qualifier))
paul@33 202
paul@42 203
    l.sort(key=lambda x: x.level)
paul@33 204
    return l
paul@33 205
paul@33 206
def get_datetime_structure(datetime):
paul@33 207
paul@33 208
    "Return the structure of 'datetime' for recurrence production."
paul@33 209
paul@33 210
    l = []
paul@42 211
    offset = 0
paul@42 212
    for level, value in enumerate(datetime):
paul@42 213
        if level == 2:
paul@44 214
            offset = 3
paul@42 215
        l.append(Enum(level + offset, {"values" : [value]}, "DT"))
paul@33 216
    return l
paul@33 217
paul@33 218
def combine_datetime_with_qualifiers(datetime, qualifiers):
paul@33 219
paul@33 220
    """
paul@33 221
    Combine 'datetime' with 'qualifiers' to produce a structure for recurrence
paul@33 222
    production.
paul@33 223
    """
paul@33 224
paul@33 225
    iter_dt = iter(get_datetime_structure(datetime))
paul@33 226
    iter_q = iter(order_qualifiers(qualifiers))
paul@33 227
paul@33 228
    l = []
paul@33 229
paul@33 230
    from_dt = get_next(iter_dt)
paul@33 231
    from_q = get_next(iter_q)
paul@33 232
paul@33 233
    have_q = False
paul@33 234
    context = []
paul@39 235
    context.append(from_dt.args["values"][0])
paul@33 236
paul@33 237
    # Consume from both lists, merging entries.
paul@33 238
paul@33 239
    while from_dt and from_q:
paul@42 240
        _level = from_dt.level
paul@42 241
        level = from_q.level
paul@33 242
paul@33 243
        # Datetime value at wider resolution.
paul@33 244
paul@42 245
        if _level < level:
paul@39 246
            from_dt = get_next(iter_dt)
paul@38 247
            context.append(from_dt.args["values"][0])
paul@33 248
paul@33 249
        # Qualifier at wider or same resolution as datetime value.
paul@33 250
paul@33 251
        else:
paul@33 252
            if not have_q:
paul@42 253
                if isinstance(from_q, Enum) and level > 0:
paul@45 254
                    repeat = Pattern(level - 1, {"interval" : 1}, None)
paul@38 255
                    repeat.context = tuple(context)
paul@33 256
                    l.append(repeat)
paul@33 257
                have_q = True
paul@33 258
paul@43 259
            from_q.context = tuple(context)
paul@43 260
            l.append(from_q)
paul@43 261
            from_q = get_next(iter_q)
paul@33 262
paul@43 263
            if _level == level:
paul@554 264
                context.append(from_dt.args["values"][0])
paul@33 265
                from_dt = get_next(iter_dt)
paul@33 266
paul@33 267
    # Complete the list.
paul@33 268
paul@33 269
    while from_dt:
paul@33 270
        l.append(from_dt)
paul@33 271
        from_dt = get_next(iter_dt)
paul@33 272
paul@33 273
    while from_q:
paul@33 274
        if not have_q:
paul@42 275
            if isinstance(from_q, Enum) and level > 0:
paul@45 276
                repeat = Pattern(level - 1, {"interval" : 1}, None)
paul@38 277
                repeat.context = tuple(context)
paul@33 278
                l.append(repeat)
paul@33 279
            have_q = True
paul@43 280
paul@43 281
        from_q.context = tuple(context)
paul@33 282
        l.append(from_q)
paul@33 283
        from_q = get_next(iter_q)
paul@33 284
paul@33 285
    return l
paul@33 286
paul@33 287
# Datetime arithmetic.
paul@33 288
paul@33 289
def combine(t1, t2):
paul@322 290
paul@322 291
    """
paul@322 292
    Combine tuples 't1' and 't2', returning a copy of 't1' filled with values
paul@322 293
    from 't2' in positions where 0 appeared in 't1'.
paul@322 294
    """
paul@322 295
paul@33 296
    return tuple(map(lambda x, y: x or y, t1, t2))
paul@33 297
paul@33 298
def scale(interval, pos):
paul@322 299
paul@322 300
    """
paul@322 301
    Scale the given 'interval' value to the indicated position 'pos', returning
paul@322 302
    a tuple with leading zero elements and 'interval' at the stated position.
paul@322 303
    """
paul@322 304
paul@33 305
    return (0,) * pos + (interval,)
paul@33 306
paul@33 307
def get_seconds(t):
paul@33 308
paul@33 309
    "Convert the sub-day part of 't' into seconds."
paul@33 310
paul@33 311
    t = t + (0,) * (6 - len(t))
paul@33 312
    return (t[3] * 60 + t[4]) * 60 + t[5]
paul@33 313
paul@33 314
def update(t, step):
paul@33 315
paul@33 316
    "Update 't' by 'step' at the resolution of 'step'."
paul@33 317
paul@33 318
    i = len(step)
paul@33 319
paul@33 320
    # Years only.
paul@33 321
paul@33 322
    if i == 1:
paul@33 323
        return (t[0] + step[0],) + tuple(t[1:])
paul@33 324
paul@33 325
    # Years and months.
paul@33 326
paul@33 327
    elif i == 2:
paul@33 328
        month = t[1] + step[1]
paul@33 329
        return (t[0] + step[0] + (month - 1) / 12, (month - 1) % 12 + 1) + tuple(t[2:])
paul@33 330
paul@33 331
    # Dates and datetimes.
paul@33 332
paul@33 333
    else:
paul@33 334
        updated_for_months = update(t, step[:2])
paul@33 335
paul@33 336
        # Dates only.
paul@33 337
paul@33 338
        if i == 3:
paul@33 339
            d = datetime(*updated_for_months)
paul@33 340
            s = timedelta(step[2])
paul@33 341
paul@33 342
        # Datetimes.
paul@33 343
paul@33 344
        else:
paul@33 345
            d = datetime(*updated_for_months)
paul@33 346
            s = timedelta(step[2], get_seconds(step))
paul@33 347
paul@39 348
        return to_tuple(d + s, len(t))
paul@39 349
paul@46 350
def to_tuple(d, n=None):
paul@322 351
paul@322 352
    "Return 'd' as a tuple, optionally trimming the result to 'n' positions."
paul@322 353
paul@46 354
    if not isinstance(d, date):
paul@46 355
        return d
paul@46 356
    if n is None:
paul@46 357
        if isinstance(d, datetime):
paul@46 358
            n = 6
paul@46 359
        else:
paul@46 360
            n = 3
paul@39 361
    return d.timetuple()[:n]
paul@39 362
paul@39 363
def get_first_day(first_day, weekday):
paul@322 364
paul@322 365
    "Return the first occurrence at or after 'first_day' of 'weekday'."
paul@322 366
paul@39 367
    first_day = date(*first_day)
paul@39 368
    first_weekday = first_day.isoweekday()
paul@39 369
    if first_weekday > weekday:
paul@39 370
        return first_day + timedelta(7 - first_weekday + weekday)
paul@39 371
    else:
paul@39 372
        return first_day + timedelta(weekday - first_weekday)
paul@39 373
paul@39 374
def get_last_day(last_day, weekday):
paul@322 375
paul@322 376
    "Return the last occurrence at or before 'last_day' of 'weekday'."
paul@322 377
paul@39 378
    last_day = date(*last_day)
paul@39 379
    last_weekday = last_day.isoweekday()
paul@39 380
    if last_weekday < weekday:
paul@39 381
        return last_day - timedelta(last_weekday + 7 - weekday)
paul@39 382
    else:
paul@39 383
        return last_day - timedelta(last_weekday - weekday)
paul@33 384
paul@33 385
# Classes for producing instances from recurrence structures.
paul@33 386
paul@33 387
class Selector:
paul@358 388
paul@358 389
    "A generic selector."
paul@358 390
paul@42 391
    def __init__(self, level, args, qualifier, selecting=None):
paul@358 392
paul@358 393
        """
paul@358 394
        Initialise at the given 'level' a selector employing the given 'args'
paul@358 395
        defined in the interpretation of recurrence rule qualifiers, with the
paul@358 396
        'qualifier' being the name of the rule qualifier, and 'selecting' being
paul@358 397
        an optional selector used to find more specific instances from those
paul@358 398
        found by this selector.
paul@358 399
        """
paul@358 400
paul@42 401
        self.level = level
paul@42 402
        self.pos = positions[level]
paul@33 403
        self.args = args
paul@33 404
        self.qualifier = qualifier
paul@33 405
        self.context = ()
paul@33 406
        self.selecting = selecting
paul@33 407
paul@33 408
    def __repr__(self):
paul@42 409
        return "%s(%r, %r, %r, %r)" % (self.__class__.__name__, self.level, self.args, self.qualifier, self.context)
paul@33 410
paul@359 411
    def materialise(self, start, end, count=None, setpos=None, inclusive=False):
paul@358 412
paul@358 413
        """
paul@358 414
        Starting at 'start', materialise instances up to but not including any
paul@358 415
        at 'end' or later, returning at most 'count' if specified, and returning
paul@358 416
        only the occurrences indicated by 'setpos' if specified. A list of
paul@358 417
        instances is returned.
paul@359 418
paul@359 419
        If 'inclusive' is specified, the selection of instances will include the
paul@359 420
        end of the search period if present in the results.
paul@358 421
        """
paul@358 422
paul@46 423
        start = to_tuple(start)
paul@46 424
        end = to_tuple(end)
paul@33 425
        counter = count and [0, count]
paul@359 426
        results = self.materialise_items(self.context, start, end, counter, setpos, inclusive)
paul@39 427
        results.sort()
paul@41 428
        return results[:count]
paul@33 429
paul@359 430
    def materialise_item(self, current, earliest, next, counter, setpos=None, inclusive=False):
paul@358 431
paul@358 432
        """
paul@358 433
        Given the 'current' instance, the 'earliest' acceptable instance, the
paul@358 434
        'next' instance, an instance 'counter', and the optional 'setpos'
paul@358 435
        criteria, return a list of result items. Where no selection within the
paul@358 436
        current instance occurs, the current instance will be returned as a
paul@358 437
        result if the same or later than the earliest acceptable instance.
paul@358 438
        """
paul@358 439
paul@45 440
        if self.selecting:
paul@359 441
            return self.selecting.materialise_items(current, earliest, next, counter, setpos, inclusive)
paul@358 442
        elif earliest <= current:
paul@45 443
            return [current]
paul@45 444
        else:
paul@45 445
            return []
paul@45 446
paul@45 447
    def convert_positions(self, setpos):
paul@358 448
paul@358 449
        "Convert 'setpos' to 0-based indexes."
paul@358 450
paul@45 451
        l = []
paul@45 452
        for pos in setpos:
paul@45 453
            lower = pos < 0 and pos or pos - 1
paul@45 454
            upper = pos > 0 and pos or pos < -1 and pos + 1 or None
paul@45 455
            l.append((lower, upper))
paul@45 456
        return l
paul@45 457
paul@45 458
    def select_positions(self, results, setpos):
paul@358 459
paul@358 460
        "Select in 'results' the 1-based positions given by 'setpos'."
paul@358 461
paul@45 462
        results.sort()
paul@45 463
        l = []
paul@45 464
        for lower, upper in self.convert_positions(setpos):
paul@45 465
            l += results[lower:upper]
paul@45 466
        return l
paul@45 467
paul@359 468
    def filter_by_period(self, results, start, end, inclusive):
paul@358 469
paul@358 470
        """
paul@358 471
        Filter 'results' so that only those at or after 'start' and before 'end'
paul@358 472
        are returned.
paul@359 473
paul@359 474
        If 'inclusive' is specified, the selection of instances will include the
paul@359 475
        end of the search period if present in the results.
paul@358 476
        """
paul@358 477
paul@45 478
        l = []
paul@45 479
        for result in results:
paul@359 480
            if start <= result and (inclusive and result <= end or result < end):
paul@45 481
                l.append(result)
paul@45 482
        return l
paul@33 483
paul@33 484
class Pattern(Selector):
paul@358 485
paul@358 486
    "A selector of instances according to a repeating pattern."
paul@358 487
paul@359 488
    def materialise_items(self, context, start, end, counter, setpos=None, inclusive=False):
paul@38 489
        first = scale(self.context[self.pos], self.pos)
paul@34 490
paul@34 491
        # Define the step between items.
paul@34 492
paul@33 493
        interval = self.args.get("interval", 1) * units.get(self.qualifier, 1)
paul@33 494
        step = scale(interval, self.pos)
paul@34 495
paul@34 496
        # Define the scale of a single item.
paul@34 497
paul@33 498
        unit_interval = units.get(self.qualifier, 1)
paul@33 499
        unit_step = scale(unit_interval, self.pos)
paul@34 500
paul@34 501
        current = combine(context, first)
paul@33 502
        results = []
paul@34 503
paul@359 504
        while (inclusive and current <= end or current < end) and (counter is None or counter[0] < counter[1]):
paul@33 505
            next = update(current, step)
paul@33 506
            current_end = update(current, unit_step)
paul@359 507
            interval_results = self.materialise_item(current, max(current, start), min(current_end, end), counter, setpos, inclusive)
paul@45 508
            if counter is not None:
paul@45 509
                counter[0] += len(interval_results)
paul@45 510
            results += interval_results
paul@33 511
            current = next
paul@34 512
paul@33 513
        return results
paul@33 514
paul@35 515
class WeekDayFilter(Selector):
paul@358 516
paul@358 517
    "A selector of instances specified in terms of day numbers."
paul@358 518
paul@359 519
    def materialise_items(self, context, start, end, counter, setpos=None, inclusive=False):
paul@39 520
        step = scale(1, 2)
paul@33 521
        results = []
paul@34 522
paul@39 523
        # Get weekdays in the year.
paul@39 524
paul@39 525
        if len(context) == 1:
paul@39 526
            first_day = (context[0], 1, 1)
paul@39 527
            last_day = (context[0], 12, 31)
paul@39 528
paul@39 529
        # Get weekdays in the month.
paul@39 530
paul@39 531
        elif len(context) == 2:
paul@39 532
            first_day = (context[0], context[1], 1)
paul@39 533
            last_day = update((context[0], context[1], 1), (0, 1, 0))
paul@39 534
            last_day = update(last_day, (0, 0, -1))
paul@39 535
paul@39 536
        # Get weekdays in the week.
paul@39 537
paul@39 538
        else:
paul@39 539
            current = context
paul@39 540
            values = [value for (value, index) in self.args["values"]]
paul@39 541
paul@359 542
            while (inclusive and current <= end or current < end):
paul@39 543
                next = update(current, step)
paul@39 544
                if date(*current).isoweekday() in values:
paul@359 545
                    results += self.materialise_item(current, max(current, start), min(next, end), counter, inclusive=inclusive)
paul@39 546
                current = next
paul@45 547
paul@45 548
            if setpos:
paul@45 549
                return self.select_positions(results, setpos)
paul@45 550
            else:
paul@45 551
                return results
paul@39 552
paul@39 553
        # Find each of the given days.
paul@39 554
paul@39 555
        for value, index in self.args["values"]:
paul@39 556
            if index is not None:
paul@39 557
                offset = timedelta(7 * (abs(index) - 1))
paul@39 558
paul@39 559
                if index < 0:
paul@39 560
                    current = to_tuple(get_last_day(last_day, value) - offset, 3)
paul@39 561
                else:
paul@39 562
                    current = to_tuple(get_first_day(first_day, value) + offset, 3)
paul@39 563
paul@45 564
                next = update(current, step)
paul@45 565
paul@45 566
                # To support setpos, only current and next bound the search, not
paul@45 567
                # the period in addition.
paul@45 568
paul@359 569
                results += self.materialise_item(current, current, next, counter, inclusive=inclusive)
paul@39 570
paul@39 571
            else:
paul@39 572
                if index < 0:
paul@39 573
                    current = to_tuple(get_last_day(last_day, value), 3)
paul@39 574
                    direction = operator.sub
paul@39 575
                else:
paul@39 576
                    current = to_tuple(get_first_day(first_day, value), 3)
paul@39 577
                    direction = operator.add
paul@39 578
paul@39 579
                while first_day <= current <= last_day:
paul@45 580
                    next = update(current, step)
paul@45 581
paul@45 582
                    # To support setpos, only current and next bound the search, not
paul@45 583
                    # the period in addition.
paul@45 584
paul@359 585
                    results += self.materialise_item(current, current, next, counter, inclusive=inclusive)
paul@39 586
                    current = to_tuple(direction(date(*current), timedelta(7)), 3)
paul@34 587
paul@45 588
        # Extract selected positions and remove out-of-period instances.
paul@45 589
paul@45 590
        if setpos:
paul@45 591
            results = self.select_positions(results, setpos)
paul@45 592
paul@359 593
        return self.filter_by_period(results, start, end, inclusive)
paul@33 594
paul@33 595
class Enum(Selector):
paul@359 596
    def materialise_items(self, context, start, end, counter, setpos=None, inclusive=False):
paul@33 597
        step = scale(1, self.pos)
paul@33 598
        results = []
paul@33 599
        for value in self.args["values"]:
paul@33 600
            current = combine(context, scale(value, self.pos))
paul@45 601
            next = update(current, step)
paul@45 602
paul@45 603
            # To support setpos, only current and next bound the search, not
paul@45 604
            # the period in addition.
paul@45 605
paul@359 606
            results += self.materialise_item(current, current, next, counter, setpos, inclusive)
paul@45 607
paul@45 608
        # Extract selected positions and remove out-of-period instances.
paul@45 609
paul@45 610
        if setpos:
paul@45 611
            results = self.select_positions(results, setpos)
paul@45 612
paul@359 613
        return self.filter_by_period(results, start, end, inclusive)
paul@35 614
paul@35 615
class MonthDayFilter(Enum):
paul@359 616
    def materialise_items(self, context, start, end, counter, setpos=None, inclusive=False):
paul@35 617
        last_day = monthrange(context[0], context[1])[1]
paul@35 618
        step = scale(1, self.pos)
paul@35 619
        results = []
paul@35 620
        for value in self.args["values"]:
paul@35 621
            if value < 0:
paul@35 622
                value = last_day + 1 + value
paul@35 623
            current = combine(context, scale(value, self.pos))
paul@45 624
            next = update(current, step)
paul@45 625
paul@45 626
            # To support setpos, only current and next bound the search, not
paul@45 627
            # the period in addition.
paul@45 628
paul@359 629
            results += self.materialise_item(current, current, next, counter, inclusive=inclusive)
paul@45 630
paul@45 631
        # Extract selected positions and remove out-of-period instances.
paul@45 632
paul@45 633
        if setpos:
paul@45 634
            results = self.select_positions(results, setpos)
paul@45 635
paul@359 636
        return self.filter_by_period(results, start, end, inclusive)
paul@33 637
paul@37 638
class YearDayFilter(Enum):
paul@359 639
    def materialise_items(self, context, start, end, counter, setpos=None, inclusive=False):
paul@37 640
        first_day = date(context[0], 1, 1)
paul@37 641
        next_first_day = date(context[0] + 1, 1, 1)
paul@37 642
        year_length = (next_first_day - first_day).days
paul@37 643
        step = scale(1, self.pos)
paul@37 644
        results = []
paul@37 645
        for value in self.args["values"]:
paul@37 646
            if value < 0:
paul@37 647
                value = year_length + 1 + value
paul@39 648
            current = to_tuple(first_day + timedelta(value - 1), 3)
paul@45 649
            next = update(current, step)
paul@45 650
paul@45 651
            # To support setpos, only current and next bound the search, not
paul@45 652
            # the period in addition.
paul@45 653
paul@359 654
            results += self.materialise_item(current, current, next, counter, inclusive=inclusive)
paul@45 655
paul@45 656
        # Extract selected positions and remove out-of-period instances.
paul@45 657
paul@45 658
        if setpos:
paul@45 659
            results = self.select_positions(results, setpos)
paul@45 660
paul@359 661
        return self.filter_by_period(results, start, end, inclusive)
paul@37 662
paul@46 663
special_enum_levels = {
paul@46 664
    "BYDAY" : WeekDayFilter,
paul@46 665
    "BYMONTHDAY" : MonthDayFilter,
paul@46 666
    "BYYEARDAY" : YearDayFilter,
paul@46 667
    }
paul@46 668
paul@46 669
# Public functions.
paul@46 670
paul@46 671
def connect_selectors(selectors):
paul@358 672
paul@358 673
    """
paul@358 674
    Make the 'selectors' reference each other in a hierarchy so that
paul@358 675
    materialising the principal selector causes the more specific ones to be
paul@358 676
    employed in the operation.
paul@358 677
    """
paul@358 678
paul@33 679
    current = selectors[0]
paul@33 680
    for selector in selectors[1:]:
paul@33 681
        current.selecting = selector
paul@33 682
        current = selector
paul@33 683
    return selectors[0]
paul@33 684
paul@46 685
def get_selector(dt, qualifiers):
paul@322 686
paul@322 687
    """
paul@322 688
    Combine the initial datetime 'dt' with the given 'qualifiers', returning an
paul@322 689
    object that can be used to select recurrences described by the 'qualifiers'.
paul@322 690
    """
paul@322 691
paul@46 692
    dt = to_tuple(dt)
paul@46 693
    return connect_selectors(combine_datetime_with_qualifiers(dt, qualifiers))
paul@46 694
paul@46 695
def get_rule(dt, rule):
paul@317 696
paul@317 697
    """
paul@317 698
    Using the given initial datetime 'dt', interpret the 'rule' (a semicolon-
paul@317 699
    separated collection of "key=value" strings), and return the resulting
paul@317 700
    selector object.
paul@317 701
    """
paul@317 702
paul@351 703
    if not isinstance(rule, tuple):
paul@351 704
        rule = rule.split(";")
paul@351 705
    qualifiers = get_qualifiers(rule)
paul@46 706
    return get_selector(dt, qualifiers)
paul@35 707
paul@33 708
# vim: tabstop=4 expandtab shiftwidth=4