imip-agent

Annotated imiptools/dates.py

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