Skip to content

Commit 89054d7

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 89054d7

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: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,3 +222,66 @@ 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+
257+
258+
259+
260+
def test_implicit_create_only(read_config):
261+
"""Test that implicit create works."""
262+
errors, c = read_config(
263+
"""
264+
[general]
265+
status_path = "/tmp/status/"
266+
267+
[pair my_pair]
268+
a = "my_a"
269+
b = "my_b"
270+
collections = ["from a", "from b"]
271+
implicit = "create"
272+
273+
[storage my_a]
274+
type = "filesystem"
275+
path = "{base}/path_a/"
276+
fileext = ".txt"
277+
278+
[storage my_b]
279+
type = "filesystem"
280+
path = "{base}/path_b/"
281+
fileext = ".txt"
282+
"""
283+
)
284+
285+
assert not errors
286+
pair = c.pairs["my_pair"]
287+
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: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,11 @@ async def collections_for_pair(
9393
connector=connector,
9494
)
9595

96+
async def _handle_collection_not_found(config, collection, e=None, implicit_create=False):
97+
return await handle_collection_not_found(
98+
config, collection, e=e, implicit_create=pair.implicit == "create"
99+
)
100+
96101
# We have to use a list here because the special None/null value would get
97102
# mangled to string (because JSON objects always have string keys).
98103
rv = await aiostream.stream.list(
@@ -102,7 +107,7 @@ async def collections_for_pair(
102107
config_b=pair.config_b,
103108
get_a_discovered=a_discovered.get_self,
104109
get_b_discovered=b_discovered.get_self,
105-
_handle_collection_not_found=handle_collection_not_found,
110+
_handle_collection_not_found=_handle_collection_not_found,
106111
)
107112
)
108113

vdirsyncer/cli/utils.py

Lines changed: 3 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,7 @@ 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(config, collection, e=None, implicit_create=False):
346346
storage_name = config.get("instance_name", None)
347347

348348
cli_logger.warning(
@@ -351,7 +351,7 @@ async def handle_collection_not_found(config, collection, e=None):
351351
)
352352
)
353353

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

0 commit comments

Comments
 (0)