imip-agent

Annotated imiptools/dates.py

1065:2175787d1896
2016-03-04 Paul Boddie Merged changes from the default branch. freebusy-collections
paul@152 1
#!/usr/bin/env python
paul@152 2
paul@152 3
"""
paul@152 4
Date processing functions.
paul@152 5
paul@1038 6
Copyright (C) 2014, 2015, 2016 Paul Boddie <paul@boddie.org.uk>
paul@152 7
paul@152 8
This program is free software; you can redistribute it and/or modify it under
paul@152 9
the terms of the GNU General Public License as published by the Free Software
paul@152 10
Foundation; either version 3 of the License, or (at your option) any later
paul@152 11
version.
paul@152 12
paul@152 13
This program is distributed in the hope that it will be useful, but WITHOUT
paul@152 14
ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
paul@152 15
FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
paul@152 16
details.
paul@152 17
paul@152 18
You should have received a copy of the GNU General Public License along with
paul@152 19
this program.  If not, see <http://www.gnu.org/licenses/>.
paul@152 20
"""
paul@152 21
paul@657 22
from bisect import bisect_left
paul@195 23
from datetime import date, datetime, timedelta
paul@291 24
from os.path import exists
paul@152 25
from pytz import timezone, UnknownTimeZoneError
paul@152 26
import re
paul@152 27
paul@152 28
# iCalendar date and datetime parsing (from DateSupport in MoinSupport).
paul@152 29
paul@388 30
_date_icalendar_regexp_str = ur'(?P<year>[0-9]{4})(?P<month>[0-9]{2})(?P<day>[0-9]{2})'
paul@388 31
date_icalendar_regexp_str = _date_icalendar_regexp_str + '$'
paul@388 32
paul@388 33
datetime_icalendar_regexp_str = _date_icalendar_regexp_str + \
paul@152 34
    ur'(?:' \
paul@152 35
    ur'T(?P<hour>[0-2][0-9])(?P<minute>[0-5][0-9])(?P<second>[0-6][0-9])' \
paul@152 36
    ur'(?P<utc>Z)?' \
paul@388 37
    ur')?$'
paul@152 38
paul@388 39
_duration_time_icalendar_regexp_str = \
paul@387 40
    ur'T' \
paul@387 41
    ur'(?:' \
paul@387 42
    ur'([0-9]+H)(?:([0-9]+M)([0-9]+S)?)?' \
paul@387 43
    ur'|' \
paul@387 44
    ur'([0-9]+M)([0-9]+S)?' \
paul@387 45
    ur'|' \
paul@387 46
    ur'([0-9]+S)' \
paul@387 47
    ur')'
paul@387 48
paul@387 49
duration_icalendar_regexp_str = ur'P' \
paul@387 50
    ur'(?:' \
paul@387 51
    ur'([0-9]+W)' \
paul@387 52
    ur'|' \
paul@387 53
    ur'(?:%s)' \
paul@387 54
    ur'|' \
paul@387 55
    ur'([0-9]+D)(?:%s)?' \
paul@388 56
    ur')$' % (_duration_time_icalendar_regexp_str, _duration_time_icalendar_regexp_str)
paul@387 57
paul@152 58
match_date_icalendar = re.compile(date_icalendar_regexp_str, re.UNICODE).match
paul@152 59
match_datetime_icalendar = re.compile(datetime_icalendar_regexp_str, re.UNICODE).match
paul@387 60
match_duration_icalendar = re.compile(duration_icalendar_regexp_str, re.UNICODE).match
paul@152 61
paul@551 62
# Datetime formatting.
paul@152 63
paul@152 64
def format_datetime(dt):
paul@247 65
paul@247 66
    "Format 'dt' as an iCalendar-compatible string."
paul@247 67
paul@152 68
    if not dt:
paul@152 69
        return None
paul@152 70
    elif isinstance(dt, datetime):
paul@152 71
        if dt.tzname() == "UTC":
