Merge branch 'unify_creation'

fix #157
This commit is contained in:
Markus Unterwaditzer 2015-01-01 22:02:05 +01:00
commit 998a3884fd
13 changed files with 131 additions and 74 deletions

View file

@ -14,6 +14,9 @@ Version 0.4.1
*yet to be released* *yet to be released*
- All ``create`` arguments from all storages are gone. Vdirsyncer now asks if
it should try to create collections.
Version 0.4.0 Version 0.4.0
============= =============

View file

@ -39,8 +39,6 @@ collections = ["from b"]
type = filesystem type = filesystem
path = ~/.contacts/ path = ~/.contacts/
fileext = .vcf fileext = .vcf
# Create the directory if it doesn't exist: `true` or `false`
#create = true
[storage bob_contacts_remote] [storage bob_contacts_remote]
type = carddav type = carddav

View file

@ -35,20 +35,12 @@ class TestFilesystemStorage(StorageTests):
return {'path': path, 'fileext': '.txt', 'collection': collection} return {'path': path, 'fileext': '.txt', 'collection': collection}
return inner return inner
def test_create_is_false(self, tmpdir):
with pytest.raises(IOError):
self.storage_class(str(tmpdir) + '/lol', '.txt', create=False)
def test_is_not_directory(self, tmpdir): def test_is_not_directory(self, tmpdir):
with pytest.raises(IOError): with pytest.raises(IOError):
f = tmpdir.join('hue') f = tmpdir.join('hue')
f.write('stub') f.write('stub')
self.storage_class(str(tmpdir) + '/hue', '.txt') self.storage_class(str(tmpdir) + '/hue', '.txt')
def test_create_is_true(self, tmpdir):
self.storage_class(str(tmpdir) + '/asd', '.txt')
assert tmpdir.listdir() == [tmpdir.join('asd')]
def test_broken_data(self, tmpdir): def test_broken_data(self, tmpdir):
s = self.storage_class(str(tmpdir), '.txt') s = self.storage_class(str(tmpdir), '.txt')

View file

@ -53,7 +53,7 @@ class TestHttpStorage(StorageTests):
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def setup_tmpdir(self, tmpdir, monkeypatch): def setup_tmpdir(self, tmpdir, monkeypatch):
self.tmpfile = str(tmpdir.join('collection.txt')) self.tmpfile = str(tmpdir.ensure('collection.txt'))
def _request(method, url, *args, **kwargs): def _request(method, url, *args, **kwargs):
assert method == 'GET' assert method == 'GET'

View file

@ -21,7 +21,7 @@ class TestSingleFileStorage(StorageTests):
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def setup(self, tmpdir): def setup(self, tmpdir):
self._path = str(tmpdir.join('test.txt')) self._path = str(tmpdir.ensure('test.txt'))
@pytest.fixture @pytest.fixture
def get_storage_args(self): def get_storage_args(self):
@ -33,14 +33,3 @@ class TestSingleFileStorage(StorageTests):
def test_collection_arg(self, tmpdir): def test_collection_arg(self, tmpdir):
with pytest.raises(ValueError): with pytest.raises(ValueError):
self.storage_class(str(tmpdir.join('foo.ics')), collection='ha') self.storage_class(str(tmpdir.join('foo.ics')), collection='ha')
def test_create_arg(self, tmpdir):
s = self.storage_class(str(tmpdir) + '/foo.ics')
assert not s.list()
s.create = False
with pytest.raises(IOError):
s.list()
with pytest.raises(IOError):
s = self.storage_class(str(tmpdir) + '/foo.ics', create=False)

View file

