Package flumotion :: Package common :: Module eventcalendar
[hide private]

Source Code for Module flumotion.common.eventcalendar

  1  # -*- Mode:Python; test-case-name:flumotion.test.test_common_eventcalendar -*- 
  2  # vi:si:et:sw=4:sts=4:ts=4 
  3   
  4  # Flumotion - a streaming media server 
  5  # Copyright (C) 2004,2005,2006,2007,2008,2009 Fluendo, S.L. 
  6  # Copyright (C) 2010,2011 Flumotion Services, S.A. 
  7  # All rights reserved. 
  8  # 
  9  # This file may be distributed and/or modified under the terms of 
 10  # the GNU Lesser General Public License version 2.1 as published by 
 11  # the Free Software Foundation. 
 12  # This file is distributed without any warranty; without even the implied 
 13  # warranty of merchantability or fitness for a particular purpose. 
 14  # See "LICENSE.LGPL" in the source distribution for more information. 
 15  # 
 16  # Headers in this file shall remain intact. 
 17   
 18  import datetime 
 19   
 20  HAS_ICALENDAR = False 
 21  try: 
 22      import icalendar 
 23      HAS_ICALENDAR = True 
 24  except ImportError: 
 25      pass 
 26   
 27  # for documentation on dateutil, see http://labix.org/python-dateutil 
 28  HAS_DATEUTIL = False 
 29  try: 
 30      from dateutil import rrule 
 31      HAS_DATEUTIL = True 
 32  except ImportError: 
 33      pass 
 34   
 35  from flumotion.common import tz 
 36  from flumotion.extern.log import log 
 37   
 38  """ 
 39  Implementation of a calendar that can inform about events beginning and 
 40  ending, as well as active event instances at a given time. 
 41   
 42  This uses iCalendar as defined in 
 43  http://www.ietf.org/rfc/rfc2445.txt 
 44   
 45  The users of this module should check if it has both HAS_ICALENDAR 
 46  and HAS_DATEUTIL properties and if any of them is False, they should 
 47  withhold from further using the module. 
 48  """ 
 49   
 50   
