imip-agent

Annotated vRecurrence.py

1272:65e999dd88f0
2017-09-18 Paul Boddie Added a convenience method for loading objects. Added docstrings. client-editing-simplification
paul@33 1
#!/usr/bin/env python
paul@33 2
paul@33 3
"""
paul@33 4
Recurrence rule calculation.
paul@33 5
paul@1237 6
Copyright (C) 2014, 2015, 2017 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@1241 87
# Levels defining days.
paul@1241 88
paul@1241 89
daylevels = [2, 3, 4, 5]
paul@1241 90
paul@33 91
# Map from levels to lengths of datetime tuples.
paul@33 92
paul@44 93
lengths = [1, 2, 3, 3, 3, 3, 4, 5, 6]
paul@33 94
positions = [l-1 for l in lengths]
paul@33 95
paul@1241 96
# Define the lowest values at each resolution (years, months, days... hours,
paul@1241 97
# minutes, seconds).
paul@1241 98
paul@1241 99
firstvalues = [0, 1, 1, 1, 1, 1, 0, 0, 0]
paul@1241 100
paul@33 101
# Map from qualifiers to interval units. Here, weeks are defined as 7 days.
paul@33 102
paul@33 103
units = {"WEEKLY" : 7}
paul@33 104
paul@33 105
# Make dictionaries mapping qualifiers to levels.
paul@33 106
paul@44 107
freq = dict([(level, i) for (i, level) in enumerate(freq_levels) if level])
paul@44 108
enum = dict([(level, i) for (i, level) in enumerate(enum_levels) if level])
paul@46 109
weekdays = dict([(weekday, i+1) for (i, weekday) in enumerate(["MO", "TU", "WE", "TH", "FR", "SA", "SU"])])
paul@33 110
paul@33 111
# Functions for structuring the recurrences.
paul@33 112
paul@33 113
def get_next(it):
paul@33 114
    try:
paul@33 115
        return it.next()
paul@33 116
    except StopIteration:
paul@33 117
        return None
paul@33 118
paul@46 119
def get_parameters(values):
paul@46 120
paul@46 121
    "Return parameters from the given list of 'values'."
paul@46 122
paul@46 123
    d = {}
paul@46 124
    for value in values:
paul@46 125
        parts = value.split("=", 1)
paul@46 126
        if len(parts) < 2:
paul@46 127
            continue
paul@46 128
        key, value = parts
paul@46 129
        if key in ("COUNT", "BYSETPOS"):
paul@46 130
            d[key] = int(value)
paul@521 131
        else:
paul@521 132
            d[key] = value
paul@46 133
    return d
paul@46 134
paul@46 135
def get_qualifiers(values):
paul@46 136
paul@46 137
    """
paul@46 138
    Process the list of 'values' of the form "key=value", returning a list of
paul@358 139
    qualifiers of the form (qualifier name, args).
paul@46 140
    """
paul@46 141
paul@46 142
    qualifiers = []
paul@46 143
    frequency = None
paul@46 144
    interval = 1
paul@46 145
paul@46 146
    for value in values:
paul@46 147
        parts = value.split("=", 1)
paul@46 148
        if len(parts) < 2:
paul@46 149
            continue
paul@46 150
        key, value = parts
paul@1239 151
paul@1239 152
        # Accept frequency indicators as qualifiers.
paul@1239 153
paul@46 154
        if key == "FREQ" and freq.has_key(value):
paul@46 155
            qualifier = frequency = (value, {})
paul@1239 156
paul@1239 157
        # Accept interval indicators for frequency qualifier parameterisation.
paul@1239 158
paul@46 159
        elif key == "INTERVAL":
paul@46 160
            interval = int(value)
paul@46 161
            continue
paul@1239 162
paul@1239 163
        # Accept enumerators as qualifiers.
paul@1239 164
paul@46 165
        elif enum.has_key(key):
paul@46 166
            qualifier = (key, {"values" : get_qualifier_values(key, value)})
paul@1239 167
paul@1239 168
        # Ignore other items.
paul@1239 169
paul@46 170
        else:
paul@46 171
            continue
paul@46 172
paul@46 173
        qualifiers.append(qualifier)
paul@46 174
paul@1239 175
    # Parameterise any frequency qualifier with the interval.
paul@1239 176
paul@46 177
    if frequency:
paul@46 178
        frequency[1]["interval"] = interval
paul@46 179
paul@46 180
    return qualifiers
paul@46 181
paul@46 182
def get_qualifier_values(qualifier, value):
paul@46 183
paul@46 184
    """
