diff options
author | Guido Guenther <agx@sigxcpu.org> | 2008-06-20 09:11:42 +0200 |
---|---|---|
committer | Guido Guenther <agx@sigxcpu.org> | 2008-06-20 09:11:42 +0200 |
commit | bd308fbf5f91cd2d9f86f9ea437bcf54a120a09c (patch) | |
tree | 6361d7bceda4ac2ef9f4b6de0565874f9da0c1a2 /vobject | |
parent | 7a6dcb8d42e0611d4d49130d0cb492d9f58e51d6 (diff) |
Imported Upstream version 0.6.6
Diffstat (limited to 'vobject')
-rw-r--r-- | vobject/__init__.py | 86 | ||||
-rw-r--r-- | vobject/base.py | 1106 | ||||
-rw-r--r-- | vobject/behavior.py | 164 | ||||
-rw-r--r-- | vobject/hcalendar.py | 125 | ||||
-rw-r--r-- | vobject/icalendar.py | 1892 | ||||
-rw-r--r-- | vobject/ics_diff.py | 219 | ||||
-rw-r--r-- | vobject/vcard.py | 289 | ||||
-rw-r--r-- | vobject/win32tz.py | 156 |
8 files changed, 4037 insertions, 0 deletions
diff --git a/vobject/__init__.py b/vobject/__init__.py new file mode 100644 index 0000000..d5daf30 --- /dev/null +++ b/vobject/__init__.py @@ -0,0 +1,86 @@ +""" +VObject Overview +================ + vobject parses vCard or vCalendar files, returning a tree of Python objects. + It also provids an API to create vCard or vCalendar data structures which + can then be serialized. + + Parsing existing streams + ------------------------ + Streams containing one or many L{Component<base.Component>}s can be + parsed using L{readComponents<base.readComponents>}. As each Component + is parsed, vobject will attempt to give it a L{Behavior<behavior.Behavior>}. + If an appropriate Behavior is found, any base64, quoted-printable, or + backslash escaped data will automatically be decoded. Dates and datetimes + will be transformed to datetime.date or datetime.datetime instances. + Components containing recurrence information will have a special rruleset + attribute (a dateutil.rrule.rruleset instance). + + Validation + ---------- + L{Behavior<behavior.Behavior>} classes implement validation for + L{Component<base.Component>}s. To validate, an object must have all + required children. There (TODO: will be) a toggle to raise an exception or + just log unrecognized, non-experimental children and parameters. + + Creating objects programatically + -------------------------------- + A L{Component<base.Component>} can be created from scratch. No encoding + is necessary, serialization will encode data automatically. Factory + functions (TODO: will be) available to create standard objects. + + Serializing objects + ------------------- + Serialization: + - Looks for missing required children that can be automatically generated, + like a UID or a PRODID, and adds them + - Encodes all values that can be automatically encoded + - Checks to make sure the object is valid (unless this behavior is + explicitly disabled) + - Appends the serialized object to a buffer, or fills a new + buffer and returns it + + Examples + -------- + + >>> import datetime + >>> import dateutil.rrule as rrule + >>> x = iCalendar() + >>> x.add('vevent') + <VEVENT| []> + >>> x + <VCALENDAR| [<VEVENT| []>]> + >>> v = x.vevent + >>> utc = icalendar.utc + >>> v.add('dtstart').value = datetime.datetime(2004, 12, 15, 14, tzinfo = utc) + >>> v + <VEVENT| [<DTSTART{}2004-12-15 14:00:00+00:00>]> + >>> x + <VCALENDAR| [<VEVENT| [<DTSTART{}2004-12-15 14:00:00+00:00>]>]> + >>> newrule = rrule.rruleset() + >>> newrule.rrule(rrule.rrule(rrule.WEEKLY, count=2, dtstart=v.dtstart.value)) + >>> v.rruleset = newrule + >>> list(v.rruleset) + [datetime.datetime(2004, 12, 15, 14, 0, tzinfo=tzutc()), datetime.datetime(2004, 12, 22, 14, 0, tzinfo=tzutc())] + >>> v.add('uid').value = "randomuid@MYHOSTNAME" + >>> print x.serialize() + BEGIN:VCALENDAR + VERSION:2.0 + PRODID:-//PYVOBJECT//NONSGML Version 1//EN + BEGIN:VEVENT + UID:randomuid@MYHOSTNAME + DTSTART:20041215T140000Z + RRULE:FREQ=WEEKLY;COUNT=2 + END:VEVENT + END:VCALENDAR + +""" + +import base, icalendar, vcard +from base import readComponents, readOne, newFromBehavior + +def iCalendar(): + return newFromBehavior('vcalendar', '2.0') + +def vCard(): + return newFromBehavior('vcard', '3.0')
\ No newline at end of file diff --git a/vobject/base.py b/vobject/base.py new file mode 100644 index 0000000..107e71c --- /dev/null +++ b/vobject/base.py @@ -0,0 +1,1106 @@ +"""vobject module for reading vCard and vCalendar files.""" + +import copy +import re +import sys +import logging +import StringIO, cStringIO +import string +import exceptions +import codecs + +#------------------------------------ Logging ---------------------------------- +logger = logging.getLogger(__name__) +if not logging.getLogger().handlers: + handler = logging.StreamHandler() + formatter = logging.Formatter('%(name)s %(levelname)s %(message)s') + handler.setFormatter(formatter) + logger.addHandler(handler) +logger.setLevel(logging.ERROR) # Log errors +DEBUG = False # Don't waste time on debug calls +#----------------------------------- Constants --------------------------------- +CR = '\r' +LF = '\n' +CRLF = CR + LF +SPACE = ' ' +TAB = '\t' +SPACEORTAB = SPACE + TAB +#-------------------------------- Useful modules ------------------------------- +# use doctest, it kills two birds with one stone and docstrings often become +# more readable to boot (see parseLine's docstring). +# use logging, then when debugging we can just set our verbosity. +# use epydoc syntax for documenting code, please document every class and non- +# trivial method (see http://epydoc.sourceforge.net/epytext.html +# and http://epydoc.sourceforge.net/fields.html). Also, please +# follow http://www.python.org/peps/pep-0257.html for docstrings. +#------------------------------------------------------------------------------- + +#--------------------------------- Main classes -------------------------------- +class VBase(object): + """Base class for ContentLine and Component. + + @ivar behavior: + The Behavior class associated with this object, which controls + validation, transformations, and encoding. + @ivar parentBehavior: + The object's parent's behavior, or None if no behaviored parent exists. + @ivar isNative: + Boolean describing whether this component is a Native instance. + @ivar group: + An optional group prefix, should be used only to indicate sort order in + vCards, according to RFC2426 + """ + def __init__(self, group=None, *args, **kwds): + super(VBase, self).__init__(*args, **kwds) + self.group = group + self.behavior = None + self.parentBehavior = None + self.isNative = False + + def copy(self, copyit): + self.group = copyit.group + self.behavior = copyit.behavior + self.parentBehavior = copyit.parentBehavior + self.isNative = copyit.isNative + + def validate(self, *args, **kwds): + """Call the behavior's validate method, or return True.""" + if self.behavior: + return self.behavior.validate(self, *args, **kwds) + else: return True + + def getChildren(self): + """Return an iterable containing the contents of the object.""" + return [] + + def clearBehavior(self, cascade=True): + """Set behavior to None. Do for all descendants if cascading.""" + self.behavior=None + if cascade: self.transformChildrenFromNative() + + def autoBehavior(self, cascade=False): + """Set behavior if name is in self.parentBehavior.knownChildren. + + If cascade is True, unset behavior and parentBehavior for all + descendants, then recalculate behavior and parentBehavior. + + """ + parentBehavior = self.parentBehavior + if parentBehavior is not None: + knownChildTup = parentBehavior.knownChildren.get(self.name, None) + if knownChildTup is not None: + behavior = getBehavior(self.name, knownChildTup[2]) + if behavior is not None: + self.setBehavior(behavior, cascade) + if isinstance(self, ContentLine) and self.encoded: + self.behavior.decode(self) + elif isinstance(self, ContentLine): + self.behavior = parentBehavior.defaultBehavior + if self.encoded and self.behavior: + self.behavior.decode(self) + + def setBehavior(self, behavior, cascade=True): + """Set behavior. If cascade is True, autoBehavior all descendants.""" + self.behavior=behavior + if cascade: + for obj in self.getChildren(): + obj.parentBehavior=behavior + obj.autoBehavior(True) + + def transformToNative(self): + """Transform this object into a custom VBase subclass. + + transformToNative should always return a representation of this object. + It may do so by modifying self in place then returning self, or by + creating a new object. + + """ + if self.isNative or not self.behavior or not self.behavior.hasNative: + return self + else: + try: + return self.behavior.transformToNative(self) + except Exception, e: + # wrap errors in transformation in a ParseError + lineNumber = getattr(self, 'lineNumber', None) + if isinstance(e, ParseError): + if lineNumber is not None: + e.lineNumber = lineNumber + raise + else: + msg = "In transformToNative, unhandled exception: %s: %s" + msg = msg % (sys.exc_info()[0], sys.exc_info()[1]) + new_error = ParseError(msg, lineNumber) + raise ParseError, new_error, sys.exc_info()[2] + + + def transformFromNative(self): + """Return self transformed into a ContentLine or Component if needed. + + May have side effects. If it does, transformFromNative and + transformToNative MUST have perfectly inverse side effects. Allowing + such side effects is convenient for objects whose transformations only + change a few attributes. + + Note that it isn't always possible for transformFromNative to be a + perfect inverse of transformToNative, in such cases transformFromNative + should return a new object, not self after modifications. + + """ + if self.isNative and self.behavior and self.behavior.hasNative: + try: + return self.behavior.transformFromNative(self) + except Exception, e: + # wrap errors in transformation in a NativeError + lineNumber = getattr(self, 'lineNumber', None) + if isinstance(e, NativeError): + if lineNumber is not None: + e.lineNumber = lineNumber + raise + else: + msg = "In transformFromNative, unhandled exception: %s: %s" + msg = msg % (sys.exc_info()[0], sys.exc_info()[1]) + new_error = NativeError(msg, lineNumber) + raise NativeError, new_error, sys.exc_info()[2] + else: return self + + def transformChildrenToNative(self): + """Recursively replace children with their native representation.""" + pass + + def transformChildrenFromNative(self, clearBehavior=True): + """Recursively transform native children to vanilla representations.""" + pass + + def serialize(self, buf=None, lineLength=75, validate=True, behavior=None): + """Serialize to buf if it exists, otherwise return a string. + + Use self.behavior.serialize if behavior exists. + + """ + if not behavior: + behavior = self.behavior + + if behavior: + if DEBUG: logger.debug("serializing %s with behavior" % self.name) + return behavior.serialize(self, buf, lineLength, validate) + else: + if DEBUG: logger.debug("serializing %s without behavior" % self.name) + return defaultSerialize(self, buf, lineLength) + +def ascii(s): + """Turn s into a printable string. Won't work for 8-bit ASCII.""" + return unicode(s).encode('ascii', 'replace') + +def toVName(name, stripNum = 0, upper = False): + """ + Turn a Python name into an iCalendar style name, optionally uppercase and + with characters stripped off. + """ + if upper: + name = name.upper() + if stripNum != 0: + name = name[:-stripNum] + return name.replace('_', '-') + +class ContentLine(VBase): + """Holds one content line for formats like vCard and vCalendar. + + For example:: + <SUMMARY{u'param1' : [u'val1'], u'param2' : [u'val2']}Bastille Day Party> + + @ivar name: + The uppercased name of the contentline. + @ivar params: + A dictionary of parameters and associated lists of values (the list may + be empty for empty parameters). + @ivar value: + The value of the contentline. + @ivar singletonparams: + A list of parameters for which it's unclear if the string represents the + parameter name or the parameter value. In vCard 2.1, "The value string + can be specified alone in those cases where the value is unambiguous". + This is crazy, but we have to deal with it. + @ivar encoded: + A boolean describing whether the data in the content line is encoded. + Generally, text read from a serialized vCard or vCalendar should be + considered encoded. Data added programmatically should not be encoded. + @ivar lineNumber: + An optional line number associated with the contentline. + """ + def __init__(self, name, params, value, group=None, + encoded=False, isNative=False, + lineNumber = None, *args, **kwds): + """Take output from parseLine, convert params list to dictionary.""" + # group is used as a positional argument to match parseLine's return + super(ContentLine, self).__init__(group, *args, **kwds) + self.name = name.upper() + self.value = value + self.encoded = encoded + self.params = {} + self.singletonparams = [] + self.isNative = isNative + self.lineNumber = lineNumber + def updateTable(x): + if len(x) == 1: + self.singletonparams += x + else: + paramlist = self.params.setdefault(x[0].upper(), []) + paramlist.extend(x[1:]) + map(updateTable, params) + qp = False + if 'ENCODING' in self.params: + if 'QUOTED-PRINTABLE' in self.params['ENCODING']: + qp = True + self.params['ENCODING'].remove('QUOTED-PRINTABLE') + if 0==len(self.params['ENCODING']): + del self.params['ENCODING'] + if 'QUOTED-PRINTABLE' in self.singletonparams: + qp = True + self.singletonparams.remove('QUOTED-PRINTABLE') + if qp: + self.value = str(self.value).decode('quoted-printable') + + # self.value should be unicode for iCalendar, but if quoted-printable + # is used, or if the quoted-printable state machine is used, text may be + # encoded + if type(self.value) is str: + charset = 'iso-8859-1' + if 'CHARSET' in self.params: + charsets = self.params.pop('CHARSET') + if charsets: + charset = charsets[0] + self.value = unicode(self.value, charset) + + @classmethod + def duplicate(clz, copyit): + newcopy = clz('', {}, '') + newcopy.copy(copyit) + return newcopy + + def copy(self, copyit): + super(ContentLine, self).copy(copyit) + self.name = copyit.name + self.value = copy.copy(copyit.value) + self.encoded = self.encoded + self.params = copy.copy(copyit.params) + self.singletonparams = copy.copy(copyit.singletonparams) + self.lineNumber = copyit.lineNumber + + def __eq__(self, other): + try: + return (self.name == other.name) and (self.params == other.params) and (self.value == other.value) + except: + return False + + def __getattr__(self, name): + """Make params accessible via self.foo_param or self.foo_paramlist. + + Underscores, legal in python variable names, are converted to dashes, + which are legal in IANA tokens. + + """ + try: + if name.endswith('_param'): + return self.params[toVName(name, 6, True)][0] + elif name.endswith('_paramlist'): + return self.params[toVName(name, 10, True)] + else: + raise exceptions.AttributeError, name + except KeyError: + raise exceptions.AttributeError, name + + def __setattr__(self, name, value): + """Make params accessible via self.foo_param or self.foo_paramlist. + + Underscores, legal in python variable names, are converted to dashes, + which are legal in IANA tokens. + + """ + if name.endswith('_param'): + if type(value) == list: + self.params[toVName(name, 6, True)] = value + else: + self.params[toVName(name, 6, True)] = [value] + elif name.endswith('_paramlist'): + if type(value) == list: + self.params[toVName(name, 10, True)] = value + else: + raise VObjectError("Parameter list set to a non-list") + else: + prop = getattr(self.__class__, name, None) + if isinstance(prop, property): + prop.fset(self, value) + else: + object.__setattr__(self, name, value) + + def __delattr__(self, name): + try: + if name.endswith('_param'): + del self.params[toVName(name, 6, True)] + elif name.endswith('_paramlist'): + del self.params[toVName(name, 10, True)] + else: + object.__delattr__(self, name) + except KeyError: + raise exceptions.AttributeError, name + + def valueRepr( self ): + """transform the representation of the value according to the behavior, + if any""" + v = self.value + if self.behavior: + v = self.behavior.valueRepr( self ) + return ascii( v ) + + def __str__(self): + return "<"+ascii(self.name)+ascii(self.params)+self.valueRepr()+">" + + def __repr__(self): + return self.__str__().replace('\n', '\\n') + + def prettyPrint(self, level = 0, tabwidth=3): + pre = ' ' * level * tabwidth + print pre, self.name + ":", self.valueRepr() + if self.params: + lineKeys= self.params.keys() + print pre, "params for ", self.name +':' + for aKey in lineKeys: + print pre + ' ' * tabwidth, aKey, ascii(self.params[aKey]) + +class Component(VBase): + """A complex property that can contain multiple ContentLines. + + For our purposes, a component must start with a BEGIN:xxxx line and end with + END:xxxx, or have a PROFILE:xxx line if a top-level component. + + @ivar contents: + A dictionary of lists of Component or ContentLine instances. The keys + are the lowercased names of child ContentLines or Components. + Note that BEGIN and END ContentLines are not included in contents. + @ivar name: + Uppercase string used to represent this Component, i.e VCARD if the + serialized object starts with BEGIN:VCARD. + @ivar useBegin: + A boolean flag determining whether BEGIN: and END: lines should + be serialized. + + """ + def __init__(self, name=None, *args, **kwds): + super(Component, self).__init__(*args, **kwds) + self.contents = {} + if name: + self.name=name.upper() + self.useBegin = True + else: + self.name = '' + self.useBegin = False + + self.autoBehavior() + + @classmethod + def duplicate(clz, copyit): + newcopy = clz() + newcopy.copy(copyit) + return newcopy + + def copy(self, copyit): + super(Component, self).copy(copyit) + + # deep copy of contents + self.contents = {} + for key, lvalue in copyit.contents.items(): + newvalue = [] + for value in lvalue: + newitem = value.duplicate(value) + newvalue.append(newitem) + self.contents[key] = newvalue + + self.name = copyit.name + self.useBegin = copyit.useBegin + + def setProfile(self, name): + """Assign a PROFILE to this unnamed component. + + Used by vCard, not by vCalendar. + + """ + if self.name or self.useBegin: + if self.name == name: return + raise VObjectError("This component already has a PROFILE or uses BEGIN.") + self.name = name.upper() + + def __getattr__(self, name): + """For convenience, make self.contents directly accessible. + + Underscores, legal in python variable names, are converted to dashes, + which are legal in IANA tokens. + + """ + try: + if name.endswith('_list'): + return self.contents[toVName(name, 5)] + else: + return self.contents[toVName(name)][0] + except KeyError: + raise exceptions.AttributeError, name + + normal_attributes = ['contents','name','behavior','parentBehavior','group'] + def __setattr__(self, name, value): + """For convenience, make self.contents directly accessible. + + Underscores, legal in python variable names, are converted to dashes, + which are legal in IANA tokens. + + """ + if name not in self.normal_attributes and name.lower()==name: + if type(value) == list: + if name.endswith('_list'): + name = name[:-5] + self.contents[toVName(name)] = value + elif name.endswith('_list'): + raise VObjectError("Component list set to a non-list") + else: + self.contents[toVName(name)] = [value] + else: + prop = getattr(self.__class__, name, None) + if isinstance(prop, property): + prop.fset(self, value) + else: + object.__setattr__(self, name, value) + + def __delattr__(self, name): + try: + if name not in self.normal_attributes and name.lower()==name: + if name.endswith('_list'): + del self.contents[toVName(name, 5)] + else: + del self.contents[toVName(name)] + else: + object.__delattr__(self, name) + except KeyError: + raise exceptions.AttributeError, name + + def getChildValue(self, childName, default = None, childNumber = 0): + """Return a child's value (the first, by default), or None.""" + child = self.contents.get(toVName(childName)) + if child is None: + return default + else: + return child[childNumber].value + + def add(self, objOrName, group = None): + """Add objOrName to contents, set behavior if it can be inferred. + + If objOrName is a string, create an empty component or line based on + behavior. If no behavior is found for the object, add a ContentLine. + + group is an optional prefix to the name of the object (see + RFC 2425). + """ + if isinstance(objOrName, VBase): + obj = objOrName + if self.behavior: + obj.parentBehavior = self.behavior + obj.autoBehavior(True) + else: + name = objOrName.upper() + try: + id=self.behavior.knownChildren[name][2] + behavior = getBehavior(name, id) + if behavior.isComponent: + obj = Component(name) + else: + obj = ContentLine(name, [], '', group) + obj.parentBehavior = self.behavior + obj.behavior = behavior + obj = obj.transformToNative() + except (KeyError, AttributeError): + obj = ContentLine(objOrName, [], '', group) + if obj.behavior is None and self.behavior is not None: + if isinstance(obj, ContentLine): + obj.behavior = self.behavior.defaultBehavior + self.contents.setdefault(obj.name.lower(), []).append(obj) + return obj + + def remove(self, obj): + """Remove obj from contents.""" + named = self.contents.get(obj.name.lower()) + if named: + try: + named.remove(obj) + if len(named) == 0: + del self.contents[obj.name.lower()] + except ValueError: + pass; + + def getChildren(self): + """Return an iterable of all children.""" + for objList in self.contents.values(): + for obj in objList: yield obj + + def components(self): + """Return an iterable of all Component children.""" + return (i for i in self.getChildren() if isinstance(i, Component)) + + def lines(self): + """Return an iterable of all ContentLine children.""" + return (i for i in self.getChildren() if isinstance(i, ContentLine)) + + def sortChildKeys(self): + try: + first = [s for s in self.behavior.sortFirst if s in self.contents] + except: + first = [] + return first + sorted(k for k in self.contents.keys() if k not in first) + + def getSortedChildren(self): + return [obj for k in self.sortChildKeys() for obj in self.contents[k]] + + def setBehaviorFromVersionLine(self, versionLine): + """Set behavior if one matches name, versionLine.value.""" + v=getBehavior(self.name, versionLine.value) + if v: self.setBehavior(v) + + def transformChildrenToNative(self): + """Recursively replace children with their native representation.""" + #sort to get dependency order right, like vtimezone before vevent + for childArray in (self.contents[k] for k in self.sortChildKeys()): + for i in xrange(len(childArray)): + childArray[i]=childArray[i].transformToNative() + childArray[i].transformChildrenToNative() + + def transformChildrenFromNative(self, clearBehavior=True): + """Recursively transform native children to vanilla representations.""" + for childArray in self.contents.values(): + for i in xrange(len(childArray)): + childArray[i]=childArray[i].transformFromNative() + childArray[i].transformChildrenFromNative(clearBehavior) + if clearBehavior: + childArray[i].behavior = None + childArray[i].parentBehavior = None + + def __str__(self): + if self.name: + return "<" + self.name + "| " + str(self.getSortedChildren()) + ">" + else: + return '<' + '*unnamed*' + '| ' + str(self.getSortedChildren()) + '>' + + def __repr__(self): + return self.__str__() + + def prettyPrint(self, level = 0, tabwidth=3): + pre = ' ' * level * tabwidth + print pre, self.name + if isinstance(self, Component): + for line in self.getChildren(): + line.prettyPrint(level + 1, tabwidth) + print + +class VObjectError(Exception): + def __init__(self, message, lineNumber=None): + self.message = message + if lineNumber is not None: + self.lineNumber = lineNumber + def __str__(self): + if hasattr(self, 'lineNumber'): + return "At line %s: %s" % \ + (self.lineNumber, self.message) + else: + return repr(self.message) + +class ParseError(VObjectError): + pass + +class ValidateError(VObjectError): + pass + +class NativeError(VObjectError): + pass + +#-------------------------- Parsing functions ---------------------------------- + +# parseLine regular expressions + +patterns = {} + +# Note that underscore is not legal for names, it's included because +# Lotus Notes uses it +patterns['name'] = '[a-zA-Z0-9\-_]+' +patterns['safe_char'] = '[^";:,]' +patterns['qsafe_char'] = '[^"]' + +# the combined Python string replacement and regex syntax is a little confusing; +# remember that %(foobar)s is replaced with patterns['foobar'], so for instance +# param_value is any number of safe_chars or any number of qsaf_chars surrounded +# by double quotes. + +patterns['param_value'] = ' "%(qsafe_char)s * " | %(safe_char)s * ' % patterns + + +# get a tuple of two elements, one will be empty, the other will have the value +patterns['param_value_grouped'] = """ +" ( %(qsafe_char)s * )" | ( %(safe_char)s + ) +""" % patterns + +# get a parameter and its values, without any saved groups +patterns['param'] = r""" +; (?: %(name)s ) # parameter name +(?: + (?: = (?: %(param_value)s ) )? # 0 or more parameter values, multiple + (?: , (?: %(param_value)s ) )* # parameters are comma separated +)* +""" % patterns + +# get a parameter, saving groups for name and value (value still needs parsing) +patterns['params_grouped'] = r""" +; ( %(name)s ) + +(?: = + ( + (?: (?: %(param_value)s ) )? # 0 or more parameter values, multiple + (?: , (?: %(param_value)s ) )* # parameters are comma separated + ) +)? +""" % patterns + +# get a full content line, break it up into group, name, parameters, and value +patterns['line'] = r""" +^ ((?P<group> %(name)s)\.)?(?P<name> %(name)s) # name group + (?P<params> (?: %(param)s )* ) # params group (may be empty) +: (?P<value> .* )$ # value group +""" % patterns + +' "%(qsafe_char)s*" | %(safe_char)s* ' + +param_values_re = re.compile(patterns['param_value_grouped'], re.VERBOSE) +params_re = re.compile(patterns['params_grouped'], re.VERBOSE) +line_re = re.compile(patterns['line'], re.DOTALL | re.VERBOSE) +begin_re = re.compile('BEGIN', re.IGNORECASE) + + +def parseParams(string): + """ + >>> parseParams(';ALTREP="http://www.wiz.org"') + [['ALTREP', 'http://www.wiz.org']] + >>> parseParams('') + [] + >>> parseParams(';ALTREP="http://www.wiz.org;;",Blah,Foo;NEXT=Nope;BAR') + [['ALTREP', 'http://www.wiz.org;;', 'Blah', 'Foo'], ['NEXT', 'Nope'], ['BAR']] + """ + all = params_re.findall(string) + allParameters = [] + for tup in all: + paramList = [tup[0]] # tup looks like (name, valuesString) + for pair in param_values_re.findall(tup[1]): + # pair looks like ('', value) or (value, '') + if pair[0] != '': + paramList.append(pair[0]) + else: + paramList.append(pair[1]) + allParameters.append(paramList) + return allParameters + + +def parseLine(line, lineNumber = None): + """ + >>> parseLine("BLAH:") + ('BLAH', [], '', None) + >>> parseLine("RDATE:VALUE=DATE:19970304,19970504,19970704,19970904") + ('RDATE', [], 'VALUE=DATE:19970304,19970504,19970704,19970904', None) + >>> parseLine('DESCRIPTION;ALTREP="http://www.wiz.org":The Fall 98 Wild Wizards Conference - - Las Vegas, NV, USA') + ('DESCRIPTION', [['ALTREP', 'http://www.wiz.org']], 'The Fall 98 Wild Wizards Conference - - Las Vegas, NV, USA', None) + >>> parseLine("EMAIL;PREF;INTERNET:john@nowhere.com") + ('EMAIL', [['PREF'], ['INTERNET']], 'john@nowhere.com', None) + >>> parseLine('EMAIL;TYPE="blah",hah;INTERNET="DIGI",DERIDOO:john@nowhere.com') + ('EMAIL', [['TYPE', 'blah', 'hah'], ['INTERNET', 'DIGI', 'DERIDOO']], 'john@nowhere.com', None) + >>> parseLine('item1.ADR;type=HOME;type=pref:;;Reeperbahn 116;Hamburg;;20359;') + ('ADR', [['type', 'HOME'], ['type', 'pref']], ';;Reeperbahn 116;Hamburg;;20359;', 'item1') + >>> parseLine(":") + Traceback (most recent call last): + ... + ParseError: 'Failed to parse line: :' + """ + + match = line_re.match(line) + if match is None: + raise ParseError("Failed to parse line: %s" % line, lineNumber) + # Underscores are replaced with dash to work around Lotus Notes + return (match.group('name').replace('_','-'), + parseParams(match.group('params')), + match.group('value'), match.group('group')) + +# logical line regular expressions + +patterns['lineend'] = r'(?:\r\n|\r|\n|$)' +patterns['wrap'] = r'%(lineend)s [\t ]' % patterns +patterns['logicallines'] = r""" +( + (?: [^\r\n] | %(wrap)s )* + %(lineend)s +) +""" % patterns + +patterns['wraporend'] = r'(%(wrap)s | %(lineend)s )' % patterns + +wrap_re = re.compile(patterns['wraporend'], re.VERBOSE) +logical_lines_re = re.compile(patterns['logicallines'], re.VERBOSE) + +testLines=""" +Line 0 text + , Line 0 continued. +Line 1;encoding=quoted-printable:this is an evil= + evil= + format. +Line 2 is a new line, it does not start with whitespace. +""" + +def getLogicalLines(fp, allowQP=True, findBegin=False): + """Iterate through a stream, yielding one logical line at a time. + + Because many applications still use vCard 2.1, we have to deal with the + quoted-printable encoding for long lines, as well as the vCard 3.0 and + vCalendar line folding technique, a whitespace character at the start + of the line. + + Quoted-printable data will be decoded in the Behavior decoding phase. + + >>> import StringIO + >>> f=StringIO.StringIO(testLines) + >>> for n, l in enumerate(getLogicalLines(f)): + ... print "Line %s: %s" % (n, l[0]) + ... + Line 0: Line 0 text, Line 0 continued. + Line 1: Line 1;encoding=quoted-printable:this is an evil= + evil= + format. + Line 2: Line 2 is a new line, it does not start with whitespace. + + """ + if not allowQP: + bytes = fp.read(-1) + if len(bytes) > 0: + if type(bytes[0]) == unicode: + val = bytes + elif not findBegin: + val = bytes.decode('utf-8') + else: + for encoding in 'utf-8', 'utf-16-LE', 'utf-16-BE', 'iso-8859-1': + try: + val = bytes.decode(encoding) + if begin_re.search(val) is not None: + break + except UnicodeDecodeError: + pass + else: + raise ParseError, 'Could not find BEGIN when trying to determine encoding' + else: + val = bytes + + # strip off any UTF8 BOMs which Python's UTF8 decoder leaves + + val = val.lstrip( unicode( codecs.BOM_UTF8, "utf8" ) ) + + lineNumber = 1 + for match in logical_lines_re.finditer(val): + line, n = wrap_re.subn('', match.group()) + if line != '': + yield line, lineNumber + lineNumber += n + + else: + quotedPrintable=False + newbuffer = StringIO.StringIO + logicalLine = newbuffer() + lineNumber = 0 + lineStartNumber = 0 + while True: + line = fp.readline() + if line == '': + break + else: + line = line.rstrip(CRLF) + lineNumber += 1 + if line.rstrip() == '': + if logicalLine.pos > 0: + yield logicalLine.getvalue(), lineStartNumber + lineStartNumber = lineNumber + logicalLine = newbuffer() + quotedPrintable=False + continue + + if quotedPrintable and allowQP: + logicalLine.write('\n') + logicalLine.write(line) + quotedPrintable=False + elif line[0] in SPACEORTAB: + logicalLine.write(line[1:]) + elif logicalLine.pos > 0: + yield logicalLine.getvalue(), lineStartNumber + lineStartNumber = lineNumber + logicalLine = newbuffer() + logicalLine.write(line) + else: + logicalLine = newbuffer() + logicalLine.write(line) + + # hack to deal with the fact that vCard 2.1 allows parameters to be + # encoded without a parameter name. False positives are unlikely, but + # possible. + val = logicalLine.getvalue() + if val[-1]=='=' and val.lower().find('quoted-printable') >= 0: + quotedPrintable=True + + if logicalLine.pos > 0: + yield logicalLine.getvalue(), lineStartNumber + + +def textLineToContentLine(text, n=None): + return ContentLine(*parseLine(text, n), **{'encoded':True, 'lineNumber' : n}) + + +def dquoteEscape(param): + """Return param, or "param" if ',' or ';' or ':' is in param.""" + if param.find('"') >= 0: + raise VObjectError("Double quotes aren't allowed in parameter values.") + for char in ',;:': + if param.find(char) >= 0: + return '"'+ param + '"' + return param + +def foldOneLine(outbuf, input, lineLength = 75): + # Folding line procedure that ensures multi-byte utf-8 sequences are not broken + # across lines + + if len(input) < lineLength: + # Optimize for unfolded line case + outbuf.write(input) + else: + # Look for valid utf8 range and write that out + start = 0 + written = 0 + while written < len(input): + # Start max length -1 chars on from where we are + offset = start + lineLength - 1 + if offset >= len(input): + line = input[start:] + outbuf.write(line) + written = len(input) + else: + # Check whether next char is valid utf8 lead byte + while (input[offset] > 0x7F) and ((ord(input[offset]) & 0xC0) == 0x80): + # Step back until we have a valid char + offset -= 1 + + line = input[start:offset] + outbuf.write(line) + outbuf.write("\r\n ") + written += offset - start + start = offset + outbuf.write("\r\n") + +def defaultSerialize(obj, buf, lineLength): + """Encode and fold obj and its children, write to buf or return a string.""" + + outbuf = buf or cStringIO.StringIO() + + if isinstance(obj, Component): + if obj.group is None: + groupString = '' + else: + groupString = obj.group + '.' + if obj.useBegin: + foldOneLine(outbuf, str(groupString + u"BEGIN:" + obj.name), lineLength) + for child in obj.getSortedChildren(): + #validate is recursive, we only need to validate once + child.serialize(outbuf, lineLength, validate=False) + if obj.useBegin: + foldOneLine(outbuf, str(groupString + u"END:" + obj.name), lineLength) + + elif isinstance(obj, ContentLine): + startedEncoded = obj.encoded + if obj.behavior and not startedEncoded: obj.behavior.encode(obj) + s=codecs.getwriter('utf-8')(cStringIO.StringIO()) #unfolded buffer + if obj.group is not None: + s.write(obj.group + '.') + s.write(obj.name.upper()) + for key, paramvals in obj.params.iteritems(): + s.write(';' + key + '=' + ','.join(dquoteEscape(p) for p in paramvals)) + s.write(':' + obj.value) + if obj.behavior and not startedEncoded: obj.behavior.decode(obj) + foldOneLine(outbuf, s.getvalue(), lineLength) + + return buf or outbuf.getvalue() + + +testVCalendar=""" +BEGIN:VCALENDAR +BEGIN:VEVENT +SUMMARY;blah=hi!:Bastille Day Party +END:VEVENT +END:VCALENDAR""" + +class Stack: + def __init__(self): + self.stack = [] + def __len__(self): + return len(self.stack) + def top(self): + if len(self) == 0: return None + else: return self.stack[-1] + def topName(self): + if len(self) == 0: return None + else: return self.stack[-1].name + def modifyTop(self, item): + top = self.top() + if top: + top.add(item) + else: + new = Component() + self.push(new) + new.add(item) #add sets behavior for item and children + def push(self, obj): self.stack.append(obj) + def pop(self): return self.stack.pop() + + +def readComponents(streamOrString, validate=False, transform=True, + findBegin=True, ignoreUnreadable=False, + allowQP=False): + """Generate one Component at a time from a stream. + + >>> import StringIO + >>> f = StringIO.StringIO(testVCalendar) + >>> cal=readComponents(f).next() + >>> cal + <VCALENDAR| [<VEVENT| [<SUMMARY{u'BLAH': [u'hi!']}Bastille Day Party>]>]> + >>> cal.vevent.summary + <SUMMARY{u'BLAH': [u'hi!']}Bastille Day Party> + + """ + if isinstance(streamOrString, basestring): + stream = StringIO.StringIO(streamOrString) + else: + stream = streamOrString + + try: + stack = Stack() + versionLine = None + n = 0 + for line, n in getLogicalLines(stream, allowQP, findBegin): + if ignoreUnreadable: + try: + vline = textLineToContentLine(line, n) + except VObjectError, e: + if e.lineNumber is not None: + msg = "Skipped line %(lineNumber)s, message: %(msg)s" + else: + msg = "Skipped a line, message: %(msg)s" + logger.error(msg % {'lineNumber' : e.lineNumber, + 'msg' : e.message}) + continue + else: + vline = textLineToContentLine(line, n) + if vline.name == "VERSION": + versionLine = vline + stack.modifyTop(vline) + elif vline.name == "BEGIN": + stack.push(Component(vline.value, group=vline.group)) + elif vline.name == "PROFILE": + if not stack.top(): stack.push(Component()) + stack.top().setProfile(vline.value) + elif vline.name == "END": + if len(stack) == 0: + err = "Attempted to end the %s component, \ + but it was never opened" % vline.value + raise ParseError(err, n) + if vline.value.upper() == stack.topName(): #START matches END + if len(stack) == 1: + component=stack.pop() + if versionLine is not None: + component.setBehaviorFromVersionLine(versionLine) + if validate: component.validate(raiseException=True) + if transform: component.transformChildrenToNative() + yield component #EXIT POINT + else: stack.modifyTop(stack.pop()) + else: + err = "%s component wasn't closed" + raise ParseError(err % stack.topName(), n) + else: stack.modifyTop(vline) #not a START or END line + if stack.top(): + if stack.topName() is None: + logger.warning("Top level component was never named") + elif stack.top().useBegin: + raise ParseError("Component %s was never closed" % (stack.topName()), n) + yield stack.pop() + + except ParseError, e: + e.input = streamOrString + raise + + +def readOne(stream, validate=False, transform=True, findBegin=True, + ignoreUnreadable=False, allowQP=False): + """Return the first component from stream.""" + return readComponents(stream, validate, transform, findBegin, + ignoreUnreadable, allowQP).next() + +#--------------------------- version registry ---------------------------------- +__behaviorRegistry={} + +def registerBehavior(behavior, name=None, default=False, id=None): + """Register the given behavior. + + If default is True (or if this is the first version registered with this + name), the version will be the default if no id is given. + + """ + if not name: name=behavior.name.upper() + if id is None: id=behavior.versionString + if name in __behaviorRegistry: + if default: + __behaviorRegistry[name].insert(0, (id, behavior)) + else: + __behaviorRegistry[name].append((id, behavior)) + else: + __behaviorRegistry[name]=[(id, behavior)] + +def getBehavior(name, id=None): + """Return a matching behavior if it exists, or None. + + If id is None, return the default for name. + + """ + name=name.upper() + if name in __behaviorRegistry: + if id: + for n, behavior in __behaviorRegistry[name]: + if n==id: + return behavior + else: + return __behaviorRegistry[name][0][1] + return None + +def newFromBehavior(name, id=None): + """Given a name, return a behaviored ContentLine or Component.""" + name = name.upper() + behavior = getBehavior(name, id) + if behavior is None: + raise VObjectError("No behavior found named %s" % name) + if behavior.isComponent: + obj = Component(name) + else: + obj = ContentLine(name, [], '') + obj.behavior = behavior + obj.isNative = False + return obj + + +#--------------------------- Helper function ----------------------------------- +def backslashEscape(s): + s=s.replace("\\","\\\\").replace(";","\;").replace(",","\,") + return s.replace("\r\n", "\\n").replace("\n","\\n").replace("\r","\\n") + +#------------------- Testing and running functions ----------------------------- +if __name__ == '__main__': + import tests + tests._test() diff --git a/vobject/behavior.py b/vobject/behavior.py new file mode 100644 index 0000000..226c0cc --- /dev/null +++ b/vobject/behavior.py @@ -0,0 +1,164 @@ +"""Behavior (validation, encoding, and transformations) for vobjects.""" + +import base + +#------------------------ Abstract class for behavior -------------------------- +class Behavior(object): + """Abstract class to describe vobject options, requirements and encodings. + + Behaviors are used for root components like VCALENDAR, for subcomponents + like VEVENT, and for individual lines in components. + + Behavior subclasses are not meant to be instantiated, all methods should + be classmethods. + + @cvar name: + The uppercase name of the object described by the class, or a generic + name if the class defines behavior for many objects. + @cvar description: + A brief excerpt from the RFC explaining the function of the component or + line. + @cvar versionString: + The string associated with the component, for instance, 2.0 if there's a + line like VERSION:2.0, an empty string otherwise. + @cvar knownChildren: + A dictionary with uppercased component/property names as keys and a + tuple (min, max, id) as value, where id is the id used by + L{registerBehavior}, min and max are the limits on how many of this child + must occur. None is used to denote no max or no id. + @cvar quotedPrintable: + A boolean describing whether the object should be encoded and decoded + using quoted printable line folding and character escaping. + @cvar defaultBehavior: + Behavior to apply to ContentLine children when no behavior is found. + @cvar hasNative: + A boolean describing whether the object can be transformed into a more + Pythonic object. + @cvar isComponent: + A boolean, True if the object should be a Component. + @cvar sortFirst: + The lower-case list of children which should come first when sorting. + @cvar allowGroup: + Whether or not vCard style group prefixes are allowed. + """ + name='' + description='' + versionString='' + knownChildren = {} + quotedPrintable = False + defaultBehavior = None + hasNative= False + isComponent = False + allowGroup = False + forceUTC = False + sortFirst = [] + + def __init__(self): + err="Behavior subclasses are not meant to be instantiated" + raise base.VObjectError(err) + + @classmethod + def validate(cls, obj, raiseException=False, complainUnrecognized=False): + """Check if the object satisfies this behavior's requirements. + + @param obj: + The L{ContentLine<base.ContentLine>} or + L{Component<base.Component>} to be validated. + @param raiseException: + If True, raise a L{base.ValidateError} on validation failure. + Otherwise return a boolean. + @param complainUnrecognized: + If True, fail to validate if an uncrecognized parameter or child is + found. Otherwise log the lack of recognition. + + """ + if not cls.allowGroup and obj.group is not None: + err = str(obj) + " has a group, but this object doesn't support groups" + raise base.VObjectError(err) + if isinstance(obj, base.ContentLine): + return cls.lineValidate(obj, raiseException, complainUnrecognized) + elif isinstance(obj, base.Component): + count = {} + for child in obj.getChildren(): + if not child.validate(raiseException, complainUnrecognized): + return False + name=child.name.upper() + count[name] = count.get(name, 0) + 1 + for key, val in cls.knownChildren.iteritems(): + if count.get(key,0) < val[0]: + if raiseException: + m = "%s components must contain at least %i %s" + raise base.ValidateError(m % (cls.name, val[0], key)) + return False + if val[1] and count.get(key,0) > val[1]: + if raiseException: + m = "%s components cannot contain more than %i %s" + raise base.ValidateError(m % (cls.name, val[1], key)) + return False + return True + else: + err = str(obj) + " is not a Component or Contentline" + raise base.VObjectError(err) + + @classmethod + def lineValidate(cls, line, raiseException, complainUnrecognized): + """Examine a line's parameters and values, return True if valid.""" + return True + + @classmethod + def decode(cls, line): + if line.encoded: line.encoded=0 + + @classmethod + def encode(cls, line): + if not line.encoded: line.encoded=1 + + @classmethod + def transformToNative(cls, obj): + """Turn a ContentLine or Component into a Python-native representation. + + If appropriate, turn dates or datetime strings into Python objects. + Components containing VTIMEZONEs turn into VtimezoneComponents. + + """ + return obj + + @classmethod + def transformFromNative(cls, obj): + """Inverse of transformToNative.""" + raise base.NativeError("No transformFromNative defined") + + @classmethod + def generateImplicitParameters(cls, obj): + """Generate any required information that don't yet exist.""" + pass + + @classmethod + def serialize(cls, obj, buf, lineLength, validate=True): + """Set implicit parameters, do encoding, return unicode string. + + If validate is True, raise VObjectError if the line doesn't validate + after implicit parameters are generated. + + Default is to call base.defaultSerialize. + + """ + + cls.generateImplicitParameters(obj) + if validate: cls.validate(obj, raiseException=True) + + if obj.isNative: + transformed = obj.transformFromNative() + undoTransform = True + else: + transformed = obj + undoTransform = False + + out = base.defaultSerialize(transformed, buf, lineLength) + if undoTransform: obj.transformToNative() + return out + + @classmethod + def valueRepr( cls, line ): + """return the representation of the given content line value""" + return line.value
\ No newline at end of file diff --git a/vobject/hcalendar.py b/vobject/hcalendar.py new file mode 100644 index 0000000..93614ab --- /dev/null +++ b/vobject/hcalendar.py @@ -0,0 +1,125 @@ +""" +hCalendar: A microformat for serializing iCalendar data + (http://microformats.org/wiki/hcalendar) + +Here is a sample event in an iCalendar: + +BEGIN:VCALENDAR +PRODID:-//XYZproduct//EN +VERSION:2.0 +BEGIN:VEVENT +URL:http://www.web2con.com/ +DTSTART:20051005 +DTEND:20051008 +SUMMARY:Web 2.0 Conference +LOCATION:Argent Hotel\, San Francisco\, CA +END:VEVENT +END:VCALENDAR + +and an equivalent event in hCalendar format with various elements optimized appropriately. + +<span class="vevent"> + <a class="url" href="http://www.web2con.com/"> + <span class="summary">Web 2.0 Conference</span>: + <abbr class="dtstart" title="2005-10-05">October 5</abbr>- + <abbr class="dtend" title="2005-10-08">7</abbr>, + at the <span class="location">Argent Hotel, San Francisco, CA</span> + </a> +</span> +""" + +from base import foldOneLine, CRLF, registerBehavior +from icalendar import VCalendar2_0 +from datetime import date, datetime, timedelta +import StringIO + +class HCalendar(VCalendar2_0): + name = 'HCALENDAR' + + @classmethod + def serialize(cls, obj, buf=None, lineLength=None, validate=True): + """ + Serialize iCalendar to HTML using the hCalendar microformat (http://microformats.org/wiki/hcalendar) + """ + + outbuf = buf or StringIO.StringIO() + level = 0 # holds current indentation level + tabwidth = 3 + + def indent(): + return ' ' * level * tabwidth + + def out(s): + outbuf.write(indent()) + outbuf.write(s) + + # not serializing optional vcalendar wrapper + + vevents = obj.vevent_list + + for event in vevents: + out('<span class="vevent">' + CRLF) + level += 1 + + # URL + url = event.getChildValue("url") + if url: + out('<a class="url" href="' + url + '">' + CRLF) + level += 1 + # SUMMARY + summary = event.getChildValue("summary") + if summary: + out('<span class="summary">' + summary + '</span>:' + CRLF) + + # DTSTART + dtstart = event.getChildValue("dtstart") + if dtstart: + if type(dtstart) == date: + timeformat = "%A, %B %e" + machine = "%Y%m%d" + elif type(dtstart) == datetime: + timeformat = "%A, %B %e, %H:%M" + machine = "%Y%m%dT%H%M%S%z" + + #TODO: Handle non-datetime formats? + #TODO: Spec says we should handle when dtstart isn't included + + out('<abbr class="dtstart", title="%s">%s</abbr>\r\n' % + (dtstart.strftime(machine), dtstart.strftime(timeformat))) + + # DTEND + dtend = event.getChildValue("dtend") + if not dtend: + duration = event.getChildValue("duration") + if duration: + dtend = duration + dtstart + # TODO: If lacking dtend & duration? + + if dtend: + human = dtend + # TODO: Human readable part could be smarter, excluding repeated data + if type(dtend) == date: + human = dtend - timedelta(days=1) + + out('- <abbr class="dtend", title="%s">%s</abbr>\r\n' % + (dtend.strftime(machine), human.strftime(timeformat))) + + # LOCATION + location = event.getChildValue("location") + if location: + out('at <span class="location">' + location + '</span>' + CRLF) + + description = event.getChildValue("description") + if description: + out('<div class="description">' + description + '</div>' + CRLF) + + if url: + level -= 1 + out('</a>' + CRLF) + + level -= 1 + out('</span>' + CRLF) # close vevent + + return buf or outbuf.getvalue() + +registerBehavior(HCalendar)
\ No newline at end of file diff --git a/vobject/icalendar.py b/vobject/icalendar.py new file mode 100644 index 0000000..09862c1 --- /dev/null +++ b/vobject/icalendar.py @@ -0,0 +1,1892 @@ +"""Definitions and behavior for iCalendar, also known as vCalendar 2.0""" + +import string +import behavior +import dateutil.rrule +import dateutil.tz +import StringIO, cStringIO +import datetime +import socket, random #for generating a UID +import itertools + +from base import (VObjectError, NativeError, ValidateError, ParseError, + VBase, Component, ContentLine, logger, defaultSerialize, + registerBehavior, backslashEscape, foldOneLine, + newFromBehavior, CRLF, LF, ascii) + +#------------------------------- Constants ------------------------------------- +DATENAMES = ("rdate", "exdate") +RULENAMES = ("exrule", "rrule") +DATESANDRULES = ("exrule", "rrule", "rdate", "exdate") +PRODID = u"-//PYVOBJECT//NONSGML Version 1//EN" + +WEEKDAYS = "MO", "TU", "WE", "TH", "FR", "SA", "SU" +FREQUENCIES = ('YEARLY', 'MONTHLY', 'WEEKLY', 'DAILY', 'HOURLY', 'MINUTELY', + 'SECONDLY') + +zeroDelta = datetime.timedelta(0) +twoHours = datetime.timedelta(hours=2) + +#---------------------------- TZID registry ------------------------------------ +__tzidMap={} + +def toUnicode(s): + """Take a string or unicode, turn it into unicode, decoding as utf-8""" + if isinstance(s, str): + s = s.decode('utf-8') + return s + +def registerTzid(tzid, tzinfo): + """Register a tzid -> tzinfo mapping.""" + __tzidMap[toUnicode(tzid)]=tzinfo + +def getTzid(tzid): + """Return the tzid if it exists, or None.""" + return __tzidMap.get(toUnicode(tzid), None) + +utc = dateutil.tz.tzutc() +registerTzid("UTC", utc) + +#-------------------- Helper subclasses ---------------------------------------- + +class TimezoneComponent(Component): + """A VTIMEZONE object. + + VTIMEZONEs are parsed by dateutil.tz.tzical, the resulting datetime.tzinfo + subclass is stored in self.tzinfo, self.tzid stores the TZID associated + with this timezone. + + @ivar name: + The uppercased name of the object, in this case always 'VTIMEZONE'. + @ivar tzinfo: + A datetime.tzinfo subclass representing this timezone. + @ivar tzid: + The string used to refer to this timezone. + + """ + def __init__(self, tzinfo=None, *args, **kwds): + """Accept an existing Component or a tzinfo class.""" + super(TimezoneComponent, self).__init__(*args, **kwds) + self.isNative=True + # hack to make sure a behavior is assigned + if self.behavior is None: + self.behavior = VTimezone + if tzinfo is not None: + self.tzinfo = tzinfo + if not hasattr(self, 'name') or self.name == '': + self.name = 'VTIMEZONE' + self.useBegin = True + + @classmethod + def registerTzinfo(obj, tzinfo): + """Register tzinfo if it's not already registered, return its tzid.""" + tzid = obj.pickTzid(tzinfo) + if tzid and not getTzid(tzid): + registerTzid(tzid, tzinfo) + return tzid + + def gettzinfo(self): + # workaround for dateutil failing to parse some experimental properties + good_lines = ('rdate', 'rrule', 'dtstart', 'tzname', 'tzoffsetfrom', + 'tzoffsetto', 'tzid') + # serialize encodes as utf-8, cStringIO will leave utf-8 alone + buffer = cStringIO.StringIO() + # allow empty VTIMEZONEs + if len(self.contents) == 0: + return None + def customSerialize(obj): + if isinstance(obj, Component): + foldOneLine(buffer, u"BEGIN:" + obj.name) + for child in obj.lines(): + if child.name.lower() in good_lines: + child.serialize(buffer, 75, validate=False) + for comp in obj.components(): + customSerialize(comp) + foldOneLine(buffer, u"END:" + obj.name) + customSerialize(self) + buffer.seek(0) # tzical wants to read a stream + return dateutil.tz.tzical(buffer).get() + + def settzinfo(self, tzinfo, start=2000, end=2030): + """Create appropriate objects in self to represent tzinfo. + + Collapse DST transitions to rrules as much as possible. + + Assumptions: + - DST <-> Standard transitions occur on the hour + - never within a month of one another + - twice or fewer times a year + - never in the month of December + - DST always moves offset exactly one hour later + - tzinfo classes dst method always treats times that could be in either + offset as being in the later regime + + """ + def fromLastWeek(dt): + """How many weeks from the end of the month dt is, starting from 1.""" + weekDelta = datetime.timedelta(weeks=1) + n = 1 + current = dt + weekDelta + while current.month == dt.month: + n += 1 + current += weekDelta + return n + + # lists of dictionaries defining rules which are no longer in effect + completed = {'daylight' : [], 'standard' : []} + + # dictionary defining rules which are currently in effect + working = {'daylight' : None, 'standard' : None} + + # rule may be based on the nth week of the month or the nth from the last + for year in xrange(start, end + 1): + newyear = datetime.datetime(year, 1, 1) + for transitionTo in 'daylight', 'standard': + transition = getTransition(transitionTo, year, tzinfo) + oldrule = working[transitionTo] + + if transition == newyear: + # transitionTo is in effect for the whole year + rule = {'end' : None, + 'start' : newyear, + 'month' : 1, + 'weekday' : None, + 'hour' : None, + 'plus' : None, + 'minus' : None, + 'name' : tzinfo.tzname(newyear), + 'offset' : tzinfo.utcoffset(newyear), + 'offsetfrom' : tzinfo.utcoffset(newyear)} + if oldrule is None: + # transitionTo was not yet in effect + working[transitionTo] = rule + else: + # transitionTo was already in effect + if (oldrule['offset'] != + tzinfo.utcoffset(newyear)): + # old rule was different, it shouldn't continue + oldrule['end'] = year - 1 + completed[transitionTo].append(oldrule) + working[transitionTo] = rule + elif transition is None: + # transitionTo is not in effect + if oldrule is not None: + # transitionTo used to be in effect + oldrule['end'] = year - 1 + completed[transitionTo].append(oldrule) + working[transitionTo] = None + else: + # an offset transition was found + old_offset = tzinfo.utcoffset(transition - twoHours) + rule = {'end' : None, # None, or an integer year + 'start' : transition, # the datetime of transition + 'month' : transition.month, + 'weekday' : transition.weekday(), + 'hour' : transition.hour, + 'name' : tzinfo.tzname(transition), + 'plus' : (transition.day - 1)/ 7 + 1,#nth week of the month + 'minus' : fromLastWeek(transition), #nth from last week + 'offset' : tzinfo.utcoffset(transition), + 'offsetfrom' : old_offset} + + if oldrule is None: + working[transitionTo] = rule + else: + plusMatch = rule['plus'] == oldrule['plus'] + minusMatch = rule['minus'] == oldrule['minus'] + truth = plusMatch or minusMatch + for key in 'month', 'weekday', 'hour', 'offset': + truth = truth and rule[key] == oldrule[key] + if truth: + # the old rule is still true, limit to plus or minus + if not plusMatch: + oldrule['plus'] = None + if not minusMatch: + oldrule['minus'] = None + else: + # the new rule did not match the old + oldrule['end'] = year - 1 + completed[transitionTo].append(oldrule) + working[transitionTo] = rule + + for transitionTo in 'daylight', 'standard': + if working[transitionTo] is not None: + completed[transitionTo].append(working[transitionTo]) + + self.tzid = [] + self.daylight = [] + self.standard = [] + + self.add('tzid').value = self.pickTzid(tzinfo, True) + + old = None + for transitionTo in 'daylight', 'standard': + for rule in completed[transitionTo]: + comp = self.add(transitionTo) + dtstart = comp.add('dtstart') + dtstart.value = rule['start'] + if rule['name'] is not None: + comp.add('tzname').value = rule['name'] + line = comp.add('tzoffsetto') + line.value = deltaToOffset(rule['offset']) + line = comp.add('tzoffsetfrom') + line.value = deltaToOffset(rule['offsetfrom']) + + if rule['plus'] is not None: + num = rule['plus'] + elif rule['minus'] is not None: + num = -1 * rule['minus'] + else: + num = None + if num is not None: + dayString = ";BYDAY=" + str(num) + WEEKDAYS[rule['weekday']] + else: + dayString = "" + if rule['end'] is not None: + if rule['hour'] is None: + # all year offset, with no rule + endDate = datetime.datetime(rule['end'], 1, 1) + else: + weekday = dateutil.rrule.weekday(rule['weekday'], num) + du_rule = dateutil.rrule.rrule(dateutil.rrule.YEARLY, + bymonth = rule['month'],byweekday = weekday, + dtstart = datetime.datetime( + rule['end'], 1, 1, rule['hour']) + ) + endDate = du_rule[0] + endDate = endDate.replace(tzinfo = utc) - rule['offsetfrom'] + endString = ";UNTIL="+ dateTimeToString(endDate) + else: + endString = '' + rulestring = "FREQ=YEARLY%s;BYMONTH=%s%s" % \ + (dayString, str(rule['month']), endString) + + comp.add('rrule').value = rulestring + + tzinfo = property(gettzinfo, settzinfo) + # prevent Component's __setattr__ from overriding the tzinfo property + normal_attributes = Component.normal_attributes + ['tzinfo'] + + @staticmethod + def pickTzid(tzinfo, allowUTC=False): + """ + Given a tzinfo class, use known APIs to determine TZID, or use tzname. + """ + if tzinfo is None or (not allowUTC and tzinfo_eq(tzinfo, utc)): + #If tzinfo is UTC, we don't need a TZID + return None + # try PyICU's tzid key + if hasattr(tzinfo, 'tzid'): + return toUnicode(tzinfo.tzid) + + # try pytz zone key + if hasattr(tzinfo, 'zone'): + return toUnicode(tzinfo.zone) + + # try tzical's tzid key + elif hasattr(tzinfo, '_tzid'): + return toUnicode(tzinfo._tzid) + else: + # return tzname for standard (non-DST) time + notDST = datetime.timedelta(0) + for month in xrange(1,13): + dt = datetime.datetime(2000, month, 1) + if tzinfo.dst(dt) == notDST: + return toUnicode(tzinfo.tzname(dt)) + # there was no standard time in 2000! + raise VObjectError("Unable to guess TZID for tzinfo %s" % str(tzinfo)) + + def __str__(self): + return "<VTIMEZONE | " + str(getattr(self, 'tzid', 'No TZID')) +">" + + def __repr__(self): + return self.__str__() + + def prettyPrint(self, level, tabwidth): + pre = ' ' * level * tabwidth + print pre, self.name + print pre, "TZID:", self.tzid + print + +class RecurringComponent(Component): + """A vCalendar component like VEVENT or VTODO which may recur. + + Any recurring component can have one or multiple RRULE, RDATE, + EXRULE, or EXDATE lines, and one or zero DTSTART lines. It can also have a + variety of children that don't have any recurrence information. + + In the example below, note that dtstart is included in the rruleset. + This is not the default behavior for dateutil's rrule implementation unless + dtstart would already have been a member of the recurrence rule, and as a + result, COUNT is wrong. This can be worked around when getting rruleset by + adjusting count down by one if an rrule has a count and dtstart isn't in its + result set, but by default, the rruleset property doesn't do this work + around, to access it getrruleset must be called with addRDate set True. + + >>> import dateutil.rrule, datetime + >>> vevent = RecurringComponent(name='VEVENT') + >>> vevent.add('rrule').value =u"FREQ=WEEKLY;COUNT=2;INTERVAL=2;BYDAY=TU,TH" + >>> vevent.add('dtstart').value = datetime.datetime(2005, 1, 19, 9) + + When creating rrule's programmatically it should be kept in + mind that count doesn't necessarily mean what rfc2445 says. + + >>> list(vevent.rruleset) + [datetime.datetime(2005, 1, 20, 9, 0), datetime.datetime(2005, 2, 1, 9, 0)] + >>> list(vevent.getrruleset(addRDate=True)) + [datetime.datetime(2005, 1, 19, 9, 0), datetime.datetime(2005, 1, 20, 9, 0)] + + Also note that dateutil will expand all-day events (datetime.date values) to + datetime.datetime value with time 0 and no timezone. + + >>> vevent.dtstart.value = datetime.date(2005,3,18) + >>> list(vevent.rruleset) + [datetime.datetime(2005, 3, 29, 0, 0), datetime.datetime(2005, 3, 31, 0, 0)] + >>> list(vevent.getrruleset(True)) + [datetime.datetime(2005, 3, 18, 0, 0), datetime.datetime(2005, 3, 29, 0, 0)] + + @ivar rruleset: + A U{rruleset<https://moin.conectiva.com.br/DateUtil>}. + """ + def __init__(self, *args, **kwds): + super(RecurringComponent, self).__init__(*args, **kwds) + self.isNative=True + #self.clobberedRDates=[] + + + def getrruleset(self, addRDate = False): + """Get an rruleset created from self. + + If addRDate is True, add an RDATE for dtstart if it's not included in + an RRULE, and count is decremented if it exists. + + Note that for rules which don't match DTSTART, DTSTART may not appear + in list(rruleset), although it should. By default, an RDATE is not + created in these cases, and count isn't updated, so dateutil may list + a spurious occurrence. + + """ + rruleset = None + for name in DATESANDRULES: + addfunc = None + for line in self.contents.get(name, ()): + # don't bother creating a rruleset unless there's a rule + if rruleset is None: + rruleset = dateutil.rrule.rruleset() + if addfunc is None: + addfunc=getattr(rruleset, name) + + if name in DATENAMES: + if type(line.value[0]) == datetime.datetime: + map(addfunc, line.value) + elif type(line.value[0]) == datetime.date: + for dt in line.value: + addfunc(datetime.datetime(dt.year, dt.month, dt.day)) + else: + # ignore RDATEs with PERIOD values for now + pass + elif name in RULENAMES: + try: + dtstart = self.dtstart.value + except AttributeError, KeyError: + # Special for VTODO - try DUE property instead + try: + if self.name == "VTODO": + dtstart = self.due.value + else: + # if there's no dtstart, just return None + return None + except AttributeError, KeyError: + # if there's no due, just return None + return None + + # rrulestr complains about unicode, so cast to str + rule = dateutil.rrule.rrulestr(str(line.value), + dtstart=dtstart) + until = rule._until + if until is not None and \ + isinstance(dtstart, datetime.datetime) and \ + (until.tzinfo != dtstart.tzinfo): + # dateutil converts the UNTIL date to a datetime, + # check to see if the UNTIL parameter value was a date + vals = dict(pair.split('=') for pair in + line.value.upper().split(';')) + if len(vals.get('UNTIL', '')) == 8: + until = datetime.datetime.combine(until.date(), + dtstart.time()) + # While RFC2445 says UNTIL MUST be UTC, Chandler allows + # floating recurring events, and uses floating UNTIL values. + # Also, some odd floating UNTIL but timezoned DTSTART values + # have shown up in the wild, so put floating UNTIL values + # DTSTART's timezone + if until.tzinfo is None: + until = until.replace(tzinfo=dtstart.tzinfo) + + if dtstart.tzinfo is not None: + until = until.astimezone(dtstart.tzinfo) + + rule._until = until + + # add the rrule or exrule to the rruleset + addfunc(rule) + + if name == 'rrule' and addRDate: + try: + # dateutils does not work with all-day (datetime.date) items + # so we need to convert to a datetime.datetime + # (which is what dateutils does internally) + if not isinstance(dtstart, datetime.datetime): + adddtstart = datetime.datetime.fromordinal(dtstart.toordinal()) + else: + adddtstart = dtstart + if rruleset._rrule[-1][0] != adddtstart: + rruleset.rdate(adddtstart) + added = True + else: + added = False + except IndexError: + # it's conceivable that an rrule might have 0 datetimes + added = False + if added and rruleset._rrule[-1]._count != None: + rruleset._rrule[-1]._count -= 1 + return rruleset + + def setrruleset(self, rruleset): + + # Get DTSTART from component (or DUE if no DTSTART in a VTODO) + try: + dtstart = self.dtstart.value + except AttributeError, KeyError: + if self.name == "VTODO": + dtstart = self.due.value + else: + raise + + isDate = datetime.date == type(dtstart) + if isDate: + dtstart = datetime.datetime(dtstart.year,dtstart.month, dtstart.day) + untilSerialize = dateToString + else: + # make sure to convert time zones to UTC + untilSerialize = lambda x: dateTimeToString(x, True) + + for name in DATESANDRULES: + if hasattr(self.contents, name): + del self.contents[name] + setlist = getattr(rruleset, '_' + name) + if name in DATENAMES: + setlist = list(setlist) # make a copy of the list + if name == 'rdate' and dtstart in setlist: + setlist.remove(dtstart) + if isDate: + setlist = [dt.date() for dt in setlist] + if len(setlist) > 0: + self.add(name).value = setlist + elif name in RULENAMES: + for rule in setlist: + buf = StringIO.StringIO() + buf.write('FREQ=') + buf.write(FREQUENCIES[rule._freq]) + + values = {} + + if rule._interval != 1: + values['INTERVAL'] = [str(rule._interval)] + if rule._wkst != 0: # wkst defaults to Monday + values['WKST'] = [WEEKDAYS[rule._wkst]] + if rule._bysetpos is not None: + values['BYSETPOS'] = [str(i) for i in rule._bysetpos] + + if rule._count is not None: + values['COUNT'] = [str(rule._count)] + elif rule._until is not None: + values['UNTIL'] = [untilSerialize(rule._until)] + + days = [] + if (rule._byweekday is not None and ( + dateutil.rrule.WEEKLY != rule._freq or + len(rule._byweekday) != 1 or + rule._dtstart.weekday() != rule._byweekday[0])): + # ignore byweekday if freq is WEEKLY and day correlates + # with dtstart because it was automatically set by + # dateutil + days.extend(WEEKDAYS[n] for n in rule._byweekday) + + if rule._bynweekday is not None: + days.extend(str(n) + WEEKDAYS[day] for day, n in rule._bynweekday) + + if len(days) > 0: + values['BYDAY'] = days + + if rule._bymonthday is not None and len(rule._bymonthday) > 0: + if not (rule._freq <= dateutil.rrule.MONTHLY and + len(rule._bymonthday) == 1 and + rule._bymonthday[0] == rule._dtstart.day): + # ignore bymonthday if it's generated by dateutil + values['BYMONTHDAY'] = [str(n) for n in rule._bymonthday] + + if rule._bymonth is not None and len(rule._bymonth) > 0: + if (rule._byweekday is not None or + len(rule._bynweekday or ()) > 0 or + not (rule._freq == dateutil.rrule.YEARLY and + len(rule._bymonth) == 1 and + rule._bymonth[0] == rule._dtstart.month)): + # ignore bymonth if it's generated by dateutil + values['BYMONTH'] = [str(n) for n in rule._bymonth] + + if rule._byyearday is not None: + values['BYYEARDAY'] = [str(n) for n in rule._byyearday] + if rule._byweekno is not None: + values['BYWEEKNO'] = [str(n) for n in rule._byweekno] + + # byhour, byminute, bysecond are always ignored for now + + + for key, paramvals in values.iteritems(): + buf.write(';') + buf.write(key) + buf.write('=') + buf.write(','.join(paramvals)) + + self.add(name).value = buf.getvalue() + + + + rruleset = property(getrruleset, setrruleset) + + def __setattr__(self, name, value): + """For convenience, make self.contents directly accessible.""" + if name == 'rruleset': + self.setrruleset(value) + else: + super(RecurringComponent, self).__setattr__(name, value) + +class TextBehavior(behavior.Behavior): + """Provide backslash escape encoding/decoding for single valued properties. + + TextBehavior also deals with base64 encoding if the ENCODING parameter is + explicitly set to BASE64. + + """ + base64string = 'BASE64' # vCard uses B + + @classmethod + def decode(cls, line): + """Remove backslash escaping from line.value.""" + if line.encoded: + encoding = getattr(line, 'encoding_param', None) + if encoding and encoding.upper() == cls.base64string: + line.value = line.value.decode('base64') + else: + line.value = stringToTextValues(line.value)[0] + line.encoded=False + + @classmethod + def encode(cls, line): + """Backslash escape line.value.""" + if not line.encoded: + encoding = getattr(line, 'encoding_param', None) + if encoding and encoding.upper() == cls.base64string: + line.value = line.value.encode('base64').replace('\n', '') + else: + line.value = backslashEscape(line.value) + line.encoded=True + +class VCalendarComponentBehavior(behavior.Behavior): + defaultBehavior = TextBehavior + isComponent = True + +class RecurringBehavior(VCalendarComponentBehavior): + """Parent Behavior for components which should be RecurringComponents.""" + hasNative = True + + @staticmethod + def transformToNative(obj): + """Turn a recurring Component into a RecurringComponent.""" + if not obj.isNative: + object.__setattr__(obj, '__class__', RecurringComponent) + obj.isNative = True + return obj + + @staticmethod + def transformFromNative(obj): + if obj.isNative: + object.__setattr__(obj, '__class__', Component) + obj.isNative = False + return obj + + @staticmethod + def generateImplicitParameters(obj): + """Generate a UID if one does not exist. + + This is just a dummy implementation, for now. + + """ + if not hasattr(obj, 'uid'): + rand = str(int(random.random() * 100000)) + now = datetime.datetime.now(utc) + now = dateTimeToString(now) + host = socket.gethostname() + obj.add(ContentLine('UID', [], now + '-' + rand + '@' + host)) + + +class DateTimeBehavior(behavior.Behavior): + """Parent Behavior for ContentLines containing one DATE-TIME.""" + hasNative = True + + @staticmethod + def transformToNative(obj): + """Turn obj.value into a datetime. + + RFC2445 allows times without time zone information, "floating times" + in some properties. Mostly, this isn't what you want, but when parsing + a file, real floating times are noted by setting to 'TRUE' the + X-VOBJ-FLOATINGTIME-ALLOWED parameter. + + """ + if obj.isNative: return obj + obj.isNative = True + if obj.value == '': return obj + obj.value=str(obj.value) + #we're cheating a little here, parseDtstart allows DATE + obj.value=parseDtstart(obj) + if obj.value.tzinfo is None: + obj.params['X-VOBJ-FLOATINGTIME-ALLOWED'] = ['TRUE'] + if obj.params.get('TZID'): + # Keep a copy of the original TZID around + obj.params['X-VOBJ-ORIGINAL-TZID'] = [obj.params['TZID']] + del obj.params['TZID'] + return obj + + @classmethod + def transformFromNative(cls, obj): + """Replace the datetime in obj.value with an ISO 8601 string.""" + if obj.isNative: + obj.isNative = False + tzid = TimezoneComponent.registerTzinfo(obj.value.tzinfo) + obj.value = dateTimeToString(obj.value, cls.forceUTC) + if not cls.forceUTC and tzid is not None: + obj.tzid_param = tzid + if obj.params.get('X-VOBJ-ORIGINAL-TZID'): + if not hasattr(obj, 'tzid_param'): + obj.tzid_param = obj.x_vobj_original_tzid_param + del obj.params['X-VOBJ-ORIGINAL-TZID'] + + return obj + +class UTCDateTimeBehavior(DateTimeBehavior): + """A value which must be specified in UTC.""" + forceUTC = True + +class DateOrDateTimeBehavior(behavior.Behavior): + """Parent Behavior for ContentLines containing one DATE or DATE-TIME.""" + hasNative = True + + @staticmethod + def transformToNative(obj): + """Turn obj.value into a date or datetime.""" + if obj.isNative: return obj + obj.isNative = True + if obj.value == '': return obj + obj.value=str(obj.value) + obj.value=parseDtstart(obj, allowSignatureMismatch=True) + if getattr(obj, 'value_param', 'DATE-TIME').upper() == 'DATE-TIME': + if hasattr(obj, 'tzid_param'): + # Keep a copy of the original TZID around + obj.params['X-VOBJ-ORIGINAL-TZID'] = [obj.tzid_param] + del obj.tzid_param + return obj + + @staticmethod + def transformFromNative(obj): + """Replace the date or datetime in obj.value with an ISO 8601 string.""" + if type(obj.value) == datetime.date: + obj.isNative = False + obj.value_param = 'DATE' + obj.value = dateToString(obj.value) + return obj + else: return DateTimeBehavior.transformFromNative(obj) + +class MultiDateBehavior(behavior.Behavior): + """ + Parent Behavior for ContentLines containing one or more DATE, DATE-TIME, or + PERIOD. + + """ + hasNative = True + + @staticmethod + def transformToNative(obj): + """ + Turn obj.value into a list of dates, datetimes, or + (datetime, timedelta) tuples. + + """ + if obj.isNative: + return obj + obj.isNative = True + if obj.value == '': + obj.value = [] + return obj + tzinfo = getTzid(getattr(obj, 'tzid_param', None)) + valueParam = getattr(obj, 'value_param', "DATE-TIME").upper() + valTexts = obj.value.split(",") + if valueParam == "DATE": + obj.value = [stringToDate(x) for x in valTexts] + elif valueParam == "DATE-TIME": + obj.value = [stringToDateTime(x, tzinfo) for x in valTexts] + elif valueParam == "PERIOD": + obj.value = [stringToPeriod(x, tzinfo) for x in valTexts] + return obj + + @staticmethod + def transformFromNative(obj): + """ + Replace the date, datetime or period tuples in obj.value with + appropriate strings. + + """ + if obj.value and type(obj.value[0]) == datetime.date: + obj.isNative = False + obj.value_param = 'DATE' + obj.value = ','.join([dateToString(val) for val in obj.value]) + return obj + # Fixme: handle PERIOD case + else: + if obj.isNative: + obj.isNative = False + transformed = [] + tzid = None + for val in obj.value: + if tzid is None and type(val) == datetime.datetime: + tzid = TimezoneComponent.registerTzinfo(val.tzinfo) + if tzid is not None: + obj.tzid_param = tzid + transformed.append(dateTimeToString(val)) + obj.value = ','.join(transformed) + return obj + +class MultiTextBehavior(behavior.Behavior): + """Provide backslash escape encoding/decoding of each of several values. + + After transformation, value is a list of strings. + + """ + + @staticmethod + def decode(line): + """Remove backslash escaping from line.value, then split on commas.""" + if line.encoded: + line.value = stringToTextValues(line.value) + line.encoded=False + + @staticmethod + def encode(line): + """Backslash escape line.value.""" + if not line.encoded: + line.value = ','.join(backslashEscape(val) for val in line.value) + line.encoded=True + + +#------------------------ Registered Behavior subclasses ----------------------- +class VCalendar2_0(VCalendarComponentBehavior): + """vCalendar 2.0 behavior. With added VAVAILABILITY support.""" + name = 'VCALENDAR' + description = 'vCalendar 2.0, also known as iCalendar.' + versionString = '2.0' + sortFirst = ('version', 'calscale', 'method', 'prodid', 'vtimezone') + knownChildren = {'CALSCALE': (0, 1, None),#min, max, behaviorRegistry id + 'METHOD': (0, 1, None), + 'VERSION': (0, 1, None),#required, but auto-generated + 'PRODID': (1, 1, None), + 'VTIMEZONE': (0, None, None), + 'VEVENT': (0, None, None), + 'VTODO': (0, None, None), + 'VJOURNAL': (0, None, None), + 'VFREEBUSY': (0, None, None), + 'VAVAILABILITY': (0, None, None), + } + + @classmethod + def generateImplicitParameters(cls, obj): + """Create PRODID, VERSION, and VTIMEZONEs if needed. + + VTIMEZONEs will need to exist whenever TZID parameters exist or when + datetimes with tzinfo exist. + + """ + for comp in obj.components(): + if comp.behavior is not None: + comp.behavior.generateImplicitParameters(comp) + if not hasattr(obj, 'prodid'): + obj.add(ContentLine('PRODID', [], PRODID)) + if not hasattr(obj, 'version'): + obj.add(ContentLine('VERSION', [], cls.versionString)) + tzidsUsed = {} + + def findTzids(obj, table): + if isinstance(obj, ContentLine) and (obj.behavior is None or + not obj.behavior.forceUTC): + if getattr(obj, 'tzid_param', None): + table[obj.tzid_param] = 1 + else: + if type(obj.value) == list: + for item in obj.value: + tzinfo = getattr(obj.value, 'tzinfo', None) + tzid = TimezoneComponent.registerTzinfo(tzinfo) + if tzid: + table[tzid] = 1 + else: + tzinfo = getattr(obj.value, 'tzinfo', None) + tzid = TimezoneComponent.registerTzinfo(tzinfo) + if tzid: + table[tzid] = 1 + for child in obj.getChildren(): + if obj.name != 'VTIMEZONE': + findTzids(child, table) + + findTzids(obj, tzidsUsed) + oldtzids = [toUnicode(x.tzid.value) for x in getattr(obj, 'vtimezone_list', [])] + for tzid in tzidsUsed.keys(): + tzid = toUnicode(tzid) + if tzid != u'UTC' and tzid not in oldtzids: + obj.add(TimezoneComponent(tzinfo=getTzid(tzid))) +registerBehavior(VCalendar2_0) + +class VTimezone(VCalendarComponentBehavior): + """Timezone behavior.""" + name = 'VTIMEZONE' + hasNative = True + description = 'A grouping of component properties that defines a time zone.' + sortFirst = ('tzid', 'last-modified', 'tzurl', 'standard', 'daylight') + knownChildren = {'TZID': (1, 1, None),#min, max, behaviorRegistry id + 'LAST-MODIFIED':(0, 1, None), + 'TZURL': (0, 1, None), + 'STANDARD': (0, None, None),#NOTE: One of Standard or + 'DAYLIGHT': (0, None, None) # Daylight must appear + } + + @classmethod + def validate(cls, obj, raiseException, *args): + if not hasattr(obj, 'tzid') or obj.tzid.value is None: + if raiseException: + m = "VTIMEZONE components must contain a valid TZID" + raise ValidateError(m) + return False + if obj.contents.has_key('standard') or obj.contents.has_key('daylight'): + return super(VTimezone, cls).validate(obj, raiseException, *args) + else: + if raiseException: + m = "VTIMEZONE components must contain a STANDARD or a DAYLIGHT\ + component" + raise ValidateError(m) + return False + + + @staticmethod + def transformToNative(obj): + if not obj.isNative: + object.__setattr__(obj, '__class__', TimezoneComponent) + obj.isNative = True + obj.registerTzinfo(obj.tzinfo) + return obj + + @staticmethod + def transformFromNative(obj): + return obj + + +registerBehavior(VTimezone) + +class DaylightOrStandard(VCalendarComponentBehavior): + hasNative = False + knownChildren = {'DTSTART': (1, 1, None),#min, max, behaviorRegistry id + 'RRULE': (0, 1, None)} + +registerBehavior(DaylightOrStandard, 'STANDARD') +registerBehavior(DaylightOrStandard, 'DAYLIGHT') + + +class VEvent(RecurringBehavior): + """Event behavior.""" + name='VEVENT' + sortFirst = ('uid', 'recurrence-id', 'dtstart', 'duration', 'dtend') + + description='A grouping of component properties, and possibly including \ + "VALARM" calendar components, that represents a scheduled \ + amount of time on a calendar.' + knownChildren = {'DTSTART': (0, 1, None),#min, max, behaviorRegistry id + 'CLASS': (0, 1, None), + 'CREATED': (0, 1, None), + 'DESCRIPTION': (0, 1, None), + 'GEO': (0, 1, None), + 'LAST-MODIFIED':(0, 1, None), + 'LOCATION': (0, 1, None), + 'ORGANIZER': (0, 1, None), + 'PRIORITY': (0, 1, None), + 'DTSTAMP': (0, 1, None), + 'SEQUENCE': (0, 1, None), + 'STATUS': (0, 1, None), + 'SUMMARY': (0, 1, None), + 'TRANSP': (0, 1, None), + 'UID': (1, 1, None), + 'URL': (0, 1, None), + 'RECURRENCE-ID':(0, 1, None), + 'DTEND': (0, 1, None), #NOTE: Only one of DtEnd or + 'DURATION': (0, 1, None), # Duration can appear + 'ATTACH': (0, None, None), + 'ATTENDEE': (0, None, None), + 'CATEGORIES': (0, None, None), + 'COMMENT': (0, None, None), + 'CONTACT': (0, None, None), + 'EXDATE': (0, None, None), + 'EXRULE': (0, None, None), + 'REQUEST-STATUS': (0, None, None), + 'RELATED-TO': (0, None, None), + 'RESOURCES': (0, None, None), + 'RDATE': (0, None, None), + 'RRULE': (0, None, None), + 'VALARM': (0, None, None) + } + + @classmethod + def validate(cls, obj, raiseException, *args): + if obj.contents.has_key('dtend') and obj.contents.has_key('duration'): + if raiseException: + m = "VEVENT components cannot contain both DTEND and DURATION\ + components" + raise ValidateError(m) + return False + else: + return super(VEvent, cls).validate(obj, raiseException, *args) + +registerBehavior(VEvent) + + +class VTodo(RecurringBehavior): + """To-do behavior.""" + name='VTODO' + description='A grouping of component properties and possibly "VALARM" \ + calendar components that represent an action-item or \ + assignment.' + knownChildren = {'DTSTART': (0, 1, None),#min, max, behaviorRegistry id + 'CLASS': (0, 1, None), + 'COMPLETED': (0, 1, None), + 'CREATED': (0, 1, None), + 'DESCRIPTION': (0, 1, None), + 'GEO': (0, 1, None), + 'LAST-MODIFIED':(0, 1, None), + 'LOCATION': (0, 1, None), + 'ORGANIZER': (0, 1, None), + 'PERCENT': (0, 1, None), + 'PRIORITY': (0, 1, None), + 'DTSTAMP': (0, 1, None), + 'SEQUENCE': (0, 1, None), + 'STATUS': (0, 1, None), + 'SUMMARY': (0, 1, None), + 'UID': (0, 1, None), + 'URL': (0, 1, None), + 'RECURRENCE-ID':(0, 1, None), + 'DUE': (0, 1, None), #NOTE: Only one of Due or + 'DURATION': (0, 1, None), # Duration can appear + 'ATTACH': (0, None, None), + 'ATTENDEE': (0, None, None), + 'CATEGORIES': (0, None, None), + 'COMMENT': (0, None, None), + 'CONTACT': (0, None, None), + 'EXDATE': (0, None, None), + 'EXRULE': (0, None, None), + 'REQUEST-STATUS': (0, None, None), + 'RELATED-TO': (0, None, None), + 'RESOURCES': (0, None, None), + 'RDATE': (0, None, None), + 'RRULE': (0, None, None), + 'VALARM': (0, None, None) + } + + @classmethod + def validate(cls, obj, raiseException, *args): + if obj.contents.has_key('due') and obj.contents.has_key('duration'): + if raiseException: + m = "VTODO components cannot contain both DUE and DURATION\ + components" + raise ValidateError(m) + return False + else: + return super(VTodo, cls).validate(obj, raiseException, *args) + +registerBehavior(VTodo) + + +class VJournal(RecurringBehavior): + """Journal entry behavior.""" + name='VJOURNAL' + knownChildren = {'DTSTART': (0, 1, None),#min, max, behaviorRegistry id + 'CLASS': (0, 1, None), + 'CREATED': (0, 1, None), + 'DESCRIPTION': (0, 1, None), + 'LAST-MODIFIED':(0, 1, None), + 'ORGANIZER': (0, 1, None), + 'DTSTAMP': (0, 1, None), + 'SEQUENCE': (0, 1, None), + 'STATUS': (0, 1, None), + 'SUMMARY': (0, 1, None), + 'UID': (0, 1, None), + 'URL': (0, 1, None), + 'RECURRENCE-ID':(0, 1, None), + 'ATTACH': (0, None, None), + 'ATTENDEE': (0, None, None), + 'CATEGORIES': (0, None, None), + 'COMMENT': (0, None, None), + 'CONTACT': (0, None, None), + 'EXDATE': (0, None, None), + 'EXRULE': (0, None, None), + 'REQUEST-STATUS': (0, None, None), + 'RELATED-TO': (0, None, None), + 'RDATE': (0, None, None), + 'RRULE': (0, None, None) + } +registerBehavior(VJournal) + + +class VFreeBusy(VCalendarComponentBehavior): + """Free/busy state behavior. + + >>> vfb = newFromBehavior('VFREEBUSY') + >>> vfb.add('uid').value = 'test' + >>> vfb.add('dtstart').value = datetime.datetime(2006, 2, 16, 1, tzinfo=utc) + >>> vfb.add('dtend').value = vfb.dtstart.value + twoHours + >>> vfb.add('freebusy').value = [(vfb.dtstart.value, twoHours / 2)] + >>> vfb.add('freebusy').value = [(vfb.dtstart.value, vfb.dtend.value)] + >>> print vfb.serialize() + BEGIN:VFREEBUSY + UID:test + DTSTART:20060216T010000Z + DTEND:20060216T030000Z + FREEBUSY:20060216T010000Z/PT1H + FREEBUSY:20060216T010000Z/20060216T030000Z + END:VFREEBUSY + + """ + name='VFREEBUSY' + description='A grouping of component properties that describe either a \ + request for free/busy time, describe a response to a request \ + for free/busy time or describe a published set of busy time.' + sortFirst = ('uid', 'dtstart', 'duration', 'dtend') + knownChildren = {'DTSTART': (0, 1, None),#min, max, behaviorRegistry id + 'CONTACT': (0, 1, None), + 'DTEND': (0, 1, None), + 'DURATION': (0, 1, None), + 'ORGANIZER': (0, 1, None), + 'DTSTAMP': (0, 1, None), + 'UID': (0, 1, None), + 'URL': (0, 1, None), + 'ATTENDEE': (0, None, None), + 'COMMENT': (0, None, None), + 'FREEBUSY': (0, None, None), + 'REQUEST-STATUS': (0, None, None) + } +registerBehavior(VFreeBusy) + + +class VAlarm(VCalendarComponentBehavior): + """Alarm behavior.""" + name='VALARM' + description='Alarms describe when and how to provide alerts about events \ + and to-dos.' + knownChildren = {'ACTION': (1, 1, None),#min, max, behaviorRegistry id + 'TRIGGER': (1, 1, None), + 'DURATION': (0, 1, None), + 'REPEAT': (0, 1, None), + 'DESCRIPTION': (0, 1, None) + } + + @staticmethod + def generateImplicitParameters(obj): + """Create default ACTION and TRIGGER if they're not set.""" + try: + obj.action + except AttributeError: + obj.add('action').value = 'AUDIO' + try: + obj.trigger + except AttributeError: + obj.add('trigger').value = datetime.timedelta(0) + + + @classmethod + def validate(cls, obj, raiseException, *args): + """ + #TODO + audioprop = 2*( + + ; 'action' and 'trigger' are both REQUIRED, + ; but MUST NOT occur more than once + + action / trigger / + + ; 'duration' and 'repeat' are both optional, + ; and MUST NOT occur more than once each, + ; but if one occurs, so MUST the other + + duration / repeat / + + ; the following is optional, + ; but MUST NOT occur more than once + + attach / + + dispprop = 3*( + + ; the following are all REQUIRED, + ; but MUST NOT occur more than once + + action / description / trigger / + + ; 'duration' and 'repeat' are both optional, + ; and MUST NOT occur more than once each, + ; but if one occurs, so MUST the other + + duration / repeat / + + emailprop = 5*( + + ; the following are all REQUIRED, + ; but MUST NOT occur more than once + + action / description / trigger / summary + + ; the following is REQUIRED, + ; and MAY occur more than once + + attendee / + + ; 'duration' and 'repeat' are both optional, + ; and MUST NOT occur more than once each, + ; but if one occurs, so MUST the other + + duration / repeat / + + procprop = 3*( + + ; the following are all REQUIRED, + ; but MUST NOT occur more than once + + action / attach / trigger / + + ; 'duration' and 'repeat' are both optional, + ; and MUST NOT occur more than once each, + ; but if one occurs, so MUST the other + + duration / repeat / + + ; 'description' is optional, + ; and MUST NOT occur more than once + + description / + if obj.contents.has_key('dtend') and obj.contents.has_key('duration'): + if raiseException: + m = "VEVENT components cannot contain both DTEND and DURATION\ + components" + raise ValidateError(m) + return False + else: + return super(VEvent, cls).validate(obj, raiseException, *args) + """ + return True + +registerBehavior(VAlarm) + +class VAvailability(VCalendarComponentBehavior): + """Availability state behavior. + + >>> vav = newFromBehavior('VAVAILABILITY') + >>> vav.add('uid').value = 'test' + >>> vav.add('dtstamp').value = datetime.datetime(2006, 2, 15, 0, tzinfo=utc) + >>> vav.add('dtstart').value = datetime.datetime(2006, 2, 16, 0, tzinfo=utc) + >>> vav.add('dtend').value = datetime.datetime(2006, 2, 17, 0, tzinfo=utc) + >>> vav.add('busytype').value = "BUSY" + >>> av = newFromBehavior('AVAILABLE') + >>> av.add('uid').value = 'test1' + >>> av.add('dtstamp').value = datetime.datetime(2006, 2, 15, 0, tzinfo=utc) + >>> av.add('dtstart').value = datetime.datetime(2006, 2, 16, 9, tzinfo=utc) + >>> av.add('dtend').value = datetime.datetime(2006, 2, 16, 12, tzinfo=utc) + >>> av.add('summary').value = "Available in the morning" + >>> ignore = vav.add(av) + >>> print vav.serialize() + BEGIN:VAVAILABILITY + UID:test + DTSTART:20060216T000000Z + DTEND:20060217T000000Z + BEGIN:AVAILABLE + UID:test1 + DTSTART:20060216T090000Z + DTEND:20060216T120000Z + DTSTAMP:20060215T000000Z + SUMMARY:Available in the morning + END:AVAILABLE + BUSYTYPE:BUSY + DTSTAMP:20060215T000000Z + END:VAVAILABILITY + + """ + name='VAVAILABILITY' + description='A component used to represent a user\'s available time slots.' + sortFirst = ('uid', 'dtstart', 'duration', 'dtend') + knownChildren = {'UID': (1, 1, None),#min, max, behaviorRegistry id + 'DTSTAMP': (1, 1, None), + 'BUSYTYPE': (0, 1, None), + 'CREATED': (0, 1, None), + 'DTSTART': (0, 1, None), + 'LAST-MODIFIED': (0, 1, None), + 'ORGANIZER': (0, 1, None), + 'SEQUENCE': (0, 1, None), + 'SUMMARY': (0, 1, None), + 'URL': (0, 1, None), + 'DTEND': (0, 1, None), + 'DURATION': (0, 1, None), + 'CATEGORIES': (0, None, None), + 'COMMENT': (0, None, None), + 'CONTACT': (0, None, None), + 'AVAILABLE': (0, None, None), + } + + @classmethod + def validate(cls, obj, raiseException, *args): + if obj.contents.has_key('dtend') and obj.contents.has_key('duration'): + if raiseException: + m = "VAVAILABILITY components cannot contain both DTEND and DURATION\ + components" + raise ValidateError(m) + return False + else: + return super(VAvailability, cls).validate(obj, raiseException, *args) + +registerBehavior(VAvailability) + +class Available(RecurringBehavior): + """Event behavior.""" + name='AVAILABLE' + sortFirst = ('uid', 'recurrence-id', 'dtstart', 'duration', 'dtend') + + description='Defines a period of time in which a user is normally available.' + knownChildren = {'DTSTAMP': (1, 1, None),#min, max, behaviorRegistry id + 'DTSTART': (1, 1, None), + 'UID': (1, 1, None), + 'DTEND': (0, 1, None), #NOTE: One of DtEnd or + 'DURATION': (0, 1, None), # Duration must appear, but not both + 'CREATED': (0, 1, None), + 'LAST-MODIFIED':(0, 1, None), + 'RECURRENCE-ID':(0, 1, None), + 'RRULE': (0, 1, None), + 'SUMMARY': (0, 1, None), + 'CATEGORIES': (0, None, None), + 'COMMENT': (0, None, None), + 'CONTACT': (0, None, None), + 'EXDATE': (0, None, None), + 'RDATE': (0, None, None), + } + + @classmethod + def validate(cls, obj, raiseException, *args): + has_dtend = obj.contents.has_key('dtend') + has_duration = obj.contents.has_key('duration') + if has_dtend and has_duration: + if raiseException: + m = "AVAILABLE components cannot contain both DTEND and DURATION\ + properties" + raise ValidateError(m) + return False + elif not (has_dtend or has_duration): + if raiseException: + m = "AVAILABLE components must contain one of DTEND or DURATION\ + properties" + raise ValidateError(m) + return False + else: + return super(Available, cls).validate(obj, raiseException, *args) + +registerBehavior(Available) + +class Duration(behavior.Behavior): + """Behavior for Duration ContentLines. Transform to datetime.timedelta.""" + name = 'DURATION' + hasNative = True + + @staticmethod + def transformToNative(obj): + """Turn obj.value into a datetime.timedelta.""" + if obj.isNative: return obj + obj.isNative = True + obj.value=str(obj.value) + if obj.value == '': + return obj + else: + deltalist=stringToDurations(obj.value) + #When can DURATION have multiple durations? For now: + if len(deltalist) == 1: + obj.value = deltalist[0] + return obj + else: + raise ParseError("DURATION must have a single duration string.") + + @staticmethod + def transformFromNative(obj): + """Replace the datetime.timedelta in obj.value with an RFC2445 string. + """ + if not obj.isNative: return obj + obj.isNative = False + obj.value = timedeltaToString(obj.value) + return obj + +registerBehavior(Duration) + +class Trigger(behavior.Behavior): + """DATE-TIME or DURATION""" + name='TRIGGER' + description='This property specifies when an alarm will trigger.' + hasNative = True + forceUTC = True + + @staticmethod + def transformToNative(obj): + """Turn obj.value into a timedelta or datetime.""" + if obj.isNative: return obj + value = getattr(obj, 'value_param', 'DURATION').upper() + if hasattr(obj, 'value_param'): + del obj.value_param + if obj.value == '': + obj.isNative = True + return obj + elif value == 'DURATION': + try: + return Duration.transformToNative(obj) + except ParseError: + logger.warn("TRIGGER not recognized as DURATION, trying " + "DATE-TIME, because iCal sometimes exports " + "DATE-TIMEs without setting VALUE=DATE-TIME") + try: + obj.isNative = False + dt = DateTimeBehavior.transformToNative(obj) + return dt + except: + msg = "TRIGGER with no VALUE not recognized as DURATION " \ + "or as DATE-TIME" + raise ParseError(msg) + elif value == 'DATE-TIME': + #TRIGGERs with DATE-TIME values must be in UTC, we could validate + #that fact, for now we take it on faith. + return DateTimeBehavior.transformToNative(obj) + else: + raise ParseError("VALUE must be DURATION or DATE-TIME") + + @staticmethod + def transformFromNative(obj): + if type(obj.value) == datetime.datetime: + obj.value_param = 'DATE-TIME' + return UTCDateTimeBehavior.transformFromNative(obj) + elif type(obj.value) == datetime.timedelta: + return Duration.transformFromNative(obj) + else: + raise NativeError("Native TRIGGER values must be timedelta or datetime") + +registerBehavior(Trigger) + +class PeriodBehavior(behavior.Behavior): + """A list of (date-time, timedelta) tuples. + + >>> line = ContentLine('test', [], '', isNative=True) + >>> line.behavior = PeriodBehavior + >>> line.value = [(datetime.datetime(2006, 2, 16, 10), twoHours)] + >>> line.transformFromNative().value + '20060216T100000/PT2H' + >>> line.transformToNative().value + [(datetime.datetime(2006, 2, 16, 10, 0), datetime.timedelta(0, 7200))] + >>> line.value.append((datetime.datetime(2006, 5, 16, 10), twoHours)) + >>> print line.serialize().strip() + TEST:20060216T100000/PT2H,20060516T100000/PT2H + """ + hasNative = True + + @staticmethod + def transformToNative(obj): + """Convert comma separated periods into tuples.""" + if obj.isNative: + return obj + obj.isNative = True + if obj.value == '': + obj.value = [] + return obj + tzinfo = getTzid(getattr(obj, 'tzid_param', None)) + obj.value = [stringToPeriod(x, tzinfo) for x in obj.value.split(",")] + return obj + + @classmethod + def transformFromNative(cls, obj): + """Convert the list of tuples in obj.value to strings.""" + if obj.isNative: + obj.isNative = False + transformed = [] + for tup in obj.value: + transformed.append(periodToString(tup, cls.forceUTC)) + if len(transformed) > 0: + tzid = TimezoneComponent.registerTzinfo(tup[0].tzinfo) + if not cls.forceUTC and tzid is not None: + obj.tzid_param = tzid + + obj.value = ','.join(transformed) + + return obj + +class FreeBusy(PeriodBehavior): + """Free or busy period of time, must be specified in UTC.""" + name = 'FREEBUSY' + forceUTC = True +registerBehavior(FreeBusy) + +class RRule(behavior.Behavior): + """ + Dummy behavior to avoid having RRULEs being treated as text lines (and thus + having semi-colons inaccurately escaped). + """ +registerBehavior(RRule, 'RRULE') +registerBehavior(RRule, 'EXRULE') + +#------------------------ Registration of common classes ----------------------- + +utcDateTimeList = ['LAST-MODIFIED', 'CREATED', 'COMPLETED', 'DTSTAMP'] +map(lambda x: registerBehavior(UTCDateTimeBehavior, x), utcDateTimeList) + +dateTimeOrDateList = ['DTEND', 'DTSTART', 'DUE', 'RECURRENCE-ID'] +map(lambda x: registerBehavior(DateOrDateTimeBehavior, x), + dateTimeOrDateList) + +registerBehavior(MultiDateBehavior, 'RDATE') +registerBehavior(MultiDateBehavior, 'EXDATE') + + +textList = ['CALSCALE', 'METHOD', 'PRODID', 'CLASS', 'COMMENT', 'DESCRIPTION', + 'LOCATION', 'STATUS', 'SUMMARY', 'TRANSP', 'CONTACT', 'RELATED-TO', + 'UID', 'ACTION', 'REQUEST-STATUS', 'TZID', 'BUSYTYPE'] +map(lambda x: registerBehavior(TextBehavior, x), textList) + +multiTextList = ['CATEGORIES', 'RESOURCES'] +map(lambda x: registerBehavior(MultiTextBehavior, x), multiTextList) + +#------------------------ Serializing helper functions ------------------------- + +def numToDigits(num, places): + """Helper, for converting numbers to textual digits.""" + s = str(num) + if len(s) < places: + return ("0" * (places - len(s))) + s + elif len(s) > places: + return s[len(s)-places: ] + else: + return s + +def timedeltaToString(delta): + """Convert timedelta to an rfc2445 DURATION.""" + if delta.days == 0: sign = 1 + else: sign = delta.days / abs(delta.days) + delta = abs(delta) + days = delta.days + hours = delta.seconds / 3600 + minutes = (delta.seconds % 3600) / 60 + seconds = delta.seconds % 60 + out = '' + if sign == -1: out = '-' + out += 'P' + if days: out += str(days) + 'D' + if hours or minutes or seconds: out += 'T' + elif not days: #Deal with zero duration + out += 'T0S' + if hours: out += str(hours) + 'H' + if minutes: out += str(minutes) + 'M' + if seconds: out += str(seconds) + 'S' + return out + +def timeToString(dateOrDateTime): + """ + Wraps dateToString and dateTimeToString, returning the results + of either based on the type of the argument + """ + # Didn't use isinstance here as date and datetime sometimes evalutes as both + if (type(dateOrDateTime) == datetime.date): + return dateToString(dateOrDateTime) + elif(type(dateOrDateTime) == datetime.datetime): + return dateTimeToString(dateOrDateTime) + + +def dateToString(date): + year = numToDigits( date.year, 4 ) + month = numToDigits( date.month, 2 ) + day = numToDigits( date.day, 2 ) + return year + month + day + +def dateTimeToString(dateTime, convertToUTC=False): + """Ignore tzinfo unless convertToUTC. Output string.""" + if dateTime.tzinfo and convertToUTC: + dateTime = dateTime.astimezone(utc) + if tzinfo_eq(dateTime.tzinfo, utc): utcString = "Z" + else: utcString = "" + + year = numToDigits( dateTime.year, 4 ) + month = numToDigits( dateTime.month, 2 ) + day = numToDigits( dateTime.day, 2 ) + hour = numToDigits( dateTime.hour, 2 ) + mins = numToDigits( dateTime.minute, 2 ) + secs = numToDigits( dateTime.second, 2 ) + + return year + month + day + "T" + hour + mins + secs + utcString + +def deltaToOffset(delta): + absDelta = abs(delta) + hours = absDelta.seconds / 3600 + hoursString = numToDigits(hours, 2) + minutesString = '00' + if absDelta == delta: + signString = "+" + else: + signString = "-" + return signString + hoursString + minutesString + +def periodToString(period, convertToUTC=False): + txtstart = dateTimeToString(period[0], convertToUTC) + if isinstance(period[1], datetime.timedelta): + txtend = timedeltaToString(period[1]) + else: + txtend = dateTimeToString(period[1], convertToUTC) + return txtstart + "/" + txtend + +#----------------------- Parsing functions ------------------------------------- + +def isDuration(s): + s = string.upper(s) + return (string.find(s, "P") != -1) and (string.find(s, "P") < 2) + +def stringToDate(s): + year = int( s[0:4] ) + month = int( s[4:6] ) + day = int( s[6:8] ) + return datetime.date(year,month,day) + +def stringToDateTime(s, tzinfo=None): + """Returns datetime.datetime object.""" + try: + year = int( s[0:4] ) + month = int( s[4:6] ) + day = int( s[6:8] ) + hour = int( s[9:11] ) + minute = int( s[11:13] ) + second = int( s[13:15] ) + if len(s) > 15: + if s[15] == 'Z': + tzinfo = utc + except: + raise ParseError("'%s' is not a valid DATE-TIME" % s) + return datetime.datetime(year, month, day, hour, minute, second, 0, tzinfo) + + +escapableCharList = "\\;,Nn" + +def stringToTextValues(s, listSeparator=',', charList=None, strict=False): + """Returns list of strings.""" + + if charList is None: + charList = escapableCharList + + def escapableChar (c): + return c in charList + + def error(msg): + if strict: + raise ParseError(msg) + else: + #logger.error(msg) + print msg + + #vars which control state machine + charIterator = enumerate(s) + state = "read normal" + + current = "" + results = [] + + while True: + try: + charIndex, char = charIterator.next() + except: + char = "eof" + + if state == "read normal": + if char == '\\': + state = "read escaped char" + elif char == listSeparator: + state = "read normal" + results.append(current) + current = "" + elif char == "eof": + state = "end" + else: + state = "read normal" + current = current + char + + elif state == "read escaped char": + if escapableChar(char): + state = "read normal" + if char in 'nN': + current = current + '\n' + else: + current = current + char + else: + state = "read normal" + # leave unrecognized escaped characters for later passes + current = current + '\\' + char + + elif state == "end": #an end state + if current != "" or len(results) == 0: + results.append(current) + return results + + elif state == "error": #an end state + return results + + else: + state = "error" + error("error: unknown state: '%s' reached in %s" % (state, s)) + +def stringToDurations(s, strict=False): + """Returns list of timedelta objects.""" + def makeTimedelta(sign, week, day, hour, minute, sec): + if sign == "-": sign = -1 + else: sign = 1 + week = int(week) + day = int(day) + hour = int(hour) + minute = int(minute) + sec = int(sec) + return sign * datetime.timedelta(weeks=week, days=day, hours=hour, minutes=minute, seconds=sec) + + def error(msg): + if strict: + raise ParseError(msg) + else: + raise ParseError(msg) + #logger.error(msg) + + #vars which control state machine + charIterator = enumerate(s) + state = "start" + + durations = [] + current = "" + sign = None + week = 0 + day = 0 + hour = 0 + minute = 0 + sec = 0 + + while True: + try: + charIndex, char = charIterator.next() + except: + charIndex += 1 + char = "eof" + + if state == "start": + if char == '+': + state = "start" + sign = char + elif char == '-': + state = "start" + sign = char + elif char.upper() == 'P': + state = "read field" + elif char == "eof": + state = "error" + error("got end-of-line while reading in duration: " + s) + elif char in string.digits: + state = "read field" + current = current + char #update this part when updating "read field" + else: + state = "error" + print "got unexpected character %s reading in duration: %s" % (char, s) + error("got unexpected character %s reading in duration: %s" % (char, s)) + + elif state == "read field": + if (char in string.digits): + state = "read field" + current = current + char #update part above when updating "read field" + elif char.upper() == 'T': + state = "read field" + elif char.upper() == 'W': + state = "read field" + week = current + current = "" + elif char.upper() == 'D': + state = "read field" + day = current + current = "" + elif char.upper() == 'H': + state = "read field" + hour = current + current = "" + elif char.upper() == 'M': + state = "read field" + minute = current + current = "" + elif char.upper() == 'S': + state = "read field" + sec = current + current = "" + elif char == ",": + state = "start" + durations.append( makeTimedelta(sign, week, day, hour, minute, sec) ) + current = "" + sign = None + week = None + day = None + hour = None + minute = None + sec = None + elif char == "eof": + state = "end" + else: + state = "error" + error("got unexpected character reading in duration: " + s) + + elif state == "end": #an end state + #print "stuff: %s, durations: %s" % ([current, sign, week, day, hour, minute, sec], durations) + + if (sign or week or day or hour or minute or sec): + durations.append( makeTimedelta(sign, week, day, hour, minute, sec) ) + return durations + + elif state == "error": #an end state + error("in error state") + return durations + + else: + state = "error" + error("error: unknown state: '%s' reached in %s" % (state, s)) + +def parseDtstart(contentline, allowSignatureMismatch=False): + """Convert a contentline's value into a date or date-time. + + A variety of clients don't serialize dates with the appropriate VALUE + parameter, so rather than failing on these (technically invalid) lines, + if allowSignatureMismatch is True, try to parse both varieties. + + """ + tzinfo = getTzid(getattr(contentline, 'tzid_param', None)) + valueParam = getattr(contentline, 'value_param', 'DATE-TIME').upper() + if valueParam == "DATE": + return stringToDate(contentline.value) + elif valueParam == "DATE-TIME": + try: + return stringToDateTime(contentline.value, tzinfo) + except: + if allowSignatureMismatch: + return stringToDate(contentline.value) + else: + raise + +def stringToPeriod(s, tzinfo=None): + values = string.split(s, "/") + start = stringToDateTime(values[0], tzinfo) + valEnd = values[1] + if isDuration(valEnd): #period-start = date-time "/" dur-value + delta = stringToDurations(valEnd)[0] + return (start, delta) + else: + return (start, stringToDateTime(valEnd, tzinfo)) + + +def getTransition(transitionTo, year, tzinfo): + """Return the datetime of the transition to/from DST, or None.""" + + def firstTransition(iterDates, test): + """ + Return the last date not matching test, or None if all tests matched. + """ + success = None + for dt in iterDates: + if not test(dt): + success = dt + else: + if success is not None: + return success + return success # may be None + + def generateDates(year, month=None, day=None): + """Iterate over possible dates with unspecified values.""" + months = range(1, 13) + days = range(1, 32) + hours = range(0, 24) + if month is None: + for month in months: + yield datetime.datetime(year, month, 1) + elif day is None: + for day in days: + try: + yield datetime.datetime(year, month, day) + except ValueError: + pass + else: + for hour in hours: + yield datetime.datetime(year, month, day, hour) + + assert transitionTo in ('daylight', 'standard') + if transitionTo == 'daylight': + def test(dt): return tzinfo.dst(dt) != zeroDelta + elif transitionTo == 'standard': + def test(dt): return tzinfo.dst(dt) == zeroDelta + newyear = datetime.datetime(year, 1, 1) + monthDt = firstTransition(generateDates(year), test) + if monthDt is None: + return newyear + elif monthDt.month == 12: + return None + else: + # there was a good transition somewhere in a non-December month + month = monthDt.month + day = firstTransition(generateDates(year, month), test).day + uncorrected = firstTransition(generateDates(year, month, day), test) + if transitionTo == 'standard': + # assuming tzinfo.dst returns a new offset for the first + # possible hour, we need to add one hour for the offset change + # and another hour because firstTransition returns the hour + # before the transition + return uncorrected + datetime.timedelta(hours=2) + else: + return uncorrected + datetime.timedelta(hours=1) + +def tzinfo_eq(tzinfo1, tzinfo2, startYear = 2000, endYear=2020): + """Compare offsets and DST transitions from startYear to endYear.""" + if tzinfo1 == tzinfo2: + return True + elif tzinfo1 is None or tzinfo2 is None: + return False + + def dt_test(dt): + if dt is None: + return True + return tzinfo1.utcoffset(dt) == tzinfo2.utcoffset(dt) + + if not dt_test(datetime.datetime(startYear, 1, 1)): + return False + for year in xrange(startYear, endYear): + for transitionTo in 'daylight', 'standard': + t1=getTransition(transitionTo, year, tzinfo1) + t2=getTransition(transitionTo, year, tzinfo2) + if t1 != t2 or not dt_test(t1): + return False + return True + + +#------------------- Testing and running functions ----------------------------- +if __name__ == '__main__': + import tests + tests._test() diff --git a/vobject/ics_diff.py b/vobject/ics_diff.py new file mode 100644 index 0000000..4aaaef9 --- /dev/null +++ b/vobject/ics_diff.py @@ -0,0 +1,219 @@ +"""Compare VTODOs and VEVENTs in two iCalendar sources.""" +from base import Component, getBehavior, newFromBehavior + +def getSortKey(component): + def getUID(component): + return component.getChildValue('uid', '') + + # it's not quite as simple as getUID, need to account for recurrenceID and + # sequence + + def getSequence(component): + sequence = component.getChildValue('sequence', 0) + return "%05d" % int(sequence) + + def getRecurrenceID(component): + recurrence_id = component.getChildValue('recurrence_id', None) + if recurrence_id is None: + return '0000-00-00' + else: + return recurrence_id.isoformat() + + return getUID(component) + getSequence(component) + getRecurrenceID(component) + +def sortByUID(components): + return sorted(components, key=getSortKey) + +def deleteExtraneous(component, ignore_dtstamp=False): + """ + Recursively walk the component's children, deleting extraneous details like + X-VOBJ-ORIGINAL-TZID. + """ + for comp in component.components(): + deleteExtraneous(comp, ignore_dtstamp) + for line in component.lines(): + if line.params.has_key('X-VOBJ-ORIGINAL-TZID'): + del line.params['X-VOBJ-ORIGINAL-TZID'] + if ignore_dtstamp and hasattr(component, 'dtstamp_list'): + del component.dtstamp_list + +def diff(left, right): + """ + Take two VCALENDAR components, compare VEVENTs and VTODOs in them, + return a list of object pairs containing just UID and the bits + that didn't match, using None for objects that weren't present in one + version or the other. + + When there are multiple ContentLines in one VEVENT, for instance many + DESCRIPTION lines, such lines original order is assumed to be + meaningful. Order is also preserved when comparing (the unlikely case + of) multiple parameters of the same type in a ContentLine + + """ + + def processComponentLists(leftList, rightList): + output = [] + rightIndex = 0 + rightListSize = len(rightList) + + for comp in leftList: + if rightIndex >= rightListSize: + output.append((comp, None)) + else: + leftKey = getSortKey(comp) + rightComp = rightList[rightIndex] + rightKey = getSortKey(rightComp) + while leftKey > rightKey: + output.append((None, rightComp)) + rightIndex += 1 + if rightIndex >= rightListSize: + output.append((comp, None)) + break + else: + rightComp = rightList[rightIndex] + rightKey = getSortKey(rightComp) + + if leftKey < rightKey: + output.append((comp, None)) + elif leftKey == rightKey: + rightIndex += 1 + matchResult = processComponentPair(comp, rightComp) + if matchResult is not None: + output.append(matchResult) + + return output + + def newComponent(name, body): + if body is None: + return None + else: + c = Component(name) + c.behavior = getBehavior(name) + c.isNative = True + return c + + def processComponentPair(leftComp, rightComp): + """ + Return None if a match, or a pair of components including UIDs and + any differing children. + + """ + leftChildKeys = leftComp.contents.keys() + rightChildKeys = rightComp.contents.keys() + + differentContentLines = [] + differentComponents = {} + + for key in leftChildKeys: + rightList = rightComp.contents.get(key, []) + if isinstance(leftComp.contents[key][0], Component): + compDifference = processComponentLists(leftComp.contents[key], + rightList) + if len(compDifference) > 0: + differentComponents[key] = compDifference + + elif leftComp.contents[key] != rightList: + differentContentLines.append((leftComp.contents[key], + rightList)) + + for key in rightChildKeys: + if key not in leftChildKeys: + if isinstance(rightComp.contents[key][0], Component): + differentComponents[key] = ([], rightComp.contents[key]) + else: + differentContentLines.append(([], rightComp.contents[key])) + + if len(differentContentLines) == 0 and len(differentComponents) == 0: + return None + else: + left = newFromBehavior(leftComp.name) + right = newFromBehavior(leftComp.name) + # add a UID, if one existed, despite the fact that they'll always be + # the same + uid = leftComp.getChildValue('uid') + if uid is not None: + left.add( 'uid').value = uid + right.add('uid').value = uid + + for name, childPairList in differentComponents.iteritems(): + leftComponents, rightComponents = zip(*childPairList) + if len(leftComponents) > 0: + # filter out None + left.contents[name] = filter(None, leftComponents) + if len(rightComponents) > 0: + # filter out None + right.contents[name] = filter(None, rightComponents) + + for leftChildLine, rightChildLine in differentContentLines: + nonEmpty = leftChildLine or rightChildLine + name = nonEmpty[0].name + if leftChildLine is not None: + left.contents[name] = leftChildLine + if rightChildLine is not None: + right.contents[name] = rightChildLine + + return left, right + + + vevents = processComponentLists(sortByUID(getattr(left, 'vevent_list', [])), + sortByUID(getattr(right, 'vevent_list', []))) + + vtodos = processComponentLists(sortByUID(getattr(left, 'vtodo_list', [])), + sortByUID(getattr(right, 'vtodo_list', []))) + + return vevents + vtodos + +def prettyDiff(leftObj, rightObj): + for left, right in diff(leftObj, rightObj): + print "<<<<<<<<<<<<<<<" + if left is not None: + left.prettyPrint() + print "===============" + if right is not None: + right.prettyPrint() + print ">>>>>>>>>>>>>>>" + print + + +from optparse import OptionParser +import icalendar, base +import os +import codecs + +def main(): + options, args = getOptions() + if args: + ignore_dtstamp = options.ignore + ics_file1, ics_file2 = args + cal1 = base.readOne(file(ics_file1)) + cal2 = base.readOne(file(ics_file2)) + deleteExtraneous(cal1, ignore_dtstamp=ignore_dtstamp) + deleteExtraneous(cal2, ignore_dtstamp=ignore_dtstamp) + prettyDiff(cal1, cal2) + +version = "0.1" + +def getOptions(): + ##### Configuration options ##### + + usage = "usage: %prog [options] ics_file1 ics_file2" + parser = OptionParser(usage=usage, version=version) + parser.set_description("ics_diff will print a comparison of two iCalendar files ") + + parser.add_option("-i", "--ignore-dtstamp", dest="ignore", action="store_true", + default=False, help="ignore DTSTAMP lines [default: False]") + + (cmdline_options, args) = parser.parse_args() + if len(args) < 2: + print "error: too few arguments given" + print + print parser.format_help() + return False, False + + return cmdline_options, args + +if __name__ == "__main__": + try: + main() + except KeyboardInterrupt: + print "Aborted" diff --git a/vobject/vcard.py b/vobject/vcard.py new file mode 100644 index 0000000..01d1d42 --- /dev/null +++ b/vobject/vcard.py @@ -0,0 +1,289 @@ +"""Definitions and behavior for vCard 3.0""" + +import behavior +import itertools + +from base import VObjectError, NativeError, ValidateError, ParseError, \ + VBase, Component, ContentLine, logger, defaultSerialize, \ + registerBehavior, backslashEscape, ascii +from icalendar import stringToTextValues + +#------------------------ vCard structs ---------------------------------------- + +class Name(object): + def __init__(self, family = '', given = '', additional = '', prefix = '', + suffix = ''): + """Each name attribute can be a string or a list of strings.""" + self.family = family + self.given = given + self.additional = additional + self.prefix = prefix + self.suffix = suffix + + @staticmethod + def toString(val): + """Turn a string or array value into a string.""" + if type(val) in (list, tuple): + return ' '.join(val) + return val + + def __str__(self): + eng_order = ('prefix', 'given', 'additional', 'family', 'suffix') + out = ' '.join(self.toString(getattr(self, val)) for val in eng_order) + return ascii(out) + + def __repr__(self): + return "<Name: %s>" % self.__str__() + + def __eq__(self, other): + try: + return (self.family == other.family and + self.given == other.given and + self.additional == other.additional and + self.prefix == other.prefix and + self.suffix == other.suffix) + except: + return False + +class Address(object): + def __init__(self, street = '', city = '', region = '', code = '', + country = '', box = '', extended = ''): + """Each name attribute can be a string or a list of strings.""" + self.box = box + self.extended = extended + self.street = street + self.city = city + self.region = region + self.code = code + self.country = country + + @staticmethod + def toString(val, join_char='\n'): + """Turn a string or array value into a string.""" + if type(val) in (list, tuple): + return join_char.join(val) + return val + + lines = ('box', 'extended', 'street') + one_line = ('city', 'region', 'code') + + def __str__(self): + lines = '\n'.join(self.toString(getattr(self, val)) for val in self.lines if getattr(self, val)) + one_line = tuple(self.toString(getattr(self, val), ' ') for val in self.one_line) + lines += "\n%s, %s %s" % one_line + if self.country: + lines += '\n' + self.toString(self.country) + return ascii(lines) + + def __repr__(self): + return "<Address: %s>" % repr(str(self))[1:-1] + + def __eq__(self, other): + try: + return (self.box == other.box and + self.extended == other.extended and + self.street == other.street and + self.city == other.city and + self.region == other.region and + self.code == other.code and + self.country == other.country) + except: + False + + +#------------------------ Registered Behavior subclasses ----------------------- + +class VCardTextBehavior(behavior.Behavior): + """Provide backslash escape encoding/decoding for single valued properties. + + TextBehavior also deals with base64 encoding if the ENCODING parameter is + explicitly set to BASE64. + + """ + allowGroup = True + base64string = 'B' + + @classmethod + def decode(cls, line): + """Remove backslash escaping from line.valueDecode line, either to remove + backslash espacing, or to decode base64 encoding. The content line should + contain a ENCODING=b for base64 encoding, but Apple Addressbook seems to + export a singleton parameter of 'BASE64', which does not match the 3.0 + vCard spec. If we encouter that, then we transform the parameter to + ENCODING=b""" + if line.encoded: + if 'BASE64' in line.singletonparams: + line.singletonparams.remove('BASE64') + line.encoding_param = cls.base64string + encoding = getattr(line, 'encoding_param', None) + if encoding: + line.value = line.value.decode('base64') + else: + line.value = stringToTextValues(line.value)[0] + line.encoded=False + + @classmethod + def encode(cls, line): + """Backslash escape line.value.""" + if not line.encoded: + encoding = getattr(line, 'encoding_param', None) + if encoding and encoding.upper() == cls.base64string: + line.value = line.value.encode('base64').replace('\n', '') + else: + line.value = backslashEscape(line.value) + line.encoded=True + + +class VCardBehavior(behavior.Behavior): + allowGroup = True + defaultBehavior = VCardTextBehavior + +class VCard3_0(VCardBehavior): + """vCard 3.0 behavior.""" + name = 'VCARD' + description = 'vCard 3.0, defined in rfc2426' + versionString = '3.0' + isComponent = True + sortFirst = ('version', 'prodid', 'uid') + knownChildren = {'N': (1, 1, None),#min, max, behaviorRegistry id + 'FN': (1, 1, None), + 'VERSION': (1, 1, None),#required, auto-generated + 'PRODID': (0, 1, None), + 'LABEL': (0, None, None), + 'UID': (0, None, None), + 'ADR': (0, None, None), + 'ORG': (0, None, None), + 'PHOTO': (0, None, None), + 'CATEGORIES':(0, None, None) + } + + @classmethod + def generateImplicitParameters(cls, obj): + """Create PRODID, VERSION, and VTIMEZONEs if needed. + + VTIMEZONEs will need to exist whenever TZID parameters exist or when + datetimes with tzinfo exist. + + """ + if not hasattr(obj, 'version'): + obj.add(ContentLine('VERSION', [], cls.versionString)) +registerBehavior(VCard3_0, default=True) + +class FN(VCardTextBehavior): + name = "FN" + description = 'Formatted name' +registerBehavior(FN) + +class Label(VCardTextBehavior): + name = "Label" + description = 'Formatted address' +registerBehavior(FN) + +class Photo(VCardTextBehavior): + name = "Photo" + description = 'Photograph' + @classmethod + def valueRepr( cls, line ): + return " (BINARY PHOTO DATA at 0x%s) " % id( line.value ) + +registerBehavior(Photo) + +def toListOrString(string): + stringList = stringToTextValues(string) + if len(stringList) == 1: + return stringList[0] + else: + return stringList + +def splitFields(string): + """Return a list of strings or lists from a Name or Address.""" + return [toListOrString(i) for i in + stringToTextValues(string, listSeparator=';', charList=';')] + +def toList(stringOrList): + if isinstance(stringOrList, basestring): + return [stringOrList] + return stringOrList + +def serializeFields(obj, order=None): + """Turn an object's fields into a ';' and ',' seperated string. + + If order is None, obj should be a list, backslash escape each field and + return a ';' separated string. + """ + fields = [] + if order is None: + fields = [backslashEscape(val) for val in obj] + else: + for field in order: + escapedValueList = [backslashEscape(val) for val in + toList(getattr(obj, field))] + fields.append(','.join(escapedValueList)) + return ';'.join(fields) + +NAME_ORDER = ('family', 'given', 'additional', 'prefix', 'suffix') + +class NameBehavior(VCardBehavior): + """A structured name.""" + hasNative = True + + @staticmethod + def transformToNative(obj): + """Turn obj.value into a Name.""" + if obj.isNative: return obj + obj.isNative = True + obj.value = Name(**dict(zip(NAME_ORDER, splitFields(obj.value)))) + return obj + + @staticmethod + def transformFromNative(obj): + """Replace the Name in obj.value with a string.""" + obj.isNative = False + obj.value = serializeFields(obj.value, NAME_ORDER) + return obj +registerBehavior(NameBehavior, 'N') + +ADDRESS_ORDER = ('box', 'extended', 'street', 'city', 'region', 'code', + 'country') + +class AddressBehavior(VCardBehavior): + """A structured address.""" + hasNative = True + + @staticmethod + def transformToNative(obj): + """Turn obj.value into an Address.""" + if obj.isNative: return obj + obj.isNative = True + obj.value = Address(**dict(zip(ADDRESS_ORDER, splitFields(obj.value)))) + return obj + + @staticmethod + def transformFromNative(obj): + """Replace the Address in obj.value with a string.""" + obj.isNative = False + obj.value = serializeFields(obj.value, ADDRESS_ORDER) + return obj +registerBehavior(AddressBehavior, 'ADR') + +class OrgBehavior(VCardBehavior): + """A list of organization values and sub-organization values.""" + hasNative = True + + @staticmethod + def transformToNative(obj): + """Turn obj.value into a list.""" + if obj.isNative: return obj + obj.isNative = True + obj.value = splitFields(obj.value) + return obj + + @staticmethod + def transformFromNative(obj): + """Replace the list in obj.value with a string.""" + if not obj.isNative: return obj + obj.isNative = False + obj.value = serializeFields(obj.value) + return obj +registerBehavior(OrgBehavior, 'ORG') + diff --git a/vobject/win32tz.py b/vobject/win32tz.py new file mode 100644 index 0000000..35f997b --- /dev/null +++ b/vobject/win32tz.py @@ -0,0 +1,156 @@ +import _winreg +import struct +import datetime + +handle=_winreg.ConnectRegistry(None, _winreg.HKEY_LOCAL_MACHINE) +tzparent=_winreg.OpenKey(handle, + "SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\Time Zones") +parentsize=_winreg.QueryInfoKey(tzparent)[0] + +localkey=_winreg.OpenKey(handle, + "SYSTEM\\CurrentControlSet\\Control\\TimeZoneInformation") +WEEKS=datetime.timedelta(7) + +def list_timezones(): + """Return a list of all time zones known to the system.""" + l=[] + for i in xrange(parentsize): + l.append(_winreg.EnumKey(tzparent, i)) + return l + +class win32tz(datetime.tzinfo): + """tzinfo class based on win32's timezones available in the registry. + + >>> local = win32tz('Central Standard Time') + >>> oct1 = datetime.datetime(month=10, year=2004, day=1, tzinfo=local) + >>> dec1 = datetime.datetime(month=12, year=2004, day=1, tzinfo=local) + >>> oct1.dst() + datetime.timedelta(0, 3600) + >>> dec1.dst() + datetime.timedelta(0) + >>> braz = win32tz('E. South America Standard Time') + >>> braz.dst(oct1) + datetime.timedelta(0) + >>> braz.dst(dec1) + datetime.timedelta(0, 3600) + + """ + def __init__(self, name): + self.data=win32tz_data(name) + + def utcoffset(self, dt): + if self._isdst(dt): + return datetime.timedelta(minutes=self.data.dstoffset) + else: + return datetime.timedelta(minutes=self.data.stdoffset) + + def dst(self, dt): + if self._isdst(dt): + minutes = self.data.dstoffset - self.data.stdoffset + return datetime.timedelta(minutes=minutes) + else: + return datetime.timedelta(0) + + def tzname(self, dt): + if self._isdst(dt): return self.data.dstname + else: return self.data.stdname + + def _isdst(self, dt): + dat=self.data + dston = pickNthWeekday(dt.year, dat.dstmonth, dat.dstdayofweek, + dat.dsthour, dat.dstminute, dat.dstweeknumber) + dstoff = pickNthWeekday(dt.year, dat.stdmonth, dat.stddayofweek, + dat.stdhour, dat.stdminute, dat.stdweeknumber) + if dston < dstoff: + if dston <= dt.replace(tzinfo=None) < dstoff: return True + else: return False + else: + if dstoff <= dt.replace(tzinfo=None) < dston: return False + else: return True + + def __repr__(self): + return "<win32tz - %s>" % self.data.display + +def pickNthWeekday(year, month, dayofweek, hour, minute, whichweek): + """dayofweek == 0 means Sunday, whichweek > 4 means last instance""" + first = datetime.datetime(year=year, month=month, hour=hour, minute=minute, + day=1) + weekdayone = first.replace(day=((dayofweek - first.isoweekday()) % 7 + 1)) + for n in xrange(whichweek - 1, -1, -1): + dt=weekdayone + n * WEEKS + if dt.month == month: return dt + + +class win32tz_data(object): + """Read a registry key for a timezone, expose its contents.""" + + def __init__(self, path): + """Load path, or if path is empty, load local time.""" + if path: + keydict=valuesToDict(_winreg.OpenKey(tzparent, path)) + self.display = keydict['Display'] + self.dstname = keydict['Dlt'] + self.stdname = keydict['Std'] + + #see http://ww_winreg.jsiinc.com/SUBA/tip0300/rh0398.htm + tup = struct.unpack('=3l16h', keydict['TZI']) + self.stdoffset = -tup[0]-tup[1] #Bias + StandardBias * -1 + self.dstoffset = self.stdoffset - tup[2] # + DaylightBias * -1 + + offset=3 + self.stdmonth = tup[1 + offset] + self.stddayofweek = tup[2 + offset] #Sunday=0 + self.stdweeknumber = tup[3 + offset] #Last = 5 + self.stdhour = tup[4 + offset] + self.stdminute = tup[5 + offset] + + offset=11 + self.dstmonth = tup[1 + offset] + self.dstdayofweek = tup[2 + offset] #Sunday=0 + self.dstweeknumber = tup[3 + offset] #Last = 5 + self.dsthour = tup[4 + offset] + self.dstminute = tup[5 + offset] + + else: + keydict=valuesToDict(localkey) + + self.stdname = keydict['StandardName'] + self.dstname = keydict['DaylightName'] + + sourcekey=_winreg.OpenKey(tzparent, self.stdname) + self.display = valuesToDict(sourcekey)['Display'] + + self.stdoffset = -keydict['Bias']-keydict['StandardBias'] + self.dstoffset = self.stdoffset - keydict['DaylightBias'] + + #see http://ww_winreg.jsiinc.com/SUBA/tip0300/rh0398.htm + tup = struct.unpack('=8h', keydict['StandardStart']) + + offset=0 + self.stdmonth = tup[1 + offset] + self.stddayofweek = tup[2 + offset] #Sunday=0 + self.stdweeknumber = tup[3 + offset] #Last = 5 + self.stdhour = tup[4 + offset] + self.stdminute = tup[5 + offset] + + tup = struct.unpack('=8h', keydict['DaylightStart']) + self.dstmonth = tup[1 + offset] + self.dstdayofweek = tup[2 + offset] #Sunday=0 + self.dstweeknumber = tup[3 + offset] #Last = 5 + self.dsthour = tup[4 + offset] + self.dstminute = tup[5 + offset] + +def valuesToDict(key): + """Convert a registry key's values to a dictionary.""" + dict={} + size=_winreg.QueryInfoKey(key)[1] + for i in xrange(size): + dict[_winreg.EnumValue(key, i)[0]]=_winreg.EnumValue(key, i)[1] + return dict + +def _test(): + import win32tz, doctest + doctest.testmod(win32tz, verbose=0) + +if __name__ == '__main__': + _test()
\ No newline at end of file |