Introduce conflict resolution via commands

Fix #127

More options of conflict resolution are discussed there, but they all
require extra dependencies. This new API allows the user to plug in
third-party scripts to do those.
This commit is contained in:
Markus Unterwaditzer 2016-09-19 16:32:35 +02:00
parent e62e3a26f6
commit 22568571c2
5 changed files with 110 additions and 4 deletions

View file

@ -17,6 +17,8 @@ Version 0.13.0
cannot have a storage section ``[storage foo]`` and a pair ``[pair foo]`` in
your config, they have to have different names. This is done such that
console output is always unambigous. See :gh:`459`.
- Custom commands can now be used for conflict resolution during sync. See
:gh:`127`.
Version 0.12.1
==============

View file

@ -87,9 +87,19 @@ Pair Section
Valid values are:
- ``null``, where an error is shown and no changes are done.
- ``"a wins"`` and ``"b wins"``, where the whole item is taken from one side.
Vdirsyncer will not attempt to merge the two items.
- ``null``, the default, where an error is shown and no changes are done.
- ``["command", "vimdiff"]``: ``vimdiff <a> <b>`` will be called where
``<a>`` and ``<b>`` are temporary files that contain the item of each side
respectively. The files need to be exactly the same when the command
returns.
- ``vimdiff`` can be replaced with any other command. For example, in POSIX
``["command", "cp"]`` is equivalent to ``"a wins"``.
- Additional list items will be forwarded as arguments. For example,
``["command", "vimdiff", "--noplugin"]`` runs ``vimdiff --noplugin``.
Vdirsyncer never attempts to "automatically merge" the two items.
- ``metadata``: Metadata keys that should be synchronized when ``vdirsyncer
metasync`` is executed. Example::

View file

@ -409,3 +409,43 @@ def test_no_configured_pairs(tmpdir, runner, cmd):
result = runner.invoke([cmd])
assert result.output == 'critical: Nothing to do.\n'
assert result.exception.code == 5
@pytest.mark.parametrize('resolution,expect_foo,expect_bar', [
(['command', 'cp'], 'UID:lol\nfööcontent', 'UID:lol\nfööcontent')
])
def test_conflict_resolution(tmpdir, runner, resolution, expect_foo,
expect_bar):
runner.write_with_general(dedent('''
[pair foobar]
a = foo
b = bar
collections = null
conflict_resolution = {val}
[storage foo]
type = filesystem
fileext = .txt
path = {base}/foo
[storage bar]
type = filesystem
fileext = .txt
path = {base}/bar
'''.format(base=str(tmpdir), val=json.dumps(resolution))))
foo = tmpdir.join('foo')
bar = tmpdir.join('bar')
fooitem = foo.join('lol.txt').ensure()
fooitem.write('UID:lol\nfööcontent')
baritem = bar.join('lol.txt').ensure()
baritem.write('UID:lol\nbööcontent')
r = runner.invoke(['discover'])
assert not r.exception
r = runner.invoke(['sync'])
assert not r.exception
assert fooitem.read() == expect_foo
assert baritem.read() == expect_bar

View file

@ -3,6 +3,8 @@ import os
import string
from itertools import chain
from click_threading import get_ui_worker
from . import cli_logger
from .fetchparams import expand_fetch_params
from .. import PROJECT_HOME, exceptions
@ -251,12 +253,32 @@ class PairConfig(object):
self.name_b = options.pop('b')
self.options = options
conflict_resolution = self.options.get('conflict_resolution', None)
self._set_conflict_resolution()
self._set_collections()
def _set_conflict_resolution(self):
conflict_resolution = self.options.pop('conflict_resolution', None)
if conflict_resolution in (None, 'a wins', 'b wins'):
self.conflict_resolution = conflict_resolution
elif isinstance(conflict_resolution, list) and \
len(conflict_resolution) > 1 and \
conflict_resolution[0] == 'command':
def resolve(a, b):
a_name = self.config_a['instance_name']
b_name = self.config_b['instance_name']
command = conflict_resolution[1:]
def inner():
return resolve_conflict_via_command(a, b, command, a_name,
b_name)
ui_worker = get_ui_worker()
ui_worker.put(inner)
self.conflict_resolution = resolve
else:
raise ValueError('Invalid value for `conflict_resolution`.')
def _set_collections(self):
try:
collections = self.options['collections']
except KeyError:
@ -279,3 +301,35 @@ class CollectionConfig(object):
self.name = name
self.config_a = config_a
self.config_b = config_b
def resolve_conflict_via_command(a, b, command, a_name, b_name):
import tempfile
import shutil
import subprocess
from ..utils.vobject import Item
dir = tempfile.mkdtemp(prefix='vdirsyncer-conflict.')
try:
a_tmp = os.path.join(dir, a_name)
b_tmp = os.path.join(dir, b_name)
with open(a_tmp, 'w') as f:
f.write(a.raw)
with open(b_tmp, 'w') as f:
f.write(b.raw)
subprocess.check_call(command + [a_tmp, b_tmp])
with open(a_tmp) as f:
new_a = f.read()
with open(b_tmp) as f:
new_b = f.read()
if new_a != new_b:
raise exceptions.UserError('The two files are not completely '
'equal.')
return Item(new_a)
finally:
shutil.rmtree(dir)

View file

@ -58,7 +58,7 @@ def sync_collection(wq, collection, general, force_delete):
b = storage_instance_from_config(collection.config_b)
sync(
a, b, status,
conflict_resolution=pair.options.get('conflict_resolution', None),
conflict_resolution=pair.conflict_resolution,
force_delete=force_delete
)
except: