Skip to content

Commit

Permalink
cli/discover: remove/add local collections if the remote collection i…
Browse files Browse the repository at this point in the history
…s 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.
  • Loading branch information
dilyanpalauzov committed Apr 7, 2021
1 parent 98d28ea commit fcabd5f
Show file tree
Hide file tree
Showing 9 changed files with 104 additions and 9 deletions.
7 changes: 7 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@ Package maintainers and users who have to manually update their installation
may want to subscribe to `GitHub's tag feed
<https://github.com/pimutils/vdirsyncer/tags.atom>`_.

Unreleased 0.1
==============
- Add ``implicit`` option to storage section. It creates/deletes implicitly
collections in the destinations, when new collections are created/deleted
in the source. The deletion is implemented only for the "filesystem" storage.
See :ref:`storage_config`.

Version 0.16.8
==============

Expand Down
8 changes: 8 additions & 0 deletions docs/config.rst
Original file line number Diff line number Diff line change
Expand Up @@ -408,6 +408,8 @@ Local
fileext = "..."
#encoding = "utf-8"
#post_hook = null
#implicit = "create"
#implicit = ["create", "delete"]

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

.. storage:: singlefile

Expand Down
4 changes: 2 additions & 2 deletions tests/system/cli/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,8 @@ def test_read_config(read_config):
assert c.storages == {
'bob_a': {'type': 'filesystem', 'path': '/tmp/contacts/', 'fileext':
'.vcf', 'yesno': False, 'number': 42,
'instance_name': 'bob_a'},
'bob_b': {'type': 'carddav', 'instance_name': 'bob_b'}
'instance_name': 'bob_a', 'implicit': []},
'bob_b': {'type': 'carddav', 'instance_name': 'bob_b', 'implicit': []}
}


Expand Down
2 changes: 1 addition & 1 deletion tests/system/utils/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ def test_get_storage_init_args():
from vdirsyncer.storage.memory import MemoryStorage

all, required = utils.get_storage_init_args(MemoryStorage)
assert all == {'fileext', 'collection', 'read_only', 'instance_name'}
assert all == {'fileext', 'collection', 'read_only', 'instance_name', 'implicit'}
assert not required


Expand Down
7 changes: 7 additions & 0 deletions vdirsyncer/cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,13 @@ def _parse_section(self, section_type, name, options):
raise ValueError('More than one general section.')
self._general = options
elif section_type == 'storage':
if 'implicit' not in options:
options['implicit'] = []
elif isinstance(options['implicit'], str):
options['implicit'] = [options['implicit']]
elif not isinstance(options['implicit'], list):
raise ValueError(
'`implicit` parameter must be a list, string or absent.')
self._storages[name] = options
elif section_type == 'pair':
self._pairs[name] = options
Expand Down
25 changes: 25 additions & 0 deletions vdirsyncer/cli/discover.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@
import logging
import sys

from . import cli_logger
from .. import exceptions
from ..utils import cached_property
from .utils import handle_collection_not_found
from .utils import handle_collection_was_removed
from .utils import handle_storage_init_error
from .utils import load_status
from .utils import save_status
Expand Down Expand Up @@ -80,6 +82,29 @@ def collections_for_pair(status_path, pair, from_cache=True,
get_b_discovered=b_discovered.get_self,
_handle_collection_not_found=handle_collection_not_found
))
if "from b" in (pair.collections or []):
only_in_a = set(a_discovered.get_self().keys()) - set(
b_discovered.get_self().keys())
if only_in_a and 'delete' in pair.config_a['implicit']:
for a in only_in_a:
try:
handle_collection_was_removed(pair.config_a, a)
save_status(status_path, pair.name, a, data_type='metadata')
save_status(status_path, pair.name, a, data_type='items')
except NotImplementedError as e:
cli_logger.error(e)

if "from a" in (pair.collections or []):
only_in_b = set(b_discovered.get_self().keys()) - set(
a_discovered.get_self().keys())
if only_in_b and 'delete' in pair.config_b['implicit']:
for b in only_in_b:
try:
handle_collection_was_removed(pair.config_b, b)
save_status(status_path, pair.name, b, data_type='metadata')
save_status(status_path, pair.name, b, data_type='items')
except NotImplementedError as e:
cli_logger.error(e)

_sanity_check_collections(rv)

Expand Down
23 changes: 21 additions & 2 deletions vdirsyncer/cli/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -231,10 +231,15 @@ def manage_sync_status(base_path, pair_name, collection_name):

def save_status(base_path, pair, collection=None, data_type=None, data=None):
assert data_type is not None
assert data is not None
status_name = get_status_name(pair, collection)
path = expand_path(os.path.join(base_path, status_name)) + '.' + data_type
prepare_status_path(path)
if data is None:
try:
os.remove(path)
except OSError: # the file has not existed
pass
return

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


def handle_collection_was_removed(config, collection):
if 'delete' in config['implicit']:
storage_type = config['type']
cls, config = storage_class_from_config(config)
config['collection'] = collection
try:
args = cls.delete_collection(**config)
args['type'] = storage_type
return args
except NotImplementedError as e:
cli_logger.error(e)


def handle_collection_not_found(config, collection, e=None):
storage_name = config.get('instance_name', None)

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

if click.confirm('Should vdirsyncer attempt to create it?'):
if 'create' in config['implicit'] or click.confirm(
'Should vdirsyncer attempt to create it?'):
storage_type = config['type']
cls, config = storage_class_from_config(config)
config['collection'] = collection
Expand Down
19 changes: 18 additions & 1 deletion vdirsyncer/storage/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ class Storage(metaclass=StorageMeta):
:param read_only: Whether the synchronization algorithm should avoid writes
to this storage. Some storages accept no value other than ``True``.
:param implicit: Whether the synchronization shall create/delete collections
in the destination, when these were created/removed from the source. Must
be a possibly empty list of strings.
'''

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

def __init__(self, instance_name=None, read_only=None, collection=None):
def __init__(self, instance_name=None, read_only=None, collection=None,
implicit=None):
if read_only is None:
read_only = self.read_only
self.implicit = implicit # unused from within the Storage classes
if self.read_only and not read_only:
raise exceptions.UserError('This storage can only be read-only.')
self.read_only = bool(read_only)
Expand Down Expand Up @@ -105,6 +110,18 @@ def create_collection(cls, collection, **kwargs):
'''
raise NotImplementedError()

@classmethod
def delete_collection(cls, collection, **kwargs):
'''
Delete the specified collection and return the new arguments.
``collection=None`` means the arguments are already pointing to a
possible collection location.
The returned args should contain the collection name, for UI purposes.
'''
raise NotImplementedError()

def __repr__(self):
try:
if self.instance_name:
Expand Down
18 changes: 15 additions & 3 deletions vdirsyncer/storage/filesystem.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import errno
import logging
import os
import shutil
import subprocess

from atomicwrites import atomic_write
Expand Down Expand Up @@ -55,9 +56,7 @@ def discover(cls, path, **kwargs):
def _validate_collection(cls, path):
if not os.path.isdir(path):
return False
if os.path.basename(path).startswith('.'):
return False
return True
return not os.path.basename(path).startswith('.')

@classmethod
def create_collection(cls, collection, **kwargs):
Expand All @@ -73,6 +72,19 @@ def create_collection(cls, collection, **kwargs):
kwargs['collection'] = collection
return kwargs

@classmethod
def delete_collection(cls, collection, **kwargs):
kwargs = dict(kwargs)
path = kwargs['path']

if collection is not None:
path = os.path.join(path, collection)
shutil.rmtree(path, ignore_errors=True)

kwargs['path'] = path
kwargs['collection'] = collection
return kwargs

def _get_filepath(self, href):
return os.path.join(self.path, href)

Expand Down

0 comments on commit fcabd5f

Please sign in to comment.