vContent

Annotated vRecurrence.py

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