@ -126,6 +126,9 @@ def test_simple_run(tmpdir, runner):
fileext = .txt fileext = .txt
''').format(str(tmpdir))) ''').format(str(tmpdir)))
tmpdir.mkdir('path_a')
tmpdir.mkdir('path_b')
result = runner.invoke(['sync']) result = runner.invoke(['sync'])
assert not result.exception assert not result.exception
@ -152,6 +155,9 @@ def test_empty_storage(tmpdir, runner):
fileext = .txt fileext = .txt
''').format(str(tmpdir))) ''').format(str(tmpdir)))
tmpdir.mkdir('path_a')
tmpdir.mkdir('path_b')
result = runner.invoke(['sync']) result = runner.invoke(['sync'])
assert not result.exception assert not result.exception
@ -274,7 +280,10 @@ def test_collections_cache_invalidation(tmpdir, runner):
foo = tmpdir.mkdir('foo') foo = tmpdir.mkdir('foo')
bar = tmpdir.mkdir('bar') bar = tmpdir.mkdir('bar')
foo.mkdir('a').join('itemone.txt').write('UID:itemone') for x in 'abc':
foo.mkdir(x)
bar.mkdir(x)
foo.join('a/itemone.txt').write('UID:itemone')
result = runner.invoke(['sync']) result = runner.invoke(['sync'])
assert not result.exception assert not result.exception
@ -302,6 +311,8 @@ def test_collections_cache_invalidation(tmpdir, runner):
tmpdir.join('status').remove() tmpdir.join('status').remove()
bar2 = tmpdir.mkdir('bar2') bar2 = tmpdir.mkdir('bar2')
for x in 'abc':
bar2.mkdir(x)
result = runner.invoke(['sync']) result = runner.invoke(['sync'])
assert not result.exception assert not result.exception
@ -329,8 +340,10 @@ def test_invalid_pairs_as_cli_arg(tmpdir, runner):
collections = ["a", "b", "c"] collections = ["a", "b", "c"]
''').format(str(tmpdir))) ''').format(str(tmpdir)))
tmpdir.mkdir('foo') for base in ('foo', 'bar'):
tmpdir.mkdir('bar') base = tmpdir.mkdir(base)
for c in 'abc':
base.mkdir(c)
result = runner.invoke(['sync', 'foobar/d']) result = runner.invoke(['sync', 'foobar/d'])
assert result.exception assert result.exception
@ -356,11 +369,12 @@ def test_discover_command(tmpdir, runner):
''').format(str(tmpdir))) ''').format(str(tmpdir)))
foo = tmpdir.mkdir('foo') foo = tmpdir.mkdir('foo')
tmpdir.mkdir('bar') bar = tmpdir.mkdir('bar')
foo.mkdir('a') for x in 'abc':
foo.mkdir('b') foo.mkdir(x)
foo.mkdir('c') bar.mkdir(x)
bar.mkdir('d')
result = runner.invoke(['sync']) result = runner.invoke(['sync'])
assert not result.exception assert not result.exception
@ -369,10 +383,14 @@ def test_discover_command(tmpdir, runner):
assert 'Syncing foobar/a' in lines assert 'Syncing foobar/a' in lines
assert 'Syncing foobar/b' in lines assert 'Syncing foobar/b' in lines
assert 'Syncing foobar/c' in lines assert 'Syncing foobar/c' in lines
assert 'Syncing foobar/d' not in lines
foo.mkdir('d') foo.mkdir('d')
result = runner.invoke(['sync']) result = runner.invoke(['sync'])
assert not result.exception assert not result.exception
assert 'Syncing foobar/a' in lines
assert 'Syncing foobar/b' in lines
assert 'Syncing foobar/c' in lines
assert 'Syncing foobar/d' not in result.output assert 'Syncing foobar/d' not in result.output
result = runner.invoke(['discover']) result = runner.invoke(['discover'])
@ -380,6 +398,9 @@ def test_discover_command(tmpdir, runner):
result = runner.invoke(['sync']) result = runner.invoke(['sync'])
assert not result.exception assert not result.exception
assert 'Syncing foobar/a' in lines
assert 'Syncing foobar/b' in lines
assert 'Syncing foobar/c' in lines
assert 'Syncing foobar/d' in result.output assert 'Syncing foobar/d' in result.output
@ -403,12 +424,12 @@ def test_multiple_pairs(tmpdir, runner):
runner.write_with_general(''.join(get_cfg())) runner.write_with_general(''.join(get_cfg()))
result = runner.invoke(['sync']) result = runner.invoke(['sync'])
assert sorted(result.output.splitlines()) == [ assert set(result.output.splitlines()) > set([
'Discovering collections for pair bambaz', 'Discovering collections for pair bambaz',
'Discovering collections for pair foobar', 'Discovering collections for pair foobar',
'Syncing bambaz', 'Syncing bambaz',
'Syncing foobar', 'Syncing foobar',
] ])
def test_invalid_collections_arg(tmpdir, runner): def test_invalid_collections_arg(tmpdir, runner):
@ -437,6 +458,37 @@ def test_invalid_collections_arg(tmpdir, runner):
) )
def test_create_collections(tmpdir, runner):
runner.write_with_general(dedent('''
[pair foobar]
a = foo
b = bar
collections = ["a", "b", "c"]
[storage foo]
type = filesystem
path = {base}/foo/
fileext = .txt
[storage bar]
type = filesystem
path = {base}/bar/
fileext = .txt
'''.format(base=str(tmpdir))))
result = runner.invoke(['sync'])
assert result.exception
entries = set(x.basename for x in tmpdir.listdir())
assert 'foo' not in entries and 'bar' not in entries
result = runner.invoke(['sync'], input='y\n' * 6)
assert not result.exception
assert \
set(x.basename for x in tmpdir.join('foo').listdir()) == \
set(x.basename for x in tmpdir.join('bar').listdir()) == \
set('abc')
def test_parse_config_value(): def test_parse_config_value():
x = cli.utils.parse_config_value x = cli.utils.parse_config_value
with pytest.raises(ValueError): with pytest.raises(ValueError):

