imip-agent

imiptools/data.py

734:12f55f31c92e
2015-09-13 Paul Boddie Added support to the person handler for responding with REFRESH messages when receiving ADD messages. Tidied up organiser replacement preferences access.
     1 #!/usr/bin/env python     2      3 """     4 Interpretation of vCalendar content.     5      6 Copyright (C) 2014, 2015 Paul Boddie <paul@boddie.org.uk>     7      8 This program is free software; you can redistribute it and/or modify it under     9 the terms of the GNU General Public License as published by the Free Software    10 Foundation; either version 3 of the License, or (at your option) any later    11 version.    12     13 This program is distributed in the hope that it will be useful, but WITHOUT    14 ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS    15 FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more    16 details.    17     18 You should have received a copy of the GNU General Public License along with    19 this program.  If not, see <http://www.gnu.org/licenses/>.    20 """    21     22 from bisect import bisect_left    23 from datetime import date, datetime, timedelta    24 from email.mime.text import MIMEText    25 from imiptools.dates import check_permitted_values, correct_datetime, \    26                             format_datetime, get_datetime, \    27                             get_datetime_item as get_item_from_datetime, \    28                             get_datetime_tzid, \    29                             get_duration, get_period, get_period_item, \    30                             get_recurrence_start_point, \    31                             get_tzid, to_datetime, to_timezone, to_utc_datetime    32 from imiptools.period import FreeBusyPeriod, Period, RecurringPeriod, period_overlaps    33 from vCalendar import iterwrite, parse, ParseError, to_dict, to_node    34 from vRecurrence import get_parameters, get_rule    35 import email.utils    36     37 try:    38     from cStringIO import StringIO    39 except ImportError:    40     from StringIO import StringIO    41     42 class Object:    43     44     "Access to calendar structures."    45     46     def __init__(self, fragment):    47         self.objtype, (self.details, self.attr) = fragment.items()[0]    48     49     def get_uid(self):    50         return self.get_value("UID")    51     52     def get_recurrenceid(self):    53     54         """    55         Return the recurrence identifier, normalised to a UTC datetime if    56         specified as a datetime or date with accompanying time zone information,    57         maintained as a date or floating datetime otherwise. If no recurrence    58         identifier is present, None is returned.    59     60         Note that this normalised form of the identifier may well not be the    61         same as the originally-specified identifier because that could have been    62         specified using an accompanying TZID attribute, whereas the normalised    63         form is effectively a converted datetime value.    64         """    65     66         if not self.has_key("RECURRENCE-ID"):    67             return None    68         dt, attr = self.get_datetime_item("RECURRENCE-ID")    69     70         # Coerce any date to a UTC datetime if TZID was specified.    71     72         tzid = attr.get("TZID")    73         if tzid:    74             dt = to_timezone(to_datetime(dt, tzid), "UTC")    75         return format_datetime(dt)    76     77     def get_recurrence_start_point(self, recurrenceid, tzid):    78     79         """    80         Return the start point corresponding to the given 'recurrenceid', using    81         the fallback 'tzid' to define the specific point in time referenced by    82         the recurrence identifier if the identifier has a date representation.    83     84         If 'recurrenceid' is given as None, this object's recurrence identifier    85         is used to obtain a start point, but if this object does not provide a    86         recurrence, None is returned.    87     88         A start point is typically used to match free/busy periods which are    89         themselves defined in terms of UTC datetimes.    90         """    91     92         recurrenceid = recurrenceid or self.get_recurrenceid()    93         if recurrenceid:    94             return get_recurrence_start_point(recurrenceid, tzid)    95         else:    96             return None    97     98     def get_recurrence_start_points(self, recurrenceids, tzid):    99         return [self.get_recurrence_start_point(recurrenceid, tzid) for recurrenceid in recurrenceids]   100    101     # Structure access.   102    103     def copy(self):   104         return Object(to_dict(self.to_node()))   105    106     def get_items(self, name, all=True):   107         return get_items(self.details, name, all)   108    109     def get_item(self, name):   110         return get_item(self.details, name)   111    112     def get_value_map(self, name):   113         return get_value_map(self.details, name)   114    115     def get_values(self, name, all=True):   116         return get_values(self.details, name, all)   117    118     def get_value(self, name):   119         return get_value(self.details, name)   120    121     def get_utc_datetime(self, name, date_tzid=None):   122         return get_utc_datetime(self.details, name, date_tzid)   123    124     def get_date_values(self, name, tzid=None):   125         items = get_date_value_items(self.details, name, tzid)   126         return items and [value for value, attr in items]   127    128     def get_date_value_items(self, name, tzid=None):   129         return get_date_value_items(self.details, name, tzid)   130    131     def get_period_values(self, name, tzid=None):   132         return get_period_values(self.details, name, tzid)   133    134     def get_datetime(self, name):   135         t = get_datetime_item(self.details, name)   136         if not t: return None   137         dt, attr = t   138         return dt   139    140     def get_datetime_item(self, name):   141         return get_datetime_item(self.details, name)   142    143     def get_duration(self, name):   144         return get_duration(self.get_value(name))   145    146     def to_node(self):   147         return to_node({self.objtype : [(self.details, self.attr)]})   148    149     def to_part(self, method):   150         return to_part(method, [self.to_node()])   151    152     # Direct access to the structure.   153    154     def has_key(self, name):   155         return self.details.has_key(name)   156    157     def get(self, name):   158         return self.details.get(name)   159    160     def keys(self):   161         return self.details.keys()   162    163     def __getitem__(self, name):   164         return self.details[name]   165    166     def __setitem__(self, name, value):   167         self.details[name] = value   168    169     def __delitem__(self, name):   170         del self.details[name]   171    172     def remove(self, name):   173         try:   174             del self[name]   175         except KeyError:   176             pass   177    178     def remove_all(self, names):   179         for name in names:   180             self.remove(name)   181    182     def preserve(self, names):   183         for name in self.keys():   184             if not name in names:   185                 self.remove(name)   186    187     # Computed results.   188    189     def get_main_period_items(self, tzid):   190    191         """   192         Return two (value, attributes) items corresponding to the main start-end   193         period for the object.   194         """   195    196         dtstart, dtstart_attr = self.get_datetime_item("DTSTART")   197    198         if self.has_key("DTEND"):   199             dtend, dtend_attr = self.get_datetime_item("DTEND")   200         elif self.has_key("DURATION"):   201             duration = self.get_duration("DURATION")   202             dtend = dtstart + duration   203             dtend_attr = dtstart_attr   204         else:   205             dtend, dtend_attr = dtstart, dtstart_attr   206    207         return (dtstart, dtstart_attr), (dtend, dtend_attr)   208    209     def get_periods(self, tzid, end=None):   210    211         """   212         Return periods defined by this object, employing the given 'tzid' where   213         no time zone information is defined, and limiting the collection to a   214         window of time with the given 'end'.   215    216         If 'end' is omitted, only explicit recurrences and recurrences from   217         explicitly-terminated rules will be returned.   218         """   219    220         return get_periods(self, tzid, end)   221    222     def get_active_periods(self, recurrenceids, tzid, end=None):   223    224         """   225         Return all periods specified by this object that are not replaced by   226         those defined by 'recurrenceids', using 'tzid' as a fallback time zone   227         to convert floating dates and datetimes, and using 'end' to indicate the   228         end of the time window within which periods are considered.   229         """   230    231         # Specific recurrences yield all specified periods.   232    233         periods = self.get_periods(tzid, end)   234    235         if self.get_recurrenceid():   236             return periods   237    238         # Parent objects need to have their periods tested against redefined   239         # recurrences.   240    241         active = []   242    243         for p in periods:   244    245             # Subtract any recurrences from the free/busy details of a   246             # parent object.   247    248             if not p.is_replaced(recurrenceids):   249                 active.append(p)   250    251         return active   252    253     def get_freebusy_period(self, period, only_organiser=False):   254    255         """   256         Return a free/busy period for the given 'period' provided by this   257         object, using the 'only_organiser' status to produce a suitable   258         transparency value.   259         """   260    261         return FreeBusyPeriod(   262             period.get_start_point(),   263             period.get_end_point(),   264             self.get_value("UID"),   265             only_organiser and "ORG" or self.get_value("TRANSP") or "OPAQUE",   266             self.get_recurrenceid(),   267             self.get_value("SUMMARY"),   268             self.get_value("ORGANIZER")   269             )   270    271     def get_participation_status(self, participant):   272    273         """   274         Return the participation status of the given 'participant', with the   275         special value "ORG" indicating organiser-only participation.   276         """   277        278         attendees = self.get_value_map("ATTENDEE")   279         organiser = self.get_value("ORGANIZER")   280    281         attendee_attr = attendees.get(participant)   282         if attendee_attr:   283             return attendee_attr.get("PARTSTAT", "NEEDS-ACTION")   284         elif organiser == participant:   285             return "ORG"   286    287         return None   288    289     def get_participation(self, partstat, include_needs_action=False):   290    291         """   292         Return whether 'partstat' indicates some kind of participation in an   293         event. If 'include_needs_action' is specified as a true value, events   294         not yet responded to will be treated as events with tentative   295         participation.   296         """   297    298         return not partstat in ("DECLINED", "DELEGATED", "NEEDS-ACTION") or \   299                include_needs_action and partstat == "NEEDS-ACTION" or \   300                partstat == "ORG"   301    302     def get_tzid(self):   303    304         """   305         Return a time zone identifier used by the start or end datetimes,   306         potentially suitable for converting dates to datetimes.   307         """   308    309         if not self.has_key("DTSTART"):   310             return None   311         dtstart, dtstart_attr = self.get_datetime_item("DTSTART")   312         if self.has_key("DTEND"):   313             dtend, dtend_attr = self.get_datetime_item("DTEND")   314         else:   315             dtend_attr = None   316         return get_tzid(dtstart_attr, dtend_attr)   317    318     def is_shared(self):   319    320         """   321         Return whether this object is shared based on the presence of a SEQUENCE   322         property.   323         """   324    325         return self.get_value("SEQUENCE") is not None   326    327     def possibly_active_from(self, dt, tzid):   328    329         """   330         Return whether the object is possibly active from or after the given   331         datetime 'dt' using 'tzid' to convert any dates or floating datetimes.   332         """   333    334         dt = to_datetime(dt, tzid)   335         periods = self.get_periods(tzid)   336    337         for p in periods:   338             if p.get_end_point() > dt:   339                 return True   340    341         return self.possibly_recurring_indefinitely()   342    343     def possibly_recurring_indefinitely(self):   344    345         "Return whether this object may recur indefinitely."   346    347         rrule = self.get_value("RRULE")   348         parameters = rrule and get_parameters(rrule)   349         until = parameters and parameters.get("UNTIL")   350         count = parameters and parameters.get("COUNT")   351    352         # Non-recurring periods or constrained recurrences.   353    354         if not rrule or until or count:   355             return False   356    357         # Unconstrained recurring periods will always lie beyond any specified   358         # datetime.   359    360         else:   361             return True   362    363     # Modification methods.   364    365     def set_datetime(self, name, dt, tzid=None):   366    367         """   368         Set a datetime for property 'name' using 'dt' and the optional fallback   369         'tzid', returning whether an update has occurred.   370         """   371    372         if dt:   373             old_value = self.get_value(name)   374             self[name] = [get_item_from_datetime(dt, tzid)]   375             return format_datetime(dt) != old_value   376    377         return False   378    379     def set_period(self, period):   380    381         "Set the given 'period' as the main start and end."   382    383         result = self.set_datetime("DTSTART", period.get_start())   384         result = self.set_datetime("DTEND", period.get_end()) or result   385         if self.has_key("DURATION"):   386             del self["DURATION"]   387    388         return result   389    390     def set_periods(self, periods):   391    392         """   393         Set the given 'periods' as recurrence date properties, replacing the   394         previous RDATE properties and ignoring any RRULE properties.   395         """   396    397         old_values = set(self.get_date_values("RDATE"))   398         new_rdates = []   399    400         if self.has_key("RDATE"):   401             del self["RDATE"]   402    403         for p in periods:   404             if p.origin != "RRULE":   405                 new_rdates.append(get_period_item(p.get_start(), p.get_end()))   406    407         if new_rdates:   408             self["RDATE"] = new_rdates   409    410         return old_values != set(self.get_date_values("RDATE"))   411    412     def correct_object(self, tzid, permitted_values):   413    414         "Correct the object's period details."   415    416         corrected = set()   417         rdates = []   418    419         for period in self.get_periods(tzid):   420             start = period.get_start()   421             end = period.get_end()   422             start_errors = check_permitted_values(start, permitted_values)   423             end_errors = check_permitted_values(end, permitted_values)   424    425             if not (start_errors or end_errors):   426                 if period.origin == "RDATE":   427                     rdates.append(period)   428                 continue   429    430             if start_errors:   431                 start = correct_datetime(start, permitted_values)   432             if end_errors:   433                 end = correct_datetime(end, permitted_values)   434             period = RecurringPeriod(start, end, period.tzid, period.origin, period.get_start_attr(), period.get_end_attr())   435    436             if period.origin == "DTSTART":   437                 self.set_period(period)   438                 corrected.add("DTSTART")   439             elif period.origin == "RDATE":   440                 rdates.append(period)   441                 corrected.add("RDATE")   442    443         if "RDATE" in corrected:   444             self.set_periods(rdates)   445    446         return corrected   447    448 # Construction and serialisation.   449    450 def make_calendar(nodes, method=None):   451    452     """   453     Return a complete calendar node wrapping the given 'nodes' and employing the   454     given 'method', if indicated.   455     """   456    457     return ("VCALENDAR", {},   458             (method and [("METHOD", {}, method)] or []) +   459             [("VERSION", {}, "2.0")] +   460             nodes   461            )   462    463 def make_freebusy(freebusy, uid, organiser, organiser_attr=None, attendee=None,   464                   attendee_attr=None, period=None):   465        466     """   467     Return a calendar node defining the free/busy details described in the given   468     'freebusy' list, employing the given 'uid', for the given 'organiser' and   469     optional 'organiser_attr', with the optional 'attendee' providing recipient   470     details together with the optional 'attendee_attr'.   471    472     The result will be constrained to the 'period' if specified.   473     """   474        475     record = []   476     rwrite = record.append   477        478     rwrite(("ORGANIZER", organiser_attr or {}, organiser))   479    480     if attendee:   481         rwrite(("ATTENDEE", attendee_attr or {}, attendee))    482    483     rwrite(("UID", {}, uid))   484    485     if freebusy:   486    487         # Get a constrained view if start and end limits are specified.   488    489         if period:   490             periods = period_overlaps(freebusy, period, True)   491         else:   492             periods = freebusy   493    494         # Write the limits of the resource.   495    496         if periods:   497             rwrite(("DTSTART", {"VALUE" : "DATE-TIME"}, format_datetime(periods[0].get_start_point())))   498             rwrite(("DTEND", {"VALUE" : "DATE-TIME"}, format_datetime(periods[-1].get_end_point())))   499         else:   500             rwrite(("DTSTART", {"VALUE" : "DATE-TIME"}, format_datetime(period.get_start_point())))   501             rwrite(("DTEND", {"VALUE" : "DATE-TIME"}, format_datetime(period.get_end_point())))   502    503         for p in periods:   504             if p.transp == "OPAQUE":   505                 rwrite(("FREEBUSY", {"FBTYPE" : "BUSY"}, "/".join(   506                     map(format_datetime, [p.get_start_point(), p.get_end_point()])   507                     )))   508    509     return ("VFREEBUSY", {}, record)   510    511 def parse_object(f, encoding, objtype=None):   512    513     """   514     Parse the iTIP content from 'f' having the given 'encoding'. If 'objtype' is   515     given, only objects of that type will be returned. Otherwise, the root of   516     the content will be returned as a dictionary with a single key indicating   517     the object type.   518    519     Return None if the content was not readable or suitable.   520     """   521    522     try:   523         try:   524             doctype, attrs, elements = obj = parse(f, encoding=encoding)   525             if objtype and doctype == objtype:   526                 return to_dict(obj)[objtype][0]   527             elif not objtype:   528                 return to_dict(obj)   529         finally:   530             f.close()   531    532     # NOTE: Handle parse errors properly.   533    534     except (ParseError, ValueError):   535         pass   536    537     return None   538    539 def to_part(method, calendar):   540    541     """   542     Write using the given 'method', the 'calendar' details to a MIME   543     text/calendar part.   544     """   545    546     encoding = "utf-8"   547     out = StringIO()   548     try:   549         to_stream(out, make_calendar(calendar, method), encoding)   550         part = MIMEText(out.getvalue(), "calendar", encoding)   551         part.set_param("method", method)   552         return part   553    554     finally:   555         out.close()   556    557 def to_stream(out, fragment, encoding="utf-8"):   558     iterwrite(out, encoding=encoding).append(fragment)   559    560 # Structure access functions.   561    562 def get_items(d, name, all=True):   563    564     """   565     Get all items from 'd' for the given 'name', returning single items if   566     'all' is specified and set to a false value and if only one value is   567     present for the name. Return None if no items are found for the name or if   568     many items are found but 'all' is set to a false value.   569     """   570    571     if d.has_key(name):   572         items = [(value or None, attr) for value, attr in d[name]]   573         if all:   574             return items   575         elif len(items) == 1:   576             return items[0]   577         else:   578             return None   579     else:   580         return None   581    582 def get_item(d, name):   583     return get_items(d, name, False)   584    585 def get_value_map(d, name):   586    587     """   588     Return a dictionary for all items in 'd' having the given 'name'. The   589     dictionary will map values for the name to any attributes or qualifiers   590     that may have been present.   591     """   592    593     items = get_items(d, name)   594     if items:   595         return dict(items)   596     else:   597         return {}   598    599 def values_from_items(items):   600     return map(lambda x: x[0], items)   601    602 def get_values(d, name, all=True):   603     if d.has_key(name):   604         items = d[name]   605         if not all and len(items) == 1:   606             return items[0][0]   607         else:   608             return values_from_items(items)   609     else:   610         return None   611    612 def get_value(d, name):   613     return get_values(d, name, False)   614    615 def get_date_value_items(d, name, tzid=None):   616    617     """   618     Obtain items from 'd' having the given 'name', where a single item yields   619     potentially many values. Return a list of tuples of the form (value,   620     attributes) where the attributes have been given for the property in 'd'.   621     """   622    623     items = get_items(d, name)   624     if items:   625         all_items = []   626         for item in items:   627             values, attr = item   628             if not attr.has_key("TZID") and tzid:   629                 attr["TZID"] = tzid   630             if not isinstance(values, list):   631                 values = [values]   632             for value in values:   633                 all_items.append((get_datetime(value, attr) or get_period(value, attr), attr))   634         return all_items   635     else:   636         return None   637    638 def get_period_values(d, name, tzid=None):   639    640     """   641     Return period values from 'd' for the given property 'name', using 'tzid'   642     where specified to indicate the time zone.   643     """   644    645     values = []   646     for value, attr in get_items(d, name) or []:   647         if not attr.has_key("TZID") and tzid:   648             attr["TZID"] = tzid   649         start, end = get_period(value, attr)   650         values.append(Period(start, end, tzid=tzid))   651     return values   652    653 def get_utc_datetime(d, name, date_tzid=None):   654    655     """   656     Return the value provided by 'd' for 'name' as a datetime in the UTC zone   657     or as a date, converting any date to a datetime if 'date_tzid' is specified.   658     If no datetime or date is available, None is returned.   659     """   660    661     t = get_datetime_item(d, name)   662     if not t:   663         return None   664     else:   665         dt, attr = t   666         return dt is not None and to_utc_datetime(dt, date_tzid) or None   667    668 def get_datetime_item(d, name):   669    670     """   671     Return the value provided by 'd' for 'name' as a datetime or as a date,   672     together with the attributes describing it. Return None if no value exists   673     for 'name' in 'd'.   674     """   675    676     t = get_item(d, name)   677     if not t:   678         return None   679     else:   680         value, attr = t   681         dt = get_datetime(value, attr)   682         tzid = get_datetime_tzid(dt)   683         if tzid:   684             attr["TZID"] = tzid   685         return dt, attr   686    687 # Conversion functions.   688    689 def get_addresses(values):   690     return [address for name, address in email.utils.getaddresses(values)]   691    692 def get_address(value):   693     if not value: return None   694     value = value.lower()   695     return value.startswith("mailto:") and value[7:] or value   696    697 def get_uri(value):   698     if not value: return None   699     return value.lower().startswith("mailto:") and value.lower() or \   700            ":" in value and value or \   701            "mailto:%s" % value.lower()   702    703 uri_value = get_uri   704    705 def uri_values(values):   706     return map(get_uri, values)   707    708 def uri_dict(d):   709     return dict([(get_uri(key), value) for key, value in d.items()])   710    711 def uri_item(item):   712     return get_uri(item[0]), item[1]   713    714 def uri_items(items):   715     return [(get_uri(value), attr) for value, attr in items]   716    717 # Operations on structure data.   718    719 def is_new_object(old_sequence, new_sequence, old_dtstamp, new_dtstamp, ignore_dtstamp):   720    721     """   722     Return for the given 'old_sequence' and 'new_sequence', 'old_dtstamp' and   723     'new_dtstamp', and the 'ignore_dtstamp' indication, whether the object   724     providing the new information is really newer than the object providing the   725     old information.   726     """   727    728     have_sequence = old_sequence is not None and new_sequence is not None   729     is_same_sequence = have_sequence and int(new_sequence) == int(old_sequence)   730    731     have_dtstamp = old_dtstamp and new_dtstamp   732     is_old_dtstamp = have_dtstamp and new_dtstamp < old_dtstamp or old_dtstamp and not new_dtstamp   733    734     is_old_sequence = have_sequence and (   735         int(new_sequence) < int(old_sequence) or   736         is_same_sequence and is_old_dtstamp   737         )   738    739     return is_same_sequence and ignore_dtstamp or not is_old_sequence   740    741 def get_periods(obj, tzid, end=None, inclusive=False):   742    743     """   744     Return periods for the given object 'obj', employing the given 'tzid' where   745     no time zone information is available (for whole day events, for example),   746     confining materialised periods to before the given 'end' datetime.   747    748     If 'end' is omitted, only explicit recurrences and recurrences from   749     explicitly-terminated rules will be returned.   750    751     If 'inclusive' is set to a true value, any period occurring at the 'end'   752     will be included.   753     """   754    755     rrule = obj.get_value("RRULE")   756     parameters = rrule and get_parameters(rrule)   757    758     # Use localised datetimes.   759    760     (dtstart, dtstart_attr), (dtend, dtend_attr) = obj.get_main_period_items(tzid)   761     duration = dtend - dtstart   762    763     # Attempt to get time zone details from the object, using the supplied zone   764     # only as a fallback.   765    766     obj_tzid = obj.get_tzid()   767    768     if not rrule:   769         periods = [RecurringPeriod(dtstart, dtend, tzid, "DTSTART", dtstart_attr, dtend_attr)]   770    771     elif end or parameters and parameters.has_key("UNTIL") or parameters.has_key("COUNT"):   772    773         # Recurrence rules create multiple instances to be checked.   774         # Conflicts may only be assessed within a period defined by policy   775         # for the agent, with instances outside that period being considered   776         # unchecked.   777    778         selector = get_rule(dtstart, rrule)   779         periods = []   780    781         until = parameters.get("UNTIL")   782         if until:   783             until_dt = to_timezone(get_datetime(until, dtstart_attr), obj_tzid)   784             end = end and min(until_dt, end) or until_dt   785             inclusive = True   786    787         for recurrence_start in selector.materialise(dtstart, end, parameters.get("COUNT"), parameters.get("BYSETPOS"), inclusive):   788             create = len(recurrence_start) == 3 and date or datetime   789             recurrence_start = to_timezone(create(*recurrence_start), obj_tzid)   790             recurrence_end = recurrence_start + duration   791             periods.append(RecurringPeriod(recurrence_start, recurrence_end, tzid, "RRULE", dtstart_attr))   792    793     else:   794         periods = []   795    796     # Add recurrence dates.   797    798     rdates = obj.get_date_value_items("RDATE", tzid)   799    800     if rdates:   801         for rdate, rdate_attr in rdates:   802             if isinstance(rdate, tuple):   803                 periods.append(RecurringPeriod(rdate[0], rdate[1], tzid, "RDATE", rdate_attr))   804             else:   805                 periods.append(RecurringPeriod(rdate, rdate + duration, tzid, "RDATE", rdate_attr))   806    807     # Return a sorted list of the periods.   808    809     periods.sort()   810    811     # Exclude exception dates.   812    813     exdates = obj.get_date_value_items("EXDATE", tzid)   814    815     if exdates:   816         for exdate, exdate_attr in exdates:   817             if isinstance(exdate, tuple):   818                 period = RecurringPeriod(exdate[0], exdate[1], tzid, "EXDATE", exdate_attr)   819             else:   820                 period = RecurringPeriod(exdate, exdate + duration, tzid, "EXDATE", exdate_attr)   821             i = bisect_left(periods, period)   822             while i < len(periods) and periods[i] == period:   823                 del periods[i]   824    825     return periods   826    827 def get_sender_identities(mapping):   828    829     """   830     Return a mapping from actual senders to the identities for which they   831     have provided data, extracting this information from the given   832     'mapping'.   833     """   834    835     senders = {}   836    837     for value, attr in mapping.items():   838         sent_by = attr.get("SENT-BY")   839         if sent_by:   840             sender = get_uri(sent_by)   841         else:   842             sender = value   843    844         if not senders.has_key(sender):   845             senders[sender] = []   846    847         senders[sender].append(value)   848    849     return senders   850    851 def get_window_end(tzid, days=100):   852    853     """   854     Return a datetime in the time zone indicated by 'tzid' marking the end of a   855     window of the given number of 'days'.   856     """   857    858     return to_timezone(datetime.now(), tzid) + timedelta(days)   859    860 # vim: tabstop=4 expandtab shiftwidth=4