Merge branch 'dav_discovery'

Fix #11
This commit is contained in:
Markus Unterwaditzer 2014-03-11 18:03:47 +01:00
commit 355c53a8db
14 changed files with 145 additions and 49 deletions

View file

@ -21,9 +21,12 @@ class StorageTests(object):
item_template = item_template or self.item_template item_template = item_template or self.item_template
return Item(item_template.format(uid=uid, r=r)) return Item(item_template.format(uid=uid, r=r))
def _get_storage(self, **kwargs): def get_storage_args(self, collection=None):
raise NotImplementedError() raise NotImplementedError()
def _get_storage(self):
return self.storage_class(**self.get_storage_args())
def test_generic(self): def test_generic(self):
items = map(self._create_bogus_item, range(1, 10)) items = map(self._create_bogus_item, range(1, 10))
for i, item in enumerate(items): for i, item in enumerate(items):
@ -88,3 +91,21 @@ class StorageTests(object):
assert not list(s.list()) assert not list(s.list())
s.upload(self._create_bogus_item('1')) s.upload(self._create_bogus_item('1'))
assert list(s.list()) assert list(s.list())
def test_discover(self):
items = []
for i in range(4):
s = self.storage_class(**self.get_storage_args(collection=str(i)))
items.append(self._create_bogus_item(str(i)))
s.upload(items[-1])
d = self.storage_class.discover(
**self.get_storage_args(collection=None))
for s in d:
((href, etag),) = s.list()
item, etag = s.get(href)
assert item.raw in set(x.raw for x in items)
def test_collection_arg(self):
s = self.storage_class(**self.get_storage_args(collection='asd'))
assert s.collection == 'asd'

View file

@ -0,0 +1,6 @@
import pytest
@pytest.fixture
def class_tmpdir(request, tmpdir):
request.instance.tmpdir = str(tmpdir)

View file

@ -13,11 +13,11 @@
:license: MIT, see LICENSE for more details. :license: MIT, see LICENSE for more details.
''' '''
import tempfile
import shutil
import sys import sys
import os import os
import urlparse import urlparse
import tempfile
import shutil
import mock import mock
from werkzeug.test import Client from werkzeug.test import Client
@ -104,6 +104,7 @@ class Response(object):
self.x = x self.x = x
self.status_code = x.status_code self.status_code = x.status_code
self.content = x.get_data(as_text=False) self.content = x.get_data(as_text=False)
self.text = x.get_data(as_text=True)
self.headers = x.headers self.headers = x.headers
self.encoding = x.charset self.encoding = x.charset
@ -116,24 +117,20 @@ class Response(object):
class DavStorageTests(StorageTests): class DavStorageTests(StorageTests):
'''hrefs are paths without scheme or netloc''' '''hrefs are paths without scheme or netloc'''
tmpdir = None
storage_class = None storage_class = None
radicale_path = None
patcher = None patcher = None
tmpdir = None
def _get_storage(self, **kwargs): def setup_method(self, method):
self.tmpdir = tempfile.mkdtemp() self.tmpdir = tempfile.mkdtemp()
do_the_radicale_dance(self.tmpdir) do_the_radicale_dance(self.tmpdir)
from radicale import Application from radicale import Application
app = Application() app = Application()
c = Client(app, WerkzeugResponse) c = Client(app, WerkzeugResponse)
server = 'http://127.0.0.1'
full_url = server + self.radicale_path
def x(session, method, url, data=None, headers=None, **kw): def x(session, method, url, data=None, headers=None, **kw):
path = urlparse.urlparse(url).path or self.radicale_path path = urlparse.urlparse(url).path
assert isinstance(data, bytes) or data is None assert isinstance(data, bytes) or data is None
r = c.open(path=path, method=method, data=data, headers=headers) r = c.open(path=path, method=method, data=data, headers=headers)
r = Response(r) r = Response(r)
@ -142,7 +139,9 @@ class DavStorageTests(StorageTests):
self.patcher = p = mock.patch('requests.Session.request', new=x) self.patcher = p = mock.patch('requests.Session.request', new=x)
p.start() p.start()
return self.storage_class(url=full_url, **kwargs) def get_storage_args(self, collection=None):
url = 'http://127.0.0.1/bob/'
return {'url': url, 'collection': collection}
def teardown_method(self, method): def teardown_method(self, method):
self.app = None self.app = None