paul@152 72
            return dt.strftime("%Y%m%dT%H%M%SZ")
paul@152 73
        else:
paul@152 74
            return dt.strftime("%Y%m%dT%H%M%S")
paul@152 75
    else:
paul@152 76
        return dt.strftime("%Y%m%d")
paul@152 77
paul@285 78
def format_time(dt):
paul@285 79
paul@285 80
    "Format the time portion of 'dt' as an iCalendar-compatible string."
paul@285 81
paul@285 82
    if not dt:
paul@285 83
        return None
paul@285 84
    elif isinstance(dt, datetime):
paul@285 85
        if dt.tzname() == "UTC":
paul@285 86
            return dt.strftime("%H%M%SZ")
paul@285 87
        else:
paul@285 88
            return dt.strftime("%H%M%S")
paul@285 89
    else:
paul@285 90
        return None
paul@285 91
paul@1038 92
def format_duration(td):
paul@1038 93
paul@1038 94
    "Format the timedelta 'td' as an iCalendar-compatible string."
paul@1038 95
paul@1038 96
    if not td:
paul@1038 97
        return None
paul@1038 98
    else:
paul@1038 99
        day_portion = td.days and "%dD" % td.days or ""
paul@1038 100
        time_portion = td.seconds and "T%dS" % td.seconds or ""
paul@1038 101
        if not day_portion and not time_portion:
paul@1038 102
            time_portion = "T0S"
paul@1038 103
        return "P%s%s" % (day_portion, time_portion)
paul@1038 104
paul@551 105
# Parsing of datetime and related information.
paul@239 106
paul@152 107
def get_datetime(value, attr=None):
paul@152 108
paul@152 109
    """
paul@152 110
    Return a datetime object from the given 'value' in iCalendar format, using
paul@152 111
    the 'attr' mapping (if specified) to control the conversion.
paul@152 112
    """
paul@152 113
paul@295 114
    if not value:
paul@295 115
        return None
paul@295 116
paul@285 117
    if len(value) > 9 and (not attr or attr.get("VALUE") in (None, "DATE-TIME")):
paul@152 118
        m = match_datetime_icalendar(value)
paul@152 119
        if m:
paul@232 120
            year, month, day, hour, minute, second = map(m.group, [
paul@232 121
                "year", "month", "day", "hour", "minute", "second"
paul@232 122
                ])
paul@152 123
paul@232 124
            if hour and minute and second:
paul@232 125
                dt = datetime(
paul@232 126
                    int(year), int(month), int(day), int(hour), int(minute), int(second)
paul@232 127
                    )
paul@152 128
paul@232 129
                # Impose the indicated timezone.
paul@232 130
                # NOTE: This needs an ambiguity policy for DST changes.
paul@232 131
paul@232 132
                return to_timezone(dt, m.group("utc") and "UTC" or attr and attr.get("TZID") or None)
paul@152 133
paul@285 134
        return None
paul@285 135
paul@239 136
    # Permit dates even if the VALUE is not set to DATE.
paul@239 137
paul@239 138
    if not attr or attr.get("VALUE") in (None, "DATE"):
paul@152 139
        m = match_date_icalendar(value)
paul@152 140
        if m:
paul@232 141
            year, month, day = map(m.group, ["year", "month", "day"])
paul@232 142
            return date(int(year), int(month), int(day))
paul@232 143
paul@152 144
    return None
paul@152 145
paul@551 146
def get_duration(value):
paul@551 147
paul@759 148
    """
paul@759 149
    Return a duration for the given 'value' as a timedelta object.
paul@759 150
    Where no valid duration is specified, None is returned.
paul@759 151
    """
paul@551 152
paul@551 153
    if not value:
paul@551 154
        return None
paul@551 155
paul@551 156
    m = match_duration_icalendar(value)
paul@551 157
    if m:
paul@551 158
        weeks, days, hours, minutes, seconds = 0, 0, 0, 0, 0
