Skip to content

Commit 8259b19

Browse files
committed
cli/discover: add implicit config to pair for collection creation
Adds support for auto-creating collections when they exist only on one side and `implicit = 'create'` is set in the pair config.
1 parent 63d2e6c commit 8259b19

File tree

6 files changed

+96
-4
lines changed

6 files changed

+96
-4
lines changed

CHANGELOG.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,10 @@ Version 0.19.0
6161
- Add a new ``showconfig`` status. This prints *some* configuration values as
6262
JSON. This is intended to be used by external tools and helpers that interact
6363
with ``vdirsyncer``, and considered experimental.
64+
- Add ``implicit`` option to the :ref:`pair section <pair_config>`. When set to
65+
"create", it implicitly creates missing collections during sync without user
66+
prompts. This simplifies workflows where collections should be automatically
67+
created on both sides.
6468
- Update TLS-related tests that were failing due to weak MDs. :gh:`903`
6569
- ``pytest-httpserver`` and ``trustme`` are now required for tests.
6670
- ``pytest-localserver`` is no longer required for tests.

docs/config.rst

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,16 @@ Pair Section
128128

129129
The ``conflict_resolution`` parameter applies for these properties too.
130130

131+
.. _implicit_def:
132+
133+
- ``implicit``: Opt into implicitly creating collections. Example::
134+
135+
implicit = "create"
136+
137+
When set to "create", missing collections are automatically created on both
138+
sides during sync without prompting the user. This simplifies workflows where
139+
all collections should be synchronized bidirectionally.
140+
131141
.. _storage_config:
132142

133143
Storage Section

tests/system/cli/test_config.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,3 +222,62 @@ def test_validate_collections_param():
222222
x([["c", None, "b"]])
223223
x([["c", "a", None]])
224224
x([["c", None, None]])
225+
226+
227+
def test_invalid_implicit_value(read_config):
228+
expected_message = "`implicit` parameter must be 'create' or absent"
229+
with pytest.raises(exceptions.UserError) as excinfo:
230+
read_config(
231+
"""
232+
[general]
233+
status_path = "/tmp/status/"
234+
235+
[pair my_pair]
236+
a = "my_a"
237+
b = "my_b"
238+
collections = null
239+
implicit = "invalid"
240+
241+
[storage my_a]
242+
type = "filesystem"
243+
path = "{base}/path_a/"
244+
fileext = ".txt"
245+
246+
[storage my_b]
247+
type = "filesystem"
248+
path = "{base}/path_b/"
249+
fileext = ".txt"
250+
"""
251+
)
252+
253+
assert expected_message in str(excinfo.value)
254+
255+
256+
def test_implicit_create_only(read_config):
257+
"""Test that implicit create works."""
258+
errors, c = read_config(
259+
"""
260+
[general]
261+
status_path = "/tmp/status/"
262+
263+
[pair my_pair]
264+
a = "my_a"
265+
b = "my_b"
266+
collections = ["from a", "from b"]
267+
implicit = "create"
268+
269+
[storage my_a]
270+
type = "filesystem"
271+
path = "{base}/path_a/"
272+
fileext = ".txt"
273+
274+
[storage my_b]
275+
type = "filesystem"
276+
path = "{base}/path_b/"
277+
fileext = ".txt"
278+
"""
279+
)
280+
281+
assert not errors
282+
pair = c.pairs["my_pair"]
283+
assert pair.implicit == "create"

vdirsyncer/cli/config.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,14 @@ def _validate_collections_param(collections):
9595
raise ValueError(f"`collections` parameter, position {i}: {str(e)}")
9696

9797

98+
def _validate_implicit_param(implicit):
99+
if implicit is None:
100+
return
101+
102+
if implicit != "create":
103+
raise ValueError("`implicit` parameter must be 'create' or absent.")
104+
105+
98106
class _ConfigReader:
99107
def __init__(self, f: IO[Any]):
100108
self._file: IO[Any] = f
@@ -229,6 +237,7 @@ def __init__(self, full_config: Config, name: str, options: dict[str, str]):
229237
self.name: str = name
230238
self.name_a: str = options.pop("a")
231239
self.name_b: str = options.pop("b")
240+
self.implicit = options.pop("implicit", None)
232241

233242
self._partial_sync: str | None = options.pop("partial_sync", None)
234243
self.metadata = options.pop("metadata", None) or ()
@@ -247,6 +256,7 @@ def __init__(self, full_config: Config, name: str, options: dict[str, str]):
247256
)
248257
else:
249258
_validate_collections_param(self.collections)
259+
_validate_implicit_param(self.implicit)
250260

251261
if options:
252262
raise ValueError("Unknown options: {}".format(", ".join(options)))

vdirsyncer/cli/discover.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,13 @@ async def collections_for_pair(
9393
connector=connector,
9494
)
9595

96+
async def _handle_collection_not_found(
97+
config, collection, e=None, implicit_create=False
98+
):
99+
return await handle_collection_not_found(
100+
config, collection, e=e, implicit_create=pair.implicit == "create"
101+
)
102+
96103
# We have to use a list here because the special None/null value would get
97104
# mangled to string (because JSON objects always have string keys).
98105
rv = await aiostream.stream.list(
@@ -102,7 +109,7 @@ async def collections_for_pair(
102109
config_b=pair.config_b,
103110
get_a_discovered=a_discovered.get_self,
104111
get_b_discovered=b_discovered.get_self,
105-
_handle_collection_not_found=handle_collection_not_found,
112+
_handle_collection_not_found=_handle_collection_not_found,
106113
)
107114
)
108115

vdirsyncer/cli/utils.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -286,7 +286,7 @@ async def storage_instance_from_config(
286286
except exceptions.CollectionNotFound as e:
287287
if create:
288288
config = await handle_collection_not_found(
289-
config, config.get("collection", None), e=str(e)
289+
config, config.get("collection", None), e=str(e), implicit_create=True
290290
)
291291
return await storage_instance_from_config(
292292
config,
@@ -342,7 +342,9 @@ def assert_permissions(path: str, wanted: int) -> None:
342342
os.chmod(path, wanted)
343343

344344

345-
async def handle_collection_not_found(config, collection, e=None):
345+
async def handle_collection_not_found(
346+
config, collection, e=None, implicit_create=False
347+
):
346348
storage_name = config.get("instance_name", None)
347349

348350
cli_logger.warning(
@@ -351,7 +353,7 @@ async def handle_collection_not_found(config, collection, e=None):
351353
)
352354
)
353355

354-
if click.confirm("Should vdirsyncer attempt to create it?"):
356+
if implicit_create or click.confirm("Should vdirsyncer attempt to create it?"):
355357
storage_type = config["type"]
356358
cls, config = storage_class_from_config(config)
357359
config["collection"] = collection

0 commit comments

Comments
 (0)