mirror of
https://github.com/samsonjs/vdirsyncer.git
synced 2026-04-27 14:57:41 +00:00
Fix #42
This commit is contained in:
parent
9c45a7852a
commit
d906aa3df6
4 changed files with 59 additions and 5 deletions
|
|
@ -91,6 +91,7 @@ def test_deletion():
|
||||||
|
|
||||||
item = Item(u'UID:1')
|
item = Item(u'UID:1')
|
||||||
a.upload(item)
|
a.upload(item)
|
||||||
|
a.upload(Item(u'UID:2'))
|
||||||
sync(a, b, status)
|
sync(a, b, status)
|
||||||
b.delete('1.txt', b.get('1.txt')[1])
|
b.delete('1.txt', b.get('1.txt')[1])
|
||||||
sync(a, b, status)
|
sync(a, b, status)
|
||||||
|
|
@ -192,3 +193,17 @@ def test_uses_get_multi(monkeypatch):
|
||||||
|
|
||||||
sync(a, b, {})
|
sync(a, b, {})
|
||||||
assert get_multi_calls == [[expected_href]]
|
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)
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ from vdirsyncer.sync import sync
|
||||||
from vdirsyncer.utils import expand_path, split_dict, parse_options
|
from vdirsyncer.utils import expand_path, split_dict, parse_options
|
||||||
from vdirsyncer.storage import storage_names
|
from vdirsyncer.storage import storage_names
|
||||||
import vdirsyncer.log as log
|
import vdirsyncer.log as log
|
||||||
|
import vdirsyncer.exceptions as exceptions
|
||||||
import argvard
|
import argvard
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -196,6 +197,11 @@ def _main(env, file_cfg):
|
||||||
|
|
||||||
sync_command = argvard.Command()
|
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...]')
|
@sync_command.main('[pairs...]')
|
||||||
def sync_main(context, pairs=None):
|
def sync_main(context, pairs=None):
|
||||||
'''
|
'''
|
||||||
|
|
@ -209,6 +215,7 @@ def _main(env, file_cfg):
|
||||||
from the pair "bob".
|
from the pair "bob".
|
||||||
'''
|
'''
|
||||||
actions = []
|
actions = []
|
||||||
|
force_delete = context.get('force_delete', set())
|
||||||
for pair_name, collection in parse_pairs_args(pairs, all_pairs):
|
for pair_name, collection in parse_pairs_args(pairs, all_pairs):
|
||||||
a_name, b_name, pair_options, storage_defaults = \
|
a_name, b_name, pair_options, storage_defaults = \
|
||||||
all_pairs[pair_name]
|
all_pairs[pair_name]
|
||||||
|
|
@ -225,7 +232,8 @@ def _main(env, file_cfg):
|
||||||
'pair_name': pair_name,
|
'pair_name': pair_name,
|
||||||
'collection': collection,
|
'collection': collection,
|
||||||
'pair_options': pair_options,
|
'pair_options': pair_options,
|
||||||
'general': general
|
'general': general,
|
||||||
|
'force_delete': force_delete
|
||||||
})
|
})
|
||||||
|
|
||||||
processes = general.get('processes', 0) or len(actions)
|
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,
|
def sync_collection(config_a, config_b, pair_name, collection, pair_options,
|
||||||
general):
|
general, force_delete):
|
||||||
status_name = '_'.join(filter(bool, (pair_name, collection)))
|
status_name = '_'.join(filter(bool, (pair_name, collection)))
|
||||||
pair_description = ' from '.join(filter(bool, (collection, pair_name)))
|
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))
|
cli_logger.info('Syncing {}'.format(pair_description))
|
||||||
status = load_status(general['status_path'], status_name)
|
status = load_status(general['status_path'], status_name)
|
||||||
sync(a, b, status,
|
try:
|
||||||
pair_options.get('conflict_resolution', None))
|
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)
|
save_status(general['status_path'], status_name, status)
|
||||||
|
|
|
||||||
|
|
@ -51,3 +51,12 @@ class SyncError(Error):
|
||||||
|
|
||||||
class SyncConflict(SyncError):
|
class SyncConflict(SyncError):
|
||||||
pass
|
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]
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,8 @@ def prepare_list(storage, href_to_status):
|
||||||
yield href, props
|
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.
|
'''Syncronizes two storages.
|
||||||
|
|
||||||
:param storage_a: The first storage
|
: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
|
:param conflict_resolution: Either 'a wins' or 'b wins'. If none is
|
||||||
provided, the sync function will raise
|
provided, the sync function will raise
|
||||||
:py:exc:`vdirsyncer.exceptions.SyncConflict`.
|
: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(
|
a_href_to_status = dict(
|
||||||
(href_a, (uid, etag_a))
|
(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_a = dict(prepare_list(storage_a, a_href_to_status))
|
||||||
list_b = dict(prepare_list(storage_b, b_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))
|
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))
|
b_uid_to_href = dict((x['uid'], href) for href, x in iteritems(list_b))
|
||||||
del a_href_to_status, b_href_to_status
|
del a_href_to_status, b_href_to_status
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue