imip-agent

Annotated imiptools/dates.py

545:e3a54eaec0ef
2015-05-17 Paul Boddie Handle datetimes in different time zones when checking whether they end on the same day.
paul@152 1
#!/usr/bin/env python
paul@152 2
paul@152 3
"""
paul@152 4
Date processing functions.
paul@152 5
paul@152 6
Copyright (C) 2014, 2015 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@195 22
from datetime import date, datetime, timedelta
paul@291 23
from os.path import exists
paul@152 24
from pytz import timezone, UnknownTimeZoneError
paul@152 25
import re
paul@152 26
paul@152 27
# iCalendar date and datetime parsing (from DateSupport in MoinSupport).
paul@152 28
paul@388 29
_date_icalendar_regexp_str = ur'(?P<year>[0-9]{4})(?P<month>[0-9]{2})(?P<day>[0-9]{2})'
paul@388 30
date_icalendar_regexp_str = _date_icalendar_regexp_str + '$'
paul@388 31
paul@388 32
datetime_icalendar_regexp_str = _date_icalendar_regexp_str + \
paul@152 33
    ur'(?:' \
paul@152 34
    ur'T(?P<hour>[0-2][0-9])(?P<minute>[0-5][0-9])(?P<second>[0-6][0-9])' \
paul@152 35
    ur'(?P<utc>Z)?' \
paul@388 36
    ur')?$'
paul@152 37
paul@388 38
_duration_time_icalendar_regexp_str = \
paul@387 39
    ur'T' \
paul@387 40
    ur'(?:' \
paul@387 41
    ur'([0-9]+H)(?:([0-9]+M)([0-9]+S)?)?' \
paul@387 42
    ur'|' \
paul@387 43
    ur'([0-9]+M)([0-9]+S)?' \
paul@387 44
    ur'|' \
paul@387 45
    ur'([0-9]+S)' \
paul@387 46
    ur')'
paul@387 47
paul@387 48
duration_icalendar_regexp_str = ur'P' \
paul@387 49
    ur'(?:' \
paul@387 50
    ur'([0-9]+W)' \
paul@387 51
    ur'|' \
paul@387 52
    ur'(?:%s)' \
paul@387 53
    ur'|' \
paul@387 54
    ur'([0-9]+D)(?:%s)?' \
paul@388 55
    ur')$' % (_duration_time_icalendar_regexp_str, _duration_time_icalendar_regexp_str)
paul@387 56
paul@152 57
match_date_icalendar = re.compile(date_icalendar_regexp_str, re.UNICODE).match
paul@152 58
match_datetime_icalendar = re.compile(datetime_icalendar_regexp_str, re.UNICODE).match
paul@387 59
match_duration_icalendar = re.compile(duration_icalendar_regexp_str, re.UNICODE).match
paul@152 60
paul@506 61
def to_utc_datetime(dt, date_tzid=None):
paul@247 62
paul@506 63
    """
paul@506 64
    Return a datetime corresponding to 'dt' in the UTC time zone. If 'date_tzid'
paul@506 65
    is specified, dates are converted to datetimes using the time zone
paul@506 66
    information; otherwise, dates remain unconverted.
paul@506 67
    """
paul@247 68
paul@152 69
    if not dt:
paul@152 70
        return None
paul@152 71
    elif isinstance(dt, datetime):
paul@157 72
        return to_timezone(dt, "UTC")
paul@506 73
    elif date_tzid:
paul@506 74
        return to_timezone(to_datetime(dt, date_tzid), "UTC")
paul@152 75
    else:
paul@152 76
        return dt
paul@152 77
paul@291 78
def get_default_timezone():
paul@291 79
paul@291 80
    "Return the system time regime."
paul@291 81
paul@291 82
    filename = "/etc/timezone"
paul@291 83
paul@291 84
    if exists(filename):
paul@291 85
        f = open(filename)
paul@291 86
        try:
paul@291 87
            return f.read().strip()
paul@291 88
        finally:
paul@291 89
            f.close()
paul@291 90
    else:
paul@291 91
        return None
paul@291 92
paul@152 93
def to_timezone(dt, name):
paul@247 94
paul@247 95
    """
paul@247 96
    Return a datetime corresponding to 'dt' in the time regime having the given
paul@247 97
    'name'.
paul@247 98
    """
paul@247 99
paul@152 100
    try:
paul@152 101
        tz = name and timezone(name) or None
paul@152 102
    except UnknownTimeZoneError:
paul@152 103
        tz = None
paul@157 104
    return to_tz(dt, tz)
paul@157 105
paul@157 106
def to_tz(dt, tz):
paul@247 107
paul@247 108
    "Return a datetime corresponding to 'dt' employing the pytz.timezone 'tz'."
paul@247 109
paul@232 110
    if tz is not None and isinstance(dt, datetime):
paul@152 111
        if not dt.tzinfo:
paul@152 112
            return tz.localize(dt)
paul@152 113
        else:
paul@152 114
            return dt.astimezone(tz)
paul@152 115
    else:
paul@152 116
        return dt
paul@152 117
paul@152 118
def format_datetime(dt):
paul@247 119
paul@247 120
    "Format 'dt' as an iCalendar-compatible string."
paul@247 121
paul@152 122
    if not dt:
paul@152 123
        return None
paul@152 124
    elif isinstance(dt, datetime):
paul@152 125
        if dt.tzname() == "UTC":
paul@152 126
            return dt.strftime("%Y%m%dT%H%M%SZ")
paul@152 127
        else:
paul@152 128
            return dt.strftime("%Y%m%dT%H%M%S")
paul@152 129
    else:
paul@152 130
        return dt.strftime("%Y%m%d")
paul@152 131
paul@285 132
def format_time(dt):
paul@285 133
paul@285 134
    "Format the time portion of 'dt' as an iCalendar-compatible string."
paul@285 135
paul@285 136
    if not dt:
paul@285 137
        return None
paul@285 138
    elif isinstance(dt, datetime):
paul@285 139
        if dt.tzname() == "UTC":
paul@285 140
            return dt.strftime("%H%M%SZ")
paul@285 141
        else:
paul@285 142
            return dt.strftime("%H%M%S")
paul@285 143
    else:
paul@285 144
        return None
paul@285 145
paul@426 146
def get_datetime_attributes(dt, tzid=None):
paul@426 147
paul@426 148
    "Return attributes for 'dt' and 'tzid'."
paul@426 149
paul@426 150
    if isinstance(dt, datetime):
paul@426 151
        attr = {"VALUE" : "DATE-TIME"}
paul@426 152
        if tzid:
paul@426 153
            attr["TZID"] = tzid
paul@426 154
        return attr
paul@426 155
    else:
paul@426 156
        return {"VALUE" : "DATE"}
paul@426 157
paul@426 158
    return {}
paul@426 159
paul@426 160
def get_period_attributes(tzid=None):
paul@426 161
paul@426 162
    "Return attributes for 'tzid'."
paul@426 163
paul@426 164
    attr = {"VALUE" : "PERIOD"}
paul@426 165
    if tzid:
paul@426 166
        attr["TZID"] = tzid
paul@426 167
    return attr
paul@426 168
paul@300 169
def get_datetime_item(dt, tzid=None):
paul@247 170
paul@252 171
    "Return an iCalendar-compatible string and attributes for 'dt' and 'tzid'."
paul@247 172
paul@239 173
    if not dt:
paul@239 174
        return None, None
paul@426 175
    dt = to_timezone(dt, tzid)
paul@239 176
    value = format_datetime(dt)
paul@426 177
    attr = get_datetime_attributes(dt, tzid)
paul@426 178
    return value, attr
paul@426 179
paul@426 180
def get_period_item(start, end, tzid=None):
paul@426 181
paul@426 182
    """
paul@426 183
    Return an iCalendar-compatible string and attributes for 'start', 'end' and
paul@426 184
    'tzid'.
paul@426 185
    """
paul@426 186
paul@426 187
    start = start and to_timezone(start, tzid)
paul@426 188
    end = end and to_timezone(end, tzid)
paul@426 189
paul@426 190
    start_value = start and format_datetime(start) or None
paul@426 191
    end_value = end and format_datetime(end) or None
paul@426 192
paul@426 193
    if start and end:
paul@426 194
        attr = get_period_attributes(tzid)
paul@426 195
        return "%s/%s" % (start_value, end_value), attr
paul@426 196
    elif start:
paul@426 197
        attr = get_datetime_attributes(start, tzid)
paul@426 198
        return start_value, attr
paul@300 199
    else:
paul@426 200
        return None, None
paul@239 201
paul@152 202
def get_datetime(value, attr=None):
paul@152 203
paul@152 204
    """
paul@152 205
    Return a datetime object from the given 'value' in iCalendar format, using
paul@152 206
    the 'attr' mapping (if specified) to control the conversion.
paul@152 207
    """
paul@152 208
paul@295 209
    if not value:
paul@295 210
        return None
paul@295 211
paul@285 212
    if len(value) > 9 and (not attr or attr.get("VALUE") in (None, "DATE-TIME")):
paul@152 213
        m = match_datetime_icalendar(value)
paul@152 214
        if m:
paul@232 215
            year, month, day, hour, minute, second = map(m.group, [
paul@232 216
                "year", "month", "day", "hour", "minute", "second"
paul@232 217
                ])
paul@152 218
paul@232 219
            if hour and minute and second:
paul@232 220
                dt = datetime(
paul@232 221
                    int(year), int(month), int(day), int(hour), int(minute), int(second)
paul@232 222
                    )
paul@152 223
paul@232 224
                # Impose the indicated timezone.
paul@232 225
                # NOTE: This needs an ambiguity policy for DST changes.
paul@232 226
paul@232 227
                return to_timezone(dt, m.group("utc") and "UTC" or attr and attr.get("TZID") or None)
paul@152 228
paul@285 229
        return None
paul@285 230
paul@239 231
    # Permit dates even if the VALUE is not set to DATE.
paul@239 232
paul@239 233
    if not attr or attr.get("VALUE") in (None, "DATE"):
paul@152 234
        m = match_date_icalendar(value)
paul@152 235
        if m:
paul@232 236
            year, month, day = map(m.group, ["year", "month", "day"])
paul@232 237
            return date(int(year), int(month), int(day))
paul@232 238
paul@152 239
    return None
paul@152 240
paul@387 241
def get_period(value, attr=None):
paul@387 242
paul@387 243
    """
paul@387 244
    Return a tuple of the form (start, end) for the given 'value' in iCalendar
paul@387 245
    format, using the 'attr' mapping (if specified) to control the conversion.
paul@387 246
    """
paul@387 247
paul@387 248
    if not value or attr and attr.get("VALUE") != "PERIOD":
paul@387 249
        return None
paul@387 250
paul@387 251
    t = value.split("/")
paul@387 252
    if len(t) != 2:
paul@387 253
        return None
paul@387 254
paul@388 255
    dtattr = {}
paul@388 256
    if attr:
paul@388 257
        dtattr.update(attr)
paul@388 258
        if dtattr.has_key("VALUE"):
paul@388 259
            del dtattr["VALUE"]
paul@388 260
paul@388 261
    start = get_datetime(t[0], dtattr)
paul@387 262
    if t[1].startswith("P"):
paul@387 263
        end = start + get_duration(t[1])
paul@387 264
    else:
paul@388 265
        end = get_datetime(t[1], dtattr)
paul@387 266
paul@387 267
    return start, end
paul@387 268
paul@387 269
def get_duration(value):
paul@387 270
paul@387 271
    "Return a duration for the given 'value'."
paul@387 272
paul@387 273
    if not value:
paul@387 274
        return None
paul@387 275
paul@387 276
    m = match_duration_icalendar(value)
paul@387 277
    if m:
paul@387 278
        weeks, days, hours, minutes, seconds = 0, 0, 0, 0, 0
paul@387 279
        for s in m.groups():
paul@387 280
            if not s: continue
paul@387 281
            if s[-1] == "W": weeks += int(s[:-1])
paul@387 282
            elif s[-1] == "D": days += int(s[:-1])
paul@387 283
            elif s[-1] == "H": hours += int(s[:-1])
paul@387 284
            elif s[-1] == "M": minutes += int(s[:-1])
paul@387 285
            elif s[-1] == "S": seconds += int(s[:-1])
paul@387 286
        return timedelta(
paul@387 287
            int(weeks) * 7 + int(days),
paul@387 288
            (int(hours) * 60 + int(minutes)) * 60 + int(seconds)
paul@387 289
            )
paul@387 290
    else:
paul@387 291
        return None
paul@387 292
paul@426 293
def to_date(dt):
paul@285 294
paul@285 295
    "Return the date of 'dt'."
paul@285 296
paul@285 297
    return date(dt.year, dt.month, dt.day)
paul@285 298
paul@431 299
def to_datetime(dt, tzid):
paul@431 300
paul@431 301
    """
paul@431 302
    Return a datetime for 'dt', using the start of day for dates, and using the
paul@431 303
    'tzid' for the conversion.
paul@431 304
    """
paul@431 305
paul@431 306
    if isinstance(dt, datetime):
paul@431 307
        return dt
paul@431 308
    else:
paul@431 309
        return get_start_of_day(dt, tzid)
paul@431 310
paul@244 311
def get_start_of_day(dt, tzid):
paul@245 312
paul@245 313
    """
paul@245 314
    Get the start of the day in which 'dt' is positioned, using the given 'tzid'
paul@245 315
    to obtain a datetime in the appropriate time zone. Where time zone
paul@245 316
    transitions occur within a day, the zone of 'dt' may not be the eventual
paul@245 317
    zone of the returned object.
paul@245 318
    """
paul@245 319
paul@244 320
    start = datetime(dt.year, dt.month, dt.day, 0, 0)
paul@244 321
    return to_timezone(start, tzid)
paul@152 322
paul@244 323
def get_end_of_day(dt, tzid):
paul@245 324
paul@245 325
    """
