Add carddav storage

Fix #1

This currently breaks the tests, but i think it's radicale being a
bitch. We should test this against other servers.
This commit is contained in:
Markus Unterwaditzer 2014-03-01 23:35:57 +01:00
parent 83910f2301
commit 812d376c5d
10 changed files with 291 additions and 171 deletions

View file

@ -2,22 +2,39 @@
status_path=~/.vdirsyncer/status/
#verbose = False
[pair bob]
# This syncronizes only a single collection/calendar/addressbook
a = bob_local
b = bob_remote
# CONTACTS
[pair bob_contacts]
# This syncronizes only a single calendar/addressbook
a = bob_contacts_local
b = bob_contacts_remote
# conflict_resolution = None # abort when collisions occur
# conflict_resolution = a wins # assume a's items to be more up-to-date
# conflict_resolution = b wins # assume b's items to be more up-to-date
[storage bob_local]
# This represents only a single collection/calendar/addressbook
[storage bob_contacts_local]
# This represents only a single calendar/addressbook
type = filesystem
path = ~/.watdo/tasks/somecalendar/
#fileext = .ics
fileext = .vcf
[storage bob_remote]
# This represents only a single collection/calendar/addressbook
[storage bob_contacts_remote]
# This represents only a single calendar/addressbook
type = carddav
url = https://owncloud.example.com/remote.php/carddav/addressbooks/bob/default/
username = # blabla
password = # blabla
# CALENDAR
[pair bob_calendar]
a = bob_calendar_local
b = bob_calendar_remote
[storage bob_calendar_local]
type = filesystem
path = ~/.watdo/tasks/somecalendar/
fileext = .ics
[storage bob_calendar_remote]
type = caldav
url = https://owncloud.example.com/remote.php/caldav/calendars/bob/somecalendar/
username = # blabla

View file

@ -13,9 +13,11 @@
'''
from .dav.caldav import CaldavStorage
from .dav.carddav import CarddavStorage
from .filesystem import FilesystemStorage
storage_names = {
'caldav': CaldavStorage,
'carddav': CarddavStorage,
'filesystem': FilesystemStorage
}

View file

@ -35,7 +35,7 @@ class Storage(object):
object does.
'''
fileext = '.txt'
_repr_attributes = set()
_repr_attributes = ()
def __init__(self, item_class=Item):
self.item_class = item_class

View file

