diff options
author | Tobias Brox <tobias@redpill-linpro.com> | 2017-01-15 11:25:00 +0100 |
---|---|---|
committer | Tobias Brox <tobias@redpill-linpro.com> | 2017-01-15 11:25:00 +0100 |
commit | 7e933bd6e96440ba791effd6540260fbe92c4157 (patch) | |
tree | 81ea900869569198c78e3494394b434e35590b95 /caldav | |
download | python-caldav-7e933bd6e96440ba791effd6540260fbe92c4157.zip |
bedework.caldav-servers.tobixen.no is now up and running
Diffstat (limited to 'caldav')
-rw-r--r-- | caldav/__init__.py | 21 | ||||
-rw-r--r-- | caldav/davclient.py | 250 | ||||
-rw-r--r-- | caldav/elements/__init__.py | 4 | ||||
-rw-r--r-- | caldav/elements/base.py | 70 | ||||
-rw-r--r-- | caldav/elements/cdav.py | 130 | ||||
-rw-r--r-- | caldav/elements/dav.py | 61 | ||||
-rw-r--r-- | caldav/lib/__init__.py | 0 | ||||
-rw-r--r-- | caldav/lib/error.py | 46 | ||||
-rw-r--r-- | caldav/lib/namespace.py | 23 | ||||
-rw-r--r-- | caldav/lib/python_utilities.py | 32 | ||||
-rw-r--r-- | caldav/lib/url.py | 186 | ||||
-rw-r--r-- | caldav/lib/vcal.py | 16 | ||||
-rw-r--r-- | caldav/objects.py | 856 |
13 files changed, 1695 insertions, 0 deletions
diff --git a/caldav/__init__.py b/caldav/__init__.py new file mode 100644 index 0000000..28fb289 --- /dev/null +++ b/caldav/__init__.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python + +from .davclient import DAVClient +from .objects import * + +## possibly a bug in the tBaxter fork of vobject, this one has to be +## imported explicitly to make sure the attribute behaviour gets +## correctly loaded: +from vobject import icalendar +import logging + +# Silence notification of no default logging handler +log = logging.getLogger("caldav") + + +class NullHandler(logging.Handler): + def emit(self, record): + pass + + +log.addHandler(NullHandler()) diff --git a/caldav/davclient.py b/caldav/davclient.py new file mode 100644 index 0000000..42d9ae8 --- /dev/null +++ b/caldav/davclient.py @@ -0,0 +1,250 @@ +#!/usr/bin/env python +# -*- encoding: utf-8 -*- + +import requests +import logging +from caldav.lib.python_utilities import isPython3, to_unicode, to_wire +if isPython3(): + from urllib import parse + from urllib.parse import unquote +else: + from urlparse import unquote, urlparse as parse +import re +from lxml import etree + +from caldav.lib import error +from caldav.lib.url import URL +from caldav.objects import Principal + +log = logging.getLogger('caldav') + + +class DAVResponse: + """ + This class is a response from a DAV request. It is instantiated from + the DAVClient class. End users of the library should not need to + know anything about this class. Since we often get XML responses, + it tries to parse it into `self.tree` + """ + raw = "" + reason = "" + tree = None + headers = {} + status = 0 + + def __init__(self, response): + self.raw = response.content + self.headers = response.headers + self.status = response.status_code + self.reason = response.reason + log.debug("response headers: " + str(self.headers)) + log.debug("response status: " + str(self.status)) + log.debug("raw response: " + str(self.raw)) + + try: + self.tree = etree.XML(self.raw) + except: + self.tree = None + + +class DAVClient: + """ + Basic client for webdav, uses the requests lib; gives access to + low-level operations towards the caldav server. + + Unless you have special needs, you should probably care most about + the __init__ and principal methods. + """ + proxy = None + url = None + + def __init__(self, url, proxy=None, username=None, password=None, auth=None, ssl_verify_cert=True): + """ + Sets up a HTTPConnection object towards the server in the url. + Parameters: + * url: A fully qualified url: `scheme://user:pass@hostname:port` + * proxy: A string defining a proxy server: `hostname:port` + * username and password should be passed as arguments or in the URL + * auth and ssl_verify_cert is passed to requests.request. + ** ssl_verify_cert can be the path of a CA-bundle or False. + """ + + log.debug("url: " + str(url)) + self.url = URL.objectify(url) + + # Prepare proxy info + if proxy is not None: + self.proxy = proxy + if re.match('^.*://', proxy) is None: # requests library expects the proxy url to have a scheme + self.proxy = self.url.scheme + '://' + proxy + + # add a port is one is not specified + # TODO: this will break if using basic auth and embedding + # username:password in the proxy URL + p = self.proxy.split(":") + if len(p) == 2: + self.proxy += ':8080' + log.debug("init - proxy: %s" % (self.proxy)) + + # Build global headers + self.headers = {"User-Agent": "Mozilla/5.0", + "Content-Type": "text/xml", + "Accept": "text/xml"} + if self.url.username is not None: + username = unquote(self.url.username) + password = unquote(self.url.password) + + self.username = username + self.password = password + self.auth = auth ## TODO: it's possible to force through a specific auth method here, but no test code for this. + self.ssl_verify_cert = ssl_verify_cert + self.url = self.url.unauth() + log.debug("self.url: " + str(url)) + + def principal(self): + """ + Convenience method, it gives a bit more object-oriented feel to + write client.principal() than Principal(client). + + This method returns a :class:`caldav.Principal` object, with + higher-level methods for dealing with the principals + calendars. + """ + return Principal(self) + + def propfind(self, url=None, props="", depth=0): + """ + Send a propfind request. + + Parameters: + * url: url for the root of the propfind. + * props = (xml request), properties we want + * depth: maximum recursion depth + + Returns + * DAVResponse + """ + return self.request(url or self.url, "PROPFIND", props, {'Depth': str(depth)}) + + def proppatch(self, url, body, dummy=None): + """ + Send a proppatch request. + + Parameters: + * url: url for the root of the propfind. + * body: XML propertyupdate request + * dummy: compatibility parameter + + Returns + * DAVResponse + """ + return self.request(url, "PROPPATCH", body) + + def report(self, url, query="", depth=0): + """ + Send a report request. + + Parameters: + * url: url for the root of the propfind. + * query: XML request + * depth: maximum recursion depth + + Returns + * DAVResponse + """ + return self.request(url, "REPORT", query, + {'Depth': str(depth), "Content-Type": + "application/xml; charset=\"utf-8\""}) + + def mkcol(self, url, body, dummy=None): + """ + Send a mkcol request. + + Parameters: + * url: url for the root of the mkcol + * body: XML request + * dummy: compatibility parameter + + Returns + * DAVResponse + """ + return self.request(url, "MKCOL", body) + + def mkcalendar(self, url, body="", dummy=None): + """ + Send a mkcalendar request. + + Parameters: + * url: url for the root of the mkcalendar + * body: XML request + * dummy: compatibility parameter + + Returns + * DAVResponse + """ + return self.request(url, "MKCALENDAR", body) + + def put(self, url, body, headers={}): + """ + Send a put request. + """ + return self.request(url, "PUT", body, headers) + + def delete(self, url): + """ + Send a delete request. + """ + return self.request(url, "DELETE") + + def request(self, url, method="GET", body="", headers={}): + """ + Actually sends the request + """ + + # objectify the url + url = URL.objectify(url) + + proxies = None + if self.proxy is not None: + proxies = {url.scheme: self.proxy} + log.debug("using proxy - %s" % (proxies)) + + # ensure that url is a unicode string + url = str(url) + + combined_headers = self.headers + combined_headers.update(headers) + if body is None or body == "" and "Content-Type" in combined_headers: + del combined_headers["Content-Type"] + + log.debug("sending request - method={0}, url={1}, headers={2}\nbody:\n{3}".format(method, url, combined_headers, body)) + auth = None + if self.auth is None and self.username is not None: + auth = requests.auth.HTTPDigestAuth(self.username, self.password) + else: + auth = self.auth + + r = requests.request(method, url, data=to_wire(body), headers=combined_headers, proxies=proxies, auth=auth, verify=self.ssl_verify_cert) + response = DAVResponse(r) + + ## If server supports BasicAuth and not DigestAuth, let's try again: + if response.status == 401 and self.auth is None and auth is not None: + auth = requests.auth.HTTPBasicAuth(self.username, self.password) + r = requests.request(method, url, data=to_wire(body), headers=combined_headers, proxies=proxies, auth=auth, verify=self.ssl_verify_cert) + response = DAVResponse(r) + + # this is an error condition the application wants to know + if response.status == requests.codes.forbidden or \ + response.status == requests.codes.unauthorized: + ex = error.AuthorizationError() + ex.url = url + ex.reason = response.reason + raise ex + + ## let's save the auth object and remove the user/pass information + if not self.auth and auth: + self.auth = auth + del self.username + del self.password + + return response diff --git a/caldav/elements/__init__.py b/caldav/elements/__init__.py new file mode 100644 index 0000000..5413cba --- /dev/null +++ b/caldav/elements/__init__.py @@ -0,0 +1,4 @@ +#!/usr/bin/env python +# -*- encoding: utf-8 -*- + + diff --git a/caldav/elements/base.py b/caldav/elements/base.py new file mode 100644 index 0000000..7363ed3 --- /dev/null +++ b/caldav/elements/base.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python +# -*- encoding: utf-8 -*- + +from lxml import etree +from caldav.lib.namespace import nsmap +from caldav.lib.python_utilities import isPython3, to_unicode + + +class BaseElement(object): + children = None + tag = None + value = None + attributes = None + + def __init__(self, name=None, value=None): + self.children = [] + self.attributes = {} + value = to_unicode(value) + self.value = None + if name is not None: + self.attributes['name'] = name + if value is not None: + self.value = value + + def __add__(self, other): + return self.append(other) + + def __str__(self): + utf8 = etree.tostring(self.xmlelement(), encoding="utf-8", + xml_declaration=True, pretty_print=True) + if isPython3(): + return str(utf8, 'utf-8') + return utf8 + + def xmlelement(self): + root = etree.Element(self.tag, nsmap=nsmap) + if self.value is not None: + root.text = self.value + if len(self.attributes) > 0: + for k in list(self.attributes.keys()): + root.set(k, self.attributes[k]) + self.xmlchildren(root) + return root + + def xmlchildren(self, root): + for c in self.children: + root.append(c.xmlelement()) + + def append(self, element): + try: + iter(element) + self.children.extend(element) + except TypeError: + self.children.append(element) + return self + + +class NamedBaseElement(BaseElement): + def __init__(self, name=None): + super(NamedBaseElement, self).__init__(name=name) + + def xmlelement(self): + if self.attributes.get('name') is None: + raise Exception("name attribute must be defined") + return super(NamedBaseElement, self).xmlelement() + + +class ValuedBaseElement(BaseElement): + def __init__(self, value=None): + super(ValuedBaseElement, self).__init__(value=value) diff --git a/caldav/elements/cdav.py b/caldav/elements/cdav.py new file mode 100644 index 0000000..8ecb92d --- /dev/null +++ b/caldav/elements/cdav.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python +# -*- encoding: utf-8 -*- + +from caldav.lib.namespace import ns +from .base import BaseElement, NamedBaseElement, ValuedBaseElement + + +## Operations +class CalendarQuery(BaseElement): + tag = ns("C", "calendar-query") + +class FreeBusyQuery(BaseElement): + tag = ns("C", "free-busy-query") + +class Mkcalendar(BaseElement): + tag = ns("C", "mkcalendar") + +## Filters +class Filter(BaseElement): + tag = ns("C", "filter") + + +class CompFilter(NamedBaseElement): + tag = ns("C", "comp-filter") + + +class PropFilter(NamedBaseElement): + tag = ns("C", "prop-filter") + + +class ParamFilter(NamedBaseElement): + tag = ns("C", "param-filter") + + +## Conditions +class TextMatch(ValuedBaseElement): + tag = ns("C", "text-match") + + def __init__(self, value, collation="i;octet", negate=False): + super(TextMatch, self).__init__(value=value) + self.attributes['collation'] = collation + if negate: + self.attributes['negate-condition'] = "yes" + + +class TimeRange(BaseElement): + tag = ns("C", "time-range") + + def __init__(self, start=None, end=None): + super(TimeRange, self).__init__() + if start is not None: + self.attributes['start'] = \ + start.strftime("%Y%m%dT%H%M%SZ") + if end is not None: + self.attributes['end'] = end.strftime("%Y%m%dT%H%M%SZ") + + +class NotDefined(BaseElement): + tag = ns("C", "is-not-defined") + + +## Components / Data +class CalendarData(BaseElement): + tag = ns("C", "calendar-data") + + +class Expand(BaseElement): + tag = ns("C", "expand") + + def __init__(self, start, end=None): + super(Expand, self).__init__() + self.attributes['start'] = start.strftime("%Y%m%dT%H%M%SZ") + if end is not None: + self.attributes['end'] = end.strftime("%Y%m%dT%H%M%SZ") + + +class Comp(NamedBaseElement): + tag = ns("C", "comp") + +## Uhhm ... can't find any references to calendar-collection in rfc4791.txt and newer versions of baikal gives 403 forbidden when this one is encountered +#class CalendarCollection(BaseElement): +# tag = ns("C", "calendar-collection") + +## Properties + +class CalendarHomeSet(BaseElement): + tag = ns("C", "calendar-home-set") + +# calendar resource type, see rfc4791, sec. 4.2 +class Calendar(BaseElement): + tag = ns("C", "calendar") + + +class CalendarDescription(ValuedBaseElement): + tag = ns("C", "calendar-description") + + +class CalendarTimeZone(ValuedBaseElement): + tag = ns("C", "calendar-timezone") + + +class SupportedCalendarComponentSet(ValuedBaseElement): + tag = ns("C", "supported-calendar-component-set") + + +class SupportedCalendarData(ValuedBaseElement): + tag = ns("C", "supported-calendar-data") + + +class MaxResourceSize(ValuedBaseElement): + tag = ns("C", "max-resource-size") + + +class MinDateTime(ValuedBaseElement): + tag = ns("C", "min-date-time") + + +class MaxDateTime(ValuedBaseElement): + tag = ns("C", "max-date-time") + + +class MaxInstances(ValuedBaseElement): + tag = ns("C", "max-instances") + + +class MaxAttendeesPerInstance(ValuedBaseElement): + tag = ns("C", "max-attendees-per-instance") + +class SupportedCalendarComponentSet(BaseElement): + tag = ns("C", "supported-calendar-component-set") diff --git a/caldav/elements/dav.py b/caldav/elements/dav.py new file mode 100644 index 0000000..d2d211f --- /dev/null +++ b/caldav/elements/dav.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python +# -*- encoding: utf-8 -*- + +from caldav.lib.namespace import ns +from .base import BaseElement, ValuedBaseElement + + +## Operations +class Propfind(BaseElement): + tag = ns("D", "propfind") + + +class PropertyUpdate(BaseElement): + tag = ns("D", "propertyupdate") + + +class Mkcol(BaseElement): + tag = ns("D", "mkcol") + +## Filters + +## Conditions + +## Components / Data + + +class Prop(BaseElement): + tag = ns("D", "prop") + + +class Collection(BaseElement): + tag = ns("D", "collection") + + +class Set(BaseElement): + tag = ns("D", "set") + + +## Properties +class ResourceType(BaseElement): + tag = ns("D", "resourcetype") + + +class DisplayName(ValuedBaseElement): + tag = ns("D", "displayname") + + +class Href(BaseElement): + tag = ns("D", "href") + + +class Response(BaseElement): + tag = ns("D", "response") + + +class Status(BaseElement): + tag = ns("D", "status") + +class CurrentUserPrincipal(BaseElement): + tag = ns("D", "current-user-principal") + diff --git a/caldav/lib/__init__.py b/caldav/lib/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/caldav/lib/__init__.py diff --git a/caldav/lib/error.py b/caldav/lib/error.py new file mode 100644 index 0000000..9ed0561 --- /dev/null +++ b/caldav/lib/error.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python +# -*- encoding: utf-8 -*- + +class AuthorizationError(Exception): + """ + The client encountered an HTTP 403 error and is passing it on + to the user. The url property will contain the url in question, + the reason property will contain the excuse the server sent. + """ + url = None + reason = "PHP at work[tm]" + + def __str__(self): + return "AuthorizationError at '%s', reason '%s'" % \ + (self.url, self.reason) + + +class PropsetError(Exception): + pass + +class PropfindError(Exception): + pass + +class ReportError(Exception): + pass + +class MkcolError(Exception): + pass + +class MkcalendarError(Exception): + pass + +class PutError(Exception): + pass + + +class DeleteError(Exception): + pass + +class NotFoundError(Exception): + pass + +exception_by_method = {} +for method in ('delete', 'put', 'mkcalendar', 'mkcol', 'report', 'propset', 'propfind'): + exception_by_method[method] = locals()[method[0].upper() + method[1:] + 'Error'] + diff --git a/caldav/lib/namespace.py b/caldav/lib/namespace.py new file mode 100644 index 0000000..d784629 --- /dev/null +++ b/caldav/lib/namespace.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python +# -*- encoding: utf-8 -*- + +## nsmap2 is ref https://bitbucket.org/cyrilrbt/caldav/issue/29/centos59-minifix +## This looks wrong - should think more about it at a later stage. +## -- Tobias Brox, 2014-02-16 + +nsmap = { + "D": "DAV", + "C": "urn:ietf:params:xml:ns:caldav", +} + +nsmap2 = { + "D": "DAV:", + "C": "urn:ietf:params:xml:ns:caldav", +} + + +def ns(prefix, tag=None): + name = "{%s}" % nsmap2[prefix] + if tag is not None: + name = "%s%s" % (name, tag) + return name diff --git a/caldav/lib/python_utilities.py b/caldav/lib/python_utilities.py new file mode 100644 index 0000000..0131fce --- /dev/null +++ b/caldav/lib/python_utilities.py @@ -0,0 +1,32 @@ +import sys +from six import string_types + + +def isPython3(): + return sys.version_info >= (3, 0) + + +def to_wire(text): + if text and isinstance(text, string_types) and isPython3(): + text = bytes(text, 'utf-8') + elif not isPython3(): + text = to_unicode(text).encode('utf-8') + return text + + +def to_local(text): + if text and not isinstance(text, string_types): + text = text.decode('utf-8') + return text + + +def to_str(text): + if text and not isinstance(text, string_types): + text = text.decode('utf-8') + return text + + +def to_unicode(text): + if text and isinstance(text, string_types) and not isPython3() and not isinstance(text, unicode): + text = unicode(text, 'utf-8') + return text diff --git a/caldav/lib/url.py b/caldav/lib/url.py new file mode 100644 index 0000000..bac1fba --- /dev/null +++ b/caldav/lib/url.py @@ -0,0 +1,186 @@ +#!/usr/bin/env python +# -*- encoding: utf-8 -*- + +from caldav.lib.python_utilities import isPython3, to_unicode +if isPython3(): + from urllib import parse + from urllib.parse import ParseResult, SplitResult, urlparse, unquote +else: + from urlparse import urlparse as parse + from urlparse import ParseResult, SplitResult + from urlparse import urlparse + + +def uc2utf8(input): + ## argh! this feels wrong, but seems to be needed. + if not isPython3() and type(input) == unicode: + return input.encode('utf-8') + else: + return input + +class URL: + """ + This class is for wrapping URLs into objects. It's used + internally in the library, end users should not need to know + anything about this class. All methods that accept URLs can be + fed either with an URL object, a string or an urlparse.ParsedURL + object. + + Addresses may be one out of three: + + 1) a path relative to the DAV-root, i.e. "someuser/calendar" may + refer to + "http://my.davical-server.example.com/caldav.php/someuser/calendar". + + 2) an absolute path, i.e. "/caldav.php/someuser/calendar" + + 3) a fully qualified URL, + i.e. "http://someuser:somepass@my.davical-server.example.com/caldav.php/someuser/calendar". + Remark that hostname, port, user, pass is typically given when + instantiating the DAVClient object and cannot be overridden later. + + As of 2013-11, some methods in the caldav library expected strings + and some expected urlParseResult objects, some expected + fully qualified URLs and most expected absolute paths. The purpose + of this class is to ensure consistency and at the same time + maintaining backward compatibility. Basically, all methods should + accept any kind of URL. + + """ + def __init__(self, url): + if isinstance(url, ParseResult) or isinstance(url, SplitResult): + self.url_parsed = url + self.url_raw = None + else: + self.url_raw = url + self.url_parsed = None + + def __bool__(self): + if self.url_raw or self.url_parsed: + return True + else: + return False + + def __ne__(self, other): + return not self == other + + def __eq__(self, other): + if str(self) == str(other): + return True + ## The URLs could have insignificant differences + me = self.canonical() + if hasattr(other, 'canonical'): + other = other.canonical() + return str(me) == str(other) + + ## TODO: better naming? Will return url if url is already an URL + ## object, else will instantiate a new URL object + @classmethod + def objectify(self, url): + if url is None: + return None + if isinstance(url, URL): + return url + else: + return URL(url) + + ## To deal with all kind of methods/properties in the ParseResult + ## class + def __getattr__(self, attr): + if self.url_parsed is None: + self.url_parsed = urlparse(self.url_raw) + if hasattr(self.url_parsed, attr): + return getattr(self.url_parsed, attr) + else: + return getattr(self.__unicode__(), attr) + + ## returns the url in text format + def __str__(self): + if isPython3(): + return self.__unicode__() + return self.__unicode__().encode('utf-8') + + ## returns the url in text format + def __unicode__(self): + if self.url_raw is None: + self.url_raw = self.url_parsed.geturl() + if isinstance(self.url_raw, str): + return to_unicode(self.url_raw) + else: + return to_unicode(str(self.url_raw)) + + def __repr__(self): + return "URL(%s)" % str(self) + + def strip_trailing_slash(self): + if str(self)[-1] == '/': + return URL.objectify(str(self)[:-1]) + else: + return self + + def is_auth(self): + return self.username is not None + + def unauth(self): + if not self.is_auth(): + return self + return URL.objectify(ParseResult( + self.scheme, '%s:%s' % (self.hostname, self.port or {'https': 443, 'http': 80}[self.scheme]), + self.path.replace('//', '/'), self.params, self.query, self.fragment)) + + def canonical(self): + """ + a canonical URL ... remove authentication details, make sure there + are no double slashes, and to make sure the URL is always the same, + run it through the urlparser + """ + url = self.unauth() + + ## this is actually already done in the unauth method ... + if '//' in url.path: + raise NotImplementedError("remove the double slashes") + + ## This looks like a noop - but it may have the side effect + ## that urlparser be run (actually not - unauth ensures we + ## have an urlParseResult object) + url.scheme + + ## make sure to delete the string version + url.url_raw = None + + return url + + def join(self, path): + """ + assumes this object is the base URL or base path. If the path + is relative, it should be appended to the base. If the path + is absolute, it should be added to the connection details of + self. If the path already contains connection details and the + connection details differ from self, raise an error. + """ + pathAsString = str(path) + if not path or not pathAsString: + return self + path = URL.objectify(path) + if ( + (path.scheme and self.scheme and path.scheme != self.scheme) + or + (path.hostname and self.hostname and path.hostname != self.hostname) + or + (path.port and self.port and path.port != self.port) + ): + raise ValueError("%s can't be joined with %s" % (self, path)) + + if path.path[0] == '/': + ret_path = uc2utf8(path.path) + else: + sep = "/" + if self.path.endswith("/"): + sep = "" + ret_path = "%s%s%s" % (self.path, sep, uc2utf8(path.path)) + return URL(ParseResult( + self.scheme or path.scheme, self.netloc or path.netloc, ret_path, path.params, path.query, path.fragment)) + +def make(url): + """Backward compatibility""" + return URL.objectify(url) diff --git a/caldav/lib/vcal.py b/caldav/lib/vcal.py new file mode 100644 index 0000000..e79724e --- /dev/null +++ b/caldav/lib/vcal.py @@ -0,0 +1,16 @@ +#!/usr/bin/env python +# -*- encoding: utf-8 -*- + + +import re +from caldav.lib.python_utilities import to_local + + +def fix(event): + fixed = re.sub('COMPLETED:(\d+)\s', 'COMPLETED:\g<1>T120000Z', to_local(event)) + #The following line fixes a data bug in some Google Calendar events + fixed = re.sub('CREATED:00001231T000000Z', + 'CREATED:19700101T000000Z', fixed) + fixed = re.sub(r"\\+('\")", r"\1", fixed) + + return fixed diff --git a/caldav/objects.py b/caldav/objects.py new file mode 100644 index 0000000..0edc2ee --- /dev/null +++ b/caldav/objects.py @@ -0,0 +1,856 @@ +#!/usr/bin/env python +# -*- encoding: utf-8 -*- + +""" +A "DAV object" is anything we get from the caldav server or push into the caldav server, notably principal, calendars and calendar events. +""" + +import vobject +import io +import uuid +import re +import datetime +from lxml import etree + +from caldav.lib import error, vcal, url +from caldav.lib.url import URL +from caldav.elements import dav, cdav +from caldav.lib.python_utilities import to_unicode + +## utility for formatting a response xml tree to an error string +def errmsg(r): + return "%s %s\n\n%s" % (r.status, r.reason, r.raw) + +class DAVObject(object): + """ + Base class for all DAV objects. Can be instantiated by a client + and an absolute or relative URL, or from the parent object. + """ + id = None + url = None + client = None + parent = None + name = None + + def __init__(self, client=None, url=None, parent=None, name=None, id=None, **extra): + """ + Default constructor. + + Parameters: + * client: A DAVClient instance + * url: The url for this object. May be a full URL or a relative URL. + * parent: The parent object - used when creating objects + * name: A displayname + * id: The resource id (UID for an Event) + """ + + if client is None and parent is not None: + client = parent.client + self.client = client + self.parent = parent + self.name = name + self.id = id + self.extra_init_options = extra + ## url may be a path relative to the caldav root + if client and url: + self.url = client.url.join(url) + else: + self.url = URL.objectify(url) + + @property + def canonical_url(self): + return str(self.url.unauth()) + + def children(self, type=None): + """ + List children, using a propfind (resourcetype) on the parent object, + at depth = 1. + """ + c = [] + + depth = 1 + properties = {} + + props = [dav.ResourceType(), dav.DisplayName()] + response = self._query_properties(props, depth) + properties = self._handle_prop_response(response=response, props=props, type=type, what='tag') + + for path in list(properties.keys()): + resource_type = properties[path][dav.ResourceType.tag] + resource_name = properties[path][dav.DisplayName.tag] + + if resource_type == type or type is None: + ## TODO: investigate the RFCs thoroughly - why does a "get + ## members of this collection"-request also return the collection URL itself? + ## And why is the strip_trailing_slash-method needed? The collection URL + ## should always end with a slash according to RFC 2518, section 5.2. + if self.url.strip_trailing_slash() != self.url.join(path).strip_trailing_slash(): + c.append((self.url.join(path), resource_type, resource_name)) + + return c + + def _query_properties(self, props=[], depth=0): + """ + This is an internal method for doing a propfind query. It's a + result of code-refactoring work, attempting to consolidate + similar-looking code into a common method. + """ + root = None + # build the propfind request + if len(props) > 0: + prop = dav.Prop() + props + root = dav.Propfind() + prop + + return self._query(root, depth) + + def _query(self, root=None, depth=0, query_method='propfind', url=None, expected_return_value=None): + """ + This is an internal method for doing a query. It's a + result of code-refactoring work, attempting to consolidate + similar-looking code into a common method. + """ + if url is None: + url = self.url + body = "" + if root: + body = etree.tostring(root.xmlelement(), encoding="utf-8", + xml_declaration=True) + ret = getattr(self.client, query_method)( + url, body, depth) + if ret.status == 404: + raise error.NotFoundError(errmsg(ret)) + if ( + (expected_return_value is not None and ret.status != expected_return_value) or + ret.status >= 400): + raise error.exception_by_method[query_method](errmsg(ret)) + return ret + + + def _handle_prop_response(self, response, props=[], type=None, what='text'): + """ + Internal method to massage an XML response into a dict. (This + method is a result of some code refactoring work, attempting + to consolidate similar-looking code) + """ + properties = {} + # All items should be in a <D:response> element + for r in response.tree.findall('.//' + dav.Response.tag): + status = r.find('.//' + dav.Status.tag) + if not ' 200 ' in status.text and not ' 207 ' in status.text and not ' 404 ' in status.text: + raise error.ReportError(errmsg(response)) ## TODO: may be wrong error class + href = r.find('.//' + dav.Href.tag).text + properties[href] = {} + for p in props: + t = r.find(".//" + p.tag) + if t is None: + val = None + elif t is not None and list(t): + if type is not None: + val = t.find(".//" + type) + else: + val = t.find(".//*") + if val is not None: + val = getattr(val, what) + else: + val = None + else: + val = t.text + properties[href][p.tag] = val + + return properties + + def get_properties(self, props=[], depth=0): + """ + Get properties (PROPFIND) for this object. Works only for + properties, that don't have complex types. + + Parameters: + * props = [dav.ResourceType(), dav.DisplayName(), ...] + + Returns: + * {proptag: value, ...} + """ + rc = None + response = self._query_properties(props, depth) + properties = self._handle_prop_response(response, props) + path = self.url.path + exchange_path = self.url.path + '/' + + if path in list(properties.keys()): + rc = properties[path] + elif exchange_path in list(properties.keys()): + rc = properties[exchange_path] + else: + raise Exception("The CalDAV server you are using has " + "a problem with path handling.") + + return rc + + def set_properties(self, props=[]): + """ + Set properties (PROPPATCH) for this object. + + * props = [dav.DisplayName('name'), ...] + + Returns: + * self + """ + prop = dav.Prop() + props + set = dav.Set() + prop + root = dav.PropertyUpdate() + set + + r = self._query(root, query_method='proppatch') + + statuses = r.tree.findall(".//" + dav.Status.tag) + for s in statuses: + if not ' 200 ' in s.text: + raise error.PropsetError(errmsg(r)) + + return self + + def save(self): + """ + Save the object. This is an abstract method, that all classes + derived .from DAVObject implement. + + Returns: + * self + """ + raise NotImplementedError() + + def delete(self): + """ + Delete the object. + """ + if self.url is not None: + r = self.client.delete(self.url) + + #TODO: find out why we get 404 + if r.status not in (200, 204, 404): + raise error.DeleteError(errmsg(r)) + + def __str__(self): + return str(self.url) + + def __repr__(self): + return "%s(%s)" % (self.__class__.__name__, self.url) + + +class CalendarSet(DAVObject): + def calendars(self): + """ + List all calendar collections in this set. + + Returns: + * [Calendar(), ...] + """ + cals = [] + + data = self.children(cdav.Calendar.tag) + for c_url, c_type, c_name in data: + cals.append(Calendar(self.client, c_url, parent=self, name=c_name)) + + return cals + + def make_calendar(self, name=None, cal_id=None, supported_calendar_component_set=None): + """ + Utility method for creating a new calendar. + + Parameters: + * name: the name of the new calendar + * cal_id: the uuid of the new calendar + * supported_calendar_component_set: what kind of objects (EVENT, VTODO, VFREEBUSY, VJOURNAL) the calendar should handle. Should be set to ['VTODO'] when creating a task list in Zimbra - in most other cases the default will be OK. + + Returns: + * Calendar(...)-object + """ + return Calendar(self.client, name=name, parent=self, id=cal_id, supported_calendar_component_set=supported_calendar_component_set).save() + + def calendar(self, name=None, cal_id=None): + """ + The calendar method will return a calendar object. It will not + initiate any communication with the server. + + Parameters: + * name: return the calendar with this name + * cal_id: return the calendar with this calendar id + + Returns: + * Calendar(...)-object + """ + return Calendar(self.client, name=name, parent = self, + url = self.url.join(cal_id), id=cal_id) + + +class Principal(DAVObject): + """ + This class represents a DAV Principal. It doesn't do much, except + keep track of the URLs for the calendar-home-set, etc. + """ + def __init__(self, client=None, url=None): + """ + Returns a Principal. + + Parameters: + * client: a DAVClient() oject + * url: Deprecated - for backwards compatibility purposes only. + + If url is not given, deduct principal path as well as calendar home set path from doing propfinds. + """ + self.client = client + self._calendar_home_set = None + + ## backwards compatibility. + if url is not None: + self.url = client.url.join(URL.objectify(url)) + else: + self.url = self.client.url + cup = self.get_properties([dav.CurrentUserPrincipal()]) + self.url = self.client.url.join(URL.objectify(cup['{DAV:}current-user-principal'])) + + def make_calendar(self, name=None, cal_id=None, supported_calendar_component_set=None): + """ + Convenience method, bypasses the self.calendar_home_set object. + See CalendarSet.make_calendar for details. + """ + return self.calendar_home_set.make_calendar(name, cal_id, supported_calendar_component_set=supported_calendar_component_set) + + def calendar(self, name=None, cal_id=None): + """ + The calendar method will return a calendar object. It will not initiate any communication with the server. + """ + return self.calendar_home_set.calendar(name, cal_id) + + @property + def calendar_home_set(self): + if not self._calendar_home_set: + chs = self.get_properties([cdav.CalendarHomeSet()]) + self.calendar_home_set = chs['{urn:ietf:params:xml:ns:caldav}calendar-home-set'] + return self._calendar_home_set + + @calendar_home_set.setter + def calendar_home_set(self, url): + if isinstance(url, CalendarSet): + self._calendar_home_set = url + return + sanitized_url = URL.objectify(url) + if sanitized_url.hostname and sanitized_url.hostname != self.client.url.hostname: + ## icloud (and others?) having a load balanced system, where each principal resides on one named host + self.client.url = sanitized_url + self._calendar_home_set = CalendarSet(self.client, self.client.url.join(sanitized_url)) + + def calendars(self): + """ + Return the principials calendars + """ + return self.calendar_home_set.calendars() + +class Calendar(DAVObject): + """ + The `Calendar` object is used to represent a calendar collection. + Refer to the RFC for details: http://www.ietf.org/rfc/rfc4791.txt + """ + def _create(self, name, id=None, supported_calendar_component_set=None): + """ + Create a new calendar with display name `name` in `parent`. + """ + if id is None: + id = str(uuid.uuid1()) + self.id = id + + path = self.parent.url.join(id) + self.url = path + + ## TODO: mkcalendar seems to ignore the body on most servers? + ## at least the name doesn't get set this way. + ## zimbra gives 500 (!) if body is omitted ... + + ## ehm ... this element seems non-existent in the RFC? Breaks with baikal, too ... + #cal = cdav.CalendarCollection() + #coll = dav.Collection() # + cal ## also breaks on baikal, and probably not needed? + #type = dav.ResourceType() ## probably not needed? + + prop = dav.Prop() #+ [type,] + if name: + display_name = dav.DisplayName(name) + prop += [display_name,] + if supported_calendar_component_set: + sccs = cdav.SupportedCalendarComponentSet() + for scc in supported_calendar_component_set: + sccs += cdav.Comp(scc) + prop += sccs + set = dav.Set() + prop + + mkcol = cdav.Mkcalendar() + set + + r = self._query(root=mkcol, query_method='mkcalendar', url=path, expected_return_value=201) + + if name: + try: + self.set_properties([display_name]) + except: + self.delete() + raise + + ## Special hack for Zimbra! The calendar we've made exists at + ## the specified URL, and we can do operations like ls, even + ## PUT an event to the calendar. Zimbra will enforce that the + ## event uuid matches the event url, and return either 201 or + ## 302 - but alas, try to do a GET towards the event and we + ## get 404! But turn around and replace the calendar ID with + ## the calendar name in the URL and hey ... it works! + + ## TODO: write test cases for calendars with non-trivial + ## names and calendars with names already matching existing + ## calendar urls and ensure they pass. + zimbra_url = self.parent.url.join(name) + try: + ret = self.client.request(zimbra_url) + if ret.status == 404: + raise error.NotFoundError + ## special hack for radicale. It will happily accept any calendar-URL without returning 404. + ret = self.client.request(self.parent.url.join('ANYTHINGGOESHEREthisshouldforsurereturn404')) + if ret.status == 404: + ## insane server + self.url = zimbra_url + except error.NotFoundError: + ## sane server + pass + + def add_event(self, ical): + """ + Add a new event to the calendar, with the given ical. + + Parameters: + * ical - ical object (text) + """ + return Event(self.client, data = ical, parent = self).save() + + def add_todo(self, ical): + """ + Add a new task to the calendar, with the given ical. + + Parameters: + * ical - ical object (text) + """ + return Todo(self.client, data=ical, parent=self).save() + + def add_journal(self, ical): + """ + Add a new journal entry to the calendar, with the given ical. + + Parameters: + * ical - ical object (text) + """ + return Journal(self.client, data=ical, parent=self).save() + + def save(self): + """ + The save method for a calendar is only used to create it, for now. + We know we have to create it when we don't have a url. + + Returns: + * self + """ + if self.url is None: + self._create(name=self.name, id=self.id, **self.extra_init_options) + if not self.url.endswith('/'): + self.url = URL.objectify(str(self.url) + '/') + return self + + def date_search(self, start, end=None, compfilter="VEVENT"): + """ + Search events by date in the calendar. Recurring events are + expanded if they are occuring during the specified time frame + and if an end timestamp is given. + + Parameters: + * start = datetime.today(). + * end = same as above. + * compfilter = defaults to events only. Set to None to fetch all calendar components. + + Returns: + * [CalendarObjectResource(), ...] + + """ + matches = [] + + # build the request + + ## Some servers will raise an error if we send the expand flag + ## but don't set any end-date - expand doesn't make much sense + ## if we have one recurring event describing an indefinite + ## series of events. Hence, if the end date is not set, we + ## skip asking for expanded events. + if end: + data = cdav.CalendarData() + cdav.Expand(start, end) + else: + data = cdav.CalendarData() + prop = dav.Prop() + data + + query = cdav.TimeRange(start, end) + if compfilter: + query = cdav.CompFilter(compfilter) + query + vcalendar = cdav.CompFilter("VCALENDAR") + query + filter = cdav.Filter() + vcalendar + + root = cdav.CalendarQuery() + [prop, filter] + response = self._query(root, 1, 'report') + results = self._handle_prop_response(response=response, props=[cdav.CalendarData()]) + for r in results: + matches.append( + Event(self.client, url=self.url.join(r), data=results[r][cdav.CalendarData.tag], parent=self)) + + return matches + + def freebusy_request(self, start, end): + """ + Search the calendar, but return only the free/busy information. + + Parameters: + * start = datetime.today(). + * end = same as above. + + Returns: + * [FreeBusy(), ...] + + """ + root = cdav.FreeBusyQuery() + [ cdav.TimeRange(start, end) ] + response = self._query(root, 1, 'report') + return FreeBusy(self, response.raw) + + def journals(self): + """ + fetches a list of journal entries. + """ + + def todos(self, sort_keys=('due','priority'), include_completed=False, sort_key=None): + """ + fetches a list of todo events. + + Parameters: + * sort_keys: use this field in the VTODO for sorting (iterable of lower case string, i.e. ('priority','due')). + * include_completed: boolean - by default, only pending tasks are listed + * sort_key: DEPRECATED, for backwards compatibility with version 0.4. + """ + ## ref https://www.ietf.org/rfc/rfc4791.txt, section 7.8.9 + matches = [] + + # build the request + data = cdav.CalendarData() + prop = dav.Prop() + data + + if sort_key: + sort_keys = (sort_key,) + + if not include_completed: + vnotcompleted = cdav.TextMatch('COMPLETED', negate=True) + vnotcancelled = cdav.TextMatch('CANCELLED', negate=True) + vstatusNotCompleted = cdav.PropFilter('STATUS') + vnotcompleted + vstatusNotCancelled = cdav.PropFilter('STATUS') + vnotcancelled + vnocompletedate = cdav.PropFilter('COMPLETED') + cdav.NotDefined() + vtodo = cdav.CompFilter("VTODO") + vnocompletedate + vstatusNotCompleted + vstatusNotCancelled + else: + vtodo = cdav.CompFilter("VTODO") + vcalendar = cdav.CompFilter("VCALENDAR") + vtodo + filter = cdav.Filter() + vcalendar + + root = cdav.CalendarQuery() + [prop, filter] + + response = self._query(root, 1, 'report') + results = self._handle_prop_response(response=response, props=[cdav.CalendarData()]) + for r in results: + matches.append( + Todo(self.client, url=self.url.join(r), data=results[r][cdav.CalendarData.tag], parent=self)) + + def sort_key_func(x): + ret = [] + defaults = { + 'due': '2050-01-01', + 'dtstart': '1970-01-01', + 'priority': '0', + 'isnt_overdue': not (hasattr(x.instance.vtodo, 'due') and x.instance.vtodo.due.value.strftime('%F%H%M%S') < datetime.datetime.now().strftime('%F%H%M%S')), + 'hasnt_started': (hasattr(x.instance.vtodo, 'dtstart') and x.instance.vtodo.dtstart.value.strftime('%F%H%M%S') > datetime.datetime.now().strftime('%F%H%M%S')) + } + for sort_key in sort_keys: + val = getattr(x.instance.vtodo, sort_key, None) + if val is None: + ret.append(defaults.get(sort_key,'0')) + continue + val = val.value + if hasattr(val, 'strftime'): + ret.append(val.strftime('%F%H%M%S')) + else: + ret.append(val) + return ret + if sort_keys: + matches.sort(key=sort_key_func) + return matches + + def _calendar_comp_class_by_data(self, data): + for line in data.split('\n'): + if line == 'BEGIN:VEVENT': + return Event + if line == 'BEGIN:VTODO': + return Todo + if line == 'BEGIN:VJOURNAL': + return Journal + if line == 'BEGIN:VFREEBUSY': + return FreeBusy + + def event_by_url(self, href, data=None): + """ + Returns the event with the given URL + """ + return Event(url=href, data=data, parent=self).load() + + def object_by_uid(self, uid, comp_filter=None): + """ + Get one event from the calendar. + + Parameters: + * uid: the event uid + + Returns: + * Event() or None + """ + data = cdav.CalendarData() + prop = dav.Prop() + data + + query = cdav.TextMatch(uid) + query = cdav.PropFilter("UID") + query + if comp_filter: + query = comp_filter + query + vcalendar = cdav.CompFilter("VCALENDAR") + query + filter = cdav.Filter() + vcalendar + + root = cdav.CalendarQuery() + [prop, filter] + + response = self._query(root, 1, 'report') + + if response.status == 404: + raise error.NotFoundError(errmsg(response)) + elif response.status == 400: + raise error.ReportError(errmsg(response)) + + r = response.tree.find(".//" + dav.Response.tag) + if r is not None: + href = r.find(".//" + dav.Href.tag).text + data = r.find(".//" + cdav.CalendarData.tag).text + return self._calendar_comp_class_by_data(data)(self.client, url=URL.objectify(href), data=data, parent=self) + else: + raise error.NotFoundError(errmsg(response)) + + def todo_by_uid(self, uid): + return self.object_by_uid(uid, comp_filter=cdav.CompFilter("VTODO")) + + def event_by_uid(self, uid): + return self.object_by_uid(uid, comp_filter=cdav.CompFilter("VEVENT")) + ## alias for backward compatibility + event = event_by_uid + + def events(self): + """ + List all events from the calendar. + + Returns: + * [Event(), ...] + """ + all = [] + + data = cdav.CalendarData() + prop = dav.Prop() + data + vevent = cdav.CompFilter("VEVENT") + vcalendar = cdav.CompFilter("VCALENDAR") + vevent + filter = cdav.Filter() + vcalendar + root = cdav.CalendarQuery() + [prop, filter] + + response = self._query(root, 1, query_method='report') + results = self._handle_prop_response(response, props=[cdav.CalendarData()]) + for r in results: + all.append(Event(self.client, url=self.url.join(r), data=results[r][cdav.CalendarData.tag], parent=self)) + + return all + + def journals(self): + """ + List all journals from the calendar. + + Returns: + * [Journal(), ...] + """ + ## TODO: this is basically a copy of events() - can we do more + ## refactoring and consolidation here? Maybe it's wrong to do + ## separate methods for journals, todos and events? + all = [] + + data = cdav.CalendarData() + prop = dav.Prop() + data + vevent = cdav.CompFilter("VJOURNAL") + vcalendar = cdav.CompFilter("VCALENDAR") + vevent + filter = cdav.Filter() + vcalendar + root = cdav.CalendarQuery() + [prop, filter] + + response = self._query(root, 1, query_method='report') + results = self._handle_prop_response(response, props=[cdav.CalendarData()]) + for r in results: + all.append(Journal(self.client, url=self.url.join(r), data=results[r][cdav.CalendarData.tag], parent=self)) + + return all + +class CalendarObjectResource(DAVObject): + """ + Ref RFC 4791, section 4.1, a "Calendar Object Resource" can be an + event, a todo-item, a journal entry, a free/busy entry, etc. + """ + _instance = None + _data = None + + def __init__(self, client=None, url=None, data=None, parent=None, id=None): + """ + CalendarObjectResource has an additional parameter for its constructor: + * data = "...", vCal data for the event + """ + DAVObject.__init__(self, client=client, url=url, parent=parent, id=id) + if data is not None: + self.data = data + + def copy(self, keep_uid=False, new_parent=None): + """ + Events, todos etc can be copied within the same calendar, to another calendar or even to another caldav server + """ + return self.__class__( + parent=new_parent or self.parent, + data=self.data, + id = self.id if keep_uid else str(uuid.uuid1())) + + def load(self): + """ + Load the object from the caldav server. + """ + r = self.client.request(self.url) + if r.status == 404: + raise error.NotFoundError(errmsg(r)) + self.data = vcal.fix(r.raw) + return self + + def _create(self, data, id=None, path=None): + if id is None and path is not None and str(path).endswith('.ics'): + id = re.search('(/|^)([^/]*).ics',str(path)).group(2) + elif id is None: + for obj_type in ('vevent', 'vtodo', 'vjournal', 'vfreebusy'): + obj = None + if hasattr(self.instance, obj_type): + obj = getattr(self.instance, obj_type) + elif self.instance.name.lower() == obj_type: + obj = self.instance + if obj is not None: + id = obj.uid.value + break + else: + for obj_type in ('vevent', 'vtodo', 'vjournal', 'vfreebusy'): + obj = None + if hasattr(self.instance, obj_type): + obj = getattr(self.instance, obj_type) + elif self.instance.name.lower() == obj_type: + obj = self.instance + if obj is not None: + if not hasattr(obj, 'uid'): + obj.add('uid') + obj.uid = id + break + if path is None: + path = id + ".ics" + path = self.parent.url.join(path) + r = self.client.put(path, data, + {"Content-Type": 'text/calendar; charset="utf-8"'}) + + if r.status == 302: + path = [x[1] for x in r.headers if x[0]=='location'][0] + elif not (r.status in (204, 201)): + raise error.PutError(errmsg(r)) + + self.url = URL.objectify(path) + self.id = id + + def save(self): + """ + Save the object, can be used for creation and update. + + Returns: + * self + """ + if self._instance is not None: + path = self.url.path if self.url else None + self._create(self._instance.serialize(), self.id, path) + return self + + def __str__(self): + return "%s: %s" % (self.__class__.__name__, self.url) + + def _set_data(self, data): + self._data = vcal.fix(data) + self._instance = vobject.readOne(to_unicode(self._data)) + return self + + def _get_data(self): + return self._data + data = property(_get_data, _set_data, + doc="vCal representation of the object") + + def _set_instance(self, inst): + self._instance = inst + self._data = inst.serialize() + return self + + def _get_instance(self): + return self._instance + instance = property(_get_instance, _set_instance, + doc="vobject instance of the object") + + +class Event(CalendarObjectResource): + """ + The `Event` object is used to represent an event (VEVENT). + """ + pass + +class Journal(CalendarObjectResource): + """ + The `Journal` object is used to represent a journal entry (VJOURNAL). + """ + pass + +class FreeBusy(CalendarObjectResource): + """ + The `FreeBusy` object is used to represent a freebusy response from the server. + """ + def __init__(self, parent, data): + """ + A freebusy response object has no URL or ID (TODO: reconsider the + class hierarchy? most of the inheritated methods are moot and + will fail?). Raw response can be accessed through self.data, + instantiated vobject as self.instance. + """ + CalendarObjectResource.__init__(self, client=parent.client, url=None, data=data, parent=parent, id=None) + +class Todo(CalendarObjectResource): + """ + The `Todo` object is used to represent a todo item (VTODO). + """ + def complete(self, completion_timestamp=None): + """ + Marks the task as completed. + + Parameters: + * completion_timestamp - datetime object. Defaults to datetime.datetime.now(). + """ + if not completion_timestamp: + completion_timestamp = datetime.datetime.now() + if not hasattr(self.instance.vtodo, 'status'): + self.instance.vtodo.add('status') + self.instance.vtodo.status.value = 'COMPLETED' + self.instance.vtodo.add('completed').value = completion_timestamp + self.save() + + |