51 -def _toDateTime(d):
52 """ 53 If d is a L{datetime.date}, convert it to L{datetime.datetime}. 54 55 @type d: anything 56 57 @rtype: L{datetime.datetime} or anything 58 @returns: The equivalent datetime.datetime if d is a datetime.date; 59 d if not 60 """ 61 if isinstance(d, datetime.date) and not isinstance(d, datetime.datetime): 62 return datetime.datetime(d.year, d.month, d.day, tzinfo=tz.UTC) 63 return d
64 65
66 -class Point(log.Loggable):
67 """ 68 I represent a start or an end point linked to an event instance 69 of an event. 70 71 @type eventInstance: L{EventInstance} 72 @type which: str 73 @type dt: L{datetime.datetime} 74 """ 75
76 - def __init__(self, eventInstance, which, dt):
77 """ 78 @param eventInstance: An instance of an event. 79 @type eventInstance: L{EventInstance} 80 @param which: 'start' or 'end' 81 @type which: str 82 @param dt: Timestamp of this point. It will 83 be used when comparing Points. 84 @type dt: L{datetime.datetime} 85 """ 86 self.which = which 87 self.dt = dt 88 self.eventInstance = eventInstance
89
90 - def __repr__(self):
91 return "Point '%s' at %r for %r" % ( 92 self.which, self.dt, self.eventInstance)
93
94 - def __cmp__(self, other):
95 # compare based on dt, then end before start 96 # relies on alphabetic order of end before start 97 return cmp(self.dt, other.dt) \ 98 or cmp(self.which, other.which)
99 100
101 -class EventInstance(log.Loggable):
102 """ 103 I represent one event instance of an event. 104 105 @type event: L{Event} 106 @type start: L{datetime.datetime} 107 @type end: L{datetime.datetime} 108 """ 109
110 - def __init__(self, event, start, end):
111 """ 112 @type event: L{Event} 113 @type start: L{datetime.datetime} 114 @type end: L{datetime.datetime} 115 """ 116 self.event = event 117 self.start = start 118 self.end = end
119
120 - def getPoints(self):
121 """ 122 Get a list of start and end points. 123 124 @rtype: list of L{Point} 125 """ 126 ret = [] 127 128 ret.append(Point(self, 'start', self.start)) 129 ret.append(Point(self, 'end', self.end)) 130 131 return ret
132
133 - def __eq__(self, other):
134 return self.start == other.start and self.end == other.end and \ 135 self.event == other.event
136
137 - def __ne__(self, other):
138 return not self.__eq__(other)
139 140
141 -class Event(log.Loggable):
142 """ 143 I represent a VEVENT entry in a calendar for our purposes. 144 I can have recurrence. 145 I can be scheduled between a start time and an end time, 146 returning a list of start and end points. 147 I can have exception dates. 148 """ 149
150 - def __init__(self, uid, start, end, content, rrules=None, 151 recurrenceid=None, exdates=None):
152 """ 153 @param uid: identifier of the event 154 @type uid: str 155 @param start: start time of the event 156 @type start: L{datetime.datetime} 157 @param end: end time of the event 158 @type end: L{datetime.datetime} 159 @param content: label to describe the content 160 @type content: unicode 161 @param rrules: a list of RRULE string 162 @type rrules: list of str 163 @param recurrenceid: a RECURRENCE-ID, used with 164 recurrence events 165 @type recurrenceid: L{datetime.datetime} 166 @param exdates: list of exceptions to the recurrence rule 167 @type exdates: list of L{datetime.datetime} or None 168 """ 169 170 self.start = self._ensureTimeZone(start) 171 self.end = self._ensureTimeZone(end) 172 self.content = content 173 self.uid = uid 174 self.rrules = rrules 175 if rrules and len(rrules) > 1: 176 raise NotImplementedError( 177 "Events with multiple RRULE are not yet supported") 178 self.recurrenceid = recurrenceid 179 if exdates: 180 self.exdates = [] 181 for exdate in exdates: 182 exdate = self._ensureTimeZone(exdate) 183 self.exdates.append(exdate) 184 else: 185 self.exdates = None
186
187 - def _ensureTimeZone(self, dateTime, tz=tz.UTC):
188 # add timezone information if it is not specified for some reason 189 if dateTime.tzinfo: 190 return dateTime 191 192 return datetime.datetime(dateTime.year, dateTime.month, dateTime.day, 193 dateTime.hour, dateTime.minute, dateTime.second, 194 dateTime.microsecond, tz)
195
196 - def __repr__(self):
197 return "<Event %r >" % (self.toTuple(), )
198
199 - def toTuple(self):
200 return (self.uid, self.start, self.end, self.content, self.rrules, 201 self.exdates)
202 203 # FIXME: these are only here so the rrdmon stuff can use Event instances 204 # in an avltree 205
206 - def __lt__(self, other):
207 return self.toTuple() < other.toTuple()
208
209 - def __gt__(self, other):
210 return self.toTuple() > other.toTuple()
211 212 # FIXME: but these should be kept, so that events with different id 213 # but same properties are the same 214
215 - def __eq__(self, other):
216 return self.toTuple() == other.toTuple()
217
218 - def __ne__(self, other):
219 return not self.__eq__(other)
220 221
222 -class EventSet(log.Loggable):
223 """ 224 I represent a set of VEVENT entries in a calendar sharing the same uid. 225 I can have recurrence. 226 I can be scheduled between a start time and an end time, 227 returning a list of start and end points in UTC. 228 I can have exception dates. 229 """ 230
231 - def __init__(self, uid):
232 """ 233 @param uid: the uid shared among the events on this set 234 @type uid: str 235 """ 236 self.uid = uid 237 self._events = []
238
239 - def __repr__(self):
240 return "<EventSet for uid %r >" % ( 241 self.uid)
242
243 - def addEvent(self, event):
244 """ 245 Add an event to the set. The event must have the same uid as the set. 246 247 @param event: the event to add. 248 @type event: L{Event} 249 """ 250 assert self.uid == event.uid, \ 251 "my uid %s does not match Event uid %s" % (self.uid, event.uid) 252 assert event not in self._events, "event %r already in set %r" % ( 253 event, self._events) 254 255 self._events.append(event)
256
257 - def removeEvent(self, event):
258 """ 259 Remove an event from the set. 260 261 @param event: the event to add. 262 @type event: L{Event} 263 """ 264 assert self.uid == event.uid, \ 265 "my uid %s does not match Event uid %s" % (self.uid, event.uid) 266 self._events.remove(event)
267
268 - def getPoints(self, start=None, delta=None, clip=True):
269 """ 270 Get an ordered list of start and end points from the given start 271 point, with the given delta, in this set of Events. 272 273 start defaults to now. 274 delta defaults to 0, effectively returning all points at this time. 275 the returned list includes the extremes (start and start + delta) 276 277 @param start: the start time 278 @type start: L{datetime.datetime} 279 @param delta: the delta 280 @type delta: L{datetime.timedelta} 281 @param clip: whether to clip all event instances to the given 282 start and end 283 """ 284 if start is None: 285 start = datetime.datetime.now(tz.UTC) 286 287 if delta is None: 288 delta = datetime.timedelta(seconds=0) 289 290 points = [] 291 292 eventInstances = self._getEventInstances(start, start + delta, clip) 293 for i in eventInstances: 294 for p in i.getPoints(): 295 if p.dt >= start and p.dt <= start + delta: 296 points.append(p) 297 points.sort() 298 299 return points
300
301 - def _getRecurringEvent(self):
302 recurring = None 303 304 # get the event in the event set that is recurring, if any 305 for v in self._events: 306 if v.rrules: 307 assert not recurring, \ 308 "Cannot have two RRULE VEVENTs with UID %s" % self.uid 309 recurring = v 310 else: 311 if len(self._events) > 1: 312 assert v.recurrenceid, \ 313 "With multiple VEVENTs with UID %s, " \ 314 "each VEVENT should either have a " \ 315 "reccurrence rule or have a recurrence id" % self.uid 316 317 return recurring
318
319 - def _getEventInstances(self, start, end, clip):
320 # get all instances whose start and/or end fall between the given 321 # datetimes 322 # clips the event to the given start and end if asked for 323 # FIXME: decide if clip is inclusive or exclusive; maybe compare 324 # to dateutil's solution 325 326 eventInstances = [] 327 328 recurring = self._getRecurringEvent() 329 330 # find all instances between the two given times 331 if recurring: 332 eventInstances = self._getEventInstancesRecur( 333 recurring, start, end) 334 335 # an event that has a recurrence id overrides the instance of the 336 # recurrence with a start time matching the recurrence id, so 337 # throw it out 338 for event in self._events: 339 # skip the main event 340 if event is recurring: 341 continue 342 343 if event.recurrenceid: 344 # Remove recurrent instance(s) that start at this recurrenceid 345 for i in eventInstances[:]: 346 if i.start == event.recurrenceid: 347 eventInstances.remove(i) 348 break 349 350 i = self._getEventInstanceSingle(event, start, end) 351 if i: 352 eventInstances.append(i) 353 354 if clip: 355 # fix all incidences that lie partly outside of the range 356 # to be in the range 357 for i in eventInstances[:]: 358 if i.start < start: 359 i.start = start 360 if start >= i.end: 361 eventInstances.remove(i) 362 if i.end > end: 363 i.end = end 364 365 return eventInstances
366
367 - def _getEventInstanceSingle(self, event, start, end):
368 # is this event within the range asked for ? 369 if start > event.end: 370 return None 371 if end < event.start: 372 return None 373 374 return EventInstance(event, event.start, event.end)
375
376 - def _getEventInstancesRecur(self, event, start, end):
377 # get all event instances for this recurring event that start before 378 # the given end time and end after the given start time. 379 # The UNTIL value applies to the start of a recurring event, 380 # not to the end. So if you would calculate based on the end for the 381 # recurrence rule, and there is a recurring instance that starts before 382 # UNTIL but ends after UNTIL, it would not be taken into account. 383 384 ret = [] 385 386 # don't calculate endPoint based on end recurrence rule, because 387 # if the next one after a start point is past UNTIL then the rrule 388 # returns None 389 delta = event.end - event.start 390 391 # FIXME: support multiple RRULE; see 4.8.5.4 Recurrence Rule 392 r = None 393 if event.rrules: 394 r = event.rrules[0] 395 startRecurRule = rrule.rrulestr(r, dtstart=event.start) 396 397 for startTime in startRecurRule: 398 # ignore everything stopping before our start time 399 if startTime + delta < start: 400 continue 401 402 # stop looping if it's past the requested end time 403 if startTime >= end: 404 break 405 406 # skip if it's on our list of exceptions 407 if event.exdates: 408 if startTime in event.exdates: 409 self.debug("startTime %r is listed as EXDATE, skipping", 410 startTime) 411 continue 412 413 endTime = startTime + delta 414 415 i = EventInstance(event, startTime, endTime) 416 417 ret.append(i) 418 419 return ret
420
421 - def getActiveEventInstances(self, dt=None):
422 """ 423 Get all event instances active at the given dt. 424 425 @type dt: L{datetime.datetime} 426 427 @rtype: list of L{EventInstance} 428 """ 429 if not dt: 430 dt = datetime.datetime.now(tz=tz.UTC) 431 432 result = [] 433 434 # handle recurrence events first 435 recurring = self._getRecurringEvent() 436 if recurring: 437 # FIXME: support multiple RRULE; see 4.8.5.4 Recurrence Rule 438 startRecurRule = rrule.rrulestr(recurring.rrules[0], 439 dtstart=recurring.start) 440 dtstart = startRecurRule.before(dt) 441 442 if dtstart: 443 skip = False 444 # ignore if we have another event with this recurrence-id 445 for event in self._events: 446 if event.recurrenceid: 447 if event.recurrenceid == dtstart: 448 self.log( 449 'event %r, recurrenceid %r matches dtstart %r', 450 event, event.recurrenceid, dtstart) 451 skip = True 452 453 # add if it's not on our list of exceptions 454 if recurring.exdates and dtstart in recurring.exdates: 455 self.log('recurring event %r has exdate for %r', 456 recurring, dtstart) 457 skip = True 458 459 if not skip: 460 delta = recurring.end - recurring.start 461 dtend = dtstart + delta 462 if dtend >= dt: 463 # starts before our dt, and ends after, so add 464 result.append(EventInstance(recurring, dtstart, dtend)) 465 466 # handle all other events 467 for event in self._events: 468 if event is recurring: 469 continue 470 471 if event.start < dt < event.end: 472 result.append(EventInstance(event, event.start, event.end)) 473 474 self.log('events active at %s: %r', str(dt), result) 475 476 return result
477
478 - def getEvents(self):
479 """ 480 Return the list of events. 481 482 @rtype: list of L{Event} 483 """ 484 return self._events
485 486
487 -class Calendar(log.Loggable):
488 """ 489 I represent a parsed iCalendar resource. 490 I have a list of VEVENT sets from which I can be asked to schedule 491 points marking the start or end of event instances. 492 """ 493 494 logCategory = 'calendar' 495
496 - def __init__(self):
497 self._eventSets = {} # uid -> EventSet
498
499 - def addEvent(self, event):
500 """ 501 Add a parsed VEVENT definition. 502 503 @type event: L{Event} 504 """ 505 uid = event.uid 506 self.log("adding event %s with content %r", uid, event.content) 507 if uid not in self._eventSets: 508 self._eventSets[uid] = EventSet(uid) 509 self._eventSets[uid].addEvent(event)
510
511 - def getPoints(self, start=None, delta=None):
512 """ 513 Get all points from the given start time within the given delta. 514 End Points will be ordered before Start Points with the same time. 515 516 All points have a dt in the timezone as specified in the calendar. 517 518 start defaults to now. 519 delta defaults to 0, effectively returning all points at this time. 520 521 @type start: L{datetime.datetime} 522 @type delta: L{datetime.timedelta} 523 524 @rtype: list of L{Point} 525 """ 526 result = [] 527 528 for eventSet in self._eventSets.values(): 529 points = eventSet.getPoints(start, delta=delta, clip=False) 530 result.extend(points) 531 532 result.sort() 533 534 return result
535
536 - def getActiveEventInstances(self, when=None):
537 """ 538 Get a list of active event instances at the given time. 539 540 @param when: the time to check; defaults to right now 541 @type when: L{datetime.datetime} 542 543 @rtype: list of L{EventInstance} 544 """ 545 result = [] 546 547 if not when: 548 when = datetime.datetime.now(tz.UTC) 549 550 for eventSet in self._eventSets.values(): 551 result.extend(eventSet.getActiveEventInstances(when)) 552 553 self.debug('%d active event instances at %s', len(result), str(when)) 554 return result
555 556
557 -class NotCompilantError(Exception):
558
559 - def __init__(self, value):
560 self.value = value
561
562 - def __str__(self):
563 return "The calendar is not compilant. " + repr(self.value)
564 565
566 -def vDDDToDatetime(v, timezones):
567 """ 568 Convert a vDDDType to a datetime, respecting timezones. 569 570 @param v: the time to convert 571 @type v: L{icalendar.prop.vDDDTypes} 572 573 @param timezones: Defined timezones in the calendar 574 575 """ 576 if v is None: 577 return None 578 dt = _toDateTime(v.dt) 579 if dt.tzinfo is None: 580 # We might have a "floating" DATE-TIME value here, in 581 # which case we will not have a TZID parameter; see 582 # 4.3.5, FORM #3 583 tzid = v.params.get('TZID') 584 if tzid is None: 585 timezone = tz.LOCAL 586 else: 587 # If the timezone is not in the calendar, try one last time 588 # with the system's timezones 589 timezone = timezones.get(tzid, tz.gettz(tzid)) 590 if timezone is None: 591 raise NotCompilantError("You are trying to use a timezone\ 592 that is not defined in this calendar") 593 elif isinstance(timezone, tz.DSTTimezone): 594 timezone = timezone.copy() 595 dt = datetime.datetime(dt.year, dt.month, dt.day, 596 dt.hour, dt.minute, dt.second, 597 dt.microsecond, timezone) 598 return dt
599 600
601 -def vDDDToTimedelta(v):
602 """ 603 Convert a vDDDType (vDuration) to a timedelta. 604 605 @param v: the duration to convert 606 @type v: L{icalendar.prop.vDDDTypes} 607 608 @rtype : L{datetime.timedelta} 609 """ 610 if v is None or not isinstance(v.dt, datetime.timedelta): 611 return None 612 return v.dt
613 614
615 -def parseTimezone(vtimezone):
616 """ 617 Parses a VTIMEZONE section and returns a tzinfo 618 """ 619 620 def getRecurrence(observance, dtstart): 621 if 'RRULE' in observance: 622 return rrule.rrulestr(str(observance['RRULE']), dtstart=dtstart, 623 cache=True) 624 if 'RDATE' in observance: 625 return rrule.rrule('YEARLY', str(observance['RDATE']), cache=True) 626 return None
627 628 def parseObservance(observance, tzname): 629 try: 630 required = (observance['DTSTART'].dt, 631 observance['TZOFFSETFROM'].td, 632 observance['TZOFFSETTO'].td) 633 except KeyError: 634 raise NotCompilantError( 635 "VTIMEZONE does not define one of the following required " 636 "elements: TZOFFSETFROM, TZOFFSETTO or DTSTART") 637 rr = getRecurrence(observance, required[0]) 638 return required + (observance.get('TZNAME', tzname), rr) 639 640 tzid = vtimezone.get('tzid') 641 try: 642 standard = vtimezone.walk('standard')[0] 643 dstend, stdoffsetfrom, stdoffset, stdname, stdrrule = \ 644 parseObservance(standard, 'Standard') 645 except KeyError: 646 standard = None 647 648 daylight_w = vtimezone.walk('daylight') 649 if len(daylight_w) != 0: 650 dststart, dstoffsetfrom, dstoffset, dstname, dstrrule = \ 651 parseObservance(daylight_w[0], 'Daylight') 652 if standard is None: 653 return tz.FixedOffsetTimezone(dstoffset, dstname) 654 else: 655 return tz.DSTTimezone(tzid, stdname, dstname, stdoffset, dstoffset, 656 stdoffsetfrom, dstoffsetfrom, dststart, 657 dstend, stdrrule, dstrrule) 658 else: 659 if standard is None: 660 raise NotCompilantError("One of DAYLIGHT or STANDARD must be " 661 "defined") 662 return tz.FixedOffsetTimezone(stdoffset, stdname) 663 664
665 -def fromICalendar(iCalendar):
666 """ 667 Parse an icalendar Calendar object into our Calendar object. 668 669 @param iCalendar: The calendar to parse 670 @type iCalendar: L{icalendar.Calendar} 671 672 @rtype: L{Calendar} 673 """ 674 calendar = Calendar() 675 timezones = {'UTC': tz.UTC} 676 677 # We need to parse all the timezones defined for the current iCalendar 678 for vtimezone in iCalendar.walk('vtimezone'): 679 tzinfo = parseTimezone(vtimezone) 680 tzid = str(tzinfo) 681 if tzid not in timezones: 682 timezones[tzid] = tzinfo 683 else: 684 raise NotCompilantError("Timezones must have a unique TZID") 685 686 for event in iCalendar.walk('vevent'): 687 # extract to function ? 688 689 # DTSTART is REQUIRED in VEVENT; see 4.8.2.4 690 start = vDDDToDatetime(event.get('dtstart'), timezones) 691 # DTEND is optional; see 4.8.2.3 692 end = vDDDToDatetime(event.get('dtend', None), timezones) 693 # DURATION can replace DTEND; see 4.8.2.5 694 if not end: 695 duration = vDDDToTimedelta(event.get('duration', None)) 696 end = duration and start + duration or None 697 698 # an event without DURATION or DTEND is defined to not consume any 699 # time; see 6; so we skip it 700 if not end: 701 continue 702 703 if end == start: 704 continue 705 706 assert end > start, "end %r should not be before start %r" % ( 707 end, start) 708 709 summary = event.decoded('SUMMARY', None) 710 uid = event['UID'] 711 # When there is only one rrule, we don't get a list, but the 712 # single rrule Bad API 713 recur = event.get('RRULE', []) 714 if not isinstance(recur, list): 715 recur = [recur, ] 716 recur = [r.ical() for r in recur] 717 718 recurrenceid = event.get('RECURRENCE-ID', None) 719 if recurrenceid: 720 recurrenceid = vDDDToDatetime(recurrenceid, timezones) 721 722 exdates = event.get('EXDATE', []) 723 # When there is only one exdate, we don't get a list, but the 724 # single exdate. Bad API 725 if not isinstance(exdates, list): 726 exdates = [exdates, ] 727 728 # this is a list of icalendar.propvDDDTypes on which we can call 729 # .dt() or .ical() 730 exdates = [vDDDToDatetime(i, timezones) for i in exdates] 731 732 if event.get('RDATE'): 733 raise NotImplementedError("We don't handle RDATE yet") 734 735 if event.get('EXRULE'): 736 raise NotImplementedError("We don't handle EXRULE yet") 737 738 #if not start: 739 # raise AssertionError, "event %r does not have start" % event 740 #if not end: 741 # raise AssertionError, "event %r does not have end" % event 742 e = Event(uid, start, end, summary, recur, recurrenceid, exdates) 743 744 calendar.addEvent(e) 745 746 return calendar
747 748
749 -def fromFile(file):
750 """ 751 Create a new calendar from an open file object. 752 753 @type file: file object 754 755 @rtype: L{Calendar} 756 """ 757 data = file.read() 758 759 # FIXME Google calendar recently started introducing things like 760 # CREATED:0000XXXXTXXXXXXZ, which means: created in year 0000 761 # this breaks the icalendar parsing code. Guard against that. 762 data = data.replace('\nCREATED:0000', '\nCREATED:2008') 763 try: 764 cal = icalendar.Calendar.from_string(data) 765 except ValueError: 766 log.warning("icalendar", "The ics file we've received is not encoded " 767 "in UTF-8. We'll decode it but some characters may be " 768 "missing") 769 data = data.decode('utf-8', 'ignore') 770 cal = icalendar.Calendar.from_string(data) 771 return fromICalendar(cal)
772