paul@46 185
    For the given 'qualifier', process the 'value' string, returning a list of
paul@46 186
    suitable values.
paul@46 187
    """
paul@46 188
paul@46 189
    if qualifier != "BYDAY":
paul@46 190
        return map(int, value.split(","))
paul@46 191
paul@46 192
    values = []
paul@46 193
    for part in value.split(","):
paul@46 194
        weekday = weekdays.get(part[-2:])
paul@46 195
        if not weekday:
paul@46 196
            continue
paul@46 197
        index = part[:-2]
paul@46 198
        if index:
paul@46 199
            index = int(index)
paul@46 200
        else:
paul@46 201
            index = None
paul@46 202
        values.append((weekday, index))
paul@46 203
paul@46 204
    return values
paul@46 205
paul@33 206
def order_qualifiers(qualifiers):
paul@33 207
paul@33 208
    "Return the 'qualifiers' in order of increasing resolution."
paul@33 209
paul@33 210
    l = []
paul@33 211
paul@33 212
    for qualifier, args in qualifiers:
paul@1237 213
paul@1237 214
        # Distinguish between enumerators, used to select particular periods,
paul@1237 215
        # and frequencies, used to select repeating periods.
paul@1237 216
paul@33 217
        if enum.has_key(qualifier):
paul@33 218
            level = enum[qualifier]
paul@35 219
            if special_enum_levels.has_key(qualifier):
paul@33 220
                args["interval"] = 1
paul@35 221
                selector = special_enum_levels[qualifier]
paul@33 222
            else:
paul@33 223
                selector = Enum
paul@33 224
        else:
paul@33 225
            level = freq[qualifier]
paul@33 226
            selector = Pattern
paul@33 227
paul@42 228
        l.append(selector(level, args, qualifier))
paul@33 229
paul@42 230
    l.sort(key=lambda x: x.level)
paul@33 231
    return l
paul@33 232
paul@33 233
def get_datetime_structure(datetime):
paul@33 234
paul@33 235
    "Return the structure of 'datetime' for recurrence production."
paul@33 236
paul@33 237
    l = []
paul@42 238
    offset = 0
paul@1237 239
paul@42 240
    for level, value in enumerate(datetime):
paul@1237 241
paul@1237 242
        # At the day number, adjust the frequency level offset to reference
paul@1237 243
        # days (and then hours, minutes, seconds).
paul@1237 244
paul@42 245
        if level == 2:
paul@44 246
            offset = 3
paul@1237 247
paul@42 248
        l.append(Enum(level + offset, {"values" : [value]}, "DT"))
paul@1237 249
paul@33 250
    return l
paul@33 251
paul@33 252
def combine_datetime_with_qualifiers(datetime, qualifiers):
paul@33 253
paul@33 254
    """
paul@33 255
    Combine 'datetime' with 'qualifiers' to produce a structure for recurrence
paul@33 256
    production.
paul@1241 257
paul@1241 258
    Initial datetime values appearing at broader resolutions than any qualifiers
paul@1241 259
    are ignored, since their details will be used when materialising the
paul@1241 260
    results.
paul@1241 261
paul@1241 262
    Qualifiers are accumulated in order to define a selection. Datetime values
paul@1241 263
    occurring between qualifiers or at the same resolution as qualifiers are
paul@1241 264
    ignored.
paul@1241 265
paul@1241 266
    Any remaining datetime values are introduced as enumerators, provided that
paul@1241 267
    they do not conflict with qualifiers. For example, specific day values
paul@1241 268
    conflict with day selectors and weekly qualifiers.
paul@1241 269
paul@1241 270
    The purpose of the remaining datetime values is to define a result within
