imip-agent

Annotated vRecurrence.py

1039:a12150034cbd
2016-02-08 Paul Boddie Added a journal storage area, maintaining quota and collective scheduling data for scheduling decisions. Introduced confirmation and retraction functions for resource scheduling so that quotas and collective schedules can be maintained and thus queried by scheduling functions. Updated the documentation, tools and tests.
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