Skip to content

Commit 6055664

Browse files
dilyanpalauzovsamsonjs
authored andcommitted
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 1f7497c commit 6055664

File tree

9 files changed

+116
-8
lines changed

9 files changed

+116
-8
lines changed

CHANGELOG.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@ Version 0.19.0
4040
use that as a reference.
4141

4242
.. _etesync-dav: https://github.com/etesync/etesync-dav
43+
- Add ``implicit`` option to storage section. It creates/deletes implicitly
44+
collections in the destinations, when new collections are created/deleted
45+
in the source. The deletion is implemented only for the "filesystem" storage.
46+
See :ref:`storage_config`.
4347

4448
Version 0.18.0
4549
==============

docs/config.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,8 @@ Local
365365
#encoding = "utf-8"
366366
#post_hook = null
367367
#fileignoreext = ".tmp"
368+
#implicit = "create"
369+
#implicit = ["create", "delete"]
368370

369371
Can be used with `khal <http://lostpackets.de/khal/>`_. See :doc:`vdir` for
370372
a more formal description of the format.
@@ -387,6 +389,12 @@ Local
387389
new/updated file.
388390
:param fileeignoreext: The file extention to ignore. It is only useful
389391
if fileext is set to the empty string. The default is ``.tmp``.
392+
:param implicit: When a new collection is created on the source, and the
393+
value is "create", create the collection in the destination without
394+
asking questions. When the value is "delete" and a collection
395+
is removed on the source, remove it in the destination. The value
396+
can be a string or an array of strings. The deletion is implemented
397+
only for the "filesystem" storage.
390398

391399
.. storage:: singlefile
392400

tests/system/cli/test_config.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,13 @@ def test_read_config(read_config):
6060
"yesno": False,
6161
"number": 42,
6262
"instance_name": "bob_a",
63+
"implicit": [],
64+
},
65+
"bob_b": {
66+
"type": "carddav",
67+
"instance_name": "bob_b",
68+
"implicit": [],
6369
},
64-
"bob_b": {"type": "carddav", "instance_name": "bob_b"},
6570
}
6671

6772

tests/system/utils/test_main.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ def test_get_storage_init_args():
2020
from vdirsyncer.storage.memory import MemoryStorage
2121

2222
all, required = utils.get_storage_init_args(MemoryStorage)
23-
assert all == {"fileext", "collection", "read_only", "instance_name"}
23+
assert all == {"fileext", "collection", "read_only", "instance_name", "implicit"}
2424
assert not required
2525

2626

vdirsyncer/cli/config.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,14 @@ def _parse_section(self, section_type, name, options):
113113
raise ValueError("More than one general section.")
114114
self._general = options
115115
elif section_type == "storage":
116+
if "implicit" not in options:
117+
options["implicit"] = []
118+
elif isinstance(options["implicit"], str):
119+
options["implicit"] = [options["implicit"]]
120+
elif not isinstance(options["implicit"], list):
121+
raise ValueError(
122+
"`implicit` parameter must be a list, string or absent."
123+
)
116124
self._storages[name] = options
117125
elif section_type == "pair":
118126
self._pairs[name] = options

vdirsyncer/cli/discover.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@
88
import aiostream
99

1010
from .. import exceptions
11+
from . import cli_logger
1112
from .utils import handle_collection_not_found
13+
from .utils import handle_collection_was_removed
1214
from .utils import handle_storage_init_error
1315
from .utils import load_status
1416
from .utils import save_status
@@ -118,6 +120,33 @@ async def collections_for_pair(
118120
"cache_key": cache_key,
119121
},
120122
)
123+
124+
if "from b" in (pair.collections or []):
125+
only_in_a = set(a_discovered.get_self().keys()) - set(
126+
b_discovered.get_self().keys()
127+
)
128+
if only_in_a and "delete" in pair.config_a["implicit"]:
129+
for a in only_in_a:
130+
try:
131+
handle_collection_was_removed(pair.config_a, a)
132+
save_status(status_path, pair.name, a, data_type="metadata")
133+
save_status(status_path, pair.name, a, data_type="items")
134+
except NotImplementedError as e:
135+
cli_logger.error(e)
136+
137+
if "from a" in (pair.collections or []):
138+
only_in_b = set(b_discovered.get_self().keys()) - set(
139+
a_discovered.get_self().keys()
140+
)
141+
if only_in_b and "delete" in pair.config_b["implicit"]:
142+
for b in only_in_b:
143+
try:
144+
handle_collection_was_removed(pair.config_b, b)
145+
save_status(status_path, pair.name, b, data_type="metadata")
146+
save_status(status_path, pair.name, b, data_type="items")
147+
except NotImplementedError as e:
148+
cli_logger.error(e)
149+
121150
return rv
122151

123152

vdirsyncer/cli/utils.py

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -227,10 +227,15 @@ def manage_sync_status(base_path, pair_name, collection_name):
227227

228228
def save_status(base_path, pair, collection=None, data_type=None, data=None):
229229
assert data_type is not None
230-
assert data is not None
231230
status_name = get_status_name(pair, collection)
232231
path = expand_path(os.path.join(base_path, status_name)) + "." + data_type
233232
prepare_status_path(path)
233+
if data is None:
234+
try:
235+
os.remove(path)
236+
except OSError: # the file has not existed
237+
pass
238+
return
234239

235240
with atomic_write(path, mode="w", overwrite=True) as f:
236241
json.dump(data, f)
@@ -330,6 +335,19 @@ def assert_permissions(path, wanted):
330335
os.chmod(path, wanted)
331336

332337

338+
def handle_collection_was_removed(config, collection):
339+
if "delete" in config["implicit"]:
340+
storage_type = config["type"]
341+
cls, config = storage_class_from_config(config)
342+
config["collection"] = collection
343+
try:
344+
args = cls.delete_collection(**config)
345+
args["type"] = storage_type
346+
return args
347+
except NotImplementedError as e:
348+
cli_logger.error(e)
349+
350+
333351
async def handle_collection_not_found(config, collection, e=None):
334352
storage_name = config.get("instance_name", None)
335353

@@ -339,7 +357,9 @@ async def handle_collection_not_found(config, collection, e=None):
339357
)
340358
)
341359

342-
if click.confirm("Should vdirsyncer attempt to create it?"):
360+
if "create" in config["implicit"] or click.confirm(
361+
"Should vdirsyncer attempt to create it?"
362+
):
343363
storage_type = config["type"]
344364
cls, config = storage_class_from_config(config)
345365
config["collection"] = collection

vdirsyncer/storage/base.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,9 @@ class Storage(metaclass=StorageMeta):
5252
5353
:param read_only: Whether the synchronization algorithm should avoid writes
5454
to this storage. Some storages accept no value other than ``True``.
55+
:param implicit: Whether the synchronization shall create/delete collections
56+
in the destination, when these were created/removed from the source. Must
57+
be a possibly empty list of strings.
5558
"""
5659

5760
fileext = ".txt"
@@ -75,9 +78,16 @@ class Storage(metaclass=StorageMeta):
7578
# The attribute values to show in the representation of the storage.
7679
_repr_attributes: List[str] = []
7780

78-
def __init__(self, instance_name=None, read_only=None, collection=None):
81+
def __init__(
82+
self,
83+
instance_name=None,
84+
read_only=None,
85+
collection=None,
86+
implicit=None,
87+
):
7988
if read_only is None:
8089
read_only = self.read_only
90+
self.implicit = implicit # unused from within the Storage classes
8191
if self.read_only and not read_only:
8292
raise exceptions.UserError("This storage can only be read-only.")
8393
self.read_only = bool(read_only)
@@ -119,6 +129,18 @@ async def create_collection(cls, collection, **kwargs):
119129
"""
120130
raise NotImplementedError()
121131

132+
@classmethod
133+
def delete_collection(cls, collection, **kwargs):
134+
"""
135+
Delete the specified collection and return the new arguments.
136+
137+
``collection=None`` means the arguments are already pointing to a
138+
possible collection location.
139+
140+
The returned args should contain the collection name, for UI purposes.
141+
"""
142+
raise NotImplementedError()
143+
122144
def __repr__(self):
123145
try:
124146
if self.instance_name:

vdirsyncer/storage/filesystem.py

Lines changed: 15 additions & 3 deletions
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
@@ -62,9 +63,7 @@ async def discover(cls, path, **kwargs):
6263
def _validate_collection(cls, path):
6364
if not os.path.isdir(path):
6465
return False
65-
if os.path.basename(path).startswith("."):
66-
return False
67-
return True
66+
return not os.path.basename(path).startswith(".")
6867

6968
@classmethod
7069
async def create_collection(cls, collection, **kwargs):
@@ -80,6 +79,19 @@ async def create_collection(cls, collection, **kwargs):
8079
kwargs["collection"] = collection
8180
return kwargs
8281

82+
@classmethod
83+
def delete_collection(cls, collection, **kwargs):
84+
kwargs = dict(kwargs)
85+
path = kwargs["path"]
86+
87+
if collection is not None:
88+
path = os.path.join(path, collection)
89+
shutil.rmtree(path, ignore_errors=True)
90+
91+
kwargs["path"] = path
92+
kwargs["collection"] = collection
93+
return kwargs
94+
8395
def _get_filepath(self, href):
8496
return os.path.join(self.path, href)
8597

0 commit comments

Comments
 (0)