@ -11,10 +11,18 @@ from ..base import Storage
import vdirsyncer.exceptions as exceptions
import requests
import urlparse
from lxml import etree
class DavStorage(Storage):
fileext = None
item_mimetype = None
dav_header = None
get_multi_template = None
get_multi_data_query = None
list_xml = None
_session = None
_repr_attributes = ('url', 'username')
@ -51,14 +59,30 @@ class DavStorage(Storage):
headers = self._default_headers()
headers['Depth'] = 1
response = self._request(
'OPTIONS',
'',
headers=headers
)
response.raise_for_status()
if self.dav_header not in response.headers.get('DAV', ''):
raise exceptions.StorageError('URL is not a collection')
def _simplify_href(self, href):
'''Used to strip hrefs off the collection's URL, to leave only the
filename.'''
href = urlparse.urlparse(href).path
if href.startswith(self.parsed_url.path):
href = href[len(self.parsed_url.path):]
assert '/' not in href, href
return href
def _default_headers(self):
return {
'User-Agent': self.useragent,
'Content-Type': 'application/xml; charset=UTF-8'
}
def _request(self, method, item, data=None, headers=None):
if self._session is None:
self._session = requests.session()
@ -77,3 +101,120 @@ class DavStorage(Storage):
((actual_href, obj, etag),) = self.get_multi([href])
assert href == actual_href
return obj, etag
def get_multi(self, hrefs):
if not hrefs:
return ()
href_xml = []
for href in hrefs:
assert '/' not in href, href
href_xml.append('<D:href>{}</D:href>'.format(self.url + href))
data = self.get_multi_template.format(hrefs='\n'.join(href_xml))
response = self._request(
'REPORT',
'',
data=data,
headers=self._default_headers()
)
response.raise_for_status()
root = etree.XML(response.content) # etree only can handle bytes
rv = []
hrefs_left = set(hrefs)
for element in root.iter('{DAV:}response'):
href = self._simplify_href(
element.find('{DAV:}href').text.decode(response.encoding))
obj = element \
.find('{DAV:}propstat') \
.find('{DAV:}prop') \
.find(self.get_multi_data_query).text
etag = element \
.find('{DAV:}propstat') \
.find('{DAV:}prop') \
.find('{DAV:}getetag').text
if isinstance(obj, bytes):
obj = obj.decode(response.encoding)
if isinstance(etag, bytes):
etag = etag.decode(response.encoding)
rv.append((href, Item(obj), etag))
hrefs_left.remove(href)
for href in hrefs_left:
raise exceptions.NotFoundError(href)
return rv
def has(self, href):
try:
self.get(href)
except exceptions.PreconditionFailed:
return False
else:
return True
def update(self, href, obj, etag):
headers = self._default_headers()
headers.update({
'Content-Type': self.item_mimetype,
'If-Match': etag
})
response = self._request(
'PUT',
href,
data=obj.raw,
headers=headers
)
self._check_response(response)
etag = response.headers.get('etag', None)
if not etag:
obj2, etag = self.get(href)
assert obj2.raw == obj.raw
return href, etag
def upload(self, obj):
href = self._get_href(obj.uid)
headers = self._default_headers()
headers.update({
'Content-Type': self.item_mimetype,
'If-None-Match': '*'
})
response = self._request(
'PUT',
href,
data=obj.raw,
headers=headers
)
self._check_response(response)
etag = response.headers.get('etag', None)
if not etag:
obj2, etag = self.get(href)
assert obj2.raw == obj.raw
return href, etag
def delete(self, href, etag):
headers = self._default_headers()
headers.update({
'If-Match': etag
})
response = self._request(
'DELETE',
href,
headers=headers
)
self._check_response(response)
def list(self):
response = self._request(
'REPORT',
'',
data=self.list_xml,
headers=self._default_headers()
)
response.raise_for_status()
root = etree.XML(response.content)
for element in root.iter('{DAV:}response'):
etag = element.find('{DAV:}propstat').find(
'{DAV:}prop').find('{DAV:}getetag').text
href = self._simplify_href(element.find('{DAV:}href').text)
yield href, etag

View file