paul@1241 271
    a period selected by the most precise qualifier. For example, the selection
paul@1241 272
    of a day and month in a year recurrence.
paul@33 273
    """
paul@33 274
paul@33 275
    iter_dt = iter(get_datetime_structure(datetime))
paul@33 276
    iter_q = iter(order_qualifiers(qualifiers))
paul@33 277
paul@33 278
    l = []
paul@33 279
paul@33 280
    from_dt = get_next(iter_dt)
paul@33 281
    from_q = get_next(iter_q)
paul@33 282
    have_q = False
paul@1237 283
paul@33 284
    # Consume from both lists, merging entries.
paul@33 285
paul@33 286
    while from_dt and from_q:
paul@42 287
        _level = from_dt.level
paul@42 288
        level = from_q.level
paul@33 289
paul@1241 290
        # Datetime value at wider resolution.
paul@33 291
paul@42 292
        if _level < level:
paul@39 293
            from_dt = get_next(iter_dt)
paul@33 294
paul@33 295
        # Qualifier at wider or same resolution as datetime value.
paul@33 296
paul@33 297
        else:
paul@33 298
            if not have_q:
paul@1241 299
                add_initial_qualifier(from_q, level, l)
paul@33 300
                have_q = True
paul@33 301
paul@1241 302
            # Add the qualifier to the combined list.
paul@1237 303
paul@43 304
            l.append(from_q)
paul@1237 305
paul@1241 306
            # Datetime value at same resolution.
paul@33 307
paul@43 308
            if _level == level:
paul@33 309
                from_dt = get_next(iter_dt)
paul@33 310
paul@1237 311
            # Get the next qualifier.
paul@1237 312
paul@1237 313
            from_q = get_next(iter_q)
paul@1237 314
paul@1237 315
    # Complete the list by adding remaining datetime enumerators.
paul@33 316
paul@33 317
    while from_dt:
paul@1241 318
paul@1241 319
        # Ignore datetime values that conflict with day-level qualifiers.
paul@1241 320
paul@1241 321
        if not l or from_dt.level != freq["DAILY"] or l[-1].level not in daylevels:
paul@1241 322
            l.append(from_dt)
paul@1241 323
paul@33 324
        from_dt = get_next(iter_dt)
paul@33 325
paul@1237 326
    # Complete the list by adding remaining qualifiers.
paul@1237 327
paul@33 328
    while from_q:
paul@33 329
        if not have_q:
paul@1241 330
            add_initial_qualifier(from_q, level, l)
paul@33 331
            have_q = True
paul@43 332
paul@1241 333
        # Add the qualifier to the combined list.
paul@1237 334
paul@33 335
        l.append(from_q)
paul@1237 336
paul@1237 337
        # Get the next qualifier.
paul@1237 338
paul@33 339
        from_q = get_next(iter_q)
paul@33 340
paul@33 341
    return l
paul@33 342
paul@1241 343
def add_initial_qualifier(from_q, level, l):
paul@1237 344
paul@1237 345
    """
paul@1237 346
    Take the first qualifier 'from_q' at the given resolution 'level', using it
paul@1241 347
    to create an initial qualifier, adding it to the combined list 'l' if
paul@1241 348
    required.
paul@1237 349
    """
paul@1237 350
paul@1237 351
    if isinstance(from_q, Enum) and level > 0:
paul@1237 352
        repeat = Pattern(level - 1, {"interval" : 1}, None)
paul@1237 353
        l.append(repeat)
paul@1237 354
paul@33 355
# Datetime arithmetic.
paul@33 356
paul@33 357
def combine(t1, t2):
paul@322 358
paul@322 359
    """
paul@322 360
    Combine tuples 't1' and 't2', returning a copy of 't1' filled with values
paul@322 361
    from 't2' in positions where 0 appeared in 't1'.
paul@322 362
    """
paul@322 363
paul@33 364
    return tuple(map(lambda x, y: x or y, t1, t2))
paul@33 365
paul@33 366
def scale(interval, pos):
paul@322 367
paul@322 368
    """
paul@322 369
    Scale the given 'interval' value to the indicated position 'pos', returning
