diff --git a/tests/storage/__init__.py b/tests/storage/__init__.py index 751c933..fc570bc 100644 --- a/tests/storage/__init__.py +++ b/tests/storage/__init__.py @@ -21,9 +21,12 @@ class StorageTests(object): item_template = item_template or self.item_template return Item(item_template.format(uid=uid, r=r)) - def _get_storage(self, **kwargs): + def get_storage_args(self, collection=None): raise NotImplementedError() + def _get_storage(self): + return self.storage_class(**self.get_storage_args()) + def test_generic(self): items = map(self._create_bogus_item, range(1, 10)) for i, item in enumerate(items): @@ -88,3 +91,21 @@ class StorageTests(object): assert not list(s.list()) s.upload(self._create_bogus_item('1')) 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' diff --git a/tests/storage/conftest.py b/tests/storage/conftest.py new file mode 100644 index 0000000..9a07d98 --- /dev/null +++ b/tests/storage/conftest.py @@ -0,0 +1,6 @@ +import pytest + + +@pytest.fixture +def class_tmpdir(request, tmpdir): + request.instance.tmpdir = str(tmpdir) diff --git a/tests/storage/dav/__init__.py b/tests/storage/dav/__init__.py index f8350a1..22b2e6c 100644 --- a/tests/storage/dav/__init__.py +++ b/tests/storage/dav/__init__.py @@ -13,11 +13,11 @@ :license: MIT, see LICENSE for more details. ''' -import tempfile -import shutil import sys import os import urlparse +import tempfile +import shutil import mock from werkzeug.test import Client @@ -104,6 +104,7 @@ class Response(object): self.x = x self.status_code = x.status_code self.content = x.get_data(as_text=False) + self.text = x.get_data(as_text=True) self.headers = x.headers self.encoding = x.charset @@ -116,24 +117,20 @@ class Response(object): class DavStorageTests(StorageTests): '''hrefs are paths without scheme or netloc''' - tmpdir = None storage_class = None - radicale_path = None patcher = None + tmpdir = None - def _get_storage(self, **kwargs): + def setup_method(self, method): self.tmpdir = tempfile.mkdtemp() - do_the_radicale_dance(self.tmpdir) from radicale import Application app = Application() 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): - path = urlparse.urlparse(url).path or self.radicale_path + path = urlparse.urlparse(url).path assert isinstance(data, bytes) or data is None r = c.open(path=path, method=method, data=data, headers=headers) r = Response(r) @@ -142,7 +139,9 @@ class DavStorageTests(StorageTests): self.patcher = p = mock.patch('requests.Session.request', new=x) 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): self.app = None diff --git a/tests/storage/dav/test_caldav.py b/tests/storage/dav/test_caldav.py index ce9c713..ee1eee7 100644 --- a/tests/storage/dav/test_caldav.py +++ b/tests/storage/dav/test_caldav.py @@ -10,7 +10,6 @@ from unittest import TestCase -from vdirsyncer.storage.base import Item from vdirsyncer.storage.dav.caldav import CaldavStorage from . import DavStorageTests @@ -46,7 +45,6 @@ END:VCALENDAR''' class CaldavStorageTests(TestCase, DavStorageTests): storage_class = CaldavStorage - radicale_path = '/bob/test.ics/' item_template = TASK_TEMPLATE diff --git a/tests/storage/dav/test_carddav.py b/tests/storage/dav/test_carddav.py index b1defa2..940477b 100644 --- a/tests/storage/dav/test_carddav.py +++ b/tests/storage/dav/test_carddav.py @@ -10,14 +10,12 @@ 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/' item_template = (u'BEGIN:VCARD\n' u'VERSION:3.0\n' diff --git a/tests/storage/test_filesystem.py b/tests/storage/test_filesystem.py index 56e70a4..5d93e62 100644 --- a/tests/storage/test_filesystem.py +++ b/tests/storage/test_filesystem.py @@ -9,20 +9,18 @@ ''' from unittest import TestCase -import tempfile -import shutil +import pytest +import os from vdirsyncer.storage.filesystem import FilesystemStorage from . import StorageTests +@pytest.mark.usefixtures('class_tmpdir') class FilesystemStorageTests(TestCase, StorageTests): - tmpdir = None + storage_class = FilesystemStorage - def _get_storage(self, **kwargs): - path = self.tmpdir = tempfile.mkdtemp() - return FilesystemStorage(path=path, fileext='.txt', **kwargs) - - def teardown_method(self, method): - if self.tmpdir is not None: - shutil.rmtree(self.tmpdir) - self.tmpdir = None + def get_storage_args(self, collection=None): + path = self.tmpdir + if collection is not None: + os.makedirs(os.path.join(path, collection)) + return {'path': path, 'fileext': '.txt', 'collection': collection} diff --git a/tests/storage/test_http.py b/tests/storage/test_http.py index f3d0821..c4553a6 100644 --- a/tests/storage/test_http.py +++ b/tests/storage/test_http.py @@ -8,12 +8,10 @@ ''' import mock -import requests from unittest import TestCase -from . import StorageTests from .. import assert_item_equals from textwrap import dedent -from vdirsyncer.storage.http import HttpStorage, Item, split_collection +from vdirsyncer.storage.http import HttpStorage, Item class HttpStorageTests(TestCase): @@ -31,7 +29,6 @@ class HttpStorageTests(TestCase): METHOD:PUBLISH BEGIN:VEVENT UID:461092315540@example.com - ORGANIZER;CN="Alice Balder, Example Inc.":MAILTO:alice@example.com LOCATION:Somewhere SUMMARY:Eine Kurzinfo DESCRIPTION:Beschreibung des Termines @@ -48,7 +45,7 @@ class HttpStorageTests(TestCase): BEGIN:VALARM ACTION:AUDIO 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 DURATION:PT1H END:VALARM @@ -68,7 +65,6 @@ class HttpStorageTests(TestCase): assert_item_equals(item, Item(dedent(u''' BEGIN:VEVENT UID:461092315540@example.com - ORGANIZER;CN="Alice Balder, Example Inc.":MAILTO:alice@example.com LOCATION:Somewhere SUMMARY:Eine Kurzinfo DESCRIPTION:Beschreibung des Termines @@ -89,7 +85,7 @@ class HttpStorageTests(TestCase): BEGIN:VALARM ACTION:AUDIO 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 DURATION:PT1H END:VALARM diff --git a/tests/storage/test_memory.py b/tests/storage/test_memory.py index d52e7e7..eca9184 100644 --- a/tests/storage/test_memory.py +++ b/tests/storage/test_memory.py @@ -15,5 +15,13 @@ from . import StorageTests class MemoryStorageTests(TestCase, StorageTests): - def _get_storage(self, **kwargs): - return MemoryStorage(**kwargs) + storage_class = MemoryStorage + + 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.''' diff --git a/vdirsyncer/cli.py b/vdirsyncer/cli.py index 6ca05e2..c7c6284 100644 --- a/vdirsyncer/cli.py +++ b/vdirsyncer/cli.py @@ -144,7 +144,8 @@ def _main(env, file_cfg): actions = [] for pair_name in pairs: 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: cli_logger.critical('Pair not found: {}'.format(pair_name)) cli_logger.critical('These are the pairs found: ') diff --git a/vdirsyncer/storage/base.py b/vdirsyncer/storage/base.py index 24b1178..06c88e2 100644 --- a/vdirsyncer/storage/base.py +++ b/vdirsyncer/storage/base.py @@ -30,15 +30,31 @@ class Storage(object): Terminology: - 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 - 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' _repr_attributes = () - def __init__(self, item_class=Item): - self.item_class = item_class + @classmethod + 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): return uid + self.fileext diff --git a/vdirsyncer/storage/dav/base.py b/vdirsyncer/storage/dav/base.py index 4f23974..3e5949c 100644 --- a/vdirsyncer/storage/dav/base.py +++ b/vdirsyncer/storage/dav/base.py @@ -16,12 +16,20 @@ from lxml import etree class DavStorage(Storage): + # the file extension of items. Useful for testing against radicale. fileext = None + # mimetype of items item_mimetype = None + # The expected header for resource validation. dav_header = None + # XML to use when fetching multiple hrefs. get_multi_template = None + # The LXML query for extracting results in get_multi 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 _repr_attributes = ('url', 'username') @@ -29,12 +37,13 @@ class DavStorage(Storage): def __init__(self, url, username='', password='', collection=None, 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 password: Password for authentication. :param verify: Verify SSL certificate, default True. :param auth: Authentication method, from {'basic', 'digest'}, default - 'basic'. + 'basic'. :param useragent: Default 'vdirsyncer'. ''' super(DavStorage, self).__init__(**kwargs) @@ -56,6 +65,7 @@ class DavStorage(Storage): url = urlparse.urljoin(url, collection) self.url = url.rstrip('/') + '/' self.parsed_url = urlparse.urlparse(self.url) + self.collection = collection headers = self._default_headers() headers['Depth'] = 1 @@ -68,6 +78,25 @@ class DavStorage(Storage): if self.dav_header not in response.headers.get('DAV', ''): 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): '''Normalize the href to be a path only relative to hostname and schema.''' @@ -180,6 +209,8 @@ class DavStorage(Storage): def update(self, href, obj, etag): 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) def upload(self, obj): diff --git a/vdirsyncer/storage/dav/caldav.py b/vdirsyncer/storage/dav/caldav.py index 2a5de6e..7df8c4e 100644 --- a/vdirsyncer/storage/dav/caldav.py +++ b/vdirsyncer/storage/dav/caldav.py @@ -21,9 +21,10 @@ class CaldavStorage(DavStorage): fileext = '.ics' item_mimetype = 'text/calendar' dav_header = 'calendar-access' + leif_class = 'CalDiscover' + start_date = None end_date = None - item_types = None get_multi_template = '''