imip-agent

Annotated vRecurrence.py

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