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