# Calendaring is a simple CMF/Plone calendaring implementation. # Copyright (C) 2004 Enfold Systems # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA # # Some portions of this module are Copyright Shuttleworth Foundation. # The original copyright statement is reproduced below. # # SchoolTool - common information systems platform for school administration # Copyright (c) 2003 Shuttleworth Foundation # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA # """ SchoolTool calendaring stuff. $Id: parser.py,v 1.4 2005/01/25 01:03:38 dreamcatcher Exp $ """ import re import datetime from sets import Set Persistent = 0 # make parser available outside of zope try: from Persistence import Persistent except: class Persistent: pass class DateRange: def __init__(self, first, last): self.first = first self.last = last if last < first: # import timemachine raise ValueError("Last date %r less than first date %r" % (last, first)) def __iter__(self): date = self.first while date <= self.last: yield date date += datetime.date.resolution def __len__(self): return (self.last - self.first).days + 1 def __contains__(self, date): return self.first <= date <= self.last # # iCalendar parsing # class ICalParseError(Exception): """Invalid syntax in an iCalendar file.""" def parse_text(value): r"""Parse iCalendar TEXT value. >>> parse_text('Foo') 'Foo' >>> parse_text('\\\\') '\\' >>> parse_text('\\;') ';' >>> parse_text('\\,') ',' >>> parse_text('\\n') '\n' >>> parse_text('A string with\\; some\\\\ characters\\nin\\Nit') 'A string with; some\\ characters\nin\nit' >>> parse_text('Unterminated \\') Traceback (most recent call last): ... IndexError: string index out of range """ if '\\' not in value: return value out = [] prev = 0 while True: idx = value.find('\\', prev) if idx == -1: break out.append(value[prev:idx]) if value[idx + 1] in 'nN': out.append('\n') else: out.append(value[idx + 1]) prev = idx + 2 out.append(value[prev:]) return "".join(out) def ical_text(value): r"""Format value according to iCalendar TEXT escaping rules. >>> ical_text('Foo') 'Foo' >>> ical_text('\\') '\\\\' >>> ical_text(';') '\\;' >>> ical_text(',') '\\,' >>> ical_text('\n') '\\n' >>> ical_text('\r\n') '\\n' """ return (value.replace('\\', '\\\\').replace(';', '\\;').replace(',', '\\,') .replace('\r\n', '\n').replace('\n', '\\n')) def parse_duration(value): """Parse iCalendar DURATION value. Returns a timedelta instance. >>> parse_duration('+P11D') datetime.timedelta(11) >>> parse_duration('-P2W') datetime.timedelta(-14) >>> parse_duration('P1DT2H3M4S') datetime.timedelta(1, 7384) >>> parse_duration('P1DT2H3M') datetime.timedelta(1, 7380) >>> parse_duration('P1DT2H') datetime.timedelta(1, 7200) >>> parse_duration('PT2H') datetime.timedelta(0, 7200) >>> parse_duration('PT2H3M4S') datetime.timedelta(0, 7384) >>> parse_duration('PT3M4S') datetime.timedelta(0, 184) >>> parse_duration('PT22S') datetime.timedelta(0, 22) >>> parse_duration('') Traceback (most recent call last): ... ValueError: Invalid iCalendar duration: '' >>> parse_duration('xyzzy') Traceback (most recent call last): ... ValueError: Invalid iCalendar duration: 'xyzzy' >>> parse_duration('P') Traceback (most recent call last): ... ValueError: Invalid iCalendar duration: 'P' >>> parse_duration('P1WT2H') Traceback (most recent call last): ... ValueError: Invalid iCalendar duration: 'P1WT2H' """ date_part = r'(\d+)D' time_part = r'T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?' datetime_part = '(?:%s)?(?:%s)?' % (date_part, time_part) weeks_part = r'(\d+)W' duration_rx = re.compile(r'([-+]?)P(?:%s|%s)$' % (weeks_part, datetime_part)) match = duration_rx.match(value) if match is None: raise ValueError('Invalid iCalendar duration: %r' % value) sign, weeks, days, hours, minutes, seconds = match.groups() if weeks: value = datetime.timedelta(weeks=int(weeks)) else: if (days is None and hours is None and minutes is None and seconds is None): raise ValueError('Invalid iCalendar duration: %r' % value) value = datetime.timedelta(days=int(days or 0), hours=int(hours or 0), minutes=int(minutes or 0), seconds=int(seconds or 0)) if sign == '-': value = -value return value def ical_duration(value): """Format a timedelta as an iCalendar DURATION value. >>> ical_duration(datetime.timedelta(11)) 'P11D' >>> ical_duration(datetime.timedelta(-14)) '-P14D' >>> ical_duration(datetime.timedelta(1, 7384)) 'P1DT2H3M4S' >>> ical_duration(datetime.timedelta(1, 7380)) 'P1DT2H3M' >>> ical_duration(datetime.timedelta(1, 7200)) 'P1DT2H' >>> ical_duration(datetime.timedelta(0, 7200)) 'PT2H' >>> ical_duration(datetime.timedelta(0, 7384)) 'PT2H3M4S' >>> ical_duration(datetime.timedelta(0, 184)) 'PT3M4S' >>> ical_duration(datetime.timedelta(0, 22)) 'PT22S' >>> ical_duration(datetime.timedelta(0, 3622)) 'PT1H0M22S' """ sign = "" if value.days < 0: sign = "-" timepart = "" if value.seconds: timepart = "T" hours = value.seconds // 3600 minutes = value.seconds % 3600 // 60 seconds = value.seconds % 60 if hours: timepart += "%dH" % hours if minutes or (hours and seconds): timepart += "%dM" % minutes if seconds: timepart += "%dS" % seconds if value.days == 0 and timepart: return "%sP%s" % (sign, timepart) else: return "%sP%dD%s" % (sign, abs(value.days), timepart) def parse_date(value): """Parse iCalendar DATE value. Returns a date instance. >>> parse_date('20030405') datetime.date(2003, 4, 5) >>> parse_date('20030405T060708') Traceback (most recent call last): ... ValueError: Invalid iCalendar date: '20030405T060708' >>> parse_date('') Traceback (most recent call last): ... ValueError: Invalid iCalendar date: '' >>> parse_date('yyyymmdd') Traceback (most recent call last): ... ValueError: Invalid iCalendar date: 'yyyymmdd' """ if len(value) != 8: raise ValueError('Invalid iCalendar date: %r' % value) try: y, m, d = int(value[0:4]), int(value[4:6]), int(value[6:8]) except ValueError: raise ValueError('Invalid iCalendar date: %r' % value) else: return datetime.date(y, m, d) def parse_date_time(value): """Parse iCalendar DATE-TIME value. Returns a datetime instance. >>> parse_date_time('20030405T060708') datetime.datetime(2003, 4, 5, 6, 7, 8) >>> parse_date_time('20030405T060708Z') datetime.datetime(2003, 4, 5, 6, 7, 8) >>> parse_date_time('20030405T060708A') Traceback (most recent call last): ... ValueError: Invalid iCalendar date-time: '20030405T060708A' >>> parse_date_time('') Traceback (most recent call last): ... ValueError: Invalid iCalendar date-time: '' """ datetime_rx = re.compile(r'(\d{4})(\d{2})(\d{2})' r'T(\d{2})(\d{2})(\d{2})(Z?)$') match = datetime_rx.match(value) if match is None: raise ValueError('Invalid iCalendar date-time: %r' % value) y, m, d, hh, mm, ss, utc = match.groups() return datetime.datetime(int(y), int(m), int(d), int(hh), int(mm), int(ss)) def parse_period(value): """Parse iCalendar PERIOD value. Returns a Period instance. >>> p = parse_period('20030405T060708/20030405T060709') >>> print repr(p).replace('),', '),\\n ') Period(datetime.datetime(2003, 4, 5, 6, 7, 8), datetime.datetime(2003, 4, 5, 6, 7, 9)) >>> parse_period('20030405T060708/PT1H1M1S') Period(datetime.datetime(2003, 4, 5, 6, 7, 8), datetime.timedelta(0, 3661)) >>> parse_period('xyzzy') Traceback (most recent call last): ... ValueError: Invalid iCalendar period: 'xyzzy' >>> parse_period('foo/foe') Traceback (most recent call last): ... ValueError: Invalid iCalendar period: 'foo/foe' """ parts = value.split('/') if len(parts) != 2: raise ValueError('Invalid iCalendar period: %r' % value) try: start = parse_date_time(parts[0]) try: end_or_duration = parse_date_time(parts[1]) except ValueError: end_or_duration = parse_duration(parts[1]) except ValueError: raise ValueError('Invalid iCalendar period: %r' % value) else: return Period(start, end_or_duration) def parse_attendee(value): """Parse list of attendees. Return list of person instances. >>> attendees = [('mailto:sidnei@awkly.org', ... {'RSVP': 'FALSE', ... 'ROLE': 'REQ-PARTICIPANT', ... 'PARTSTAT': 'ACCEPTED', ... 'CN': 'Sidnei da Silva'}), ... ('mailto:paul@zope-europe.org', ... {'RSVP': 'FALSE', ... 'ROLE': 'REQ-PARTICIPANT', ... 'PARTSTAT': 'TENTATIVE', ... 'CN': 'Paul Everitt'})] >>> parse_attendee(attendees) [Person('sidnei@awkly.org', 'Sidnei da Silva'), Person('paul@zope-europe.org', 'Paul Everitt')] """ res = [] for k, v in value: res.append(Person(k, v.get('CN'))) return res def parse_categories(value): """Parse list of categories. Return list of category strings. >>> categories = [('testing', ... {}), ... ('another', ... {})] >>> parse_categories(categories) ['testing', 'another'] """ return [k for k, v in value] class Person: """ A person """ def __init__(self, mail, name): mail = mail.lower() if not 'mailto:' in mail: mail = '' else: mail = filter(None, mail.split('mailto:')) self.mail = mail and mail[0] or '' self.name = name def __repr__(self): return "Person%r" % ((self.mail, self.name),) def __cmp__(self, other): if not isinstance(other, Person): raise NotImplementedError('Cannot compare Person with %r' % other) return cmp((self.mail, self.name), (other.mail, other.name)) class Period: """A period of time""" def __init__(self, start, end_or_duration): self.start = start self.end_or_duration = end_or_duration if isinstance(end_or_duration, datetime.timedelta): self.duration = end_or_duration self.end = self.start + self.duration else: self.end = end_or_duration self.duration = self.end - self.start if self.start > self.end: raise ValueError("Start time is greater than end time") def __repr__(self): return "Period(%r, %r)" % (self.start, self.end_or_duration) def __cmp__(self, other): if not isinstance(other, Period): raise NotImplementedError('Cannot compare Period with %r' % other) return cmp((self.start, self.end), (other.start, other.end)) def overlaps(self, other): if self.start > other.start: return other.overlaps(self) if self.start <= other.start < self.end: return True return False class VEvent: """iCalendar event. Life cycle: when a VEvent is created, a number of properties should be added to it using the add method. Then validate should be called. After that you can start using query methods (getOne, hasProp, iterDates). Events are classified into two kinds: - normal events - all-day events All-day events are identified by their DTSTART property having a DATE value instead of the default DATE-TIME. All-day events should satisfy the following requirements (otherwise an exception will be raised): - DURATION property (if defined) should be an integral number of days - DTEND property (if defined) should have a DATE value - any RDATE and EXDATE properties should only contain DATE values The first two requirements are stated in RFC 2445; I'm not so sure about the third one. """ default_type = { # Default value types for some properties 'DTSTAMP': 'DATE-TIME', 'DTSTART': 'DATE-TIME', 'CREATED': 'DATE-TIME', 'DTEND': 'DATE-TIME', 'DURATION': 'DURATION', 'LAST-MODIFIED': 'DATE-TIME', 'PRIORITY': 'INTEGER', 'RECURRENCE-ID': 'DATE-TIME', 'SEQUENCE': 'INTEGER', 'URL': 'URI', 'ATTACH': 'URI', 'EXDATE': 'DATE-TIME', 'EXRULE': 'RECUR', 'RDATE': 'DATE-TIME', 'RRULE': 'RECUR', 'LOCATION': 'TEXT', 'UID': 'TEXT', 'ATTENDEE':'ATTENDEE', } converters = { 'INTEGER': int, 'DATE': parse_date, 'DATE-TIME': parse_date_time, 'DURATION': parse_duration, 'PERIOD': parse_period, 'TEXT': parse_text, } complex_converters = { 'ATTENDEE': parse_attendee, 'CATEGORIES': parse_categories, } singleton_properties = Set([ 'DTSTAMP', 'DTSTART', 'UID', 'CLASS', 'CREATED', 'DESCRIPTION', 'DTEND', 'DURATION', 'GEO', 'LAST-MODIFIED', 'LOCATION', 'ORGANIZER', 'PRIORITY', 'RECURRENCE-ID', 'SEQUENCE', 'STATUS', 'SUMMARY', 'TRANSP', 'URL', ]) rdate_types = Set(['DATE', 'DATE-TIME', 'PERIOD']) exdate_types = Set(['DATE', 'DATE-TIME']) def __init__(self): self._props = {} def add(self, property, value, params=None): """Add a property. Property name is case insensitive. Params should be a dict from uppercased parameter names to their values. Multiple calls to add with the same property name override the value. This is sufficient for now, but will have to be changed soon. """ if params is None: params = {} key = property.upper() if key in self._props: if key in self.singleton_properties: raise ICalParseError("Property %s cannot occur more than once" % key) self._props[key].append((value, params)) else: self._props[key] = [(value, params)] def validate(self): """Check that this VEvent has all the necessary properties. Also sets the following attributes: uid The unique id of this event summary Textual summary of this event all_day_event True if this is an all-day event dtstart start of the event (inclusive) dtend end of the event (not inclusive) duration length of the event location location of the event rdates a list of recurrence dates or periods exdates a list of exception dates """ if not self.hasProp('UID'): raise ICalParseError("VEVENT must have a UID property") if not self.hasProp('DTSTART'): raise ICalParseError("VEVENT must have a DTSTART property") if self._getType('DTSTART') not in ('DATE', 'DATE-TIME'): raise ICalParseError("DTSTART property should have a DATE or" " DATE-TIME value") if self.hasProp('DTEND'): if self._getType('DTEND') != self._getType('DTSTART'): raise ICalParseError("DTEND property should have the same type" " as DTSTART") if self.hasProp('DURATION'): raise ICalParseError("VEVENT cannot have both a DTEND" " and a DURATION property") if self.hasProp('DURATION'): if self._getType('DURATION') != 'DURATION': raise ICalParseError("DURATION property should have type" " DURATION") self.uid = self.getOne('UID') self.summary = self.getOne('SUMMARY') self.all_day_event = self._getType('DTSTART') == 'DATE' self.dtstart = self.getOne('DTSTART') if self.hasProp('DURATION'): self.duration = self.getOne('DURATION') self.dtend = self.dtstart + self.duration else: self.dtend = self.getOne('DTEND', None) if self.dtend is None: self.dtend = self.dtstart if self.all_day_event: self.dtend += datetime.date.resolution self.duration = self.dtend - self.dtstart self.location = self.getOne('LOCATION', None) if self.dtstart > self.dtend: raise ICalParseError("Event start time should precede end time") if self.all_day_event and self.dtstart == self.dtend: raise ICalParseError("Event start time should precede end time") self.rdates = self._extractListOfDates('RDATE', self.rdate_types, self.all_day_event) self.exdates = self._extractListOfDates('EXDATE', self.exdate_types, self.all_day_event) def _extractListOfDates(self, key, accepted_types, all_day_event): """Parse a comma separated list of values. If all_day_event is True, only accepts DATE values. Otherwise accepts all value types listed in 'accepted_types'. """ dates = [] default_type = self.default_type[key] for value, params in self._props.get(key, []): value_type = params.get('VALUE', default_type) if value_type not in accepted_types: raise ICalParseError('Invalid value type for %s: %s' % (key, value_type)) if all_day_event and value_type != 'DATE': raise ICalParseError('I do not understand how to interpret ' '%s values in %s for all-day events.' % (value_type, key)) converter = self.converters.get(value_type) dates.extend(map(converter, value.split(','))) return dates def _getType(self, property): """Return the type of the property value.""" key = property.upper() values = self._props.get(key) default_type = self.default_type.get(key, 'TEXT') if values is None: return default_type if key in self.singleton_properties: assert len(values) == 1, (key, values) value, params = values[0] return params.get('VALUE', default_type) def getOne(self, property, default=None): """Return the value of a property as an appropriate Python object. Only call getOne for properties that do not occur more than once. """ try: values = self._props[property.upper()] assert len(values) == 1 value, params = values[0] except KeyError: return default else: converter = self.converters.get(self._getType(property)) if converter is None: return value else: return converter(value) def getProp(self, property, default=None): """Return the value of a property as an appropriate Python object. """ key = property.upper() try: values = self._props[key] if key in self.singleton_properties: assert len(values) == 1, (key, values) except KeyError: return default else: res = [] for val, params in values: c_params = {} v_conv = self.converters.get(self._getType(key)) if v_conv is not None: val = v_conv(val) res.append((val, c_params)) for k, v in params.items(): p_key = k.upper() p_conv = self.converters.get(self._getType(p_key)) if p_conv is not None: v = p_conv(v) c_params[k] = v if key not in self.singleton_properties: c_conv = self.complex_converters.get(key) if c_conv is not None: return c_conv(res) else: return res[0][0] return res def hasProp(self, property): """Return True if this VEvent has a named property.""" return property.upper() in self._props def iterDates(self): """Iterate over all dates within this event. This is only valid for all-day events at the moment. """ if not self.all_day_event: raise ValueError('iterDates is only defined for all-day events') # Find out the set of start dates start_set = {self.dtstart: None} for rdate in self.rdates: start_set[rdate] = rdate for exdate in self.exdates: if exdate in start_set: del start_set[exdate] # Find out the set of all dates date_set = Set(start_set) duration = self.duration.days for d in start_set: for n in range(1, duration): d += datetime.date.resolution date_set.add(d) # Yield all dates in chronological order dates = list(date_set) dates.sort() for d in dates: yield d class ICalReader: """An object which reads in an iCal. Short grammar of iCalendar files (RFC 2445 is the full spec): contentline = name *(";" param ) ":" value CRLF ; content line first must be unfolded by replacing CRLF followed by a ; single WSP with an empty string name = x-name / iana-token x-name = "X-" [vendorid "-"] 1*(ALPHA / DIGIT / "-") iana-token = 1*(ALPHA / DIGIT / "-") vendorid = 3*(ALPHA / DIGIT) param = param-name "=" param-value *("," param-value) param-name = iana-token / x-token param-value = paramtext / quoted-string paramtext = *SAFE-CHAR value = *VALUE-CHAR quoted-string = DQUOTE *QSAFE-CHAR DQUOTE NON-US-ASCII = %x80-F8 QSAFE-CHAR = WSP / %x21 / %x23-7E / NON-US-ASCII ; Any character except CTLs and DQUOTE SAFE-CHAR = WSP / %x21 / %x23-2B / %x2D-39 / %x3C-7E / NON-US-ASCII ; Any character except CTLs, DQUOTE, ";", ":", "," VALUE-CHAR = WSP / %x21-7E / NON-US-ASCII ; anything except CTLs CR = %x0D LF = %x0A CRLF = CR LF CTL = %x00-08 / %x0A-1F / %x7F ALPHA = %x41-5A / %x61-7A ; A-Z / a-z DIGIT = %x30-39 ; 0-9 DQUOTE = %x22 ; Quotation Mark WSP = SPACE / HTAB SPACE = %x20 HTAB = %x09 """ def __init__(self, file): self.file = file def _parseRow(record_str): """Parse a single content line. A content line consists of a property name (optionally followed by a number of parameters) and a value, separated by a colon. Parameters (if present) are separated from the property name and from each other with semicolons. Parameters are of the form name=value; value can be double-quoted. Returns a tuple (name, value, param_dict). Case-insensitive values (i.e. property names, parameter names, unquoted parameter values) are uppercased. Raises ICalParseError on syntax errors. >>> ICalReader._parseRow('foo:bar') ('FOO', 'bar', {}) >>> ICalReader._parseRow('foo;value=bar:BAZFOO') ('FOO', 'BAZFOO', {'VALUE': 'BAR'}) """ it = iter(record_str) getChar = it.next def err(msg): raise ICalParseError("%s in line:\n%s" % (msg, record_str)) try: c = getChar() # name key = '' while c.isalnum() or c == '-': key += c c = getChar() if not key: err("Missing property name") key = key.upper() # optional parameters params = {} while c == ';': c = getChar() # param name param = '' while c.isalnum() or c == '-': param += c c = getChar() if not param: err("Missing parameter name") param = param.upper() # = if c != '=': err("Expected '='") # value (or rather a list of values) pvalues = [] while True: c = getChar() if c == '"': c = getChar() pvalue = '' while c >= ' ' and c not in ('\177', '"'): pvalue += c c = getChar() # value is case-sensitive in this case if c != '"': err("Expected '\"'") c = getChar() else: # unquoted value pvalue = '' while c >= ' ' and c not in ('\177', '"', ';', ':', ','): pvalue += c c = getChar() pvalue = pvalue.upper() pvalues.append(pvalue) if c != ',': break if len(pvalues) > 1: params[param] = pvalues else: params[param] = pvalues[0] # colon and value if c != ':': err("Expected ':'") value = ''.join(it) except StopIteration: err("Syntax error") else: return (key, value, params) _parseRow = staticmethod(_parseRow) def _iterRow(self): """A generator that returns one record at a time, as a tuple of (name, value, params). """ record = [] for line in self.file.readlines(): if not line.strip(): continue if line[0] in '\t ': line = line[1:] elif record: yield self._parseRow("".join(record)) record = [] if line.endswith('\r\n'): record.append(line[:-2]) elif line.endswith('\n'): # strictly speaking this is a violation of RFC 2445 record.append(line[:-1]) else: # strictly speaking this is a violation of RFC 2445 record.append(line) if record: yield self._parseRow("".join(record)) def iterEvents(self): """Iterate over all VEVENT objects in an ICalendar file.""" iterator = self._iterRow() # Check that the stream begins with BEGIN:VCALENDAR try: key, value, params = iterator.next() if (key, value, params) != ('BEGIN', 'VCALENDAR', {}): raise ICalParseError('This is not iCalendar') except StopIteration: # The file is empty. Mozilla produces a 0-length file when # publishing an empty calendar. Let's accept it as a valid # calendar that has no events. XXX I'm not sure if a 0-length # file is a valid text/calendar object according to RFC 2445. raise component_stack = ['VCALENDAR'] # Extract all VEVENT components obj = None for key, value, params in iterator: if key == "BEGIN": if obj is not None: # Subcomponents terminate the processing of a VEVENT # component. We can get away with this now, because we're # not interested in alarms and RFC 2445 specifies, that all # properties inside a VEVENT component ought to precede any # VALARM subcomponents. obj.validate() yield obj obj = None if not component_stack and value != "VCALENDAR": raise ICalParseError("Text outside VCALENDAR component") if value == "VEVENT": obj = VEvent() component_stack.append(value) elif key == "END": if obj is not None and value == "VEVENT": obj.validate() yield obj obj = None if not component_stack or component_stack[-1] != value: raise ICalParseError("Mismatched BEGIN/END") component_stack.pop() elif obj is not None: obj.add(key, value, params) elif not component_stack: raise ICalParseError("Text outside VCALENDAR component") if component_stack: raise ICalParseError("Unterminated components") class CalendarEvent(Persistent): def __init__(self, dt, duration, title, owner=None, context=None): self.dtstart = dt self.duration = duration self.title = title self.owner = owner self.context = context def __cmp__(self, other): if not isinstance(other, CalendarEvent): raise NotImplementedError('Cannot compare CalendarEvent with %r' % other) return cmp((self.dtstart, self.title, self.duration, hash(self.owner), hash(self.context)), (other.dtstart, other.title, other.duration, hash(other.owner), hash(other.context))) def __hash__(self): return hash((self.dtstart, self.title, self.duration, self.owner, self.context)) def __repr__(self): return ("CalendarEvent%r" % ((self.dtstart, self.duration, self.title, self.owner, self.context), )) class Calendar(Persistent): def __init__(self): self._events = Set() self.__name__ = None self.__parent__ = None def __iter__(self): return iter(self._events) def __getitem__(self, idx): return list(self._events)[idx] def byDate(self, date): cal = Calendar() for event in self: event_start = event.dtstart.date() event_end = (event.dtstart + event.duration).date() if event_start <= date and event_end >= date: cal.addEvent(event) return cal def addEvent(self, event): self._events.add(event) self._events = self._events # make persistence work def removeEvent(self, event): self._events.remove(event) self._events = self._events # make persistence work def update(self, calendar): for event in calendar: self._events.add(event) self._events = self._events # make persistence work def clear(self): self._events.clear() self._events = self._events # make persistence work