@ -22,9 +22,23 @@ CONFIG_DT_FORMAT = '%Y-%m-%d'
class CaldavStorage(DavStorage):
fileext = '.ics'
item_mimetype = 'text/calendar'
dav_header = 'calendar-access'
start_date = None
end_date = None
get_multi_template = '''<?xml version="1.0" encoding="utf-8" ?>
<C:calendar-multiget xmlns:D="DAV:"
xmlns:C="urn:ietf:params:xml:ns:caldav">
<D:prop>
<D:getetag/>
<C:calendar-data/>
</D:prop>
{hrefs}
</C:calendar-multiget>'''
get_multi_data_query = '{urn:ietf:params:xml:ns:caldav}calendar-data'
def __init__(self, start_date=None, end_date=None, **kwargs):
'''
:param start_date: Start date of timerange to show, default -inf.
@ -43,30 +57,14 @@ class CaldavStorage(DavStorage):
(eval(end_date, namespace) if isinstance(end_date, str)
else end_date)
headers = self._default_headers()
headers['Depth'] = 1
response = self._request(
'OPTIONS',
'',
headers=headers
)
response.raise_for_status()
if 'calendar-access' not in response.headers.get('DAV', ''):
raise exceptions.StorageError('URL is not a CalDAV collection')
def _default_headers(self):
return {
'User-Agent': self.useragent,
'Content-Type': 'application/xml; charset=UTF-8'
}
def list(self):
@property
def list_xml(self):
data = '''<?xml version="1.0" encoding="utf-8" ?>
<C:calendar-query xmlns:D="DAV:"
xmlns:C="urn:ietf:params:xml:ns:caldav">
<D:prop>
<D:getetag/>
</D:prop>
</D:prop>
<C:filter>
<C:comp-filter name="VCALENDAR">
{caldavfilter}
@ -82,131 +80,5 @@ class CaldavStorage(DavStorage):
'<C:time-range start="{start}" end="{end}"/>'
'</C:comp-filter>').format(start=start,
end=end)
data = data.format(caldavfilter=caldavfilter)
else:
data = data.format(caldavfilter='')
response = self._request(
'REPORT',
'',
data=data,
headers=self._default_headers()
)
response.raise_for_status()
root = etree.XML(response.content)
for element in root.iter('{DAV:}response'):
etag = element.find('{DAV:}propstat').find(
'{DAV:}prop').find('{DAV:}getetag').text
href = self._simplify_href(element.find('{DAV:}href').text)
yield href, etag
def get_multi(self, hrefs):
if not hrefs:
return ()
data = '''<?xml version="1.0" encoding="utf-8" ?>
<C:calendar-multiget xmlns:D="DAV:"
xmlns:C="urn:ietf:params:xml:ns:caldav">
<D:prop>
<D:getetag/>
<C:calendar-data/>
</D:prop>
{hrefs}
</C:calendar-multiget>'''
href_xml = []
for href in hrefs:
assert '/' not in href, href
href_xml.append('<D:href>{}</D:href>'.format(self.url + href))
data = data.format(hrefs='\n'.join(href_xml))
response = self._request(
'REPORT',
'',
data=data,
headers=self._default_headers()
)
response.raise_for_status()
root = etree.XML(response.content) # etree only can handle bytes
rv = []
hrefs_left = set(hrefs)
for element in root.iter('{DAV:}response'):
href = self._simplify_href(
element.find('{DAV:}href').text.decode(response.encoding))
obj = element \
.find('{DAV:}propstat') \
.find('{DAV:}prop') \
.find('{urn:ietf:params:xml:ns:caldav}calendar-data').text
etag = element \
.find('{DAV:}propstat') \
.find('{DAV:}prop') \
.find('{DAV:}getetag').text
if isinstance(obj, bytes):
obj = obj.decode(response.encoding)
if isinstance(etag, bytes):
etag = etag.decode(response.encoding)
rv.append((href, Item(obj), etag))
hrefs_left.remove(href)
for href in hrefs_left:
raise exceptions.NotFoundError(href)
return rv
def has(self, href):
try:
self.get(href)
except exceptions.PreconditionFailed:
return False
else:
return True
def upload(self, obj):
href = self._get_href(obj.uid)
headers = self._default_headers()
headers.update({
'Content-Type': 'text/calendar',
'If-None-Match': '*'
})
response = self._request(
'PUT',
href,
data=obj.raw,
headers=headers
)
self._check_response(response)
etag = response.headers.get('etag', None)
if not etag:
obj2, etag = self.get(href)
assert obj2.raw == obj.raw
return href, etag
def update(self, href, obj, etag):
headers = self._default_headers()
headers.update({
'Content-Type': 'text/calendar',
'If-Match': etag
})
response = self._request(
'PUT',
href,
data=obj.raw,
headers=headers
)
self._check_response(response)
etag = response.headers.get('etag', None)
if not etag:
obj2, etag = self.get(href)
assert obj2.raw == obj.raw
return href, etag
def delete(self, href, etag):
headers = self._default_headers()
headers.update({
'If-Match': etag
})
response = self._request(
'DELETE',
href,
headers=headers
)
self._check_response(response)
return data.format(caldavfilter=caldavfilter)
return data.format(caldavfilter='')

View file

@ -0,0 +1,46 @@
# -*- coding: utf-8 -*-
'''
vdirsyncer.storage.dav.carddav
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Original version from pycarddav: https://github.com/geier/pycarddav
:copyright: (c) 2014 Markus Unterwaditzer, Christian Geier and contributors
:license: MIT, see LICENSE for more details.
'''
from .base import DavStorage
from ..base import Item
import vdirsyncer.exceptions as exceptions
from lxml import etree
class CarddavStorage(DavStorage):
fileext = '.vcf'
item_mimetype = 'text/vcard'
dav_header = 'addressbook'
get_multi_template = '''<?xml version="1.0" encoding="utf-8" ?>
<C:addressbook-multiget xmlns:D="DAV:"
xmlns:C="urn:ietf:params:xml:ns:carddav">
<D:prop>
<D:getetag/>
<C:address-data/>
</D:prop>
{hrefs}
</C:addressbook-multiget>'''
get_multi_data_query = '{urn:ietf:params:xml:ns:carddav}address-data'
list_xml = '''<?xml version="1.0" encoding="utf-8" ?>
<C:addressbook-query xmlns:D="DAV:"
xmlns:C="urn:ietf:params:xml:ns:carddav">
<D:prop>
<D:getetag/>
</D:prop>
<C:filter>
<C:comp-filter name="VCARD"/>
</C:filter>
</C:addressbook-query>'''

View file

