From d906aa3df683422d426c77a0ab4aad1e6c3d7c25 Mon Sep 17 00:00:00 2001 From: Markus Unterwaditzer Date: Thu, 1 May 2014 21:55:34 +0200 Subject: [PATCH] Fix #42 --- tests/test_sync.py | 15 +++++++++++++++ vdirsyncer/cli.py | 30 ++++++++++++++++++++++++++---- vdirsyncer/exceptions.py | 9 +++++++++ vdirsyncer/sync.py | 10 +++++++++- 4 files changed, 59 insertions(+), 5 deletions(-) diff --git a/tests/test_sync.py b/tests/test_sync.py index 0d60b6d..25579f8 100644 --- a/tests/test_sync.py +++ b/tests/test_sync.py @@ -91,6 +91,7 @@ def test_deletion(): item = Item(u'UID:1') a.upload(item) + a.upload(Item(u'UID:2')) sync(a, b, status) b.delete('1.txt', b.get('1.txt')[1]) sync(a, b, status) @@ -192,3 +193,17 @@ def test_uses_get_multi(monkeypatch): sync(a, b, {}) assert get_multi_calls == [[expected_href]] + + +def test_empty_storage_dataloss(): + a = MemoryStorage() + b = MemoryStorage() + a.upload(Item(u'UID:1')) + a.upload(Item(u'UID:2')) + status = {} + sync(a, b, status) + with pytest.raises(exceptions.StorageEmpty): + sync(MemoryStorage(), b, status) + + with pytest.raises(exceptions.StorageEmpty): + sync(a, MemoryStorage(), status) diff --git a/vdirsyncer/cli.py b/vdirsyncer/cli.py index 5204cdc..788bd01 100644 --- a/vdirsyncer/cli.py +++ b/vdirsyncer/cli.py @@ -14,6 +14,7 @@ from vdirsyncer.sync import sync from vdirsyncer.utils import expand_path, split_dict, parse_options from vdirsyncer.storage import storage_names import vdirsyncer.log as log +import vdirsyncer.exceptions as exceptions import argvard @@ -196,6 +197,11 @@ def _main(env, file_cfg): sync_command = argvard.Command() + @sync_command.option('--force-delete status_name') + def force_delete(context, status_name): + '''Pretty please delete all my data.''' + context.setdefault('force_delete', set()).add(status_name) + @sync_command.main('[pairs...]') def sync_main(context, pairs=None): ''' @@ -209,6 +215,7 @@ def _main(env, file_cfg): from the pair "bob". ''' actions = [] + force_delete = context.get('force_delete', set()) for pair_name, collection in parse_pairs_args(pairs, all_pairs): a_name, b_name, pair_options, storage_defaults = \ all_pairs[pair_name] @@ -225,7 +232,8 @@ def _main(env, file_cfg): 'pair_name': pair_name, 'collection': collection, 'pair_options': pair_options, - 'general': general + 'general': general, + 'force_delete': force_delete }) processes = general.get('processes', 0) or len(actions) @@ -249,7 +257,7 @@ def _sync_collection(x): def sync_collection(config_a, config_b, pair_name, collection, pair_options, - general): + general, force_delete): status_name = '_'.join(filter(bool, (pair_name, collection))) pair_description = ' from '.join(filter(bool, (collection, pair_name))) @@ -258,6 +266,20 @@ def sync_collection(config_a, config_b, pair_name, collection, pair_options, cli_logger.info('Syncing {}'.format(pair_description)) status = load_status(general['status_path'], status_name) - sync(a, b, status, - pair_options.get('conflict_resolution', None)) + try: + sync( + a, b, status, + conflict_resolution=pair_options.get('conflict_resolution', None), + force_delete=status_name in force_delete + ) + except exceptions.StorageEmpty as e: + side = 'a' if e.empty_storage is a else 'b' + storage = e.empty_storage + cli_logger.error('{pair_description}: Storage "{side}" ({storage}) ' + 'was completely emptied. Use "--force-delete ' + '{status_name}" to synchronize that emptyness to ' + 'the other side, or delete the status by yourself to ' + 'restore the empty side from the other one.' + .format(**locals())) + sys.exit(1) save_status(general['status_path'], status_name, status) diff --git a/vdirsyncer/exceptions.py b/vdirsyncer/exceptions.py index 7301a03..e13da8e 100644 --- a/vdirsyncer/exceptions.py +++ b/vdirsyncer/exceptions.py @@ -51,3 +51,12 @@ class SyncError(Error): class SyncConflict(SyncError): pass + + +class StorageEmpty(SyncError): + '''One storage unexpectedly got completely empty between two + synchronizations. The first argument is the empty storage.''' + + @property + def empty_storage(self): + return self.args[0] diff --git a/vdirsyncer/sync.py b/vdirsyncer/sync.py index d087a04..744ff62 100644 --- a/vdirsyncer/sync.py +++ b/vdirsyncer/sync.py @@ -42,7 +42,8 @@ def prepare_list(storage, href_to_status): yield href, props -def sync(storage_a, storage_b, status, conflict_resolution=None): +def sync(storage_a, storage_b, status, conflict_resolution=None, + force_delete=False): '''Syncronizes two storages. :param storage_a: The first storage @@ -56,6 +57,10 @@ def sync(storage_a, storage_b, status, conflict_resolution=None): :param conflict_resolution: Either 'a wins' or 'b wins'. If none is provided, the sync function will raise :py:exc:`vdirsyncer.exceptions.SyncConflict`. + :param force_delete: When one storage got completely emptied between two + syncs, :py:exc:`vdirsyncer.exceptions.StorageEmpty` is raised for + safety. Setting this parameter to ``True`` disables this safety + measure. ''' a_href_to_status = dict( (href_a, (uid, etag_a)) @@ -69,6 +74,9 @@ def sync(storage_a, storage_b, status, conflict_resolution=None): list_a = dict(prepare_list(storage_a, a_href_to_status)) list_b = dict(prepare_list(storage_b, b_href_to_status)) + if bool(list_a) != bool(list_b) and status and not force_delete: + raise exceptions.StorageEmpty(storage_b if list_a else storage_a) + a_uid_to_href = dict((x['uid'], href) for href, x in iteritems(list_a)) b_uid_to_href = dict((x['uid'], href) for href, x in iteritems(list_b)) del a_href_to_status, b_href_to_status