paul@322 370
    a tuple with leading zero elements and 'interval' at the stated position.
paul@322 371
    """
paul@322 372
paul@33 373
    return (0,) * pos + (interval,)
paul@33 374
paul@33 375
def get_seconds(t):
paul@33 376
paul@33 377
    "Convert the sub-day part of 't' into seconds."
paul@33 378
paul@33 379
    t = t + (0,) * (6 - len(t))
paul@33 380
    return (t[3] * 60 + t[4]) * 60 + t[5]
paul@33 381
paul@33 382
def update(t, step):
paul@33 383
paul@33 384
    "Update 't' by 'step' at the resolution of 'step'."
paul@33 385
paul@33 386
    i = len(step)
paul@33 387
paul@33 388
    # Years only.
paul@33 389
paul@33 390
    if i == 1:
paul@33 391
        return (t[0] + step[0],) + tuple(t[1:])
paul@33 392
paul@33 393
    # Years and months.
paul@33 394
paul@33 395
    elif i == 2:
paul@33 396
        month = t[1] + step[1]
paul@33 397
        return (t[0] + step[0] + (month - 1) / 12, (month - 1) % 12 + 1) + tuple(t[2:])
paul@33 398
paul@33 399
    # Dates and datetimes.
paul@33 400
paul@33 401
    else:
paul@33 402
        updated_for_months = update(t, step[:2])
paul@33 403
paul@33 404
        # Dates only.
paul@33 405
paul@33 406
        if i == 3:
paul@33 407
            d = datetime(*updated_for_months)
paul@33 408
            s = timedelta(step[2])
paul@33 409
paul@33 410
        # Datetimes.
paul@33 411
paul@33 412
        else:
paul@33 413
            d = datetime(*updated_for_months)
paul@33 414
            s = timedelta(step[2], get_seconds(step))
paul@33 415
paul@39 416
        return to_tuple(d + s, len(t))
paul@39 417
paul@46 418
def to_tuple(d, n=None):
paul@322 419
paul@322 420
    "Return 'd' as a tuple, optionally trimming the result to 'n' positions."
paul@322 421
paul@46 422
    if not isinstance(d, date):
paul@46 423
        return d
paul@46 424
    if n is None:
paul@46 425
        if isinstance(d, datetime):
paul@46 426
            n = 6
paul@46 427
        else:
paul@46 428
            n = 3
paul@39 429
    return d.timetuple()[:n]
paul@39 430
paul@39 431
def get_first_day(first_day, weekday):
paul@322 432
paul@322 433
    "Return the first occurrence at or after 'first_day' of 'weekday'."
paul@322 434
paul@39 435
    first_day = date(*first_day)
paul@39 436
    first_weekday = first_day.isoweekday()
paul@39 437
    if first_weekday > weekday:
paul@39 438
        return first_day + timedelta(7 - first_weekday + weekday)
paul@39 439
    else:
paul@39 440
        return first_day + timedelta(weekday - first_weekday)
paul@39 441
paul@39 442
def get_last_day(last_day, weekday):
paul@322 443
paul@322 444
    "Return the last occurrence at or before 'last_day' of 'weekday'."
paul@322 445
paul@39 446
    last_day = date(*last_day)
paul@39 447
    last_weekday = last_day.isoweekday()
paul@39 448
    if last_weekday < weekday:
paul@39 449
        return last_day - timedelta(last_weekday + 7 - weekday)
paul@39 450
    else:
paul@39 451
        return last_day - timedelta(last_weekday - weekday)
paul@33 452
paul@33 453
# Classes for producing instances from recurrence structures.
paul@33 454
paul@33 455
class Selector:
paul@358 456
paul@358 457
    "A generic selector."
paul@358 458
paul@1241 459
    def __init__(self, level, args, qualifier, selecting=None, first=False):
paul@358 460
paul@358 461
        """
paul@358 462
        Initialise at the given 'level' a selector employing the given 'args'
paul@358 463
        defined in the interpretation of recurrence rule qualifiers, with the
paul@358 464
        'qualifier' being the name of the rule qualifier, and 'selecting' being