paul@551 159
        for s in m.groups():
paul@551 160
            if not s: continue
paul@551 161
            if s[-1] == "W": weeks += int(s[:-1])
paul@551 162
            elif s[-1] == "D": days += int(s[:-1])
paul@551 163
            elif s[-1] == "H": hours += int(s[:-1])
paul@551 164
            elif s[-1] == "M": minutes += int(s[:-1])
paul@551 165
            elif s[-1] == "S": seconds += int(s[:-1])
paul@551 166
        return timedelta(
paul@551 167
            int(weeks) * 7 + int(days),
paul@551 168
            (int(hours) * 60 + int(minutes)) * 60 + int(seconds)
paul@551 169
            )
paul@551 170
    else:
paul@551 171
        return None
paul@551 172
paul@387 173
def get_period(value, attr=None):
paul@387 174
paul@387 175
    """
paul@387 176
    Return a tuple of the form (start, end) for the given 'value' in iCalendar
paul@387 177
    format, using the 'attr' mapping (if specified) to control the conversion.
paul@387 178
    """
paul@387 179
paul@630 180
    if not value or attr and attr.get("VALUE") and attr.get("VALUE") != "PERIOD":
paul@387 181
        return None
paul@387 182
paul@387 183
    t = value.split("/")
paul@387 184
    if len(t) != 2:
paul@387 185
        return None
paul@387 186
paul@388 187
    dtattr = {}
paul@388 188
    if attr:
paul@388 189
        dtattr.update(attr)
paul@388 190
        if dtattr.has_key("VALUE"):
paul@388 191
            del dtattr["VALUE"]
paul@388 192
paul@388 193
    start = get_datetime(t[0], dtattr)
paul@387 194
    if t[1].startswith("P"):
paul@387 195
        end = start + get_duration(t[1])
paul@387 196
    else:
paul@388 197
        end = get_datetime(t[1], dtattr)
paul@387 198
paul@387 199
    return start, end
paul@387 200
paul@551 201
# Time zone conversions and retrieval.
paul@551 202
paul@551 203
def ends_on_same_day(dt, end, tzid):
paul@387 204
paul@551 205
    """
paul@551 206
    Return whether 'dt' ends on the same day as 'end', testing the date
paul@551 207
    components of 'dt' and 'end' against each other, but also testing whether
paul@551 208
    'end' is the actual end of the day in which 'dt' is positioned.
paul@387 209
paul@551 210
    Since time zone transitions may occur within a day, 'tzid' is required to
paul@551 211
    determine the end of the day in which 'dt' is positioned, using the zone
paul@551 212
    appropriate at that point in time, not necessarily the zone applying to
paul@551 213
    'dt'.
paul@551 214
    """
paul@387 215
paul@551 216
    return (
paul@551 217
        to_timezone(dt, tzid).date() == to_timezone(end, tzid).date() or
paul@551 218
        end == get_end_of_day(dt, tzid)
paul@551 219
        )
paul@551 220
paul@551 221
def get_default_timezone():
paul@551 222
paul@551 223
    "Return the system time regime."
paul@551 224
paul@551 225
    filename = "/etc/timezone"
paul@551 226
paul@551 227
    if exists(filename):
paul@551 228
        f = open(filename)
paul@551 229
        try:
paul@551 230
            return f.read().strip()
paul@551 231
        finally:
paul@551 232
            f.close()
paul@387 233
    else:
paul@387 234
        return None
paul@387 235
paul@551 236
def get_end_of_day(dt, tzid):
paul@431 237
paul@431 238
    """
paul@551 239
    Get the end of the day in which 'dt' is positioned, using the given 'tzid'
paul@551 240
    to obtain a datetime in the appropriate time zone. Where time zone
paul@551 241
    transitions occur within a day, the zone of 'dt' may not be the eventual
paul@551 242
    zone of the returned object.
paul@431 243
    """
paul@431 244
paul@551 245
    return get_start_of_day(dt + timedelta(1), tzid)
