diff --git a/.travis.yml b/.travis.yml index 30d187e..7fed586 100644 --- a/.travis.yml +++ b/.travis.yml @@ -165,6 +165,18 @@ "env": "BUILD=test DAV_SERVER=davical REQUIREMENTS=minimal ", "python": "3.6" }, + { + "env": "BUILD=test DAV_SERVER=icloud REQUIREMENTS=devel ", + "python": "3.6" + }, + { + "env": "BUILD=test DAV_SERVER=icloud REQUIREMENTS=release ", + "python": "3.6" + }, + { + "env": "BUILD=test DAV_SERVER=icloud REQUIREMENTS=minimal ", + "python": "3.6" + }, { "env": "BUILD=test DAV_SERVER=skip REQUIREMENTS=devel ", "python": "pypy3" diff --git a/scripts/make_travisconf.py b/scripts/make_travisconf.py index 69fd3ad..d4c482c 100644 --- a/scripts/make_travisconf.py +++ b/scripts/make_travisconf.py @@ -41,7 +41,7 @@ matrix.append({ for python in python_versions: if python == latest_python: dav_servers = ("skip", "radicale", "owncloud", "nextcloud", "baikal", - "davical") + "davical", "icloud") rs_servers = () else: dav_servers = ("skip", "radicale") diff --git a/tests/storage/__init__.py b/tests/storage/__init__.py index 00ef7c0..8cd00f8 100644 --- a/tests/storage/__init__.py +++ b/tests/storage/__init__.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- import random +import uuid import textwrap from urllib.parse import quote as urlquote, unquote as urlunquote @@ -199,7 +200,10 @@ class StorageTests(object): def test_create_collection(self, requires_collections, get_storage_args, get_item): if getattr(self, 'dav_server', '') == 'radicale': - pytest.xfail('MKCOL is broken under Radicale 1.x') + pytest.skip('MKCOL is broken under Radicale 1.x') + + if getattr(self, 'dav_server', '') == 'icloud': + pytest.skip('iCloud requires a minimum-length for collection name') args = get_storage_args(collection=None) args['collection'] = 'test' @@ -233,8 +237,9 @@ class StorageTests(object): if s.storage_name == 'filesystem': pytest.skip('Behavior depends on the filesystem.') - s.upload(get_item(uid='A' * 42)) - s.upload(get_item(uid='a' * 42)) + uid = str(uuid.uuid4()) + s.upload(get_item(uid=uid.upper())) + s.upload(get_item(uid=uid.lower())) items = list(href for href, etag in s.list()) assert len(items) == 2 assert len(set(items)) == 2 @@ -242,7 +247,9 @@ class StorageTests(object): def test_specialchars(self, monkeypatch, requires_collections, get_storage_args, get_item): if getattr(self, 'dav_server', '') == 'radicale': - pytest.xfail('Radicale is fundamentally broken.') + pytest.skip('Radicale is fundamentally broken.') + if getattr(self, 'dav_server', '') == 'icloud': + pytest.skip('iCloud rejects uploads.') monkeypatch.setattr('vdirsyncer.utils.generate_href', lambda x: x) diff --git a/tests/storage/dav/test_caldav.py b/tests/storage/dav/test_caldav.py index b5a0808..c84ffd3 100644 --- a/tests/storage/dav/test_caldav.py +++ b/tests/storage/dav/test_caldav.py @@ -133,6 +133,8 @@ class TestCalDAVStorage(DAVStorageTests): list(s.list()) assert len(calls) == 1 + @pytest.mark.skipif(dav_server == 'icloud', + reason='iCloud only accepts VEVENT') def test_item_types_general(self, s): event = s.upload(format_item(EVENT_TEMPLATE))[0] task = s.upload(format_item(TASK_TEMPLATE))[0] diff --git a/tests/storage/servers/icloud/__init__.py b/tests/storage/servers/icloud/__init__.py new file mode 100644 index 0000000..f7a69f9 --- /dev/null +++ b/tests/storage/servers/icloud/__init__.py @@ -0,0 +1,56 @@ +import os +import uuid + +import pytest + + +def _clear_collection(s): + for href, etag in s.list(): + s.delete(href, etag) + + +class ServerMixin(object): + + @pytest.fixture + def get_storage_args(self, item_type, request): + # We need to properly clean up because otherwise we will run into + # iCloud's storage limit. + collections_to_delete = [] + + def delete_collections(): + for s in collections_to_delete: + s.session.request('DELETE', '') + + request.addfinalizer(delete_collections) + + if item_type != 'VEVENT': + # For some reason the collections created by vdirsyncer are not + # usable as task lists. + pytest.skip('iCloud doesn\'t support anything else than VEVENT') + + def inner(collection='test'): + args = { + 'username': os.environ['ICLOUD_USERNAME'], + 'password': os.environ['ICLOUD_PASSWORD'] + } + + if self.storage_class.fileext == '.ics': + args['url'] = 'https://caldav.icloud.com/' + elif self.storage_class.fileext == '.vcf': + args['url'] = 'https://contacts.icloud.com/' + else: + raise RuntimeError() + + if collection is not None: + assert collection.startswith('test') + # iCloud requires a minimum length for collection names + collection += '-vdirsyncer-ci-' + str(uuid.uuid4()) + + args = self.storage_class.create_collection(collection, + **args) + s = self.storage_class(**args) + _clear_collection(s) + assert not list(s.list()) + collections_to_delete.append(s) + return args + return inner diff --git a/tests/storage/servers/icloud/install.sh b/tests/storage/servers/icloud/install.sh new file mode 100644 index 0000000..e69de29 diff --git a/vdirsyncer/storage/base.py b/vdirsyncer/storage/base.py index 7dd92d0..09eeb9d 100644 --- a/vdirsyncer/storage/base.py +++ b/vdirsyncer/storage/base.py @@ -243,4 +243,7 @@ class Storage(metaclass=StorageMeta): def normalize_meta_value(value): - return (value or u'').strip() + # `None` is returned by iCloud for empty properties. + if not value or value == 'None': + value = '' + return value.strip() diff --git a/vdirsyncer/utils/vobject.py b/vdirsyncer/utils/vobject.py index d0b5007..3dab219 100644 --- a/vdirsyncer/utils/vobject.py +++ b/vdirsyncer/utils/vobject.py @@ -107,6 +107,8 @@ def normalize_item(item, ignore_props=IGNORE_PROPS): if not isinstance(item, Item): item = Item(item) + item = _strip_timezones(item) + x = _Component('TEMP', item.raw.splitlines(), []) for prop in IGNORE_PROPS: del x[prop] @@ -115,6 +117,17 @@ def normalize_item(item, ignore_props=IGNORE_PROPS): return u'\r\n'.join(filter(bool, (line.strip() for line in x.props))) +def _strip_timezones(item): + parsed = item.parsed + if not parsed or parsed.name != 'VCALENDAR': + return item + + parsed.subcomponents = [c for c in parsed.subcomponents + if c.name != 'VTIMEZONE'] + + return Item('\r\n'.join(parsed.dump_lines())) + + def hash_item(text): return hashlib.sha256(normalize_item(text).encode('utf-8')).hexdigest()