paul@358 465
        an optional selector used to find more specific instances from those
paul@358 466
        found by this selector.
paul@358 467
        """
paul@358 468
paul@42 469
        self.level = level
paul@33 470
        self.args = args
paul@33 471
        self.qualifier = qualifier
paul@1237 472
        self.selecting = selecting
paul@1241 473
        self.first = first
paul@1237 474
paul@1237 475
        # Define the index of values from datetimes involved with this selector.
paul@1237 476
paul@1237 477
        self.pos = positions[level]
paul@33 478
paul@33 479
    def __repr__(self):
paul@1241 480
        return "%s(%r, %r, %r, %r)" % (self.__class__.__name__, self.level, self.args, self.qualifier, self.first)
paul@33 481
paul@359 482
    def materialise(self, start, end, count=None, setpos=None, inclusive=False):
paul@358 483
paul@358 484
        """
paul@358 485
        Starting at 'start', materialise instances up to but not including any
paul@358 486
        at 'end' or later, returning at most 'count' if specified, and returning
paul@358 487
        only the occurrences indicated by 'setpos' if specified. A list of
paul@358 488
        instances is returned.
paul@359 489
paul@359 490
        If 'inclusive' is specified, the selection of instances will include the
paul@359 491
        end of the search period if present in the results.
paul@358 492
        """
paul@358 493
paul@46 494
        start = to_tuple(start)
paul@46 495
        end = to_tuple(end)
paul@33 496
        counter = count and [0, count]
paul@1241 497
        results = self.materialise_items(start, start, end, counter, setpos, inclusive)
paul@39 498
        results.sort()
paul@41 499
        return results[:count]
paul@33 500
paul@359 501
    def materialise_item(self, current, earliest, next, counter, setpos=None, inclusive=False):
paul@358 502
paul@358 503
        """
paul@358 504
        Given the 'current' instance, the 'earliest' acceptable instance, the
paul@358 505
        'next' instance, an instance 'counter', and the optional 'setpos'
paul@358 506
        criteria, return a list of result items. Where no selection within the
paul@358 507
        current instance occurs, the current instance will be returned as a
paul@358 508
        result if the same or later than the earliest acceptable instance.
paul@358 509
        """
paul@358 510
paul@45 511
        if self.selecting:
paul@359 512
            return self.selecting.materialise_items(current, earliest, next, counter, setpos, inclusive)
paul@358 513
        elif earliest <= current:
paul@45 514
            return [current]
paul@45 515
        else:
paul@45 516
            return []
paul@45 517
paul@45 518
    def convert_positions(self, setpos):
paul@358 519
paul@358 520
        "Convert 'setpos' to 0-based indexes."
paul@358 521
paul@45 522
        l = []
paul@45 523
        for pos in setpos:
paul@45 524
            lower = pos < 0 and pos or pos - 1
paul@45 525
            upper = pos > 0 and pos or pos < -1 and pos + 1 or None
paul@45 526
            l.append((lower, upper))
paul@45 527
        return l
paul@45 528
paul@45 529
    def select_positions(self, results, setpos):
paul@358 530
paul@358 531
        "Select in 'results' the 1-based positions given by 'setpos'."
paul@358 532
paul@45 533
        results.sort()
paul@45 534
        l = []
paul@45 535
        for lower, upper in self.convert_positions(setpos):
paul@45 536
            l += results[lower:upper]
paul@45 537
        return l
paul@45 538
paul@359 539
    def filter_by_period(self, results, start, end, inclusive):
paul@358 540
paul@358 541
        """
paul@358 542
        Filter 'results' so that only those at or after 'start' and before 'end'
paul@358 543
        are returned.
paul@359 544
paul@359 545
        If 'inclusive' is specified, the selection of instances will include the
paul@359 546
        end of the search period if present in the results.
paul@358 547
        """
paul@358 548
paul@45 549
        l = []
paul@45 550
        for result in results:
paul@359 551
            if start <= result and (inclusive and result <= end or result < end):
paul@45 552
                l.append(result)
paul@45 553
        return l
paul@33 554
paul@33 555
class Pattern(Selector):
paul@358 556
paul@1237 557
    "A selector of time periods according to a repeating pattern."
paul@358 558
paul@359 559
    def materialise_items(self, context, start, end, counter, setpos=None, inclusive=False):
paul@1237 560
paul@1237 561
        """