paul@431 246
paul@244 247
def get_start_of_day(dt, tzid):
paul@245 248
paul@245 249
    """
paul@245 250
    Get the start of the day in which 'dt' is positioned, using the given 'tzid'
paul@245 251
    to obtain a datetime in the appropriate time zone. Where time zone
paul@245 252
    transitions occur within a day, the zone of 'dt' may not be the eventual
paul@245 253
    zone of the returned object.
paul@245 254
    """
paul@245 255
paul@244 256
    start = datetime(dt.year, dt.month, dt.day, 0, 0)
paul@244 257
    return to_timezone(start, tzid)
paul@152 258
paul@244 259
def get_start_of_next_day(dt, tzid):
paul@245 260
paul@245 261
    """
paul@245 262
    Get the start of the day after the day in which 'dt' is positioned. This
paul@245 263
    function is intended to extend either dates or datetimes to the end of a
paul@245 264
    day for the purpose of generating a missing end date or datetime for an
paul@245 265
    event.
paul@245 266
paul@245 267
    If 'dt' is a date and not a datetime, a plain date object for the next day
paul@245 268
    will be returned.
paul@245 269
paul@245 270
    If 'dt' is a datetime, the given 'tzid' is used to obtain a datetime in the
paul@245 271
    appropriate time zone. Where time zone transitions occur within a day, the
paul@245 272
    zone of 'dt' may not be the eventual zone of the returned object.
paul@245 273
    """
paul@245 274
paul@239 275
    if isinstance(dt, datetime):
paul@239 276
        return get_end_of_day(dt, tzid)
paul@239 277
    else:
paul@239 278
        return dt + timedelta(1)
paul@239 279
paul@616 280
def get_datetime_tzid(dt):
paul@616 281
paul@616 282
    "Return the time zone identifier from 'dt' or None if unknown."
paul@616 283
paul@616 284
    if not isinstance(dt, datetime):
paul@616 285
        return None
paul@616 286
    elif dt.tzname() == "UTC":
paul@616 287
        return "UTC"
paul@616 288
    elif dt.tzinfo and hasattr(dt.tzinfo, "zone"):
paul@616 289
        return dt.tzinfo.zone
paul@616 290
    else:
paul@616 291
        return None
paul@616 292
paul@627 293
def get_period_tzid(start, end):
paul@627 294
paul@627 295
    "Return the time zone identifier for 'start' and 'end' or None if unknown."
paul@627 296
paul@627 297
    if isinstance(start, datetime) or isinstance(end, datetime):
paul@627 298
        return get_datetime_tzid(start) or get_datetime_tzid(end)
paul@627 299
    else:
paul@627 300
        return None
paul@627 301
paul@551 302
def to_date(dt):
paul@551 303
paul@551 304
    "Return the date of 'dt'."
paul@551 305
paul@551 306
    return date(dt.year, dt.month, dt.day)
paul@551 307
paul@551 308
def to_datetime(dt, tzid):
paul@551 309
paul@551 310
    """
paul@551 311
    Return a datetime for 'dt', using the start of day for dates, and using the
paul@551 312
    'tzid' for the conversion.
paul@551 313
    """
paul@551 314
paul@551 315
    if isinstance(dt, datetime):
paul@637 316
        return to_timezone(dt, tzid)
paul@551 317
    else:
paul@551 318
        return get_start_of_day(dt, tzid)
paul@551 319
paul@637 320
def to_utc_datetime(dt, tzid=None):
paul@245 321
paul@245 322
    """
paul@637 323
    Return a datetime corresponding to 'dt' in the UTC time zone. If 'tzid'
paul@637 324
    is specified, dates and floating datetimes are converted to UTC datetimes
paul@637 325
    using the time zone information; otherwise, such dates and datetimes remain
paul@637 326
    unconverted.
paul@245 327
    """
paul@245 328
paul@551 329
    if not dt:
paul@551 330
        return None
paul@637 331
    elif get_datetime_tzid(dt):
paul@551 332
        return to_timezone(dt, "UTC")
paul@637 333
    elif tzid:
paul@637 334
        return to_timezone(to_datetime(dt, tzid), "UTC")
paul@551 335
    else:
paul@551 336
        return dt
paul@551 337
paul@616 338
def to_timezone(dt, tzid):
paul@551 339
paul@551 340
    """
paul@551 341
    Return a datetime corresponding to 'dt' in the time regime having the given
paul@616 342
    'tzid'.
paul@551 343
    """
paul@195 344
paul@551 345
    try:
paul@616 346
        tz = tzid and timezone(tzid) or None
paul@551 347
    except UnknownTimeZoneError:
paul@551 348
        tz = None
paul@551 349
    return to_tz(dt, tz)
paul@551 350
paul@551 351
def to_tz(dt, tz):
paul@247 352
paul@551 353
    "Return a datetime corresponding to 'dt' employing the pytz.timezone 'tz'."
paul@247 354
paul@551 355
    if tz is not None and isinstance(dt, datetime):
paul@551 356
        if not dt.tzinfo:
paul@551 357
            return tz.localize(dt)
paul@551 358
        else:
paul@551 359
            return dt.astimezone(tz)
paul@551 360
    else:
paul@551 361
        return dt
paul@222 362
paul@551 363
# iCalendar-related conversions.
paul@551 364
paul@551 365
def end_date_from_calendar(dt):
paul@291 366
paul@291 367
    """
paul@551 368
    Change end dates to refer to the actual dates, not the iCalendar "next day"
paul@551 369
    dates.
paul@291 370
    """
paul@291 371
paul@551 372
    if not isinstance(dt, datetime):
paul@551 373
        return dt - timedelta(1)
paul@551 374
    else:
paul@551 375
        return dt
paul@291 376
paul@532 377
def end_date_to_calendar(dt):
paul@532 378
paul@532 379
    """
paul@532 380
    Change end dates to refer to the iCalendar "next day" dates, not the actual
paul@532 381
    dates.
paul@532 382
    """
paul@532 383
paul@532 384
    if not isinstance(dt, datetime):
paul@532 385
        return dt + timedelta(1)
paul@532 386
    else:
paul@532 387
        return dt
paul@532 388
paul@551 389
def get_datetime_attributes(dt, tzid=None):
paul@551 390
paul@616 391
    """
paul@616 392
    Return attributes for the 'dt' date or datetime object with 'tzid'
paul@616 393
    indicating the time zone if not otherwise defined.
paul@616 394
    """
paul@551 395
paul@551 396
    if isinstance(dt, datetime):
paul@551 397
        attr = {"VALUE" : "DATE-TIME"}
paul@616 398
        tzid = get_datetime_tzid(dt) or tzid
paul@551 399
        if tzid:
paul@551 400
            attr["TZID"] = tzid
paul@551 401
        return attr
paul@551 402
    else:
paul@551 403
        return {"VALUE" : "DATE"}
paul@551 404
paul@551 405
def get_datetime_item(dt, tzid=None):
paul@551 406
paul@615 407
    """
paul@615 408
    Return an iCalendar-compatible string and attributes for 'dt' using any
paul@616 409
    specified 'tzid' to assert a particular time zone if not otherwise defined.
paul@615 410
    """
paul@551 411
paul@551 412
    if not dt:
paul@551 413
        return None, None
paul@616 414
    if not get_datetime_tzid(dt):
paul@616 415
        dt = to_timezone(dt, tzid)
paul@551 416
    value = format_datetime(dt)
paul@551 417
    attr = get_datetime_attributes(dt, tzid)
paul@551 418
    return value, attr
paul@551 419
paul@627 420
def get_period_attributes(start, end, tzid=None):
paul@551 421
paul@627 422
    """
paul@627 423
    Return attributes for the 'start' and 'end' datetime objects with 'tzid'
paul@627 424
    indicating the time zone if not otherwise defined.
paul@627 425
    """