paul@245 326
    Get the end of the day in which 'dt' is positioned, using the given 'tzid'
paul@245 327
    to obtain a datetime in the appropriate time zone. Where time zone
paul@245 328
    transitions occur within a day, the zone of 'dt' may not be the eventual
paul@245 329
    zone of the returned object.
paul@245 330
    """
paul@245 331
paul@232 332
    return get_start_of_day(dt + timedelta(1), tzid)
paul@195 333
paul@244 334
def get_start_of_next_day(dt, tzid):
paul@245 335
paul@245 336
    """
paul@245 337
    Get the start of the day after the day in which 'dt' is positioned. This
paul@245 338
    function is intended to extend either dates or datetimes to the end of a
paul@245 339
    day for the purpose of generating a missing end date or datetime for an
paul@245 340
    event.
paul@245 341
paul@245 342
    If 'dt' is a date and not a datetime, a plain date object for the next day
paul@245 343
    will be returned.
paul@245 344
paul@245 345
    If 'dt' is a datetime, the given 'tzid' is used to obtain a datetime in the
paul@245 346
    appropriate time zone. Where time zone transitions occur within a day, the
paul@245 347
    zone of 'dt' may not be the eventual zone of the returned object.
paul@245 348
    """
paul@245 349
paul@239 350
    if isinstance(dt, datetime):
paul@239 351
        return get_end_of_day(dt, tzid)
paul@239 352
    else:
paul@239 353
        return dt + timedelta(1)
paul@239 354
paul@244 355
def ends_on_same_day(dt, end, tzid):
paul@245 356
paul@245 357
    """
paul@245 358
    Return whether 'dt' ends on the same day as 'end', testing the date
paul@245 359
    components of 'dt' and 'end' against each other, but also testing whether
paul@245 360
    'end' is the actual end of the day in which 'dt' is positioned.
paul@245 361
paul@245 362
    Since time zone transitions may occur within a day, 'tzid' is required to
paul@245 363
    determine the end of the day in which 'dt' is positioned, using the zone
paul@245 364
    appropriate at that point in time, not necessarily the zone applying to
paul@245 365
    'dt'.
paul@245 366
    """
paul@245 367
paul@195 368
    return (
paul@545 369
        to_timezone(dt, tzid).date() == to_timezone(end, tzid).date() or
paul@244 370
        end == get_end_of_day(dt, tzid)
paul@195 371
        )
paul@195 372
paul@222 373
def get_timestamp():
paul@247 374
paul@247 375
    "Return the current time as an iCalendar-compatible string."
paul@247 376
paul@222 377
    return format_datetime(to_timezone(datetime.utcnow(), "UTC"))
paul@222 378
paul@291 379
def get_freebusy_period(start, end, tzid):
paul@291 380
paul@291 381
    """
paul@291 382
    For the given 'start' datetime, together with the given 'end' datetime, and
paul@291 383
    given a 'tzid' either from the datetimes or provided for the user, return a
paul@291 384
    (start, end) tuple containing datetimes in the UTC time zone, where dates
paul@291 385
    are converted to points in time so that each day has a specific start and
paul@291 386
    end point defined in UTC.
paul@291 387
    """
paul@291 388
paul@506 389
    start = to_utc_datetime(start, tzid)
paul@506 390
    end = to_utc_datetime(end, tzid)
paul@291 391
    return start, end
paul@291 392
paul@506 393
def to_recurrence_start(recurrenceid, tzid):
paul@291 394
paul@291 395
    """
paul@506 396
    Return 'recurrenceid' in a form suitable for comparison with free/busy start
paul@506 397
    datetimes, using 'tzid' to convert recurrence identifiers that are dates.
paul@291 398
    """
paul@291 399
paul@506 400
    return format_datetime(to_utc_datetime(get_datetime(recurrenceid), tzid))
paul@291 401
paul@532 402
def end_date_to_calendar(dt):
paul@532 403
paul@532 404
    """
paul@532 405
    Change end dates to refer to the iCalendar "next day" dates, not the actual
paul@532 406
    dates.
paul@532 407
    """
paul@532 408
paul@532 409
    if not isinstance(dt, datetime):
paul@532 410
        return dt + timedelta(1)
paul@532 411
    else:
paul@532 412
        return dt
paul@532 413
paul@532 414
def end_date_from_calendar(dt):
paul@532 415
paul@532 416
    """
paul@532 417
    Change end dates to refer to the actual dates, not the iCalendar "next day"
paul@532 418
    dates.
paul@532 419
    """
paul@532 420
paul@532 421
    if not isinstance(dt, datetime):
paul@532 422
        return dt - timedelta(1)
paul@532 423
    else:
paul@532 424
        return dt
paul@532 425
paul@152 426
# vim: tabstop=4 expandtab shiftwidth=4