paul@1237 562
        Bounded by the given 'context', return periods within 'start' to 'end',
paul@1237 563
        updating the 'counter', selecting only the indexes specified by 'setpos'
paul@1237 564
        (if given).
paul@1237 565
paul@1237 566
        If 'inclusive' is specified, the selection of periods will include those
paul@1237 567
        starting at the end of the search period, if present in the results.
paul@1237 568
        """
paul@1237 569
paul@1237 570
        # Define the step between result periods.
paul@34 571
paul@33 572
        interval = self.args.get("interval", 1) * units.get(self.qualifier, 1)
paul@33 573
        step = scale(interval, self.pos)
paul@34 574
paul@1237 575
        # Define the scale of a single period.
paul@34 576
paul@33 577
        unit_interval = units.get(self.qualifier, 1)
paul@33 578
        unit_step = scale(unit_interval, self.pos)
paul@34 579
paul@1241 580
        # Employ the context as the current period if this is the first
paul@1241 581
        # qualifier in the selection chain.
paul@1241 582
paul@1241 583
        if self.first:
paul@1241 584
            current = context[:self.pos+1]
paul@1237 585
paul@1241 586
        # Otherwise, obtain the first value at this resolution within the
paul@1241 587
        # context period.
paul@1241 588
paul@1241 589
        else:
paul@1241 590
            first = scale(firstvalues[self.level], self.pos)
paul@1241 591
            current = combine(context[:self.pos], first)
paul@1241 592
paul@33 593
        results = []
paul@34 594
paul@1237 595
        # Obtain periods before the end (and also at the end if inclusive),
paul@1237 596
        # provided that any limit imposed by the counter has not been exceeded.
paul@1237 597
paul@1237 598
        while (inclusive and current <= end or current < end) and \
paul@1237 599
              (counter is None or counter[0] < counter[1]):
paul@1237 600
paul@1237 601
            # Increment the current datetime by the step for the next period.
paul@1237 602
paul@33 603
            next = update(current, step)
paul@1237 604
paul@1237 605
            # Determine the end point of the current period.
paul@1237 606
paul@33 607
            current_end = update(current, unit_step)
paul@1237 608
paul@1237 609
            # Obtain any period or periods within the bounds defined by the
paul@1237 610
            # current period and any contraining start and end points, plus
paul@1237 611
            # counter, setpos and inclusive details.
paul@1237 612
paul@359 613
            interval_results = self.materialise_item(current, max(current, start), min(current_end, end), counter, setpos, inclusive)
paul@1237 614
paul@1237 615
            # Update the counter with the number of identified results.
paul@1237 616
paul@45 617
            if counter is not None:
paul@45 618
                counter[0] += len(interval_results)
paul@1237 619
paul@1237 620
            # Accumulate the results.
paul@1237 621
paul@45 622
            results += interval_results
paul@1237 623
paul@1237 624
            # Visit the next instance.
paul@1237 625
paul@33 626
            current = next
paul@34 627
paul@33 628
        return results
paul@33 629
paul@35 630
class WeekDayFilter(Selector):
paul@358 631
paul@358 632
    "A selector of instances specified in terms of day numbers."
paul@358 633
paul@359 634
    def materialise_items(self, context, start, end, counter, setpos=None, inclusive=False):
paul@39 635
        step = scale(1, 2)
paul@33 636
        results = []
paul@34 637
paul@39 638
        # Get weekdays in the year.
paul@39 639
paul@39 640
        if len(context) == 1:
paul@39 641
            first_day = (context[0], 1, 1)
paul@39 642
            last_day = (context[0], 12, 31)
paul@39 643
paul@39 644
        # Get weekdays in the month.
paul@39 645
paul@39 646
        elif len(context) == 2:
paul@39 647
            first_day = (context[0], context[1], 1)
paul@39 648
            last_day = update((context[0], context[1], 1), (0, 1, 0))
paul@39 649
            last_day = update(last_day, (0, 0, -1))
paul@39 650
paul@39 651
        # Get weekdays in the week.
paul@39 652
paul@39 653
        else:
paul@39 654
            current = context
paul@39 655
            values = [value for (value, index) in self.args["values"]]
paul@39 656
paul@359 657
            while (inclusive and current <= end or current < end):
paul@39 658
                next = update(current, step)
paul@39 659
                if date(*current).isoweekday() in values:
paul@359 660
                    results += self.materialise_item(current, max(current, start), min(next, end), counter, inclusive=inclusive)
paul@39 661
                current = next
paul@45 662
paul@45 663
            if setpos:
paul@45 664
                return self.select_positions(results, setpos)
paul@45 665
            else:
paul@45 666
                return results
paul@39 667
paul@39 668
        # Find each of the given days.
paul@39 669
paul@39 670
        for value, index in self.args["values"]:
paul@39 671
            if index is not None:
paul@39 672
                offset = timedelta(7 * (abs(index) - 1))
paul@39 673
paul@39 674
                if index < 0:
paul@39 675
                    current = to_tuple(get_last_day(last_day, value) - offset, 3)
paul@39 676
                else:
paul@39 677
                    current = to_tuple(get_first_day(first_day, value) + offset, 3)
paul@39 678
paul@45 679
                next = update(current, step)
paul@45 680
paul@45 681
                # To support setpos, only current and next bound the search, not
paul@45 682
                # the period in addition.
paul@45 683
paul@359 684
                results += self.materialise_item(current, current, next, counter, inclusive=inclusive)
paul@39 685
paul@39 686
            else:
paul@39 687
                if index < 0:
paul@39 688
                    current = to_tuple(get_last_day(last_day, value), 3)
paul@39 689
                    direction = operator.sub
paul@39 690
                else:
paul@39 691
                    current = to_tuple(get_first_day(first_day, value), 3)
paul@39 692
                    direction = operator.add
paul@39 693
paul@39 694
                while first_day <= current <= last_day:
paul@45 695
                    next = update(current, step)
paul@45 696
paul@45 697
                    # To support setpos, only current and next bound the search, not
paul@45 698
                    # the period in addition.
paul@45 699
paul@359 700
                    results += self.materialise_item(current, current, next, counter, inclusive=inclusive)
paul@39 701
                    current = to_tuple(direction(date(*current), timedelta(7)), 3)
paul@34 702
paul@45 703
        # Extract selected positions and remove out-of-period instances.
paul@45 704
paul@45 705
        if setpos:
paul@45 706
            results = self.select_positions(results, setpos)
paul@45 707
paul@359 708
        return self.filter_by_period(results, start, end, inclusive)
paul@33 709
paul@33 710
class Enum(Selector):
paul@359 711
    def materialise_items(self, context, start, end, counter, setpos=None, inclusive=False):
paul@33 712
        step = scale(1, self.pos)
paul@33 713
        results = []
paul@33 714
        for value in self.args["values"]:
paul@1241 715
            current = combine(context[:self.pos], scale(value, self.pos))
paul@45 716
            next = update(current, step)
paul@45 717
paul@45 718
            # To support setpos, only current and next bound the search, not
paul@45 719
            # the period in addition.
paul@45 720
paul@359 721
            results += self.materialise_item(current, current, next, counter, setpos, inclusive)
paul@45 722
paul@45 723
        # Extract selected positions and remove out-of-period instances.
paul@45 724
paul@45 725
        if setpos:
paul@45 726
            results = self.select_positions(results, setpos)
paul@45 727
paul@359 728
        return self.filter_by_period(results, start, end, inclusive)
paul@35 729
paul@35 730
class MonthDayFilter(Enum):
paul@359 731
    def materialise_items(self, context, start, end, counter, setpos=None, inclusive=False):
paul@35 732
        last_day = monthrange(context[0], context[1])[1]
paul@35 733
        step = scale(1, self.pos)
paul@35 734
        results = []
paul@35 735
        for value in self.args["values"]:
paul@35 736
            if value < 0:
paul@35 737
                value = last_day + 1 + value
paul@35 738
            current = combine(context, scale(value, self.pos))
paul@45 739
            next = update(current, step)
paul@45 740
paul@45 741
            # To support setpos, only current and next bound the search, not
paul@45 742
            # the period in addition.
paul@45 743
paul@359 744
            results += self.materialise_item(current, current, next, counter, inclusive=inclusive)
paul@45 745
paul@45 746
        # Extract selected positions and remove out-of-period instances.
paul@45 747
paul@45 748
        if setpos:
paul@45 749
            results = self.select_positions(results, setpos)
paul@45 750
paul@359 751
        return self.filter_by_period(results, start, end, inclusive)
paul@33 752
paul@37 753
class YearDayFilter(Enum):
paul@359 754
    def materialise_items(self, context, start, end, counter, setpos=None, inclusive=False):
paul@37 755
        first_day = date(context[0], 1, 1)
paul@37 756
        next_first_day = date(context[0] + 1, 1, 1)
paul@37 757
        year_length = (next_first_day - first_day).days
paul@37 758
        step = scale(1, self.pos)
paul@37 759
        results = []
paul@37 760
        for value in self.args["values"]:
paul@37 761
            if value < 0:
paul@37 762
                value = year_length + 1 + value
paul@39 763
            current = to_tuple(first_day + timedelta(value - 1), 3)
paul@45 764
            next = update(current, step)
paul@45 765
paul@45 766
            # To support setpos, only current and next bound the search, not
paul@45 767
            # the period in addition.
paul@45 768
paul@359 769
            results += self.materialise_item(current, current, next, counter, inclusive=inclusive)
paul@45 770
paul@45 771
        # Extract selected positions and remove out-of-period instances.
paul@45 772
paul@45 773
        if setpos:
paul@45 774
            results = self.select_positions(results, setpos)
paul@45 775
paul@359 776
        return self.filter_by_period(results, start, end, inclusive)
paul@37 777
paul@46 778
special_enum_levels = {
paul@46 779
    "BYDAY" : WeekDayFilter,
paul@46 780
    "BYMONTHDAY" : MonthDayFilter,
paul@46 781
    "BYYEARDAY" : YearDayFilter,
paul@46 782
    }
paul@46 783
paul@46 784
# Public functions.
paul@46 785
paul@46 786
def connect_selectors(selectors):
paul@358 787
paul@358 788
    """
