Skip to content

Commit 67742b8

Browse files
cli/discover: remove/add local collections if the remote collection is deleted/created
This works when the destination backend is 'filesystem'. -- add a new parameter to storage section: implicit = ["create", "delete"] Changes cli/utils.py:save_status(): when data is None, remove the underlaying file.
1 parent 7dba251 commit 67742b8

File tree

9 files changed

+104
-9
lines changed

9 files changed

+104
-9
lines changed

CHANGELOG.rst

+7
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,13 @@ Package maintainers and users who have to manually update their installation
99
may want to subscribe to `GitHub's tag feed
1010
<https://github.com/pimutils/vdirsyncer/tags.atom>`_.
1111

12+
Unreleased 0.1
13+
==============
14+
- Add ``implicit`` option to storage section. It creates/deletes implicitly
15+
collections in the destinations, when new collections are created/deleted
16+
in the source. The deletion is implemented only for the "filesystem" storage.
17+
See :ref:`storage_config`.
18+
1219
Version 0.16.8
1320
==============
1421

docs/config.rst

+8
Original file line numberDiff line numberDiff line change
@@ -408,6 +408,8 @@ Local
408408
fileext = "..."
409409
#encoding = "utf-8"
410410
#post_hook = null
411+
#implicit = "create"
412+
#implicit = ["create", "delete"]
411413

412414
Can be used with `khal <http://lostpackets.de/khal/>`_. See :doc:`vdir` for
413415
a more formal description of the format.
@@ -426,6 +428,12 @@ Local
426428
:param post_hook: A command to call for each item creation and
427429
modification. The command will be called with the path of the
428430
new/updated file.
431+
:param implicit: When a new collection is created on the source, and the
432+
value is "create", create the collection in the destination without
433+
asking questions. When the value is "delete" and a collection
434+
is removed on the source, remove it in the destination. The value
435+
can be a string or an array of strings. The deletion is implemented
436+
only for the "filesystem" storage.
429437

430438
.. storage:: singlefile
431439

tests/system/cli/test_config.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,8 @@ def test_read_config(read_config):
5353
assert c.storages == {
5454
'bob_a': {'type': 'filesystem', 'path': '/tmp/contacts/', 'fileext':
5555
'.vcf', 'yesno': False, 'number': 42,
56-
'instance_name': 'bob_a'},
57-
'bob_b': {'type': 'carddav', 'instance_name': 'bob_b'}
56+
'instance_name': 'bob_a', 'implicit': []},
57+
'bob_b': {'type': 'carddav', 'instance_name': 'bob_b', 'implicit': []}
5858
}
5959

6060

tests/system/utils/test_main.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ def test_get_storage_init_args():
1919
from vdirsyncer.storage.memory import MemoryStorage
2020

2121
all, required = utils.get_storage_init_args(MemoryStorage)
22-
assert all == {'fileext', 'collection', 'read_only', 'instance_name'}
22+
assert all == {'fileext', 'collection', 'read_only', 'instance_name', 'implicit'}
2323
assert not required
2424

2525

vdirsyncer/cli/config.py

+7
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,13 @@ def _parse_section(self, section_type, name, options):
111111
raise ValueError('More than one general section.')
112112
self._general = options
113113
elif section_type == 'storage':
114+
if 'implicit' not in options:
115+
options['implicit'] = []
116+
elif isinstance(options['implicit'], str):
117+
options['implicit'] = [options['implicit']]
118+
elif not isinstance(options['implicit'], list):
119+
raise ValueError(
120+
'`implicit` parameter must be a list, string or absent.')
114121
self._storages[name] = options
115122
elif section_type == 'pair':
116123
self._pairs[name] = options

vdirsyncer/cli/discover.py

+25
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@
33
import logging
44
import sys
55

6+
from . import cli_logger
67
from .. import exceptions
78
from ..utils import cached_property
89
from .utils import handle_collection_not_found
10+
from .utils import handle_collection_was_removed
911
from .utils import handle_storage_init_error
1012
from .utils import load_status
1113
from .utils import save_status
@@ -80,6 +82,29 @@ def collections_for_pair(status_path, pair, from_cache=True,
8082
get_b_discovered=b_discovered.get_self,
8183
_handle_collection_not_found=handle_collection_not_found
8284
))
85+
if "from b" in (pair.collections or []):
86+
only_in_a = set(a_discovered.get_self().keys()) - set(
87+
b_discovered.get_self().keys())
88+
if only_in_a and 'delete' in pair.config_a['implicit']:
89+
for a in only_in_a:
90+
try:
91+
handle_collection_was_removed(pair.config_a, a)
92+
save_status(status_path, pair.name, a, data_type='metadata')
93+
save_status(status_path, pair.name, a, data_type='items')
94+
except NotImplementedError as e:
95+
cli_logger.error(e)
96+
97+
if "from a" in (pair.collections or []):
98+
only_in_b = set(b_discovered.get_self().keys()) - set(
99+
a_discovered.get_self().keys())
100+
if only_in_b and 'delete' in pair.config_b['implicit']:
101+
for b in only_in_b:
102+
try:
103+
handle_collection_was_removed(pair.config_b, b)
104+
save_status(status_path, pair.name, b, data_type='metadata')
105+
save_status(status_path, pair.name, b, data_type='items')
106+
except NotImplementedError as e:
107+
cli_logger.error(e)
83108

84109
_sanity_check_collections(rv)
85110

vdirsyncer/cli/utils.py

+21-2
Original file line numberDiff line numberDiff line change
@@ -231,10 +231,15 @@ def manage_sync_status(base_path, pair_name, collection_name):
231231

232232
def save_status(base_path, pair, collection=None, data_type=None, data=None):
233233
assert data_type is not None
234-
assert data is not None
235234
status_name = get_status_name(pair, collection)
236235
path = expand_path(os.path.join(base_path, status_name)) + '.' + data_type
237236
prepare_status_path(path)
237+
if data is None:
238+
try:
239+
os.remove(path)
240+
except OSError: # the file has not existed
241+
pass
242+
return
238243

239244
with atomic_write(path, mode='w', overwrite=True) as f:
240245
json.dump(data, f)
@@ -397,14 +402,28 @@ def assert_permissions(path, wanted):
397402
os.chmod(path, wanted)
398403

399404

405+
def handle_collection_was_removed(config, collection):
406+
if 'delete' in config['implicit']:
407+
storage_type = config['type']
408+
cls, config = storage_class_from_config(config)
409+
config['collection'] = collection
410+
try:
411+
args = cls.delete_collection(**config)
412+
args['type'] = storage_type
413+
return args
414+
except NotImplementedError as e:
415+
cli_logger.error(e)
416+
417+
400418
def handle_collection_not_found(config, collection, e=None):
401419
storage_name = config.get('instance_name', None)
402420

403421
cli_logger.warning('{}No collection {} found for storage {}.'
404422
.format(f'{e}\n' if e else '',
405423
json.dumps(collection), storage_name))
406424

407-
if click.confirm('Should vdirsyncer attempt to create it?'):
425+
if 'create' in config['implicit'] or click.confirm(
426+
'Should vdirsyncer attempt to create it?'):
408427
storage_type = config['type']
409428
cls, config = storage_class_from_config(config)
410429
config['collection'] = collection

vdirsyncer/storage/base.py

+18-1
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@ class Storage(metaclass=StorageMeta):
4040
4141
:param read_only: Whether the synchronization algorithm should avoid writes
4242
to this storage. Some storages accept no value other than ``True``.
43+
:param implicit: Whether the synchronization shall create/delete collections
44+
in the destination, when these were created/removed from the source. Must
45+
be a possibly empty list of strings.
4346
'''
4447

4548
fileext = '.txt'
@@ -63,9 +66,11 @@ class Storage(metaclass=StorageMeta):
6366
# The attribute values to show in the representation of the storage.
6467
_repr_attributes = ()
6568

66-
def __init__(self, instance_name=None, read_only=None, collection=None):
69+
def __init__(self, instance_name=None, read_only=None, collection=None,
70+
implicit=None):
6771
if read_only is None:
6872
read_only = self.read_only
73+
self.implicit = implicit # unused from within the Storage classes
6974
if self.read_only and not read_only:
7075
raise exceptions.UserError('This storage can only be read-only.')
7176
self.read_only = bool(read_only)
@@ -105,6 +110,18 @@ def create_collection(cls, collection, **kwargs):
105110
'''
106111
raise NotImplementedError()
107112

113+
@classmethod
114+
def delete_collection(cls, collection, **kwargs):
115+
'''
116+
Delete the specified collection and return the new arguments.
117+
118+
``collection=None`` means the arguments are already pointing to a
119+
possible collection location.
120+
121+
The returned args should contain the collection name, for UI purposes.
122+
'''
123+
raise NotImplementedError()
124+
108125
def __repr__(self):
109126
try:
110127
if self.instance_name:

vdirsyncer/storage/filesystem.py

+15-3
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import errno
22
import logging
33
import os
4+
import shutil
45
import subprocess
56

67
from atomicwrites import atomic_write
@@ -55,9 +56,7 @@ def discover(cls, path, **kwargs):
5556
def _validate_collection(cls, path):
5657
if not os.path.isdir(path):
5758
return False
58-
if os.path.basename(path).startswith('.'):
59-
return False
60-
return True
59+
return not os.path.basename(path).startswith('.')
6160

6261
@classmethod
6362
def create_collection(cls, collection, **kwargs):
@@ -73,6 +72,19 @@ def create_collection(cls, collection, **kwargs):
7372
kwargs['collection'] = collection
7473
return kwargs
7574

75+
@classmethod
76+
def delete_collection(cls, collection, **kwargs):
77+
kwargs = dict(kwargs)
78+
path = kwargs['path']
79+
80+
if collection is not None:
81+
path = os.path.join(path, collection)
82+
shutil.rmtree(path, ignore_errors=True)
83+
84+
kwargs['path'] = path
85+
kwargs['collection'] = collection
86+
return kwargs
87+
7688
def _get_filepath(self, href):
7789
return os.path.join(self.path, href)
7890

0 commit comments

Comments
 (0)