From 126a31c82abaf64ff27fc1bb5844a64a3b4e85f7 Mon Sep 17 00:00:00 2001 From: Unrud Date: Thu, 26 Apr 2018 00:05:03 +0200 Subject: [PATCH] Fork vobject --- radicale/__init__.py | 2 +- radicale/storage.py | 2 +- radicale_vobject/ACKNOWLEDGEMENTS.txt | 8 + radicale_vobject/LICENSE-2.0.txt | 202 +++ radicale_vobject/README.md | 7 + radicale_vobject/__init__.py | 88 ++ radicale_vobject/base.py | 1217 +++++++++++++++ radicale_vobject/behavior.py | 174 +++ radicale_vobject/icalendar.py | 2068 +++++++++++++++++++++++++ radicale_vobject/vcard.py | 377 +++++ setup.cfg | 2 +- setup.py | 4 +- 12 files changed, 4146 insertions(+), 5 deletions(-) create mode 100644 radicale_vobject/ACKNOWLEDGEMENTS.txt create mode 100644 radicale_vobject/LICENSE-2.0.txt create mode 100644 radicale_vobject/README.md create mode 100644 radicale_vobject/__init__.py create mode 100644 radicale_vobject/base.py create mode 100644 radicale_vobject/behavior.py create mode 100644 radicale_vobject/icalendar.py create mode 100644 radicale_vobject/vcard.py mode change 100644 => 100755 setup.py diff --git a/radicale/__init__.py b/radicale/__init__.py index 8582856..563c1d4 100644 --- a/radicale/__init__.py +++ b/radicale/__init__.py @@ -49,7 +49,7 @@ from http import client from urllib.parse import unquote, urlparse from xml.etree import ElementTree as ET -import vobject +import radicale_vobject as vobject from radicale import auth, config, log, rights, storage, web, xmlutils diff --git a/radicale/storage.py b/radicale/storage.py index c4baf24..f8215c8 100644 --- a/radicale/storage.py +++ b/radicale/storage.py @@ -44,7 +44,7 @@ from math import log from random import getrandbits from tempfile import NamedTemporaryFile, TemporaryDirectory -import vobject +import radicale_vobject as vobject if sys.version_info >= (3, 5): # HACK: Avoid import cycle for Python < 3.5 diff --git a/radicale_vobject/ACKNOWLEDGEMENTS.txt b/radicale_vobject/ACKNOWLEDGEMENTS.txt new file mode 100644 index 0000000..044911b --- /dev/null +++ b/radicale_vobject/ACKNOWLEDGEMENTS.txt @@ -0,0 +1,8 @@ +Enormous thanks to: +Jeffrey Harris, for his incredible work on the original package +Tim Baxter, for all his work maintaining vobject over the past few years +Adieu, for keeping things alive on github +Kristian Glass, for his enormous help with testing and Python3 matters +Gustavo Niemeyer, for all his work on dateutil +Dave Cridland, for helping talk about vobject and working on vcard +TJ Gabbour, for putting his heart into parsing diff --git a/radicale_vobject/LICENSE-2.0.txt b/radicale_vobject/LICENSE-2.0.txt new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/radicale_vobject/LICENSE-2.0.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/radicale_vobject/README.md b/radicale_vobject/README.md new file mode 100644 index 0000000..33daf6f --- /dev/null +++ b/radicale_vobject/README.md @@ -0,0 +1,7 @@ +# Radicale VObject + +Fork of [VObject](https://github.com/eventable/vobject). + +Radicale VObject is licensed under the [Apache 2.0 license](http://www.apache.org/licenses/LICENSE-2.0). + +Upstream git commit: [c4ae08b767](https://github.com/eventable/vobject/commit/c4ae08b7678bfdeec3c6b2dcbf74d383fd27ed14) diff --git a/radicale_vobject/__init__.py b/radicale_vobject/__init__.py new file mode 100644 index 0000000..416dea7 --- /dev/null +++ b/radicale_vobject/__init__.py @@ -0,0 +1,88 @@ +""" +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}s can be + parsed using L{readComponents}. As each Component + is parsed, vobject will attempt to give it a L{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} classes implement validation for + L{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} 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') + + >>> x + ]> + >>> v = x.vevent + >>> utc = icalendar.utc + >>> v.add('dtstart').value = datetime.datetime(2004, 12, 15, 14, tzinfo = utc) + >>> v + ]> + >>> x + ]>]> + >>> 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 + +""" + +from .base import newFromBehavior, readOne, readComponents +from . import icalendar, vcard + + +def iCalendar(): + return newFromBehavior('vcalendar', '2.0') + + +def vCard(): + return newFromBehavior('vcard', '3.0') diff --git a/radicale_vobject/base.py b/radicale_vobject/base.py new file mode 100644 index 0000000..26a0d39 --- /dev/null +++ b/radicale_vobject/base.py @@ -0,0 +1,1217 @@ +"""vobject module for reading vCard and vCalendar files.""" + +from __future__ import print_function + +import copy +import codecs +import logging +import re +import six +import sys + +# ------------------------------------ Python 2/3 compatibility challenges ---- +# Python 3 no longer has a basestring type, so.... +try: + basestring = basestring +except NameError: + basestring = (str, bytes) + +# One more problem ... in python2 the str operator breaks on unicode +# objects containing non-ascii characters +try: + unicode + + def str_(s): + """ + Return byte string with correct encoding + """ + if type(s) == unicode: + return s.encode('utf-8') + else: + return str(s) +except NameError: + def str_(s): + """ + Return string + """ + return s + +if not isinstance(b'', type('')): + unicode_type = str +else: + unicode_type = unicode # noqa + + +def to_unicode(value): + """Converts a string argument to a unicode string. + + If the argument is already a unicode string, it is returned + unchanged. Otherwise it must be a byte string and is decoded as utf8. + """ + if isinstance(value, unicode_type): + return value + + return value.decode('utf-8') + + +def to_basestring(s): + """Converts a string argument to a byte string. + + If the argument is already a byte string, it is returned unchanged. + Otherwise it must be a unicode string and is encoded as utf8. + """ + if isinstance(s, bytes): + return s + + return s.encode('utf-8') + +# ------------------------------------ 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 + +# --------------------------------- 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 spec. + + Current spec: 4.0 (http://tools.ietf.org/html/rfc6350) + """ + 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) + 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: + self_orig = copy.copy(self) + try: + return self.behavior.transformToNative(self) + except Exception as 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 on line {0}: {1}: {2}" + msg = msg.format(lineNumber, sys.exc_info()[0], sys.exc_info()[1]) + msg = msg + " (" + str(self_orig) + ")" + raise ParseError(msg, lineNumber) + + 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 as 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 on line {0} {1}: {2}" + msg = msg.format(lineNumber, sys.exc_info()[0], sys.exc_info()[1]) + raise NativeError(msg, lineNumber) + 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 {0!s} with behavior {1!s}".format(self.name, behavior)) + return behavior.serialize(self, buf, lineLength, validate) + else: + if DEBUG: + logger.debug("serializing {0!s} without behavior".format(self.name)) + return defaultSerialize(self, buf, lineLength) + + +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:: + + + @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.encoded = encoded + self.params = {} + self.singletonparams = [] + self.isNative = isNative + self.lineNumber = lineNumber + self.value = value + + def updateTable(x): + if len(x) == 1: + self.singletonparams += x + else: + paramlist = self.params.setdefault(x[0].upper(), []) + paramlist.extend(x[1:]) + + list(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 len(self.params['ENCODING']) == 0: + del self.params['ENCODING'] + if 'QUOTED-PRINTABLE' in self.singletonparams: + qp = True + self.singletonparams.remove('QUOTED-PRINTABLE') + if qp: + if 'ENCODING' in self.params: + self.value = codecs.decode(self.value.encode("utf-8"), "quoted-printable").decode(self.params['ENCODING']) + else: + if 'CHARSET' in self.params: + self.value = codecs.decode(self.value.encode("utf-8"), "quoted-printable").decode(self.params['CHARSET'][0]) + else: + self.value = codecs.decode(self.value.encode("utf-8"), "quoted-printable").decode('utf-8') + + @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) + for k, v in self.params.items(): + self.params[k] = copy.copy(v) + 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 Exception: + 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 AttributeError(name) + except KeyError: + raise 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 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 v + + def __str__(self): + try: + return "<{0}{1}{2}>".format(self.name, self.params, self.valueRepr()) + except UnicodeEncodeError as e: + return "<{0}{1}{2}>".format(self.name, self.params, self.valueRepr().encode('utf-8')) + + def __repr__(self): + return self.__str__() + + def __unicode__(self): + return u"<{0}{1}{2}>".format(self.name, self.params, self.valueRepr()) + + def prettyPrint(self, level=0, tabwidth=3): + pre = ' ' * level * tabwidth + print(pre, self.name + ":", self.valueRepr()) + if self.params: + print(pre, "params for ", self.name + ':') + for k in self.params.keys(): + print(pre + ' ' * tabwidth, k, self.params[k]) + + +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(cls, copyit): + newcopy = cls() + 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. + """ + # if the object is being re-created by pickle, self.contents may not + # be set, don't get into an infinite loop over the issue + if name == 'contents': + return object.__getattribute__(self, name) + try: + if name.endswith('_list'): + return self.contents[toVName(name, 5)] + else: + return self.contents[toVName(name)][0] + except KeyError: + raise 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 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 Exception: + 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 child in childArray: + child = child.transformToNative() + child.transformChildrenToNative() + + def transformChildrenFromNative(self, clearBehavior=True): + """ + Recursively transform native children to vanilla representations. + """ + for childArray in self.contents.values(): + for child in childArray: + child = child.transformFromNative() + child.transformChildrenFromNative(clearBehavior) + if clearBehavior: + child.behavior = None + child.parentBehavior = None + + def __str__(self): + if self.name: + return "<{0}| {1}>".format(self.name, self.getSortedChildren()) + else: + return u'<*unnamed*| {0}>'.format(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) + + +class VObjectError(Exception): + def __init__(self, msg, lineNumber=None): + self.msg = msg + if lineNumber is not None: + self.lineNumber = lineNumber + + def __str__(self): + if hasattr(self, 'lineNumber'): + return "At line {0!s}: {1!s}".format(self.lineNumber, self.msg) + else: + return repr(self.msg) + + +class ParseError(VObjectError): + pass + + +class ValidateError(VObjectError): + pass + + +class NativeError(VObjectError): + pass + + +# --------- Parsing functions and 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} 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} * '.format(**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} + ) +""".format(**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 +)* +""".format(**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 + ) +)? +""".format(**patterns) + +# get a full content line, break it up into group, name, parameters, and value +patterns['line'] = r""" +^ ((?P {name!s})\.)?(?P {name!s}) # name group + (?P (?: {param!s} )* ) # params group (may be empty) +: (?P .* )$ # value group +""".format(**patterns) + +' "%(qsafe_char)s*" | %(safe_char)s* ' # what is this line?? - never assigned? + +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): + """ + Parse parameters + """ + 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): + """ + Parse line + """ + match = line_re.match(line) + if match is None: + raise ParseError("Failed to parse line: {0!s}".format(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 ]'.format(**patterns) +patterns['logicallines'] = r""" +( + (?: [^\r\n] | {wrap!s} )* + {lineend!s} +) +""".format(**patterns) + +patterns['wraporend'] = r'({wrap!s} | {lineend!s} )'.format(**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): + """ + 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. + + # We're leaving this test in for awhile, because the unittest was ugly and dumb. + >>> from six import StringIO + >>> f=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: + val = fp.read(-1) + + 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 = six.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.tell() > 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.tell() > 0: + yield logicalLine.getvalue(), lineStartNumber + lineStartNumber = lineNumber + logicalLine = newbuffer() + logicalLine.write(line) + else: + logicalLine = newbuffer() + logicalLine.write(line) + + # 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.tell() > 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 + + TO-DO: This all seems odd. Is it still needed, especially in python3? + """ + if len(input) < lineLength: + # Optimize for unfolded line case + try: + outbuf.write(bytes(input, 'UTF-8')) + except Exception: + # fall back on py2 syntax + outbuf.write(input) + + else: + # Look for valid utf8 range and write that out + start = 0 + written = 0 + counter = 0 # counts line size in bytes + decoded = to_unicode(input) + length = len(to_basestring(input)) + while written < length: + s = decoded[start] # take one char + size = len(to_basestring(s)) # calculate it's size in bytes + if counter + size > lineLength: + try: + outbuf.write(bytes("\r\n ", 'UTF-8')) + except Exception: + # fall back on py2 syntax + outbuf.write("\r\n ") + + counter = 1 # one for space + + if str is unicode_type: + outbuf.write(to_unicode(s)) + else: + # fall back on py2 syntax + outbuf.write(s.encode('utf-8')) + + written += size + counter += size + start += 1 + try: + outbuf.write(bytes("\r\n", 'UTF-8')) + except Exception: + # fall back on py2 syntax + 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 six.StringIO() + + if isinstance(obj, Component): + if obj.group is None: + groupString = '' + else: + groupString = obj.group + '.' + if obj.useBegin: + foldOneLine(outbuf, "{0}BEGIN:{1}".format(groupString, 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, "{0}END:{1}".format(groupString, obj.name), + lineLength) + + elif isinstance(obj, ContentLine): + startedEncoded = obj.encoded + if obj.behavior and not startedEncoded: + obj.behavior.encode(obj) + + s = six.StringIO() + + if obj.group is not None: + s.write(obj.group + '.') + s.write(str_(obj.name.upper())) + keys = sorted(obj.params.keys()) + for key in keys: + paramstr = ','.join(dquoteEscape(p) for p in obj.params[key]) + s.write(";{0}={1}".format(key, paramstr)) + try: + s.write(":{0}".format(obj.value)) + except (UnicodeDecodeError, UnicodeEncodeError): + s.write(":{0}".format(obj.value.encode('utf-8'))) + if obj.behavior and not startedEncoded: + obj.behavior.decode(obj) + foldOneLine(outbuf, s.getvalue(), lineLength) + + return buf or outbuf.getvalue() + + +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, + ignoreUnreadable=False, allowQP=False): + """ + Generate one Component at a time from a stream. + """ + if isinstance(streamOrString, basestring): + stream = six.StringIO(streamOrString) + else: + stream = streamOrString + + try: + stack = Stack() + versionLine = None + n = 0 + for line, n in getLogicalLines(stream, allowQP): + if ignoreUnreadable: + try: + vline = textLineToContentLine(line, n) + except VObjectError as e: + if e.lineNumber is not None: + msg = "Skipped line {lineNumber}, message: {msg}" + else: + msg = "Skipped a line, message: {msg}" + logger.error(msg.format(**{'lineNumber': e.lineNumber, 'msg': str(e)})) + 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 {0} component but it was never opened" + raise ParseError(err.format(vline.value), 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) + else: + behavior = getBehavior(component.name) + if behavior: + component.setBehavior(behavior) + if validate: + component.validate(raiseException=True) + if transform: + component.transformChildrenToNative() + yield component # EXIT POINT + else: + stack.modifyTop(stack.pop()) + else: + err = "{0} component wasn't closed" + raise ParseError(err.format(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 {0!s} was never closed".format( + (stack.topName())), n) + yield stack.pop() + + except ParseError as e: + e.input = streamOrString + raise + + +def readOne(stream, validate=False, transform=True, ignoreUnreadable=False, + allowQP=False): + """ + Return the first component from stream. + """ + return next(readComponents(stream, validate, transform, ignoreUnreadable, + allowQP)) + + +# --------------------------- 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 + + 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 {0!s}".format(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") diff --git a/radicale_vobject/behavior.py b/radicale_vobject/behavior.py new file mode 100644 index 0000000..0f2cd41 --- /dev/null +++ b/radicale_vobject/behavior.py @@ -0,0 +1,174 @@ +from . import base + + +#------------------------ Abstract class for behavior -------------------------- +class Behavior(object): + """ + Behavior (validation, encoding, and transformations) for vobjects. + + 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} or + L{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 = "{0} has a group, but this object doesn't support groups".format(obj) + 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.items(): + if count.get(key, 0) < val[0]: + if raiseException: + m = "{0} components must contain at least {1} {2}" + raise base.ValidateError(m .format(cls.name, val[0], key)) + return False + if val[1] and count.get(key, 0) > val[1]: + if raiseException: + m = "{0} components cannot contain more than {1} {2}" + raise base.ValidateError(m.format(cls.name, val[1], key)) + return False + return True + else: + err = "{0} is not a Component or Contentline".format(obj) + 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 diff --git a/radicale_vobject/icalendar.py b/radicale_vobject/icalendar.py new file mode 100644 index 0000000..bcc4fab --- /dev/null +++ b/radicale_vobject/icalendar.py @@ -0,0 +1,2068 @@ +"""Definitions and behavior for iCalendar, also known as vCalendar 2.0""" + +from __future__ import print_function + +import datetime +import logging +import random # for generating a UID +import socket +import string +import base64 + +from dateutil import rrule, tz +import six + +try: + import pytz +except ImportError: + class Pytz: + """fake pytz module (pytz is not required)""" + + class AmbiguousTimeError(Exception): + """pytz error for ambiguous times + during transition daylight->standard""" + + class NonExistentTimeError(Exception): + """pytz error for non-existent times + during transition standard->daylight""" + + pytz = Pytz # keeps quantifiedcode happy + +from . import behavior +from .base import (VObjectError, NativeError, ValidateError, ParseError, + Component, ContentLine, logger, registerBehavior, + backslashEscape, foldOneLine) + + +# ------------------------------- 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, six.binary_type): + s = s.decode('utf-8') + return s + + +def registerTzid(tzid, tzinfo): + """ + Register a tzid -> tzinfo mapping. + """ + __tzidMap[toUnicode(tzid)] = tzinfo + + +def getTzid(tzid, smart=True): + """ + Return the tzid if it exists, or None. + """ + tz = __tzidMap.get(toUnicode(tzid), None) + if smart and tzid and not tz: + try: + from pytz import timezone, UnknownTimeZoneError + try: + tz = timezone(tzid) + registerTzid(toUnicode(tzid), tz) + except UnknownTimeZoneError as e: + logging.error(e) + except ImportError as e: + logging.error(e) + return tz + +utc = tz.tzutc() +registerTzid("UTC", utc) + + +# -------------------- Helper subclasses --------------------------------------- +class TimezoneComponent(Component): + """ + A VTIMEZONE object. + + VTIMEZONEs are parsed by 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, False): + 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 = six.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 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 nth week of the month or the nth from the last + for year in range(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 + try: + old_offset = tzinfo.utcoffset(transition - twoHours) + name = tzinfo.tzname(transition) + offset = tzinfo.utcoffset(transition) + except (pytz.AmbiguousTimeError, pytz.NonExistentTimeError): + # guaranteed that tzinfo is a pytz timezone + is_dst = (transitionTo == "daylight") + old_offset = tzinfo.utcoffset(transition - twoHours, is_dst=is_dst) + name = tzinfo.tzname(transition, is_dst=is_dst) + offset = tzinfo.utcoffset(transition, is_dst=is_dst) + rule = {'end' : None, # None, or an integer year + 'start' : transition, # the datetime of transition + 'month' : transition.month, + 'weekday' : transition.weekday(), + 'hour' : transition.hour, + 'name' : name, + 'plus' : int( + (transition.day - 1)/ 7 + 1), # nth week of the month + 'minus' : fromLastWeek(transition), # nth from last week + 'offset' : offset, + '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 # unused? + 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 = rrule.weekday(rule['weekday'], num) + du_rule = rrule.rrule(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 = '' + new_rule = "FREQ=YEARLY{0!s};BYMONTH={1!s}{2!s}"\ + .format(dayString, rule['month'], endString) + + comp.add('rrule').value = new_rule + + 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 range(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 {0!s}" + .format(tzinfo)) + + def __str__(self): + return "".format(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. + + @ivar rruleset: + A U{rruleset}. + """ + def __init__(self, *args, **kwds): + super(RecurringComponent, self).__init__(*args, **kwds) + + self.isNative = True + + 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 or RDATE, 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 = rrule.rruleset() + if addfunc is None: + addfunc = getattr(rruleset, name) + + dtstart = self.dtstart.value + + if name in DATENAMES: + if type(line.value[0]) == datetime.datetime: + list(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 + logging.error('failed to get dtstart with VTODO') + return None + except (AttributeError, KeyError): + # if there's no due, just return None + logging.error('failed to find DUE at all.') + return None + + # a Ruby iCalendar library escapes semi-colons in rrules, + # so also remove any backslashes + value = line.value.replace('\\', '') + rule = rrule.rrulestr( + value, dtstart=dtstart, + # If dtstart has no time zone, `until` + # shouldn't get one, either: + ignoretz=isinstance(dtstart, datetime.date)) + 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) + + # RFC2445 actually states that UNTIL must be a UTC + # value. Whilst the changes above work OK, one problem + # case is if DTSTART is floating but UNTIL is properly + # specified as UTC (or with a TZID). In that case + # dateutil will fail datetime comparisons. There is no + # easy solution to this as there is no obvious timezone + # (at this point) to do proper floating time offset + # comparisons. The best we can do is treat the UNTIL + # value as floating. This could mean incorrect + # determination of the last instance. The better + # solution here is to encourage clients to use COUNT + # rather than UNTIL when DTSTART is floating. + if dtstart.tzinfo is None: + until = until.replace(tzinfo=None) + + rule._until = until + + # add the rrule or exrule to the rruleset + addfunc(rule) + + if (name == 'rrule' or name == 'rdate') and addRDate: + # rlist = rruleset._rrule if name == 'rrule' else rruleset._rdate + 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 name == 'rrule': + if rruleset._rrule[-1][0] != adddtstart: + rruleset.rdate(adddtstart) + added = True + if rruleset._rrule[-1]._count is not None: + rruleset._rrule[-1]._count -= 1 + else: + added = False + elif name == 'rdate': + if rruleset._rdate[0] != adddtstart: + rruleset.rdate(adddtstart) + added = True + else: + added = False + except IndexError: + # it's conceivable that an rrule has 0 datetimes + added = False + + 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 name in self.contents: + 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 = six.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 ( + 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(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 <= 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._bynmonthday is not None and len(rule._bynmonthday) > 0: + values.setdefault('BYMONTHDAY', []).extend(str(n) for n in rule._bynmonthday) + + 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 == 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.items(): + 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 = base64.b64decode(line.value) + 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 = base64.b64encode(line.value.encode('utf-8')).decode('utf-8').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 and DTSTAMP if one does not exist. + + This is just a dummy implementation, for now. + """ + if not hasattr(obj, 'uid'): + rand = int(random.random() * 100000) + now = datetime.datetime.now(utc) + now = dateTimeToString(now) + host = socket.gethostname() + obj.add(ContentLine('UID', [], "{0} - {1}@{2}".format(now, rand, + host))) + + if not hasattr(obj, 'dtstamp'): + now = datetime.datetime.now(utc) + obj.add('dtstamp').value = now + + +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 = 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 = 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. + """ + listSeparator = "," + + @classmethod + def decode(cls, line): + """ + Remove backslash escaping from line.value, then split on commas. + """ + if line.encoded: + line.value = stringToTextValues(line.value, + listSeparator=cls.listSeparator) + line.encoded = False + + @classmethod + def encode(cls, line): + """ + Backslash escape line.value. + """ + if not line.encoded: + line.value = cls.listSeparator.join(backslashEscape(val) + for val in line.value) + line.encoded = True + + +class SemicolonMultiTextBehavior(MultiTextBehavior): + listSeparator = ";" + + +# ------------------------ 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))) + + @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 = None + outbuf = buf or six.StringIO() + if obj.group is None: + groupString = '' + else: + groupString = obj.group + '.' + if obj.useBegin: + foldOneLine(outbuf, "{0}BEGIN:{1}".format(groupString, obj.name), + lineLength) + + try: + first_props = [s for s in cls.sortFirst if s in obj.contents \ + and not isinstance(obj.contents[s][0], Component)] + first_components = [s for s in cls.sortFirst if s in obj.contents \ + and isinstance(obj.contents[s][0], Component)] + except Exception: + first_props = first_components = [] + # first_components = [] + + prop_keys = sorted(list(k for k in obj.contents.keys() if k not in first_props \ + and not isinstance(obj.contents[k][0], Component))) + comp_keys = sorted(list(k for k in obj.contents.keys() if k not in first_components \ + and isinstance(obj.contents[k][0], Component))) + + sorted_keys = first_props + prop_keys + first_components + comp_keys + children = [o for k in sorted_keys for o in obj.contents[k]] + + for child in children: + # validate is recursive, we only need to validate once + child.serialize(outbuf, lineLength, validate=False) + if obj.useBegin: + foldOneLine(outbuf, "{0}END:{1}".format(groupString, obj.name), + lineLength) + out = buf or outbuf.getvalue() + if undoTransform: + obj.transformToNative() + return out +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 'standard' in obj.contents or 'daylight' in obj.contents: + 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 TZID(behavior.Behavior): + """ + Don't use TextBehavior for TZID. + + RFC2445 only allows TZID lines to be paramtext, so they shouldn't need any + encoding or decoding. Unfortunately, some Microsoft products use commas + in TZIDs which should NOT be treated as a multi-valued text property, nor + do we want to escape them. Leaving them alone works for Microsoft's breakage, + and doesn't affect compliant iCalendar streams. + """ +registerBehavior(TZID) + + +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': (1, 1, None), # required + '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 'dtend' in obj.contents and 'duration' in obj.contents: + 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': (1, 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 'due' in obj.contents and 'duration' in obj.contents: + 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': (1, 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. + """ + 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': (1, 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 + 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. + + Used to represent user's available time slots. + """ + 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 'dtend' in obj.contents and 'duration' in obj.contents: + 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 = 'dtend' in obj.contents + has_duration = 'duration' in obj.contents + 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 = 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.warning("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. + """ + 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, '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'] +list(map(lambda x: registerBehavior(UTCDateTimeBehavior, x), utcDateTimeList)) + +dateTimeOrDateList = ['DTEND', 'DTSTART', 'DUE', 'RECURRENCE-ID'] +list(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', 'BUSYTYPE'] +list(map(lambda x: registerBehavior(TextBehavior, x), textList)) + +list(map(lambda x: registerBehavior(MultiTextBehavior, x), ['CATEGORIES', + 'RESOURCES'])) +registerBehavior(SemicolonMultiTextBehavior, 'REQUEST-STATUS') + + +# ------------------------ 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 ical DURATION. + """ + if delta.days == 0: + sign = 1 + else: + sign = delta.days / abs(delta.days) + delta = abs(delta) + days = delta.days + hours = int(delta.seconds / 3600) + minutes = int((delta.seconds % 3600) / 60) + seconds = int(delta.seconds % 60) + + output = '' + if sign == -1: + output += '-' + output += 'P' + if days: + output += '{}D'.format(days) + if hours or minutes or seconds: + output += 'T' + elif not days: # Deal with zero duration + output += 'T0S' + if hours: + output += '{}H'.format(hours) + if minutes: + output += '{}M'.format(minutes) + if seconds: + output += '{}S'.format(seconds) + return output + + +def timeToString(dateOrDateTime): + """ + Wraps dateToString and dateTimeToString, returning the results + of either based on the type of the argument + """ + if hasattr(dateOrDateTime, 'hour'): + return dateTimeToString(dateOrDateTime) + return dateToString(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) + + datestr = "{0}{1}{2}T{3}{4}{5}".format( + numToDigits(dateTime.year, 4), + numToDigits(dateTime.month, 2), + numToDigits(dateTime.day, 2), + numToDigits(dateTime.hour, 2), + numToDigits(dateTime.minute, 2), + numToDigits(dateTime.second, 2), + ) + if tzinfo_eq(dateTime.tzinfo, utc): + datestr += "Z" + return datestr + + +def deltaToOffset(delta): + absDelta = abs(delta) + hours = int(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 = s.upper() + return (s.find("P") != -1) and (s.find("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 = getTzid('UTC') + except: + raise ParseError("'{0!s}' is not a valid DATE-TIME".format(s)) + year = year and year or 2000 + if tzinfo is not None and hasattr(tzinfo,'localize'): # PyTZ case + return tzinfo.localize(datetime.datetime(year, month, day, hour, minute, second)) + return datetime.datetime(year, month, day, hour, minute, second, 0, tzinfo) + + +# DQUOTE included to work around iCal's penchant for backslash escaping it, +# although it isn't actually supposed to be escaped according to rfc2445 TEXT +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: + logging.error(msg) + + # vars which control state machine + charIterator = enumerate(s) + state = "read normal" + + current = [] + results = [] + + while True: + try: + charIndex, char = next(charIterator) + except: + char = "eof" + + if state == "read normal": + if char == '\\': + state = "read escaped char" + elif char == listSeparator: + state = "read normal" + current = "".join(current) + results.append(current) + current = [] + elif char == "eof": + state = "end" + else: + state = "read normal" + current.append(char) + + elif state == "read escaped char": + if escapableChar(char): + state = "read normal" + if char in 'nN': + current.append('\n') + else: + current.append(char) + else: + state = "read normal" + # leave unrecognized escaped characters for later passes + current.append('\\' + char) + + elif state == "end": # an end state + if len(current) or len(results) == 0: + current = "".join(current) + results.append(current) + return results + + elif state == "error": # an end state + return results + + else: + state = "error" + error("unknown state: '{0!s}' reached in {1!s}".format(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) + + # 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 = next(charIterator) + except: + 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" + error("got unexpected character {0} reading in duration: {1}" + .format(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 + 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("unknown state: '{0!s}' reached in {1!s}".format(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 = s.split("/") + 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): + try: + return tzinfo.dst(dt) != zeroDelta + except pytz.NonExistentTimeError: + return True # entering daylight time + except pytz.AmbiguousTimeError: + return False # entering standard time + elif transitionTo == 'standard': + def test(dt): + try: + return tzinfo.dst(dt) == zeroDelta + except pytz.NonExistentTimeError: + return False # entering daylight time + except pytz.AmbiguousTimeError: + return True # entering standard time + 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 range(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/radicale_vobject/vcard.py b/radicale_vobject/vcard.py new file mode 100644 index 0000000..e966e97 --- /dev/null +++ b/radicale_vobject/vcard.py @@ -0,0 +1,377 @@ +"""Definitions and behavior for vCard 3.0""" + +import codecs + +from . import behavior + +from .base import ContentLine, registerBehavior, backslashEscape, str_ +from .icalendar import stringToTextValues, DateOrDateTimeBehavior + + +# Python 3 no longer has a basestring type, so.... +try: + basestring = basestring +except NameError: + basestring = (str, bytes) + +# ------------------------ 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 str_(out) + + def __repr__(self): + return "".format(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{0!s}, {1!s} {2!s}".format(*one_line) + if self.country: + lines += '\n' + self.toString(self.country) + return lines + + def __repr__(self): + return "".format(self) + + 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: + return 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: + if isinstance(line.value, bytes): + line.value = codecs.decode(line.value, "base64") + else: + line.value = codecs.decode(line.value.encode("utf-8"), "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: + if isinstance(line.value, bytes): + line.value = codecs.encode(line.value, "base64").decode("utf-8").replace('\n', '') + else: + line.value = codecs.encode(line.value.encode(encoding), "base64").decode("utf-8") + 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': (0, 1, None), # min, max, behaviorRegistry id + 'FN': (1, None, 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), + 'REV': (0, 1, 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(Label) + + +class REV(DateOrDateTimeBehavior): + name = "REV" + description = 'Current revision of this vCard' +registerBehavior(REV) + +wacky_apple_photo_serialize = True +REALLY_LARGE = 1E50 + + +class Photo(VCardTextBehavior): + name = "Photo" + description = 'Photograph' + + @classmethod + def valueRepr(cls, line): + return " (BINARY PHOTO DATA at 0x{0!s}) ".format(id(line.value)) + + @classmethod + def serialize(cls, obj, buf, lineLength, validate): + """ + Apple's Address Book is *really* weird with images, it expects + base64 data to have very specific whitespace. It seems Address Book + can handle PHOTO if it's not wrapped, so don't wrap it. + """ + if wacky_apple_photo_serialize: + lineLength = REALLY_LARGE + VCardTextBehavior.serialize(obj, buf, lineLength, validate) + +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') +ADDRESS_ORDER = ('box', 'extended', 'street', 'city', 'region', 'code', + 'country') + + +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') + + +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/setup.cfg b/setup.cfg index 6c7e202..83848de 100644 --- a/setup.cfg +++ b/setup.cfg @@ -6,4 +6,4 @@ python-tag = py3 [tool:pytest] addopts = --flake8 --isort --cov radicale -r s -norecursedirs = dist .cache .git build Radicale.egg-info .eggs venv +norecursedirs = dist .cache .git build Radicale.egg-info .eggs venv radicale_vobject diff --git a/setup.py b/setup.py old mode 100644 new mode 100755 index 54debe2..1e97353 --- a/setup.py +++ b/setup.py @@ -63,10 +63,10 @@ setup( "Radicale-%s.tar.gz" % VERSION), license="GNU GPL v3", platforms="Any", - packages=["radicale"], + packages=["radicale", "radicale_vobject"], package_data={"radicale": WEB_FILES}, entry_points={"console_scripts": ["radicale = radicale.__main__:run"]}, - install_requires=["vobject==0.9.5", "python-dateutil==2.6.1"], + install_requires=["python-dateutil==2.6.1"], setup_requires=pytest_runner, tests_require=tests_require, extras_require={