paul@358 789
    Make the 'selectors' reference each other in a hierarchy so that
paul@358 790
    materialising the principal selector causes the more specific ones to be
paul@358 791
    employed in the operation.
paul@358 792
    """
paul@358 793
paul@33 794
    current = selectors[0]
paul@1241 795
    current.first = True
paul@33 796
    for selector in selectors[1:]:
paul@33 797
        current.selecting = selector
paul@33 798
        current = selector
paul@33 799
    return selectors[0]
paul@33 800
paul@46 801
def get_selector(dt, qualifiers):
paul@322 802
paul@322 803
    """
paul@322 804
    Combine the initial datetime 'dt' with the given 'qualifiers', returning an
paul@322 805
    object that can be used to select recurrences described by the 'qualifiers'.
paul@322 806
    """
paul@322 807
paul@46 808
    dt = to_tuple(dt)
paul@46 809
    return connect_selectors(combine_datetime_with_qualifiers(dt, qualifiers))
paul@46 810
paul@46 811
def get_rule(dt, rule):
paul@317 812
paul@317 813
    """
paul@317 814
    Using the given initial datetime 'dt', interpret the 'rule' (a semicolon-
paul@317 815
    separated collection of "key=value" strings), and return the resulting
paul@317 816
    selector object.
paul@317 817
    """
paul@317 818
paul@351 819
    if not isinstance(rule, tuple):
paul@351 820
        rule = rule.split(";")
paul@351 821
    qualifiers = get_qualifiers(rule)
paul@46 822
    return get_selector(dt, qualifiers)
paul@35 823
paul@33 824
# vim: tabstop=4 expandtab shiftwidth=4