paul@551 426
paul@551 427
    attr = {"VALUE" : "PERIOD"}
paul@627 428
    tzid = get_period_tzid(start, end) or tzid
paul@551 429
    if tzid:
paul@551 430
        attr["TZID"] = tzid
paul@551 431
    return attr
paul@551 432
paul@551 433
def get_period_item(start, end, tzid=None):
paul@532 434
paul@532 435
    """
paul@551 436
    Return an iCalendar-compatible string and attributes for 'start', 'end' and
paul@551 437
    'tzid'.
paul@532 438
    """
paul@532 439
paul@551 440
    if start and end:
paul@627 441
        attr = get_period_attributes(start, end, tzid)
paul@627 442
        start_value = format_datetime(to_timezone(start, attr.get("TZID")))
paul@627 443
        end_value = format_datetime(to_timezone(end, attr.get("TZID")))
paul@551 444
        return "%s/%s" % (start_value, end_value), attr
paul@551 445
    elif start:
paul@551 446
        attr = get_datetime_attributes(start, tzid)
paul@627 447
        start_value = format_datetime(to_timezone(start, attr.get("TZID")))
paul@551 448
        return start_value, attr
paul@532 449
    else:
paul@551 450
        return None, None
paul@551 451
paul@759 452
def get_timestamp(offset=None):
paul@551 453
paul@551 454
    "Return the current time as an iCalendar-compatible string."
paul@551 455
paul@759 456
    offset = offset or timedelta(0)
paul@759 457
    return format_datetime(to_timezone(datetime.utcnow(), "UTC") + offset)
paul@759 458
paul@883 459
def get_date(offset=None):
paul@883 460
paul@883 461
    """
paul@883 462
    Return the current date, offset by the given timedelta 'offset' if
paul@883 463
    specified. The returned date will not be positioned in any time zone.
paul@883 464
    """
paul@883 465
paul@883 466
    offset = offset or timedelta(0)
paul@883 467
    return date.today() + offset
paul@883 468
paul@759 469
def get_time(offset=None):
paul@759 470
paul@883 471
    """
paul@883 472
    Return the current time, offset by the given timedelta 'offset' if
paul@883 473
    specified. The returned time will be in the UTC time zone.
paul@883 474
    """
paul@759 475
paul@759 476
    offset = offset or timedelta(0)
paul@759 477
    return to_timezone(datetime.utcnow(), "UTC") + offset
paul@551 478
paul@551 479
def get_tzid(dtstart_attr, dtend_attr):
paul@551 480
paul@551 481
    """
paul@551 482
    Return any time regime details from the given 'dtstart_attr' and
paul@551 483
    'dtend_attr' attribute collections.
paul@551 484
    """
paul@551 485
paul@551 486
    return dtstart_attr and dtstart_attr.get("TZID") or dtend_attr and dtend_attr.get("TZID") or None
paul@551 487
paul@561 488
def get_recurrence_start(recurrenceid):
paul@561 489
paul@561 490
    """
paul@561 491
    Return 'recurrenceid' in a form suitable for comparison with period start
paul@627 492
    dates or datetimes. The 'recurrenceid' should be an identifier normalised to
paul@627 493
    a UTC datetime or employing a date or floating datetime representation where
paul@627 494
    no time zone information was originally provided.
paul@561 495
    """
paul@561 496
paul@561 497
    return get_datetime(recurrenceid)
paul@561 498
paul@561 499
def get_recurrence_start_point(recurrenceid, tzid):
paul@551 500
paul@551 501
    """
paul@551 502
    Return 'recurrenceid' in a form suitable for comparison with free/busy start
paul@551 503
    datetimes, using 'tzid' to convert recurrence identifiers that are dates.
paul@627 504
    The 'recurrenceid' should be an identifier normalised to a UTC datetime or
paul@627 505
    employing a date or floating datetime representation where no time zone
paul@627 506
    information was originally provided.
paul@551 507
    """