@ -25,12 +25,15 @@ class StorageTests(object):
for i, item in enumerate(items):
assert item.uid == unicode(i + 1), item.raw
s = self._get_storage()
hrefs = []
for item in items:
s.upload(item)
hrefs = (href for href, etag in s.list())
for href in hrefs:
hrefs.append(s.upload(item))
hrefs.sort()
assert hrefs == sorted(s.list())
for href, etag in hrefs:
assert s.has(href)
obj, etag = s.get(href)
obj, etag2 = s.get(href)
assert etag == etag2
assert 'UID:{}'.format(obj.uid) in obj.raw
def test_upload_already_existing(self):
@ -57,3 +60,9 @@ class StorageTests(object):
def test_delete_nonexisting(self):
s = self._get_storage()
self.assertRaises(exceptions.PreconditionFailed, s.delete, '1', 123)
def test_list(self):
s = self._get_storage()
assert not list(s.list())
s.upload(self._create_bogus_item('1'))
assert list(s.list())

View file

@ -2,7 +2,7 @@
# -*- coding: utf-8 -*-
'''
vdirsyncer.tests.storage.dav
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Using an actual CalDAV/CardDAV server to test the CalDAV and CardDAV
storages. Done by using Werkzeug's test client for WSGI apps. While this is
@ -12,7 +12,6 @@
:copyright: (c) 2014 Markus Unterwaditzer
:license: MIT, see LICENSE for more details.
'''
__version__ = '0.1.0'
import tempfile
import shutil
@ -72,6 +71,7 @@ class Response(object):
class DavStorageTests(StorageTests):
tmpdir = None
storage_class = None
radicale_path = None
def _get_storage(self, **kwargs):
self.tmpdir = tempfile.mkdtemp()
@ -82,12 +82,11 @@ class DavStorageTests(StorageTests):
c = Client(app, WerkzeugResponse)
server = 'http://127.0.0.1'
calendar_path = '/bob/test.ics/'
full_url = server + calendar_path
full_url = server + self.radicale_path
def x(method, item, data=None, headers=None):
assert '/' not in item
url = calendar_path + item
url = self.radicale_path + item
r = c.open(path=url, method=method, data=data, headers=headers)
r = Response(r)
return r

View file

@ -4,11 +4,6 @@
vdirsyncer.tests.storage.test_caldav
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Using an actual CalDAV server to test the CalDAV storage. Done by using
Werkzeug's test client for WSGI apps. While this is pretty fast, Radicale
has so much global state such that a clean separation of the unit tests is
not guaranteed.
:copyright: (c) 2014 Markus Unterwaditzer
:license: MIT, see LICENSE for more details.
'''
@ -23,6 +18,7 @@ from . import DavStorageTests
class CaldavStorageTests(TestCase, DavStorageTests):
storage_class = CaldavStorage
radicale_path = '/bob/test.ics/'
def _create_bogus_item(self, uid):
return Item(u'BEGIN:VCALENDAR\n'

View file

@ -0,0 +1,38 @@
# -*- coding: utf-8 -*-
'''
vdirsyncer.tests.storage.test_carddav
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
:copyright: (c) 2014 Markus Unterwaditzer
:license: MIT, see LICENSE for more details.
'''
__version__ = '0.1.0'
from unittest import TestCase
from vdirsyncer.storage.base import Item
from vdirsyncer.storage.dav.carddav import CarddavStorage
from . import DavStorageTests
class CarddavStorageTests(TestCase, DavStorageTests):
storage_class = CarddavStorage
radicale_path = '/bob/test.vcf/'
def _create_bogus_item(self, uid):
return Item(u'BEGIN:VCARD\n'
u'VERSION:3.0\n'
u'FN:Cyrus Daboo\n'
u'N:Daboo;Cyrus\n'
u'ADR;TYPE=POSTAL:;2822 Email HQ;' # address continuing
u'Suite 2821;RFCVille;PA;15213;USA\n' # on next line
u'EMAIL;TYPE=INTERNET,PREF:cyrus@example.com\n'
u'NICKNAME:me\n'
u'NOTE:Example VCard.\n'
u'ORG:Self Employed\n'
u'TEL;TYPE=WORK,VOICE:412 605 0499\n'
u'TEL;TYPE=FAX:412 605 0705\n'
u'URL:http://www.example.com\n'
u'UID:{}\n'
u'END:VCARD'.format(uid))