1"""Definitions and behavior for iCalendar, also known as vCalendar 2.0""" 2 3from __future__ import print_function 4 5import datetime 6import logging 7import random # for generating a UID 8import socket 9import string 10import base64 11 12from dateutil import rrule, tz 13import six 14 15try: 16 import pytz 17except ImportError: 18 class Pytz: 19 """fake pytz module (pytz is not required)""" 20 21 class AmbiguousTimeError(Exception): 22 """pytz error for ambiguous times 23 during transition daylight->standard""" 24 25 class NonExistentTimeError(Exception): 26 """pytz error for non-existent times 27 during transition standard->daylight""" 28 29 pytz = Pytz # keeps quantifiedcode happy 30 31from . import behavior 32from .base import (VObjectError, NativeError, ValidateError, ParseError, 33 Component, ContentLine, logger, registerBehavior, 34 backslashEscape, foldOneLine) 35 36 37# ------------------------------- Constants ------------------------------------ 38DATENAMES = ("rdate", "exdate") 39RULENAMES = ("exrule", "rrule") 40DATESANDRULES = ("exrule", "rrule", "rdate", "exdate") 41PRODID = u"-//PYVOBJECT//NONSGML Version 1//EN" 42 43WEEKDAYS = "MO", "TU", "WE", "TH", "FR", "SA", "SU" 44FREQUENCIES = ('YEARLY', 'MONTHLY', 'WEEKLY', 'DAILY', 'HOURLY', 'MINUTELY', 45 'SECONDLY') 46 47zeroDelta = datetime.timedelta(0) 48twoHours = datetime.timedelta(hours=2) 49 50 51# ---------------------------- TZID registry ----------------------------------- 52__tzidMap = {} 53 54 55def toUnicode(s): 56 """ 57 Take a string or unicode, turn it into unicode, decoding as utf-8 58 """ 59 if isinstance(s, six.binary_type): 60 s = s.decode('utf-8') 61 return s 62 63 64def registerTzid(tzid, tzinfo): 65 """ 66 Register a tzid -> tzinfo mapping. 67 """ 68 __tzidMap[toUnicode(tzid)] = tzinfo 69 70 71def getTzid(tzid, smart=True): 72 """ 73 Return the tzid if it exists, or None. 74 """ 75 tz = __tzidMap.get(toUnicode(tzid), None) 76 if smart and tzid and not tz: 77 try: 78 from pytz import timezone, UnknownTimeZoneError 79 try: 80 tz = timezone(tzid) 81 registerTzid(toUnicode(tzid), tz) 82 except UnknownTimeZoneError as e: 83 logging.error(e) 84 except ImportError as e: 85 logging.error(e) 86 return tz 87 88utc = tz.tzutc() 89registerTzid("UTC", utc) 90 91 92# -------------------- Helper subclasses --------------------------------------- 93class TimezoneComponent(Component): 94 """ 95 A VTIMEZONE object. 96 97 VTIMEZONEs are parsed by tz.tzical, the resulting datetime.tzinfo 98 subclass is stored in self.tzinfo, self.tzid stores the TZID associated 99 with this timezone. 100 101 @ivar name: 102 The uppercased name of the object, in this case always 'VTIMEZONE'. 103 @ivar tzinfo: 104 A datetime.tzinfo subclass representing this timezone. 105 @ivar tzid: 106 The string used to refer to this timezone. 107 """ 108 def __init__(self, tzinfo=None, *args, **kwds): 109 """ 110 Accept an existing Component or a tzinfo class. 111 """ 112 super(TimezoneComponent, self).__init__(*args, **kwds) 113 self.isNative = True 114 # hack to make sure a behavior is assigned 115 if self.behavior is None: 116 self.behavior = VTimezone 117 if tzinfo is not None: 118 self.tzinfo = tzinfo 119 if not hasattr(self, 'name') or self.name == '': 120 self.name = 'VTIMEZONE' 121 self.useBegin = True 122 123 @classmethod 124 def registerTzinfo(obj, tzinfo): 125 """ 126 Register tzinfo if it's not already registered, return its tzid. 127 """ 128 tzid = obj.pickTzid(tzinfo) 129 if tzid and not getTzid(tzid, False): 130 registerTzid(tzid, tzinfo) 131 return tzid 132 133 def gettzinfo(self): 134 # workaround for dateutil failing to parse some experimental properties 135 good_lines = ('rdate', 'rrule', 'dtstart', 'tzname', 'tzoffsetfrom', 136 'tzoffsetto', 'tzid') 137 # serialize encodes as utf-8, cStringIO will leave utf-8 alone 138 buffer = six.StringIO() 139 # allow empty VTIMEZONEs 140 if len(self.contents) == 0: 141 return None 142 143 def customSerialize(obj): 144 if isinstance(obj, Component): 145 foldOneLine(buffer, u"BEGIN:" + obj.name) 146 for child in obj.lines(): 147 if child.name.lower() in good_lines: 148 child.serialize(buffer, 75, validate=False) 149 for comp in obj.components(): 150 customSerialize(comp) 151 foldOneLine(buffer, u"END:" + obj.name) 152 customSerialize(self) 153 buffer.seek(0) # tzical wants to read a stream 154 return tz.tzical(buffer).get() 155 156 def settzinfo(self, tzinfo, start=2000, end=2030): 157 """ 158 Create appropriate objects in self to represent tzinfo. 159 160 Collapse DST transitions to rrules as much as possible. 161 162 Assumptions: 163 - DST <-> Standard transitions occur on the hour 164 - never within a month of one another 165 - twice or fewer times a year 166 - never in the month of December 167 - DST always moves offset exactly one hour later 168 - tzinfo classes dst method always treats times that could be in either 169 offset as being in the later regime 170 """ 171 def fromLastWeek(dt): 172 """ 173 How many weeks from the end of the month dt is, starting from 1. 174 """ 175 weekDelta = datetime.timedelta(weeks=1) 176 n = 1 177 current = dt + weekDelta 178 while current.month == dt.month: 179 n += 1 180 current += weekDelta 181 return n 182 183 # lists of dictionaries defining rules which are no longer in effect 184 completed = {'daylight': [], 'standard': []} 185 186 # dictionary defining rules which are currently in effect 187 working = {'daylight': None, 'standard': None} 188 189 # rule may be based on nth week of the month or the nth from the last 190 for year in range(start, end + 1): 191 newyear = datetime.datetime(year, 1, 1) 192 for transitionTo in 'daylight', 'standard': 193 transition = getTransition(transitionTo, year, tzinfo) 194 oldrule = working[transitionTo] 195 196 if transition == newyear: 197 # transitionTo is in effect for the whole year 198 rule = {'end' : None, 199 'start' : newyear, 200 'month' : 1, 201 'weekday' : None, 202 'hour' : None, 203 'plus' : None, 204 'minus' : None, 205 'name' : tzinfo.tzname(newyear), 206 'offset' : tzinfo.utcoffset(newyear), 207 'offsetfrom' : tzinfo.utcoffset(newyear)} 208 if oldrule is None: 209 # transitionTo was not yet in effect 210 working[transitionTo] = rule 211 else: 212 # transitionTo was already in effect 213 if (oldrule['offset'] != tzinfo.utcoffset(newyear)): 214 # old rule was different, it shouldn't continue 215 oldrule['end'] = year - 1 216 completed[transitionTo].append(oldrule) 217 working[transitionTo] = rule 218 elif transition is None: 219 # transitionTo is not in effect 220 if oldrule is not None: 221 # transitionTo used to be in effect 222 oldrule['end'] = year - 1 223 completed[transitionTo].append(oldrule) 224 working[transitionTo] = None 225 else: 226 # an offset transition was found 227 try: 228 old_offset = tzinfo.utcoffset(transition - twoHours) 229 name = tzinfo.tzname(transition) 230 offset = tzinfo.utcoffset(transition) 231 except (pytz.AmbiguousTimeError, pytz.NonExistentTimeError): 232 # guaranteed that tzinfo is a pytz timezone 233 is_dst = (transitionTo == "daylight") 234 old_offset = tzinfo.utcoffset(transition - twoHours, is_dst=is_dst) 235 name = tzinfo.tzname(transition, is_dst=is_dst) 236 offset = tzinfo.utcoffset(transition, is_dst=is_dst) 237 rule = {'end' : None, # None, or an integer year 238 'start' : transition, # the datetime of transition 239 'month' : transition.month, 240 'weekday' : transition.weekday(), 241 'hour' : transition.hour, 242 'name' : name, 243 'plus' : int( 244 (transition.day - 1)/ 7 + 1), # nth week of the month 245 'minus' : fromLastWeek(transition), # nth from last week 246 'offset' : offset, 247 'offsetfrom' : old_offset} 248 249 if oldrule is None: 250 working[transitionTo] = rule 251 else: 252 plusMatch = rule['plus'] == oldrule['plus'] 253 minusMatch = rule['minus'] == oldrule['minus'] 254 truth = plusMatch or minusMatch 255 for key in 'month', 'weekday', 'hour', 'offset': 256 truth = truth and rule[key] == oldrule[key] 257 if truth: 258 # the old rule is still true, limit to plus or minus 259 if not plusMatch: 260 oldrule['plus'] = None 261 if not minusMatch: 262 oldrule['minus'] = None 263 else: 264 # the new rule did not match the old 265 oldrule['end'] = year - 1 266 completed[transitionTo].append(oldrule) 267 working[transitionTo] = rule 268 269 for transitionTo in 'daylight', 'standard': 270 if working[transitionTo] is not None: 271 completed[transitionTo].append(working[transitionTo]) 272 273 self.tzid = [] 274 self.daylight = [] 275 self.standard = [] 276 277 self.add('tzid').value = self.pickTzid(tzinfo, True) 278 279 # old = None # unused? 280 for transitionTo in 'daylight', 'standard': 281 for rule in completed[transitionTo]: 282 comp = self.add(transitionTo) 283 dtstart = comp.add('dtstart') 284 dtstart.value = rule['start'] 285 if rule['name'] is not None: 286 comp.add('tzname').value = rule['name'] 287 line = comp.add('tzoffsetto') 288 line.value = deltaToOffset(rule['offset']) 289 line = comp.add('tzoffsetfrom') 290 line.value = deltaToOffset(rule['offsetfrom']) 291 292 if rule['plus'] is not None: 293 num = rule['plus'] 294 elif rule['minus'] is not None: 295 num = -1 * rule['minus'] 296 else: 297 num = None 298 if num is not None: 299 dayString = ";BYDAY=" + str(num) + WEEKDAYS[rule['weekday']] 300 else: 301 dayString = "" 302 if rule['end'] is not None: 303 if rule['hour'] is None: 304 # all year offset, with no rule 305 endDate = datetime.datetime(rule['end'], 1, 1) 306 else: 307 weekday = rrule.weekday(rule['weekday'], num) 308 du_rule = rrule.rrule(rrule.YEARLY, 309 bymonth=rule['month'], byweekday=weekday, 310 dtstart=datetime.datetime( 311 rule['end'], 1, 1, rule['hour'] 312 ) 313 ) 314 endDate = du_rule[0] 315 endDate = endDate.replace(tzinfo=utc) - rule['offsetfrom'] 316 endString = ";UNTIL=" + dateTimeToString(endDate) 317 else: 318 endString = '' 319 new_rule = "FREQ=YEARLY{0!s};BYMONTH={1!s}{2!s}"\ 320 .format(dayString, rule['month'], endString) 321 322 comp.add('rrule').value = new_rule 323 324 tzinfo = property(gettzinfo, settzinfo) 325 # prevent Component's __setattr__ from overriding the tzinfo property 326 normal_attributes = Component.normal_attributes + ['tzinfo'] 327 328 @staticmethod 329 def pickTzid(tzinfo, allowUTC=False): 330 """ 331 Given a tzinfo class, use known APIs to determine TZID, or use tzname. 332 """ 333 if tzinfo is None or (not allowUTC and tzinfo_eq(tzinfo, utc)): 334 # If tzinfo is UTC, we don't need a TZID 335 return None 336 # try PyICU's tzid key 337 if hasattr(tzinfo, 'tzid'): 338 return toUnicode(tzinfo.tzid) 339 340 # try pytz zone key 341 if hasattr(tzinfo, 'zone'): 342 return toUnicode(tzinfo.zone) 343 344 # try tzical's tzid key 345 elif hasattr(tzinfo, '_tzid'): 346 return toUnicode(tzinfo._tzid) 347 else: 348 # return tzname for standard (non-DST) time 349 notDST = datetime.timedelta(0) 350 for month in range(1, 13): 351 dt = datetime.datetime(2000, month, 1) 352 if tzinfo.dst(dt) == notDST: 353 return toUnicode(tzinfo.tzname(dt)) 354 # there was no standard time in 2000! 355 raise VObjectError("Unable to guess TZID for tzinfo {0!s}" 356 .format(tzinfo)) 357 358 def __str__(self): 359 return "<VTIMEZONE | {0}>".format(getattr(self, 'tzid', 'No TZID')) 360 361 def __repr__(self): 362 return self.__str__() 363 364 def prettyPrint(self, level, tabwidth): 365 pre = ' ' * level * tabwidth 366 print(pre, self.name) 367 print(pre, "TZID:", self.tzid) 368 print('') 369 370 371class RecurringComponent(Component): 372 """ 373 A vCalendar component like VEVENT or VTODO which may recur. 374 375 Any recurring component can have one or multiple RRULE, RDATE, 376 EXRULE, or EXDATE lines, and one or zero DTSTART lines. It can also have a 377 variety of children that don't have any recurrence information. 378 379 In the example below, note that dtstart is included in the rruleset. 380 This is not the default behavior for dateutil's rrule implementation unless 381 dtstart would already have been a member of the recurrence rule, and as a 382 result, COUNT is wrong. This can be worked around when getting rruleset by 383 adjusting count down by one if an rrule has a count and dtstart isn't in its 384 result set, but by default, the rruleset property doesn't do this work 385 around, to access it getrruleset must be called with addRDate set True. 386 387 @ivar rruleset: 388 A U{rruleset<https://moin.conectiva.com.br/DateUtil>}. 389 """ 390 def __init__(self, *args, **kwds): 391 super(RecurringComponent, self).__init__(*args, **kwds) 392 393 self.isNative = True 394 395 def getrruleset(self, addRDate=False): 396 """ 397 Get an rruleset created from self. 398 399 If addRDate is True, add an RDATE for dtstart if it's not included in 400 an RRULE or RDATE, and count is decremented if it exists. 401 402 Note that for rules which don't match DTSTART, DTSTART may not appear 403 in list(rruleset), although it should. By default, an RDATE is not 404 created in these cases, and count isn't updated, so dateutil may list 405 a spurious occurrence. 406 """ 407 rruleset = None 408 for name in DATESANDRULES: 409 addfunc = None 410 for line in self.contents.get(name, ()): 411 # don't bother creating a rruleset unless there's a rule 412 if rruleset is None: 413 rruleset = rrule.rruleset() 414 if addfunc is None: 415 addfunc = getattr(rruleset, name) 416 417 try: 418 dtstart = self.dtstart.value 419 except (AttributeError, KeyError): 420 # Special for VTODO - try DUE property instead 421 try: 422 if self.name == "VTODO": 423 dtstart = self.due.value 424 else: 425 # if there's no dtstart, just return None 426 logging.error('failed to get dtstart with VTODO') 427 return None 428 except (AttributeError, KeyError): 429 # if there's no due, just return None 430 logging.error('failed to find DUE at all.') 431 return None 432 433 if name in DATENAMES: 434 if type(line.value[0]) == datetime.datetime: 435 list(map(addfunc, line.value)) 436 elif type(line.value[0]) == datetime.date: 437 for dt in line.value: 438 addfunc(datetime.datetime(dt.year, dt.month, dt.day)) 439 else: 440 # ignore RDATEs with PERIOD values for now 441 pass 442 elif name in RULENAMES: 443 # a Ruby iCalendar library escapes semi-colons in rrules, 444 # so also remove any backslashes 445 value = line.value.replace('\\', '') 446 # If dtstart has no time zone, `until` 447 # shouldn't get one, either: 448 ignoretz = (not isinstance(dtstart, datetime.datetime) or 449 dtstart.tzinfo is None) 450 try: 451 until = rrule.rrulestr(value, ignoretz=ignoretz)._until 452 except ValueError: 453 # WORKAROUND: dateutil<=2.7.2 doesn't set the time zone 454 # of dtstart 455 if ignoretz: 456 raise 457 utc_now = datetime.datetime.now(datetime.timezone.utc) 458 until = rrule.rrulestr(value, dtstart=utc_now)._until 459 460 if until is not None and isinstance(dtstart, 461 datetime.datetime) and \ 462 (until.tzinfo != dtstart.tzinfo): 463 # dateutil converts the UNTIL date to a datetime, 464 # check to see if the UNTIL parameter value was a date 465 vals = dict(pair.split('=') for pair in 466 value.upper().split(';')) 467 if len(vals.get('UNTIL', '')) == 8: 468 until = datetime.datetime.combine(until.date(), 469 dtstart.time()) 470 # While RFC2445 says UNTIL MUST be UTC, Chandler allows 471 # floating recurring events, and uses floating UNTIL 472 # values. Also, some odd floating UNTIL but timezoned 473 # DTSTART values have shown up in the wild, so put 474 # floating UNTIL values DTSTART's timezone 475 if until.tzinfo is None: 476 until = until.replace(tzinfo=dtstart.tzinfo) 477 478 if dtstart.tzinfo is not None: 479 until = until.astimezone(dtstart.tzinfo) 480 481 # RFC2445 actually states that UNTIL must be a UTC 482 # value. Whilst the changes above work OK, one problem 483 # case is if DTSTART is floating but UNTIL is properly 484 # specified as UTC (or with a TZID). In that case 485 # dateutil will fail datetime comparisons. There is no 486 # easy solution to this as there is no obvious timezone 487 # (at this point) to do proper floating time offset 488 # comparisons. The best we can do is treat the UNTIL 489 # value as floating. This could mean incorrect 490 # determination of the last instance. The better 491 # solution here is to encourage clients to use COUNT 492 # rather than UNTIL when DTSTART is floating. 493 if dtstart.tzinfo is None: 494 until = until.replace(tzinfo=None) 495 496 value_without_until = ';'.join( 497 pair for pair in value.split(';') 498 if pair.split('=')[0].upper() != 'UNTIL') 499 rule = rrule.rrulestr(value_without_until, 500 dtstart=dtstart, ignoretz=ignoretz) 501 rule._until = until 502 503 # add the rrule or exrule to the rruleset 504 addfunc(rule) 505 506 if (name == 'rrule' or name == 'rdate') and addRDate: 507 # rlist = rruleset._rrule if name == 'rrule' else rruleset._rdate 508 try: 509 # dateutils does not work with all-day 510 # (datetime.date) items so we need to convert to a 511 # datetime.datetime (which is what dateutils 512 # does internally) 513 if not isinstance(dtstart, datetime.datetime): 514 adddtstart = datetime.datetime.fromordinal(dtstart.toordinal()) 515 else: 516 adddtstart = dtstart 517 518 if name == 'rrule': 519 if rruleset._rrule[-1][0] != adddtstart: 520 rruleset.rdate(adddtstart) 521 added = True 522 if rruleset._rrule[-1]._count is not None: 523 rruleset._rrule[-1]._count -= 1 524 else: 525 added = False 526 elif name == 'rdate': 527 if rruleset._rdate[0] != adddtstart: 528 rruleset.rdate(adddtstart) 529 added = True 530 else: 531 added = False 532 except IndexError: 533 # it's conceivable that an rrule has 0 datetimes 534 added = False 535 536 return rruleset 537 538 def setrruleset(self, rruleset): 539 # Get DTSTART from component (or DUE if no DTSTART in a VTODO) 540 try: 541 dtstart = self.dtstart.value 542 except (AttributeError, KeyError): 543 if self.name == "VTODO": 544 dtstart = self.due.value 545 else: 546 raise 547 548 isDate = datetime.date == type(dtstart) 549 if isDate: 550 dtstart = datetime.datetime(dtstart.year, dtstart.month, dtstart.day) 551 untilSerialize = dateToString 552 else: 553 # make sure to convert time zones to UTC 554 untilSerialize = lambda x: dateTimeToString(x, True) 555 556 for name in DATESANDRULES: 557 if name in self.contents: 558 del self.contents[name] 559 setlist = getattr(rruleset, '_' + name) 560 if name in DATENAMES: 561 setlist = list(setlist) # make a copy of the list 562 if name == 'rdate' and dtstart in setlist: 563 setlist.remove(dtstart) 564 if isDate: 565 setlist = [dt.date() for dt in setlist] 566 if len(setlist) > 0: 567 self.add(name).value = setlist 568 elif name in RULENAMES: 569 for rule in setlist: 570 buf = six.StringIO() 571 buf.write('FREQ=') 572 buf.write(FREQUENCIES[rule._freq]) 573 574 values = {} 575 576 if rule._interval != 1: 577 values['INTERVAL'] = [str(rule._interval)] 578 if rule._wkst != 0: # wkst defaults to Monday 579 values['WKST'] = [WEEKDAYS[rule._wkst]] 580 if rule._bysetpos is not None: 581 values['BYSETPOS'] = [str(i) for i in rule._bysetpos] 582 583 if rule._count is not None: 584 values['COUNT'] = [str(rule._count)] 585 elif rule._until is not None: 586 values['UNTIL'] = [untilSerialize(rule._until)] 587 588 days = [] 589 if (rule._byweekday is not None and ( 590 rrule.WEEKLY != rule._freq or 591 len(rule._byweekday) != 1 or 592 rule._dtstart.weekday() != rule._byweekday[0])): 593 # ignore byweekday if freq is WEEKLY and day correlates 594 # with dtstart because it was automatically set by dateutil 595 days.extend(WEEKDAYS[n] for n in rule._byweekday) 596 597 if rule._bynweekday is not None: 598 days.extend(n + WEEKDAYS[day] for day, n in rule._bynweekday) 599 600 if len(days) > 0: 601 values['BYDAY'] = days 602 603 if rule._bymonthday is not None and len(rule._bymonthday) > 0: 604 if not (rule._freq <= rrule.MONTHLY and 605 len(rule._bymonthday) == 1 and 606 rule._bymonthday[0] == rule._dtstart.day): 607 # ignore bymonthday if it's generated by dateutil 608 values['BYMONTHDAY'] = [str(n) for n in rule._bymonthday] 609 610 if rule._bynmonthday is not None and len(rule._bynmonthday) > 0: 611 values.setdefault('BYMONTHDAY', []).extend(str(n) for n in rule._bynmonthday) 612 613 if rule._bymonth is not None and len(rule._bymonth) > 0: 614 if (rule._byweekday is not None or 615 len(rule._bynweekday or ()) > 0 or 616 not (rule._freq == rrule.YEARLY and 617 len(rule._bymonth) == 1 and 618 rule._bymonth[0] == rule._dtstart.month)): 619 # ignore bymonth if it's generated by dateutil 620 values['BYMONTH'] = [str(n) for n in rule._bymonth] 621 622 if rule._byyearday is not None: 623 values['BYYEARDAY'] = [str(n) for n in rule._byyearday] 624 if rule._byweekno is not None: 625 values['BYWEEKNO'] = [str(n) for n in rule._byweekno] 626 627 # byhour, byminute, bysecond are always ignored for now 628 629 for key, paramvals in values.items(): 630 buf.write(';') 631 buf.write(key) 632 buf.write('=') 633 buf.write(','.join(paramvals)) 634 635 self.add(name).value = buf.getvalue() 636 637 rruleset = property(getrruleset, setrruleset) 638 639 def __setattr__(self, name, value): 640 """ 641 For convenience, make self.contents directly accessible. 642 """ 643 if name == 'rruleset': 644 self.setrruleset(value) 645 else: 646 super(RecurringComponent, self).__setattr__(name, value) 647 648 649class TextBehavior(behavior.Behavior): 650 """ 651 Provide backslash escape encoding/decoding for single valued properties. 652 653 TextBehavior also deals with base64 encoding if the ENCODING parameter is 654 explicitly set to BASE64. 655 """ 656 base64string = 'BASE64' # vCard uses B 657 658 @classmethod 659 def decode(cls, line): 660 """ 661 Remove backslash escaping from line.value. 662 """ 663 if line.encoded: 664 encoding = getattr(line, 'encoding_param', None) 665 if encoding and encoding.upper() == cls.base64string: 666 line.value = base64.b64decode(line.value) 667 else: 668 line.value = stringToTextValues(line.value)[0] 669 line.encoded = False 670 671 @classmethod 672 def encode(cls, line): 673 """ 674 Backslash escape line.value. 675 """ 676 if not line.encoded: 677 encoding = getattr(line, 'encoding_param', None) 678 if encoding and encoding.upper() == cls.base64string: 679 line.value = base64.b64encode(line.value.encode('utf-8')).decode('utf-8').replace('\n', '') 680 else: 681 line.value = backslashEscape(line.value) 682 line.encoded = True 683 684 685class VCalendarComponentBehavior(behavior.Behavior): 686 defaultBehavior = TextBehavior 687 isComponent = True 688 689 690class RecurringBehavior(VCalendarComponentBehavior): 691 """ 692 Parent Behavior for components which should be RecurringComponents. 693 """ 694 hasNative = True 695 696 @staticmethod 697 def transformToNative(obj): 698 """ 699 Turn a recurring Component into a RecurringComponent. 700 """ 701 if not obj.isNative: 702 object.__setattr__(obj, '__class__', RecurringComponent) 703 obj.isNative = True 704 return obj 705 706 @staticmethod 707 def transformFromNative(obj): 708 if obj.isNative: 709 object.__setattr__(obj, '__class__', Component) 710 obj.isNative = False 711 return obj 712 713 @staticmethod 714 def generateImplicitParameters(obj): 715 """ 716 Generate a UID and DTSTAMP if one does not exist. 717 718 This is just a dummy implementation, for now. 719 """ 720 if not hasattr(obj, 'uid'): 721 rand = int(random.random() * 100000) 722 now = datetime.datetime.now(utc) 723 now = dateTimeToString(now) 724 host = socket.gethostname() 725 obj.add(ContentLine('UID', [], "{0} - {1}@{2}".format(now, rand, 726 host))) 727 728 if not hasattr(obj, 'dtstamp'): 729 now = datetime.datetime.now(utc) 730 obj.add('dtstamp').value = now 731 732 733class DateTimeBehavior(behavior.Behavior): 734 """ 735 Parent Behavior for ContentLines containing one DATE-TIME. 736 """ 737 hasNative = True 738 739 @staticmethod 740 def transformToNative(obj): 741 """ 742 Turn obj.value into a datetime. 743 744 RFC2445 allows times without time zone information, "floating times" 745 in some properties. Mostly, this isn't what you want, but when parsing 746 a file, real floating times are noted by setting to 'TRUE' the 747 X-VOBJ-FLOATINGTIME-ALLOWED parameter. 748 """ 749 if obj.isNative: 750 return obj 751 obj.isNative = True 752 if obj.value == '': 753 return obj 754 obj.value = obj.value 755 # we're cheating a little here, parseDtstart allows DATE 756 obj.value = parseDtstart(obj) 757 if obj.value.tzinfo is None: 758 obj.params['X-VOBJ-FLOATINGTIME-ALLOWED'] = ['TRUE'] 759 if obj.params.get('TZID'): 760 # Keep a copy of the original TZID around 761 obj.params['X-VOBJ-ORIGINAL-TZID'] = [obj.params['TZID']] 762 del obj.params['TZID'] 763 return obj 764 765 @classmethod 766 def transformFromNative(cls, obj): 767 """ 768 Replace the datetime in obj.value with an ISO 8601 string. 769 """ 770 if obj.isNative: 771 obj.isNative = False 772 tzid = TimezoneComponent.registerTzinfo(obj.value.tzinfo) 773 obj.value = dateTimeToString(obj.value, cls.forceUTC) 774 if not cls.forceUTC and tzid is not None: 775 obj.tzid_param = tzid 776 if obj.params.get('X-VOBJ-ORIGINAL-TZID'): 777 if not hasattr(obj, 'tzid_param'): 778 obj.tzid_param = obj.x_vobj_original_tzid_param 779 del obj.params['X-VOBJ-ORIGINAL-TZID'] 780 781 return obj 782 783 784class UTCDateTimeBehavior(DateTimeBehavior): 785 """ 786 A value which must be specified in UTC. 787 """ 788 forceUTC = True 789 790 791class DateOrDateTimeBehavior(behavior.Behavior): 792 """ 793 Parent Behavior for ContentLines containing one DATE or DATE-TIME. 794 """ 795 hasNative = True 796 797 @staticmethod 798 def transformToNative(obj): 799 """ 800 Turn obj.value into a date or datetime. 801 """ 802 if obj.isNative: 803 return obj 804 obj.isNative = True 805 if obj.value == '': 806 return obj 807 obj.value = obj.value 808 obj.value = parseDtstart(obj, allowSignatureMismatch=True) 809 if getattr(obj, 'value_param', 'DATE-TIME').upper() == 'DATE-TIME': 810 if hasattr(obj, 'tzid_param'): 811 # Keep a copy of the original TZID around 812 obj.params['X-VOBJ-ORIGINAL-TZID'] = [obj.tzid_param] 813 del obj.tzid_param 814 return obj 815 816 @staticmethod 817 def transformFromNative(obj): 818 """ 819 Replace the date or datetime in obj.value with an ISO 8601 string. 820 """ 821 if type(obj.value) == datetime.date: 822 obj.isNative = False 823 obj.value_param = 'DATE' 824 obj.value = dateToString(obj.value) 825 return obj 826 else: 827 return DateTimeBehavior.transformFromNative(obj) 828 829 830class MultiDateBehavior(behavior.Behavior): 831 """ 832 Parent Behavior for ContentLines containing one or more DATE, DATE-TIME, or 833 PERIOD. 834 """ 835 hasNative = True 836 837 @staticmethod 838 def transformToNative(obj): 839 """ 840 Turn obj.value into a list of dates, datetimes, or 841 (datetime, timedelta) tuples. 842 """ 843 if obj.isNative: 844 return obj 845 obj.isNative = True 846 if obj.value == '': 847 obj.value = [] 848 return obj 849 tzinfo = getTzid(getattr(obj, 'tzid_param', None)) 850 valueParam = getattr(obj, 'value_param', "DATE-TIME").upper() 851 valTexts = obj.value.split(",") 852 if valueParam == "DATE": 853 obj.value = [stringToDate(x) for x in valTexts] 854 elif valueParam == "DATE-TIME": 855 obj.value = [stringToDateTime(x, tzinfo) for x in valTexts] 856 elif valueParam == "PERIOD": 857 obj.value = [stringToPeriod(x, tzinfo) for x in valTexts] 858 return obj 859 860 @staticmethod 861 def transformFromNative(obj): 862 """ 863 Replace the date, datetime or period tuples in obj.value with 864 appropriate strings. 865 """ 866 if obj.value and type(obj.value[0]) == datetime.date: 867 obj.isNative = False 868 obj.value_param = 'DATE' 869 obj.value = ','.join([dateToString(val) for val in obj.value]) 870 return obj 871 # Fixme: handle PERIOD case 872 else: 873 if obj.isNative: 874 obj.isNative = False 875 transformed = [] 876 tzid = None 877 for val in obj.value: 878 if tzid is None and type(val) == datetime.datetime: 879 tzid = TimezoneComponent.registerTzinfo(val.tzinfo) 880 if tzid is not None: 881 obj.tzid_param = tzid 882 transformed.append(dateTimeToString(val)) 883 obj.value = ','.join(transformed) 884 return obj 885 886 887class MultiTextBehavior(behavior.Behavior): 888 """ 889 Provide backslash escape encoding/decoding of each of several values. 890 891 After transformation, value is a list of strings. 892 """ 893 listSeparator = "," 894 895 @classmethod 896 def decode(cls, line): 897 """ 898 Remove backslash escaping from line.value, then split on commas. 899 """ 900 if line.encoded: 901 line.value = stringToTextValues(line.value, 902 listSeparator=cls.listSeparator) 903 line.encoded = False 904 905 @classmethod 906 def encode(cls, line): 907 """ 908 Backslash escape line.value. 909 """ 910 if not line.encoded: 911 line.value = cls.listSeparator.join(backslashEscape(val) 912 for val in line.value) 913 line.encoded = True 914 915 916class SemicolonMultiTextBehavior(MultiTextBehavior): 917 listSeparator = ";" 918 919 920# ------------------------ Registered Behavior subclasses ---------------------- 921class VCalendar2_0(VCalendarComponentBehavior): 922 """ 923 vCalendar 2.0 behavior. With added VAVAILABILITY support. 924 """ 925 name = 'VCALENDAR' 926 description = 'vCalendar 2.0, also known as iCalendar.' 927 versionString = '2.0' 928 sortFirst = ('version', 'calscale', 'method', 'prodid', 'vtimezone') 929 knownChildren = { 930 'CALSCALE': (0, 1, None), # min, max, behaviorRegistry id 931 'METHOD': (0, 1, None), 932 'VERSION': (0, 1, None), # required, but auto-generated 933 'PRODID': (1, 1, None), 934 'VTIMEZONE': (0, None, None), 935 'VEVENT': (0, None, None), 936 'VTODO': (0, None, None), 937 'VJOURNAL': (0, None, None), 938 'VFREEBUSY': (0, None, None), 939 'VAVAILABILITY': (0, None, None), 940 } 941 942 @classmethod 943 def generateImplicitParameters(cls, obj): 944 """ 945 Create PRODID, VERSION and VTIMEZONEs if needed. 946 947 VTIMEZONEs will need to exist whenever TZID parameters exist or when 948 datetimes with tzinfo exist. 949 """ 950 for comp in obj.components(): 951 if comp.behavior is not None: 952 comp.behavior.generateImplicitParameters(comp) 953 if not hasattr(obj, 'prodid'): 954 obj.add(ContentLine('PRODID', [], PRODID)) 955 if not hasattr(obj, 'version'): 956 obj.add(ContentLine('VERSION', [], cls.versionString)) 957 tzidsUsed = {} 958 959 def findTzids(obj, table): 960 if isinstance(obj, ContentLine) and (obj.behavior is None or 961 not obj.behavior.forceUTC): 962 if getattr(obj, 'tzid_param', None): 963 table[obj.tzid_param] = 1 964 else: 965 if type(obj.value) == list: 966 for item in obj.value: 967 tzinfo = getattr(obj.value, 'tzinfo', None) 968 tzid = TimezoneComponent.registerTzinfo(tzinfo) 969 if tzid: 970 table[tzid] = 1 971 else: 972 tzinfo = getattr(obj.value, 'tzinfo', None) 973 tzid = TimezoneComponent.registerTzinfo(tzinfo) 974 if tzid: 975 table[tzid] = 1 976 for child in obj.getChildren(): 977 if obj.name != 'VTIMEZONE': 978 findTzids(child, table) 979 980 findTzids(obj, tzidsUsed) 981 oldtzids = [toUnicode(x.tzid.value) for x in getattr(obj, 'vtimezone_list', [])] 982 for tzid in tzidsUsed.keys(): 983 tzid = toUnicode(tzid) 984 if tzid != u'UTC' and tzid not in oldtzids: 985 obj.add(TimezoneComponent(tzinfo=getTzid(tzid))) 986 987 @classmethod 988 def serialize(cls, obj, buf, lineLength, validate=True): 989 """ 990 Set implicit parameters, do encoding, return unicode string. 991 992 If validate is True, raise VObjectError if the line doesn't validate 993 after implicit parameters are generated. 994 995 Default is to call base.defaultSerialize. 996 997 """ 998 999 cls.generateImplicitParameters(obj) 1000 if validate: 1001 cls.validate(obj, raiseException=True) 1002 if obj.isNative: 1003 transformed = obj.transformFromNative() 1004 undoTransform = True 1005 else: 1006 transformed = obj 1007 undoTransform = False 1008 out = None 1009 outbuf = buf or six.StringIO() 1010 if obj.group is None: 1011 groupString = '' 1012 else: 1013 groupString = obj.group + '.' 1014 if obj.useBegin: 1015 foldOneLine(outbuf, "{0}BEGIN:{1}".format(groupString, obj.name), 1016 lineLength) 1017 1018 try: 1019 first_props = [s for s in cls.sortFirst if s in obj.contents \ 1020 and not isinstance(obj.contents[s][0], Component)] 1021 first_components = [s for s in cls.sortFirst if s in obj.contents \ 1022 and isinstance(obj.contents[s][0], Component)] 1023 except Exception: 1024 first_props = first_components = [] 1025 # first_components = [] 1026 1027 prop_keys = sorted(list(k for k in obj.contents.keys() if k not in first_props \ 1028 and not isinstance(obj.contents[k][0], Component))) 1029 comp_keys = sorted(list(k for k in obj.contents.keys() if k not in first_components \ 1030 and isinstance(obj.contents[k][0], Component))) 1031 1032 sorted_keys = first_props + prop_keys + first_components + comp_keys 1033 children = [o for k in sorted_keys for o in obj.contents[k]] 1034 1035 for child in children: 1036 # validate is recursive, we only need to validate once 1037 child.serialize(outbuf, lineLength, validate=False) 1038 if obj.useBegin: 1039 foldOneLine(outbuf, "{0}END:{1}".format(groupString, obj.name), 1040 lineLength) 1041 out = buf or outbuf.getvalue() 1042 if undoTransform: 1043 obj.transformToNative() 1044 return out 1045registerBehavior(VCalendar2_0) 1046 1047 1048class VTimezone(VCalendarComponentBehavior): 1049 """ 1050 Timezone behavior. 1051 """ 1052 name = 'VTIMEZONE' 1053 hasNative = True 1054 description = 'A grouping of component properties that defines a time zone.' 1055 sortFirst = ('tzid', 'last-modified', 'tzurl', 'standard', 'daylight') 1056 knownChildren = { 1057 'TZID': (1, 1, None), # min, max, behaviorRegistry id 1058 'LAST-MODIFIED': (0, 1, None), 1059 'TZURL': (0, 1, None), 1060 'STANDARD': (0, None, None), # NOTE: One of Standard or 1061 'DAYLIGHT': (0, None, None) # Daylight must appear 1062 } 1063 1064 @classmethod 1065 def validate(cls, obj, raiseException, *args): 1066 if not hasattr(obj, 'tzid') or obj.tzid.value is None: 1067 if raiseException: 1068 m = "VTIMEZONE components must contain a valid TZID" 1069 raise ValidateError(m) 1070 return False 1071 if 'standard' in obj.contents or 'daylight' in obj.contents: 1072 return super(VTimezone, cls).validate(obj, raiseException, *args) 1073 else: 1074 if raiseException: 1075 m = "VTIMEZONE components must contain a STANDARD or a DAYLIGHT\ 1076 component" 1077 raise ValidateError(m) 1078 return False 1079 1080 @staticmethod 1081 def transformToNative(obj): 1082 if not obj.isNative: 1083 object.__setattr__(obj, '__class__', TimezoneComponent) 1084 obj.isNative = True 1085 obj.registerTzinfo(obj.tzinfo) 1086 return obj 1087 1088 @staticmethod 1089 def transformFromNative(obj): 1090 return obj 1091registerBehavior(VTimezone) 1092 1093 1094class TZID(behavior.Behavior): 1095 """ 1096 Don't use TextBehavior for TZID. 1097 1098 RFC2445 only allows TZID lines to be paramtext, so they shouldn't need any 1099 encoding or decoding. Unfortunately, some Microsoft products use commas 1100 in TZIDs which should NOT be treated as a multi-valued text property, nor 1101 do we want to escape them. Leaving them alone works for Microsoft's breakage, 1102 and doesn't affect compliant iCalendar streams. 1103 """ 1104registerBehavior(TZID) 1105 1106 1107class DaylightOrStandard(VCalendarComponentBehavior): 1108 hasNative = False 1109 knownChildren = {'DTSTART': (1, 1, None), # min, max, behaviorRegistry id 1110 'RRULE': (0, 1, None)} 1111 1112registerBehavior(DaylightOrStandard, 'STANDARD') 1113registerBehavior(DaylightOrStandard, 'DAYLIGHT') 1114 1115 1116class VEvent(RecurringBehavior): 1117 """ 1118 Event behavior. 1119 """ 1120 name = 'VEVENT' 1121 sortFirst = ('uid', 'recurrence-id', 'dtstart', 'duration', 'dtend') 1122 1123 description = 'A grouping of component properties, and possibly including \ 1124 "VALARM" calendar components, that represents a scheduled \ 1125 amount of time on a calendar.' 1126 knownChildren = { 1127 'DTSTART': (0, 1, None), # min, max, behaviorRegistry id 1128 'CLASS': (0, 1, None), 1129 'CREATED': (0, 1, None), 1130 'DESCRIPTION': (0, 1, None), 1131 'GEO': (0, 1, None), 1132 'LAST-MODIFIED': (0, 1, None), 1133 'LOCATION': (0, 1, None), 1134 'ORGANIZER': (0, 1, None), 1135 'PRIORITY': (0, 1, None), 1136 'DTSTAMP': (1, 1, None), # required 1137 'SEQUENCE': (0, 1, None), 1138 'STATUS': (0, 1, None), 1139 'SUMMARY': (0, 1, None), 1140 'TRANSP': (0, 1, None), 1141 'UID': (1, 1, None), 1142 'URL': (0, 1, None), 1143 'RECURRENCE-ID': (0, 1, None), 1144 'DTEND': (0, 1, None), # NOTE: Only one of DtEnd or 1145 'DURATION': (0, 1, None), # Duration can appear 1146 'ATTACH': (0, None, None), 1147 'ATTENDEE': (0, None, None), 1148 'CATEGORIES': (0, None, None), 1149 'COMMENT': (0, None, None), 1150 'CONTACT': (0, None, None), 1151 'EXDATE': (0, None, None), 1152 'EXRULE': (0, None, None), 1153 'REQUEST-STATUS': (0, None, None), 1154 'RELATED-TO': (0, None, None), 1155 'RESOURCES': (0, None, None), 1156 'RDATE': (0, None, None), 1157 'RRULE': (0, None, None), 1158 'VALARM': (0, None, None) 1159 } 1160 1161 @classmethod 1162 def validate(cls, obj, raiseException, *args): 1163 if 'dtend' in obj.contents and 'duration' in obj.contents: 1164 if raiseException: 1165 m = "VEVENT components cannot contain both DTEND and DURATION\ 1166 components" 1167 raise ValidateError(m) 1168 return False 1169 else: 1170 return super(VEvent, cls).validate(obj, raiseException, *args) 1171 1172registerBehavior(VEvent) 1173 1174 1175class VTodo(RecurringBehavior): 1176 """ 1177 To-do behavior. 1178 """ 1179 name = 'VTODO' 1180 description = 'A grouping of component properties and possibly "VALARM" \ 1181 calendar components that represent an action-item or \ 1182 assignment.' 1183 knownChildren = { 1184 'DTSTART': (0, 1, None), # min, max, behaviorRegistry id 1185 'CLASS': (0, 1, None), 1186 'COMPLETED': (0, 1, None), 1187 'CREATED': (0, 1, None), 1188 'DESCRIPTION': (0, 1, None), 1189 'GEO': (0, 1, None), 1190 'LAST-MODIFIED': (0, 1, None), 1191 'LOCATION': (0, 1, None), 1192 'ORGANIZER': (0, 1, None), 1193 'PERCENT': (0, 1, None), 1194 'PRIORITY': (0, 1, None), 1195 'DTSTAMP': (1, 1, None), 1196 'SEQUENCE': (0, 1, None), 1197 'STATUS': (0, 1, None), 1198 'SUMMARY': (0, 1, None), 1199 'UID': (0, 1, None), 1200 'URL': (0, 1, None), 1201 'RECURRENCE-ID': (0, 1, None), 1202 'DUE': (0, 1, None), # NOTE: Only one of Due or 1203 'DURATION': (0, 1, None), # Duration can appear 1204 'ATTACH': (0, None, None), 1205 'ATTENDEE': (0, None, None), 1206 'CATEGORIES': (0, None, None), 1207 'COMMENT': (0, None, None), 1208 'CONTACT': (0, None, None), 1209 'EXDATE': (0, None, None), 1210 'EXRULE': (0, None, None), 1211 'REQUEST-STATUS': (0, None, None), 1212 'RELATED-TO': (0, None, None), 1213 'RESOURCES': (0, None, None), 1214 'RDATE': (0, None, None), 1215 'RRULE': (0, None, None), 1216 'VALARM': (0, None, None) 1217 } 1218 1219 @classmethod 1220 def validate(cls, obj, raiseException, *args): 1221 if 'due' in obj.contents and 'duration' in obj.contents: 1222 if raiseException: 1223 m = "VTODO components cannot contain both DUE and DURATION\ 1224 components" 1225 raise ValidateError(m) 1226 return False 1227 else: 1228 return super(VTodo, cls).validate(obj, raiseException, *args) 1229 1230registerBehavior(VTodo) 1231 1232 1233class VJournal(RecurringBehavior): 1234 """ 1235 Journal entry behavior. 1236 """ 1237 name = 'VJOURNAL' 1238 knownChildren = { 1239 'DTSTART': (0, 1, None), # min, max, behaviorRegistry id 1240 'CLASS': (0, 1, None), 1241 'CREATED': (0, 1, None), 1242 'DESCRIPTION': (0, 1, None), 1243 'LAST-MODIFIED': (0, 1, None), 1244 'ORGANIZER': (0, 1, None), 1245 'DTSTAMP': (1, 1, None), 1246 'SEQUENCE': (0, 1, None), 1247 'STATUS': (0, 1, None), 1248 'SUMMARY': (0, 1, None), 1249 'UID': (0, 1, None), 1250 'URL': (0, 1, None), 1251 'RECURRENCE-ID': (0, 1, None), 1252 'ATTACH': (0, None, None), 1253 'ATTENDEE': (0, None, None), 1254 'CATEGORIES': (0, None, None), 1255 'COMMENT': (0, None, None), 1256 'CONTACT': (0, None, None), 1257 'EXDATE': (0, None, None), 1258 'EXRULE': (0, None, None), 1259 'REQUEST-STATUS': (0, None, None), 1260 'RELATED-TO': (0, None, None), 1261 'RDATE': (0, None, None), 1262 'RRULE': (0, None, None) 1263 } 1264registerBehavior(VJournal) 1265 1266 1267class VFreeBusy(VCalendarComponentBehavior): 1268 """ 1269 Free/busy state behavior. 1270 """ 1271 name = 'VFREEBUSY' 1272 description = 'A grouping of component properties that describe either a \ 1273 request for free/busy time, describe a response to a request \ 1274 for free/busy time or describe a published set of busy time.' 1275 sortFirst = ('uid', 'dtstart', 'duration', 'dtend') 1276 knownChildren = { 1277 'DTSTART': (0, 1, None), # min, max, behaviorRegistry id 1278 'CONTACT': (0, 1, None), 1279 'DTEND': (0, 1, None), 1280 'DURATION': (0, 1, None), 1281 'ORGANIZER': (0, 1, None), 1282 'DTSTAMP': (1, 1, None), 1283 'UID': (0, 1, None), 1284 'URL': (0, 1, None), 1285 'ATTENDEE': (0, None, None), 1286 'COMMENT': (0, None, None), 1287 'FREEBUSY': (0, None, None), 1288 'REQUEST-STATUS': (0, None, None) 1289 } 1290 1291registerBehavior(VFreeBusy) 1292 1293 1294class VAlarm(VCalendarComponentBehavior): 1295 """ 1296 Alarm behavior. 1297 """ 1298 name = 'VALARM' 1299 description = 'Alarms describe when and how to provide alerts about events \ 1300 and to-dos.' 1301 knownChildren = { 1302 'ACTION': (1, 1, None), # min, max, behaviorRegistry id 1303 'TRIGGER': (1, 1, None), 1304 'DURATION': (0, 1, None), 1305 'REPEAT': (0, 1, None), 1306 'DESCRIPTION': (0, 1, None) 1307 } 1308 1309 @staticmethod 1310 def generateImplicitParameters(obj): 1311 """ 1312 Create default ACTION and TRIGGER if they're not set. 1313 """ 1314 try: 1315 obj.action 1316 except AttributeError: 1317 obj.add('action').value = 'AUDIO' 1318 try: 1319 obj.trigger 1320 except AttributeError: 1321 obj.add('trigger').value = datetime.timedelta(0) 1322 1323 @classmethod 1324 def validate(cls, obj, raiseException, *args): 1325 """ 1326 # TODO 1327 if obj.contents.has_key('dtend') and obj.contents.has_key('duration'): 1328 if raiseException: 1329 m = "VEVENT components cannot contain both DTEND and DURATION\ 1330 components" 1331 raise ValidateError(m) 1332 return False 1333 else: 1334 return super(VEvent, cls).validate(obj, raiseException, *args) 1335 """ 1336 return True 1337 1338registerBehavior(VAlarm) 1339 1340 1341class VAvailability(VCalendarComponentBehavior): 1342 """ 1343 Availability state behavior. 1344 1345 Used to represent user's available time slots. 1346 """ 1347 name = 'VAVAILABILITY' 1348 description = 'A component used to represent a user\'s available time slots.' 1349 sortFirst = ('uid', 'dtstart', 'duration', 'dtend') 1350 knownChildren = { 1351 'UID': (1, 1, None), # min, max, behaviorRegistry id 1352 'DTSTAMP': (1, 1, None), 1353 'BUSYTYPE': (0, 1, None), 1354 'CREATED': (0, 1, None), 1355 'DTSTART': (0, 1, None), 1356 'LAST-MODIFIED': (0, 1, None), 1357 'ORGANIZER': (0, 1, None), 1358 'SEQUENCE': (0, 1, None), 1359 'SUMMARY': (0, 1, None), 1360 'URL': (0, 1, None), 1361 'DTEND': (0, 1, None), 1362 'DURATION': (0, 1, None), 1363 'CATEGORIES': (0, None, None), 1364 'COMMENT': (0, None, None), 1365 'CONTACT': (0, None, None), 1366 'AVAILABLE': (0, None, None), 1367 } 1368 1369 @classmethod 1370 def validate(cls, obj, raiseException, *args): 1371 if 'dtend' in obj.contents and 'duration' in obj.contents: 1372 if raiseException: 1373 m = "VAVAILABILITY components cannot contain both DTEND and DURATION components" 1374 raise ValidateError(m) 1375 return False 1376 else: 1377 return super(VAvailability, cls).validate(obj, raiseException, *args) 1378 1379registerBehavior(VAvailability) 1380 1381 1382class Available(RecurringBehavior): 1383 """ 1384 Event behavior. 1385 """ 1386 name = 'AVAILABLE' 1387 sortFirst = ('uid', 'recurrence-id', 'dtstart', 'duration', 'dtend') 1388 description = 'Defines a period of time in which a user is normally available.' 1389 knownChildren = { 1390 'DTSTAMP': (1, 1, None), # min, max, behaviorRegistry id 1391 'DTSTART': (1, 1, None), 1392 'UID': (1, 1, None), 1393 'DTEND': (0, 1, None), # NOTE: One of DtEnd or 1394 'DURATION': (0, 1, None), # Duration must appear, but not both 1395 'CREATED': (0, 1, None), 1396 'LAST-MODIFIED': (0, 1, None), 1397 'RECURRENCE-ID': (0, 1, None), 1398 'RRULE': (0, 1, None), 1399 'SUMMARY': (0, 1, None), 1400 'CATEGORIES': (0, None, None), 1401 'COMMENT': (0, None, None), 1402 'CONTACT': (0, None, None), 1403 'EXDATE': (0, None, None), 1404 'RDATE': (0, None, None), 1405 } 1406 1407 @classmethod 1408 def validate(cls, obj, raiseException, *args): 1409 has_dtend = 'dtend' in obj.contents 1410 has_duration = 'duration' in obj.contents 1411 if has_dtend and has_duration: 1412 if raiseException: 1413 m = "AVAILABLE components cannot contain both DTEND and DURATION\ 1414 properties" 1415 raise ValidateError(m) 1416 return False 1417 elif not (has_dtend or has_duration): 1418 if raiseException: 1419 m = "AVAILABLE components must contain one of DTEND or DURATION\ 1420 properties" 1421 raise ValidateError(m) 1422 return False 1423 else: 1424 return super(Available, cls).validate(obj, raiseException, *args) 1425 1426registerBehavior(Available) 1427 1428 1429class Duration(behavior.Behavior): 1430 """ 1431 Behavior for Duration ContentLines. Transform to datetime.timedelta. 1432 """ 1433 name = 'DURATION' 1434 hasNative = True 1435 1436 @staticmethod 1437 def transformToNative(obj): 1438 """ 1439 Turn obj.value into a datetime.timedelta. 1440 """ 1441 if obj.isNative: 1442 return obj 1443 obj.isNative = True 1444 obj.value = obj.value 1445 if obj.value == '': 1446 return obj 1447 else: 1448 deltalist = stringToDurations(obj.value) 1449 # When can DURATION have multiple durations? For now: 1450 if len(deltalist) == 1: 1451 obj.value = deltalist[0] 1452 return obj 1453 else: 1454 raise ParseError("DURATION must have a single duration string.") 1455 1456 @staticmethod 1457 def transformFromNative(obj): 1458 """ 1459 Replace the datetime.timedelta in obj.value with an RFC2445 string. 1460 """ 1461 if not obj.isNative: 1462 return obj 1463 obj.isNative = False 1464 obj.value = timedeltaToString(obj.value) 1465 return obj 1466 1467registerBehavior(Duration) 1468 1469 1470class Trigger(behavior.Behavior): 1471 """ 1472 DATE-TIME or DURATION 1473 """ 1474 name = 'TRIGGER' 1475 description = 'This property specifies when an alarm will trigger.' 1476 hasNative = True 1477 forceUTC = True 1478 1479 @staticmethod 1480 def transformToNative(obj): 1481 """ 1482 Turn obj.value into a timedelta or datetime. 1483 """ 1484 if obj.isNative: 1485 return obj 1486 value = getattr(obj, 'value_param', 'DURATION').upper() 1487 if hasattr(obj, 'value_param'): 1488 del obj.value_param 1489 if obj.value == '': 1490 obj.isNative = True 1491 return obj 1492 elif value == 'DURATION': 1493 try: 1494 return Duration.transformToNative(obj) 1495 except ParseError: 1496 logger.warning("TRIGGER not recognized as DURATION, trying " 1497 "DATE-TIME, because iCal sometimes exports " 1498 "DATE-TIMEs without setting VALUE=DATE-TIME") 1499 try: 1500 obj.isNative = False 1501 dt = DateTimeBehavior.transformToNative(obj) 1502 return dt 1503 except: 1504 msg = "TRIGGER with no VALUE not recognized as DURATION " \ 1505 "or as DATE-TIME" 1506 raise ParseError(msg) 1507 elif value == 'DATE-TIME': 1508 # TRIGGERs with DATE-TIME values must be in UTC, we could validate 1509 # that fact, for now we take it on faith. 1510 return DateTimeBehavior.transformToNative(obj) 1511 else: 1512 raise ParseError("VALUE must be DURATION or DATE-TIME") 1513 1514 @staticmethod 1515 def transformFromNative(obj): 1516 if type(obj.value) == datetime.datetime: 1517 obj.value_param = 'DATE-TIME' 1518 return UTCDateTimeBehavior.transformFromNative(obj) 1519 elif type(obj.value) == datetime.timedelta: 1520 return Duration.transformFromNative(obj) 1521 else: 1522 raise NativeError("Native TRIGGER values must be timedelta or " 1523 "datetime") 1524registerBehavior(Trigger) 1525 1526 1527class PeriodBehavior(behavior.Behavior): 1528 """ 1529 A list of (date-time, timedelta) tuples. 1530 """ 1531 hasNative = True 1532 1533 @staticmethod 1534 def transformToNative(obj): 1535 """ 1536 Convert comma separated periods into tuples. 1537 """ 1538 if obj.isNative: 1539 return obj 1540 obj.isNative = True 1541 if obj.value == '': 1542 obj.value = [] 1543 return obj 1544 tzinfo = getTzid(getattr(obj, 'tzid_param', None)) 1545 obj.value = [stringToPeriod(x, tzinfo) for x in obj.value.split(",")] 1546 return obj 1547 1548 @classmethod 1549 def transformFromNative(cls, obj): 1550 """ 1551 Convert the list of tuples in obj.value to strings. 1552 """ 1553 if obj.isNative: 1554 obj.isNative = False 1555 transformed = [] 1556 for tup in obj.value: 1557 transformed.append(periodToString(tup, cls.forceUTC)) 1558 if len(transformed) > 0: 1559 tzid = TimezoneComponent.registerTzinfo(tup[0].tzinfo) 1560 if not cls.forceUTC and tzid is not None: 1561 obj.tzid_param = tzid 1562 1563 obj.value = ','.join(transformed) 1564 1565 return obj 1566 1567 1568class FreeBusy(PeriodBehavior): 1569 """ 1570 Free or busy period of time, must be specified in UTC. 1571 """ 1572 name = 'FREEBUSY' 1573 forceUTC = True 1574registerBehavior(FreeBusy, 'FREEBUSY') 1575 1576 1577class RRule(behavior.Behavior): 1578 """ 1579 Dummy behavior to avoid having RRULEs being treated as text lines (and thus 1580 having semi-colons inaccurately escaped). 1581 """ 1582registerBehavior(RRule, 'RRULE') 1583registerBehavior(RRule, 'EXRULE') 1584 1585 1586# ------------------------ Registration of common classes ---------------------- 1587utcDateTimeList = ['LAST-MODIFIED', 'CREATED', 'COMPLETED', 'DTSTAMP'] 1588list(map(lambda x: registerBehavior(UTCDateTimeBehavior, x), utcDateTimeList)) 1589 1590dateTimeOrDateList = ['DTEND', 'DTSTART', 'DUE', 'RECURRENCE-ID'] 1591list(map(lambda x: registerBehavior(DateOrDateTimeBehavior, x), 1592 dateTimeOrDateList)) 1593 1594registerBehavior(MultiDateBehavior, 'RDATE') 1595registerBehavior(MultiDateBehavior, 'EXDATE') 1596 1597 1598textList = ['CALSCALE', 'METHOD', 'PRODID', 'CLASS', 'COMMENT', 'DESCRIPTION', 1599 'LOCATION', 'STATUS', 'SUMMARY', 'TRANSP', 'CONTACT', 'RELATED-TO', 1600 'UID', 'ACTION', 'BUSYTYPE'] 1601list(map(lambda x: registerBehavior(TextBehavior, x), textList)) 1602 1603list(map(lambda x: registerBehavior(MultiTextBehavior, x), ['CATEGORIES', 1604 'RESOURCES'])) 1605registerBehavior(SemicolonMultiTextBehavior, 'REQUEST-STATUS') 1606 1607 1608# ------------------------ Serializing helper functions ------------------------ 1609def numToDigits(num, places): 1610 """ 1611 Helper, for converting numbers to textual digits. 1612 """ 1613 s = str(num) 1614 if len(s) < places: 1615 return ("0" * (places - len(s))) + s 1616 elif len(s) > places: 1617 return s[len(s)-places:] 1618 else: 1619 return s 1620 1621 1622def timedeltaToString(delta): 1623 """ 1624 Convert timedelta to an ical DURATION. 1625 """ 1626 if delta.days == 0: 1627 sign = 1 1628 else: 1629 sign = delta.days / abs(delta.days) 1630 delta = abs(delta) 1631 days = delta.days 1632 hours = int(delta.seconds / 3600) 1633 minutes = int((delta.seconds % 3600) / 60) 1634 seconds = int(delta.seconds % 60) 1635 1636 output = '' 1637 if sign == -1: 1638 output += '-' 1639 output += 'P' 1640 if days: 1641 output += '{}D'.format(days) 1642 if hours or minutes or seconds: 1643 output += 'T' 1644 elif not days: # Deal with zero duration 1645 output += 'T0S' 1646 if hours: 1647 output += '{}H'.format(hours) 1648 if minutes: 1649 output += '{}M'.format(minutes) 1650 if seconds: 1651 output += '{}S'.format(seconds) 1652 return output 1653 1654 1655def timeToString(dateOrDateTime): 1656 """ 1657 Wraps dateToString and dateTimeToString, returning the results 1658 of either based on the type of the argument 1659 """ 1660 if hasattr(dateOrDateTime, 'hour'): 1661 return dateTimeToString(dateOrDateTime) 1662 return dateToString(dateOrDateTime) 1663 1664 1665def dateToString(date): 1666 year = numToDigits(date.year, 4) 1667 month = numToDigits(date.month, 2) 1668 day = numToDigits(date.day, 2) 1669 return year + month + day 1670 1671 1672def dateTimeToString(dateTime, convertToUTC=False): 1673 """ 1674 Ignore tzinfo unless convertToUTC. Output string. 1675 """ 1676 if dateTime.tzinfo and convertToUTC: 1677 dateTime = dateTime.astimezone(utc) 1678 1679 datestr = "{0}{1}{2}T{3}{4}{5}".format( 1680 numToDigits(dateTime.year, 4), 1681 numToDigits(dateTime.month, 2), 1682 numToDigits(dateTime.day, 2), 1683 numToDigits(dateTime.hour, 2), 1684 numToDigits(dateTime.minute, 2), 1685 numToDigits(dateTime.second, 2), 1686 ) 1687 if tzinfo_eq(dateTime.tzinfo, utc): 1688 datestr += "Z" 1689 return datestr 1690 1691 1692def deltaToOffset(delta): 1693 absDelta = abs(delta) 1694 hours = int(absDelta.seconds / 3600) 1695 hoursString = numToDigits(hours, 2) 1696 minutesString = '00' 1697 if absDelta == delta: 1698 signString = "+" 1699 else: 1700 signString = "-" 1701 return signString + hoursString + minutesString 1702 1703 1704def periodToString(period, convertToUTC=False): 1705 txtstart = dateTimeToString(period[0], convertToUTC) 1706 if isinstance(period[1], datetime.timedelta): 1707 txtend = timedeltaToString(period[1]) 1708 else: 1709 txtend = dateTimeToString(period[1], convertToUTC) 1710 return txtstart + "/" + txtend 1711 1712 1713# ----------------------- Parsing functions ------------------------------------ 1714def isDuration(s): 1715 s = s.upper() 1716 return (s.find("P") != -1) and (s.find("P") < 2) 1717 1718 1719def stringToDate(s): 1720 year = int(s[0:4]) 1721 month = int(s[4:6]) 1722 day = int(s[6:8]) 1723 return datetime.date(year, month, day) 1724 1725 1726def stringToDateTime(s, tzinfo=None): 1727 """ 1728 Returns datetime.datetime object. 1729 """ 1730 try: 1731 year = int(s[0:4]) 1732 month = int(s[4:6]) 1733 day = int(s[6:8]) 1734 hour = int(s[9:11]) 1735 minute = int(s[11:13]) 1736 second = int(s[13:15]) 1737 if len(s) > 15: 1738 if s[15] == 'Z': 1739 tzinfo = getTzid('UTC') 1740 except: 1741 raise ParseError("'{0!s}' is not a valid DATE-TIME".format(s)) 1742 year = year and year or 2000 1743 if tzinfo is not None and hasattr(tzinfo,'localize'): # PyTZ case 1744 return tzinfo.localize(datetime.datetime(year, month, day, hour, minute, second)) 1745 return datetime.datetime(year, month, day, hour, minute, second, 0, tzinfo) 1746 1747 1748# DQUOTE included to work around iCal's penchant for backslash escaping it, 1749# although it isn't actually supposed to be escaped according to rfc2445 TEXT 1750escapableCharList = '\\;,Nn"' 1751 1752 1753def stringToTextValues(s, listSeparator=',', charList=None, strict=False): 1754 """ 1755 Returns list of strings. 1756 """ 1757 if charList is None: 1758 charList = escapableCharList 1759 1760 def escapableChar(c): 1761 return c in charList 1762 1763 def error(msg): 1764 if strict: 1765 raise ParseError(msg) 1766 else: 1767 logging.error(msg) 1768 1769 # vars which control state machine 1770 charIterator = enumerate(s) 1771 state = "read normal" 1772 1773 current = [] 1774 results = [] 1775 1776 while True: 1777 try: 1778 charIndex, char = next(charIterator) 1779 except: 1780 char = "eof" 1781 1782 if state == "read normal": 1783 if char == '\\': 1784 state = "read escaped char" 1785 elif char == listSeparator: 1786 state = "read normal" 1787 current = "".join(current) 1788 results.append(current) 1789 current = [] 1790 elif char == "eof": 1791 state = "end" 1792 else: 1793 state = "read normal" 1794 current.append(char) 1795 1796 elif state == "read escaped char": 1797 if escapableChar(char): 1798 state = "read normal" 1799 if char in 'nN': 1800 current.append('\n') 1801 else: 1802 current.append(char) 1803 else: 1804 state = "read normal" 1805 # leave unrecognized escaped characters for later passes 1806 current.append('\\' + char) 1807 1808 elif state == "end": # an end state 1809 if len(current) or len(results) == 0: 1810 current = "".join(current) 1811 results.append(current) 1812 return results 1813 1814 elif state == "error": # an end state 1815 return results 1816 1817 else: 1818 state = "error" 1819 error("unknown state: '{0!s}' reached in {1!s}".format(state, s)) 1820 1821 1822def stringToDurations(s, strict=False): 1823 """ 1824 Returns list of timedelta objects. 1825 """ 1826 def makeTimedelta(sign, week, day, hour, minute, sec): 1827 if sign == "-": 1828 sign = -1 1829 else: 1830 sign = 1 1831 week = int(week) 1832 day = int(day) 1833 hour = int(hour) 1834 minute = int(minute) 1835 sec = int(sec) 1836 return sign * datetime.timedelta(weeks=week, days=day, hours=hour, 1837 minutes=minute, seconds=sec) 1838 1839 def error(msg): 1840 if strict: 1841 raise ParseError(msg) 1842 else: 1843 raise ParseError(msg) 1844 1845 # vars which control state machine 1846 charIterator = enumerate(s) 1847 state = "start" 1848 1849 durations = [] 1850 current = "" 1851 sign = None 1852 week = 0 1853 day = 0 1854 hour = 0 1855 minute = 0 1856 sec = 0 1857 1858 while True: 1859 try: 1860 charIndex, char = next(charIterator) 1861 except: 1862 char = "eof" 1863 1864 if state == "start": 1865 if char == '+': 1866 state = "start" 1867 sign = char 1868 elif char == '-': 1869 state = "start" 1870 sign = char 1871 elif char.upper() == 'P': 1872 state = "read field" 1873 elif char == "eof": 1874 state = "error" 1875 error("got end-of-line while reading in duration: " + s) 1876 elif char in string.digits: 1877 state = "read field" 1878 current = current + char # update this part when updating "read field" 1879 else: 1880 state = "error" 1881 error("got unexpected character {0} reading in duration: {1}" 1882 .format(char, s)) 1883 1884 elif state == "read field": 1885 if (char in string.digits): 1886 state = "read field" 1887 current = current + char # update part above when updating "read field" 1888 elif char.upper() == 'T': 1889 state = "read field" 1890 elif char.upper() == 'W': 1891 state = "read field" 1892 week = current 1893 current = "" 1894 elif char.upper() == 'D': 1895 state = "read field" 1896 day = current 1897 current = "" 1898 elif char.upper() == 'H': 1899 state = "read field" 1900 hour = current 1901 current = "" 1902 elif char.upper() == 'M': 1903 state = "read field" 1904 minute = current 1905 current = "" 1906 elif char.upper() == 'S': 1907 state = "read field" 1908 sec = current 1909 current = "" 1910 elif char == ",": 1911 state = "start" 1912 durations.append(makeTimedelta(sign, week, day, hour, minute, 1913 sec)) 1914 current = "" 1915 sign = None 1916 week = None 1917 day = None 1918 hour = None 1919 minute = None 1920 sec = None 1921 elif char == "eof": 1922 state = "end" 1923 else: 1924 state = "error" 1925 error("got unexpected character reading in duration: " + s) 1926 1927 elif state == "end": # an end state 1928 if (sign or week or day or hour or minute or sec): 1929 durations.append(makeTimedelta(sign, week, day, hour, minute, 1930 sec)) 1931 return durations 1932 1933 elif state == "error": # an end state 1934 error("in error state") 1935 return durations 1936 1937 else: 1938 state = "error" 1939 error("unknown state: '{0!s}' reached in {1!s}".format(state, s)) 1940 1941 1942def parseDtstart(contentline, allowSignatureMismatch=False): 1943 """ 1944 Convert a contentline's value into a date or date-time. 1945 1946 A variety of clients don't serialize dates with the appropriate VALUE 1947 parameter, so rather than failing on these (technically invalid) lines, 1948 if allowSignatureMismatch is True, try to parse both varieties. 1949 """ 1950 tzinfo = getTzid(getattr(contentline, 'tzid_param', None)) 1951 valueParam = getattr(contentline, 'value_param', 'DATE-TIME').upper() 1952 if valueParam == "DATE": 1953 return stringToDate(contentline.value) 1954 elif valueParam == "DATE-TIME": 1955 try: 1956 return stringToDateTime(contentline.value, tzinfo) 1957 except: 1958 if allowSignatureMismatch: 1959 return stringToDate(contentline.value) 1960 else: 1961 raise 1962 1963 1964def stringToPeriod(s, tzinfo=None): 1965 values = s.split("/") 1966 start = stringToDateTime(values[0], tzinfo) 1967 valEnd = values[1] 1968 if isDuration(valEnd): # period-start = date-time "/" dur-value 1969 delta = stringToDurations(valEnd)[0] 1970 return (start, delta) 1971 else: 1972 return (start, stringToDateTime(valEnd, tzinfo)) 1973 1974 1975def getTransition(transitionTo, year, tzinfo): 1976 """ 1977 Return the datetime of the transition to/from DST, or None. 1978 """ 1979 def firstTransition(iterDates, test): 1980 """ 1981 Return the last date not matching test, or None if all tests matched. 1982 """ 1983 success = None 1984 for dt in iterDates: 1985 if not test(dt): 1986 success = dt 1987 else: 1988 if success is not None: 1989 return success 1990 return success # may be None 1991 1992 def generateDates(year, month=None, day=None): 1993 """ 1994 Iterate over possible dates with unspecified values. 1995 """ 1996 months = range(1, 13) 1997 days = range(1, 32) 1998 hours = range(0, 24) 1999 if month is None: 2000 for month in months: 2001 yield datetime.datetime(year, month, 1) 2002 elif day is None: 2003 for day in days: 2004 try: 2005 yield datetime.datetime(year, month, day) 2006 except ValueError: 2007 pass 2008 else: 2009 for hour in hours: 2010 yield datetime.datetime(year, month, day, hour) 2011 2012 assert transitionTo in ('daylight', 'standard') 2013 if transitionTo == 'daylight': 2014 def test(dt): 2015 try: 2016 return tzinfo.dst(dt) != zeroDelta 2017 except pytz.NonExistentTimeError: 2018 return True # entering daylight time 2019 except pytz.AmbiguousTimeError: 2020 return False # entering standard time 2021 elif transitionTo == 'standard': 2022 def test(dt): 2023 try: 2024 return tzinfo.dst(dt) == zeroDelta 2025 except pytz.NonExistentTimeError: 2026 return False # entering daylight time 2027 except pytz.AmbiguousTimeError: 2028 return True # entering standard time 2029 newyear = datetime.datetime(year, 1, 1) 2030 monthDt = firstTransition(generateDates(year), test) 2031 if monthDt is None: 2032 return newyear 2033 elif monthDt.month == 12: 2034 return None 2035 else: 2036 # there was a good transition somewhere in a non-December month 2037 month = monthDt.month 2038 day = firstTransition(generateDates(year, month), test).day 2039 uncorrected = firstTransition(generateDates(year, month, day), test) 2040 if transitionTo == 'standard': 2041 # assuming tzinfo.dst returns a new offset for the first 2042 # possible hour, we need to add one hour for the offset change 2043 # and another hour because firstTransition returns the hour 2044 # before the transition 2045 return uncorrected + datetime.timedelta(hours=2) 2046 else: 2047 return uncorrected + datetime.timedelta(hours=1) 2048 2049 2050def tzinfo_eq(tzinfo1, tzinfo2, startYear=2000, endYear=2020): 2051 """ 2052 Compare offsets and DST transitions from startYear to endYear. 2053 """ 2054 if tzinfo1 == tzinfo2: 2055 return True 2056 elif tzinfo1 is None or tzinfo2 is None: 2057 return False 2058 2059 def dt_test(dt): 2060 if dt is None: 2061 return True 2062 return tzinfo1.utcoffset(dt) == tzinfo2.utcoffset(dt) 2063 2064 if not dt_test(datetime.datetime(startYear, 1, 1)): 2065 return False 2066 for year in range(startYear, endYear): 2067 for transitionTo in 'daylight', 'standard': 2068 t1 = getTransition(transitionTo, year, tzinfo1) 2069 t2 = getTransition(transitionTo, year, tzinfo2) 2070 if t1 != t2 or not dt_test(t1): 2071 return False 2072 return True 2073 2074 2075# ------------------- Testing and running functions ---------------------------- 2076if __name__ == '__main__': 2077 import tests 2078 tests._test() 2079