paul@551 508
paul@552 509
    return to_utc_datetime(get_datetime(recurrenceid), tzid)
paul@552 510
paul@657 511
# Time corrections.
paul@657 512
paul@660 513
class ValidityError(Exception):
paul@660 514
    pass
paul@660 515
paul@669 516
def check_permitted_values(dt, permitted_values):
paul@660 517
paul@669 518
    "Check the datetime 'dt' against the 'permitted_values' list."
paul@660 519
paul@660 520
    if not isinstance(dt, datetime):
paul@660 521
        raise ValidityError
paul@660 522
paul@669 523
    hours, minutes, seconds = permitted_values
paul@660 524
    errors = []
paul@660 525
paul@660 526
    if hours and dt.hour not in hours:
paul@660 527
        errors.append("hour")
paul@660 528
    if minutes and dt.minute not in minutes:
paul@660 529
        errors.append("minute")
paul@660 530
    if seconds and dt.second not in seconds:
paul@660 531
        errors.append("second")
paul@660 532
paul@660 533
    return errors
paul@660 534
paul@669 535
def correct_datetime(dt, permitted_values):
paul@657 536
paul@669 537
    "Correct 'dt' using the given 'permitted_values' details."
paul@657 538
paul@669 539
    carry, hour, minute, second = correct_value((dt.hour, dt.minute, dt.second), permitted_values)
paul@657 540
    return datetime(dt.year, dt.month, dt.day, hour, minute, second, dt.microsecond, dt.tzinfo) + \
paul@657 541
           (carry and timedelta(1) or timedelta(0))
paul@657 542
paul@669 543
def correct_value(value, permitted_values):
paul@657 544
paul@657 545
    """
paul@657 546
    Correct the given (hour, minute, second) tuple 'value' according to the
paul@669 547
    'permitted_values' details.
paul@657 548
    """
paul@657 549
paul@657 550
    limits = 23, 59, 59
paul@657 551
paul@657 552
    corrected = []
paul@657 553
    reset = False
paul@657 554
paul@657 555
    # Find invalid values and reset all following values.
paul@657 556
paul@669 557
    for v, values, limit in zip(value, permitted_values, limits):
paul@657 558
        if reset:
paul@657 559
            if values:
paul@657 560
                v = values[0]
paul@657 561
            else:
paul@657 562
                v = 0
paul@657 563
paul@657 564
        elif values and v not in values:
paul@657 565
            reset = True
paul@657 566
paul@657 567
        corrected.append(v)
paul@657 568
paul@657 569
    value = corrected
paul@657 570
    corrected = []
paul@657 571
    carry = 0
paul@657 572
paul@657 573
    # Find invalid values and update them to the next valid value, updating more
paul@657 574
    # significant values if the next valid value is the first in the appropriate
paul@657 575
    # series.
paul@657 576
paul@669 577
    for v, values, limit in zip(value, permitted_values, limits)[::-1]:
paul@657 578
        if carry:
paul@657 579
            v += 1
paul@657 580
            if v > limit:
paul@657 581
                if values:
paul@657 582
                    v = values[0]
paul@657 583
                else:
paul@657 584
                    v = 0
paul@657 585
                corrected.append(v)
paul@657 586
                continue
paul@657 587
            else:
paul@657 588
                carry = 0
paul@657 589
paul@660 590
        if values:
paul@660 591
            i = bisect_left(values, v)
paul@660 592
            if i < len(values):
paul@660 593
                v = values[i]
paul@660 594
            else:
paul@660 595
                v = values[0]
paul@660 596
                carry = 1
paul@657 597
paul@657 598
        corrected.append(v)
paul@657 599
paul@657 600
    return [carry] + corrected[::-1]
paul@657 601
paul@152 602
# vim: tabstop=4 expandtab shiftwidth=4