View file

@ -16,7 +16,7 @@ import sys
import threading import threading
from itertools import chain from itertools import chain
from .. import DOCS_HOME, PROJECT_HOME, log from .. import DOCS_HOME, PROJECT_HOME, exceptions, log
from ..doubleclick import click from ..doubleclick import click
from ..storage import storage_names from ..storage import storage_names
from ..sync import StorageEmpty, SyncConflict from ..sync import StorageEmpty, SyncConflict
@ -167,22 +167,27 @@ def _get_coll(pair_name, storage_name, collection, discovered, config):
try: try:
return discovered[collection] return discovered[collection]
except KeyError: except KeyError:
return _handle_collection_not_found(config, collection)
def _handle_collection_not_found(config, collection):
storage_name = config.get('instance_name', None)
cli_logger.error('No collection {} found for storage {}.'
.format(collection, storage_name))
if click.confirm('Should vdirsyncer attempt to create it?'):
storage_type = config['type'] storage_type = config['type']
cls, config = storage_class_from_config(config) cls, config = storage_class_from_config(config)
try: try:
args = cls.join_collection(collection=collection, **config) args = cls.create_collection(collection=collection, **config)
args['type'] = storage_type args['type'] = storage_type
return args return args
except NotImplementedError as e: except NotImplementedError as e:
cli_logger.error(e) cli_logger.error(e)
raise CliError(
'{pair}: Unable to find or create collection {collection!r} ' raise CliError('Unable to find or create collection {collection!r} for '
'for storage {storage!r}. A same-named collection was found ' 'storage {storage!r}. Please create the collection '
'for the other storage, and vdirsyncer is configured to ' 'yourself.'.format(collection=collection,
'synchronize these two collections. Please create the ' storage=storage_name))
'collection yourself.'
.format(pair=pair_name, collection=collection,
storage=storage_name))
def _collections_for_pair_impl(status_path, name_a, name_b, pair_name, def _collections_for_pair_impl(status_path, name_a, name_b, pair_name,
@ -353,19 +358,25 @@ def storage_class_from_config(config):
return cls, config return cls, config
def storage_instance_from_config(config): def storage_instance_from_config(config, create=True):
''' '''
:param config: A configuration dictionary to pass as kwargs to the class :param config: A configuration dictionary to pass as kwargs to the class
corresponding to config['type'] corresponding to config['type']
:param description: A name for the storage for debugging purposes
''' '''
cls, config = storage_class_from_config(config) cls, new_config = storage_class_from_config(config)
try: try:
return cls(**config) return cls(**new_config)
except exceptions.CollectionNotFound as e:
cli_logger.error(str(e))
if create:
_handle_collection_not_found(config, None)
return storage_instance_from_config(config, create=False)
else:
raise
except Exception: except Exception:
handle_storage_init_error(cls, config) return handle_storage_init_error(cls, new_config)
def handle_storage_init_error(cls, config): def handle_storage_init_error(cls, config):

View file

@ -23,6 +23,10 @@ class Error(Exception):
super(Error, self).__init__(*args) super(Error, self).__init__(*args)
class CollectionNotFound(Error):
'''Collection not found'''
class PreconditionFailed(Error): class PreconditionFailed(Error):
''' '''
- The item doesn't exist although it should - The item doesn't exist although it should

View file

@ -109,10 +109,11 @@ class Storage(with_metaclass(StorageMeta)):
raise NotImplementedError() raise NotImplementedError()
@classmethod @classmethod
def join_collection(cls, collection, **kwargs): def create_collection(cls, collection, **kwargs):
'''Append the collection to the URL or path specified in ``**kwargs`` '''Create the specified collection and return the new arguments.
and return the new arguments.
''' ``collection=None`` means the arguments are already pointing to a
possible collection location.'''
raise NotImplementedError() raise NotImplementedError()
def __repr__(self): def __repr__(self):

View file

@ -65,6 +65,11 @@ def _fuzzy_matches_mimetype(strict, weak):
return False return False
def _get_collection_from_url(url):
_, collection = url.rstrip('/').rsplit('/', 1)
return collection
def _catch_generator_exceptions(f): def _catch_generator_exceptions(f):
@functools.wraps(f) @functools.wraps(f)
def inner(*args, **kwargs): def inner(*args, **kwargs):
@ -334,14 +339,16 @@ class DavStorage(Storage):
d = cls.discovery_class(cls._get_session(**kwargs)) d = cls.discovery_class(cls._get_session(**kwargs))
for c in d.discover(): for c in d.discover():
url = c['href'] url = c['href']
_, collection = url.rstrip('/').rsplit('/', 1) collection = _get_collection_from_url(url)
storage_args = dict(kwargs) storage_args = dict(kwargs)
storage_args.update({'url': url, 'collection': collection, storage_args.update({'url': url, 'collection': collection,
'collection_human': c['displayname']}) 'collection_human': c['displayname']})
yield storage_args yield storage_args
@classmethod @classmethod
def join_collection(cls, collection, **kwargs): def create_collection(cls, collection, **kwargs):
if collection is None:
collection = _get_collection_from_url(kwargs['url'])
session = cls._get_session(**kwargs) session = cls._get_session(**kwargs)
d = cls.discovery_class(session) d = cls.discovery_class(session)

View file

@ -33,16 +33,15 @@ class FilesystemStorage(Storage):
trigger a re-download of everything (but *should* not cause data-loss trigger a re-download of everything (but *should* not cause data-loss
of any kind). of any kind).
:param encoding: File encoding for items. :param encoding: File encoding for items.
:param create: Create directories if they don't exist.
''' '''
storage_name = 'filesystem' storage_name = 'filesystem'
_repr_attributes = ('path',) _repr_attributes = ('path',)
def __init__(self, path, fileext, encoding='utf-8', create=True, **kwargs): def __init__(self, path, fileext, encoding='utf-8', **kwargs):
super(FilesystemStorage, self).__init__(**kwargs) super(FilesystemStorage, self).__init__(**kwargs)
path = expand_path(path) path = expand_path(path)
checkdir(path, create=create) checkdir(path, create=False)
self.path = path self.path = path
self.encoding = encoding self.encoding = encoding
self.fileext = fileext self.fileext = fileext
@ -66,8 +65,10 @@ class FilesystemStorage(Storage):
yield args yield args
@classmethod @classmethod
def join_collection(cls, collection, **kwargs): def create_collection(cls, collection, **kwargs):
kwargs['path'] = os.path.join(kwargs['path'], collection) if collection is not None:
kwargs['path'] = os.path.join(kwargs['path'], collection)
checkdir(kwargs['path'], create=True)
return kwargs return kwargs
def _get_filepath(self, href): def _get_filepath(self, href):

View file

@ -38,7 +38,6 @@ class SingleFileStorage(Storage):
:param path: The filepath to the file to be written to. :param path: The filepath to the file to be written to.
:param encoding: Which encoding the file should use. Defaults to UTF-8. :param encoding: Which encoding the file should use. Defaults to UTF-8.
:param create: Create the file if it does not exist.
Example for syncing with :py:class:`vdirsyncer.storage.CaldavStorage`:: Example for syncing with :py:class:`vdirsyncer.storage.CaldavStorage`::
@ -68,7 +67,7 @@ class SingleFileStorage(Storage):
_items = None _items = None
_last_mtime = None _last_mtime = None
def __init__(self, path, encoding='utf-8', create=True, **kwargs): def __init__(self, path, encoding='utf-8', **kwargs):
super(SingleFileStorage, self).__init__(**kwargs) super(SingleFileStorage, self).__init__(**kwargs)
path = expand_path(path) path = expand_path(path)
@ -77,15 +76,19 @@ class SingleFileStorage(Storage):
raise ValueError('collection is not a valid argument for {}' raise ValueError('collection is not a valid argument for {}'
.format(type(self).__name__)) .format(type(self).__name__))
checkfile(path, create=create) checkfile(path, create=False)
if create:
self._write_mode = 'wb+'
self._append_mode = 'ab+'
self.path = path self.path = path
self.encoding = encoding self.encoding = encoding
self.create = create
@classmethod
def create_collection(cls, collection, **kwargs):
if collection is not None:
raise ValueError('collection is not a valid argument for {}'
.format(cls.__name__))
checkfile(kwargs['path'], create=True)
return kwargs
def list(self): def list(self):
self._items = collections.OrderedDict() self._items = collections.OrderedDict()

View file

@ -325,10 +325,8 @@ def checkdir(path, create=False, mode=0o750):
if create: if create:
os.makedirs(path, mode) os.makedirs(path, mode)
else: else:
raise IOError('Directory {} does not exist. Use create = ' raise exceptions.CollectionNotFound('Directory {} does not exist.'
'True in your configuration to automatically ' .format(path))
'create it, or create it '
'yourself.'.format(path))
def checkfile(path, create=False): def checkfile(path, create=False):
@ -343,10 +341,8 @@ def checkfile(path, create=False):
if os.path.exists(path): if os.path.exists(path):
raise IOError('{} is not a file.'.format(path)) raise IOError('{} is not a file.'.format(path))
if not create: if not create:
raise IOError('File {} does not exist. Use create = ' raise exceptions.CollectionNotFound('File {} does not exist.'
'True in your configuration to automatically ' .format(path))
'create it, or create it '
'yourself.'.format(path))
class cached_property(object): class cached_property(object):