summaryrefslogtreecommitdiff
path: root/caldav
diff options
context:
space:
mode:
authorTobias Brox <tobias@redpill-linpro.com>2017-01-15 11:25:00 +0100
committerTobias Brox <tobias@redpill-linpro.com>2017-01-15 11:25:00 +0100
commit7e933bd6e96440ba791effd6540260fbe92c4157 (patch)
tree81ea900869569198c78e3494394b434e35590b95 /caldav
downloadpython-caldav-7e933bd6e96440ba791effd6540260fbe92c4157.zip
bedework.caldav-servers.tobixen.no is now up and running
Diffstat (limited to 'caldav')
-rw-r--r--caldav/__init__.py21
-rw-r--r--caldav/davclient.py250
-rw-r--r--caldav/elements/__init__.py4
-rw-r--r--caldav/elements/base.py70
-rw-r--r--caldav/elements/cdav.py130
-rw-r--r--caldav/elements/dav.py61
-rw-r--r--caldav/lib/__init__.py0
-rw-r--r--caldav/lib/error.py46
-rw-r--r--caldav/lib/namespace.py23
-rw-r--r--caldav/lib/python_utilities.py32
-rw-r--r--caldav/lib/url.py186
-rw-r--r--caldav/lib/vcal.py16
-rw-r--r--caldav/objects.py856
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()
+
+