View file

@ -10,7 +10,6 @@
from unittest import TestCase from unittest import TestCase
from vdirsyncer.storage.base import Item
from vdirsyncer.storage.dav.caldav import CaldavStorage from vdirsyncer.storage.dav.caldav import CaldavStorage
from . import DavStorageTests from . import DavStorageTests
@ -46,7 +45,6 @@ END:VCALENDAR'''
class CaldavStorageTests(TestCase, DavStorageTests): class CaldavStorageTests(TestCase, DavStorageTests):
storage_class = CaldavStorage storage_class = CaldavStorage
radicale_path = '/bob/test.ics/'
item_template = TASK_TEMPLATE item_template = TASK_TEMPLATE

View file

@ -10,14 +10,12 @@
from unittest import TestCase from unittest import TestCase
from vdirsyncer.storage.base import Item
from vdirsyncer.storage.dav.carddav import CarddavStorage from vdirsyncer.storage.dav.carddav import CarddavStorage
from . import DavStorageTests from . import DavStorageTests
class CarddavStorageTests(TestCase, DavStorageTests): class CarddavStorageTests(TestCase, DavStorageTests):
storage_class = CarddavStorage storage_class = CarddavStorage
radicale_path = '/bob/test.vcf/'
item_template = (u'BEGIN:VCARD\n' item_template = (u'BEGIN:VCARD\n'
u'VERSION:3.0\n' u'VERSION:3.0\n'

View file

@ -9,20 +9,18 @@
''' '''
from unittest import TestCase from unittest import TestCase
import tempfile import pytest
import shutil import os
from vdirsyncer.storage.filesystem import FilesystemStorage from vdirsyncer.storage.filesystem import FilesystemStorage
from . import StorageTests from . import StorageTests
@pytest.mark.usefixtures('class_tmpdir')
class FilesystemStorageTests(TestCase, StorageTests): class FilesystemStorageTests(TestCase, StorageTests):
tmpdir = None storage_class = FilesystemStorage
def _get_storage(self, **kwargs): def get_storage_args(self, collection=None):
path = self.tmpdir = tempfile.mkdtemp() path = self.tmpdir
return FilesystemStorage(path=path, fileext='.txt', **kwargs) if collection is not None:
os.makedirs(os.path.join(path, collection))
def teardown_method(self, method): return {'path': path, 'fileext': '.txt', 'collection': collection}
if self.tmpdir is not None:
shutil.rmtree(self.tmpdir)
self.tmpdir = None

View file

@ -8,12 +8,10 @@
''' '''
import mock import mock
import requests
from unittest import TestCase from unittest import TestCase
from . import StorageTests
from .. import assert_item_equals from .. import assert_item_equals
from textwrap import dedent from textwrap import dedent
from vdirsyncer.storage.http import HttpStorage, Item, split_collection from vdirsyncer.storage.http import HttpStorage, Item
class HttpStorageTests(TestCase): class HttpStorageTests(TestCase):
@ -31,7 +29,6 @@ class HttpStorageTests(TestCase):
METHOD:PUBLISH METHOD:PUBLISH
BEGIN:VEVENT BEGIN:VEVENT
UID:461092315540@example.com UID:461092315540@example.com
ORGANIZER;CN="Alice Balder, Example Inc.":MAILTO:alice@example.com
LOCATION:Somewhere LOCATION:Somewhere
SUMMARY:Eine Kurzinfo SUMMARY:Eine Kurzinfo
DESCRIPTION:Beschreibung des Termines DESCRIPTION:Beschreibung des Termines
@ -48,7 +45,7 @@ class HttpStorageTests(TestCase):
BEGIN:VALARM BEGIN:VALARM
ACTION:AUDIO ACTION:AUDIO
TRIGGER:19980403T120000 TRIGGER:19980403T120000
ATTACH;FMTTYPE=audio/basic:http://host.com/pub/audio-files/ssbanner.aud ATTACH;FMTTYPE=audio/basic:http://host.com/pub/ssbanner.aud
REPEAT:4 REPEAT:4
DURATION:PT1H DURATION:PT1H
END:VALARM END:VALARM
@ -68,7 +65,6 @@ class HttpStorageTests(TestCase):
assert_item_equals(item, Item(dedent(u''' assert_item_equals(item, Item(dedent(u'''
BEGIN:VEVENT BEGIN:VEVENT
UID:461092315540@example.com UID:461092315540@example.com
ORGANIZER;CN="Alice Balder, Example Inc.":MAILTO:alice@example.com
LOCATION:Somewhere LOCATION:Somewhere
SUMMARY:Eine Kurzinfo SUMMARY:Eine Kurzinfo
DESCRIPTION:Beschreibung des Termines DESCRIPTION:Beschreibung des Termines
@ -89,7 +85,7 @@ class HttpStorageTests(TestCase):
BEGIN:VALARM BEGIN:VALARM
ACTION:AUDIO ACTION:AUDIO
TRIGGER:19980403T120000 TRIGGER:19980403T120000
ATTACH;FMTTYPE=audio/basic:http://host.com/pub/audio-files/ssbanner.aud ATTACH;FMTTYPE=audio/basic:http://host.com/pub/ssbanner.aud
REPEAT:4 REPEAT:4
DURATION:PT1H DURATION:PT1H
END:VALARM END:VALARM

View file

@ -15,5 +15,13 @@ from . import StorageTests
class MemoryStorageTests(TestCase, StorageTests): class MemoryStorageTests(TestCase, StorageTests):
def _get_storage(self, **kwargs): storage_class = MemoryStorage
return MemoryStorage(**kwargs)
def get_storage_args(self, **kwargs):
return kwargs
def test_discover(self):
'''This test doesn't make any sense here.'''
def test_collection_arg(self):
'''This test doesn't make any sense here.'''

View file

@ -144,7 +144,8 @@ def _main(env, file_cfg):
actions = [] actions = []
for pair_name in pairs: for pair_name in pairs:
try: try:
a_name, b_name, pair_options, storage_defaults = all_pairs[pair_name] a_name, b_name, pair_options, storage_defaults = \
all_pairs[pair_name]
except KeyError: except KeyError:
cli_logger.critical('Pair not found: {}'.format(pair_name)) cli_logger.critical('Pair not found: {}'.format(pair_name))
cli_logger.critical('These are the pairs found: ') cli_logger.critical('These are the pairs found: ')

View file

@ -30,15 +30,31 @@ class Storage(object):
Terminology: Terminology:
- UID: Global identifier of the item, across storages. - UID: Global identifier of the item, across storages.
- HREF: Per-storage identifier of item, might be UID. - HREF: Per-storage identifier of item, might be UID. The reason items
aren't just referenced by their UID is because the CalDAV and CardDAV
specifications make this imperformant to implement.
- ETAG: Checksum of item, or something similar that changes when the - ETAG: Checksum of item, or something similar that changes when the
object does. object does.
:param collection: If None, the given URL or path is already directly
referring to a collection. Otherwise it will be treated as a basepath
to many collections (e.g. a vdir) and the given collection name will be
looked for.
''' '''
fileext = '.txt' fileext = '.txt'
_repr_attributes = () _repr_attributes = ()
def __init__(self, item_class=Item): @classmethod
self.item_class = item_class def discover(cls, **kwargs):
'''
Discover collections given a basepath or -URL to many collections.
:param **kwargs: Keyword arguments to additionally pass to the storage
instances returned. You shouldn't pass `collection` here, otherwise
TypeError will be raised.
:returns: Iterable of storages which represent the discovered
collections, all of which are passed kwargs during initialization.
'''
raise NotImplementedError()
def _get_href(self, uid): def _get_href(self, uid):
return uid + self.fileext return uid + self.fileext

View file

@ -16,12 +16,20 @@ from lxml import etree
class DavStorage(Storage): class DavStorage(Storage):
# the file extension of items. Useful for testing against radicale.
fileext = None fileext = None
# mimetype of items
item_mimetype = None item_mimetype = None
# The expected header for resource validation.
dav_header = None dav_header = None
# XML to use when fetching multiple hrefs.
get_multi_template = None get_multi_template = None
# The LXML query for extracting results in get_multi
get_multi_data_query = None get_multi_data_query = None
list_xml = None # The leif class to use for autodiscovery
# This should be the class *name* (i.e. "module attribute name") instead of
# the class, because leif is an optional dependency
leif_class = None
_session = None _session = None
_repr_attributes = ('url', 'username') _repr_attributes = ('url', 'username')
@ -29,12 +37,13 @@ class DavStorage(Storage):
def __init__(self, url, username='', password='', collection=None, def __init__(self, url, username='', password='', collection=None,
verify=True, auth='basic', useragent='vdirsyncer', **kwargs): verify=True, auth='basic', useragent='vdirsyncer', **kwargs):
''' '''
:param url: Direct URL for the CalDAV collection. No autodiscovery. :param url: Base URL or an URL to a collection. Autodiscovery should be
done via :py:meth:`DavStorage.discover`.
:param username: Username for authentication. :param username: Username for authentication.
:param password: Password for authentication. :param password: Password for authentication.
:param verify: Verify SSL certificate, default True. :param verify: Verify SSL certificate, default True.
:param auth: Authentication method, from {'basic', 'digest'}, default :param auth: Authentication method, from {'basic', 'digest'}, default
'basic'. 'basic'.
:param useragent: Default 'vdirsyncer'. :param useragent: Default 'vdirsyncer'.
''' '''
super(DavStorage, self).__init__(**kwargs) super(DavStorage, self).__init__(**kwargs)
@ -56,6 +65,7 @@ class DavStorage(Storage):
url = urlparse.urljoin(url, collection) url = urlparse.urljoin(url, collection)
self.url = url.rstrip('/') + '/' self.url = url.rstrip('/') + '/'
self.parsed_url = urlparse.urlparse(self.url) self.parsed_url = urlparse.urlparse(self.url)
self.collection = collection
headers = self._default_headers() headers = self._default_headers()
headers['Depth'] = 1 headers['Depth'] = 1
@ -68,6 +78,25 @@ class DavStorage(Storage):
if self.dav_header not in response.headers.get('DAV', ''): if self.dav_header not in response.headers.get('DAV', ''):
raise exceptions.StorageError('URL is not a collection') raise exceptions.StorageError('URL is not a collection')
@classmethod
def discover(cls, url, **kwargs):
if kwargs.pop('collection', None) is not None:
raise TypeError('collection argument must not be given.')
from leif import leif
d = getattr(leif, cls.leif_class)(
url,
user=kwargs.get('username', None),
password=kwargs.get('password', None),
ssl_verify=kwargs.get('verify', True)
)
for c in d.discover():
collection = c['href']
if collection.startswith(url):
collection = collection[len(url):]
s = cls(url=url, collection=collection, **kwargs)
s.displayname = c['displayname']
yield s
def _normalize_href(self, href): def _normalize_href(self, href):
'''Normalize the href to be a path only relative to hostname and '''Normalize the href to be a path only relative to hostname and
schema.''' schema.'''
@ -180,6 +209,8 @@ class DavStorage(Storage):
def update(self, href, obj, etag): def update(self, href, obj, etag):
href = self._normalize_href(href) href = self._normalize_href(href)
if etag is None:
raise ValueError('etag must be given and must not be None.')
return self._put(href, obj, etag) return self._put(href, obj, etag)
def upload(self, obj): def upload(self, obj):

View file

@ -21,9 +21,10 @@ class CaldavStorage(DavStorage):
fileext = '.ics' fileext = '.ics'
item_mimetype = 'text/calendar' item_mimetype = 'text/calendar'
dav_header = 'calendar-access' dav_header = 'calendar-access'
leif_class = 'CalDiscover'
start_date = None start_date = None
end_date = None end_date = None
item_types = None
get_multi_template = '''<?xml version="1.0" encoding="utf-8" ?> get_multi_template = '''<?xml version="1.0" encoding="utf-8" ?>
<C:calendar-multiget xmlns:D="DAV:" <C:calendar-multiget xmlns:D="DAV:"
@ -42,6 +43,10 @@ class CaldavStorage(DavStorage):
''' '''
:param start_date: Start date of timerange to show, default -inf. :param start_date: Start date of timerange to show, default -inf.
:param end_date: End date of timerange to show, default +inf. :param end_date: End date of timerange to show, default +inf.
:param item_types: The item types to show from the server. Dependent on
server functionality, no clientside validation of results. This
currently only affects the `list` method, but this shouldn't cause
problems in the normal usecase.
''' '''
super(CaldavStorage, self).__init__(**kwargs) super(CaldavStorage, self).__init__(**kwargs)
if isinstance(item_types, str): if isinstance(item_types, str):

View file

@ -17,6 +17,7 @@ class CarddavStorage(DavStorage):
fileext = '.vcf' fileext = '.vcf'
item_mimetype = 'text/vcard' item_mimetype = 'text/vcard'
dav_header = 'addressbook' dav_header = 'addressbook'
leif_class = 'CardDiscover'
get_multi_template = '''<?xml version="1.0" encoding="utf-8" ?> get_multi_template = '''<?xml version="1.0" encoding="utf-8" ?>
<C:addressbook-multiget xmlns:D="DAV:" <C:addressbook-multiget xmlns:D="DAV:"

View file

@ -23,16 +23,34 @@ class FilesystemStorage(Storage):
_repr_attributes = ('path',) _repr_attributes = ('path',)
def __init__(self, path, fileext, collection=None, encoding='utf-8', **kwargs): def __init__(self, path, fileext, collection=None, encoding='utf-8',
**kwargs):
''' '''
:param path: Absolute path to a *collection* inside a vdir. :param path: Absolute path to a vdir or collection, depending on the
collection parameter (see
:py:class:`vdirsyncer.storage.base.Storage`).
:param fileext: The file extension to use (e.g. `".txt"`). Contained in
the href, so if you change the file extension after a sync, this
will trigger a re-download of everything (but *should* not cause
data-loss of any kind).
:param encoding: File encoding for items.
''' '''
self.path = expand_path(path) super(FilesystemStorage, self).__init__(**kwargs)
if collection is not None: if collection is not None:
self.path = os.path.join(self.path, collection) path = os.path.join(path, collection)
self.collection = collection
self.path = expand_path(path)
self.encoding = encoding self.encoding = encoding
self.fileext = fileext self.fileext = fileext
super(FilesystemStorage, self).__init__(**kwargs)
@classmethod
def discover(cls, path, **kwargs):
if kwargs.pop('collection', None) is not None:
raise TypeError('collection argument must not be given.')
for collection in os.listdir(path):
s = cls(path=path, collection=collection, **kwargs)
if next(s.list(), None) is not None:
yield s
def _get_filepath(self, href): def _get_filepath(self, href):
return os.path.join(self.path, href) return os.path.join(self.path, href)