From 8259b19a6eb6045074e38f3ff38f2cec11332094 Mon Sep 17 00:00:00 2001 From: Sami Samhuri Date: Fri, 13 Jun 2025 10:22:21 -0700 Subject: [PATCH] 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. --- CHANGELOG.rst | 4 +++ docs/config.rst | 10 ++++++ tests/system/cli/test_config.py | 59 +++++++++++++++++++++++++++++++++ vdirsyncer/cli/config.py | 10 ++++++ vdirsyncer/cli/discover.py | 9 ++++- vdirsyncer/cli/utils.py | 8 +++-- 6 files changed, 96 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index a98c18285..6d9ef7ec6 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -61,6 +61,10 @@ Version 0.19.0 - Add a new ``showconfig`` status. This prints *some* configuration values as JSON. This is intended to be used by external tools and helpers that interact with ``vdirsyncer``, and considered experimental. +- Add ``implicit`` option to the :ref:`pair section `. When set to + "create", it implicitly creates missing collections during sync without user + prompts. This simplifies workflows where collections should be automatically + created on both sides. - Update TLS-related tests that were failing due to weak MDs. :gh:`903` - ``pytest-httpserver`` and ``trustme`` are now required for tests. - ``pytest-localserver`` is no longer required for tests. diff --git a/docs/config.rst b/docs/config.rst index a351f5c82..707f7d6fd 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -128,6 +128,16 @@ Pair Section The ``conflict_resolution`` parameter applies for these properties too. +.. _implicit_def: + +- ``implicit``: Opt into implicitly creating collections. Example:: + + implicit = "create" + + When set to "create", missing collections are automatically created on both + sides during sync without prompting the user. This simplifies workflows where + all collections should be synchronized bidirectionally. + .. _storage_config: Storage Section diff --git a/tests/system/cli/test_config.py b/tests/system/cli/test_config.py index f701bb88a..16ead29ef 100644 --- a/tests/system/cli/test_config.py +++ b/tests/system/cli/test_config.py @@ -222,3 +222,62 @@ def test_validate_collections_param(): x([["c", None, "b"]]) x([["c", "a", None]]) x([["c", None, None]]) + + +def test_invalid_implicit_value(read_config): + expected_message = "`implicit` parameter must be 'create' or absent" + with pytest.raises(exceptions.UserError) as excinfo: + read_config( + """ + [general] + status_path = "/tmp/status/" + + [pair my_pair] + a = "my_a" + b = "my_b" + collections = null + implicit = "invalid" + + [storage my_a] + type = "filesystem" + path = "{base}/path_a/" + fileext = ".txt" + + [storage my_b] + type = "filesystem" + path = "{base}/path_b/" + fileext = ".txt" + """ + ) + + assert expected_message in str(excinfo.value) + + +def test_implicit_create_only(read_config): + """Test that implicit create works.""" + errors, c = read_config( + """ + [general] + status_path = "/tmp/status/" + + [pair my_pair] + a = "my_a" + b = "my_b" + collections = ["from a", "from b"] + implicit = "create" + + [storage my_a] + type = "filesystem" + path = "{base}/path_a/" + fileext = ".txt" + + [storage my_b] + type = "filesystem" + path = "{base}/path_b/" + fileext = ".txt" + """ + ) + + assert not errors + pair = c.pairs["my_pair"] + assert pair.implicit == "create" diff --git a/vdirsyncer/cli/config.py b/vdirsyncer/cli/config.py index 94d7a846d..3cb03df6e 100644 --- a/vdirsyncer/cli/config.py +++ b/vdirsyncer/cli/config.py @@ -95,6 +95,14 @@ def _validate_collections_param(collections): raise ValueError(f"`collections` parameter, position {i}: {str(e)}") +def _validate_implicit_param(implicit): + if implicit is None: + return + + if implicit != "create": + raise ValueError("`implicit` parameter must be 'create' or absent.") + + class _ConfigReader: def __init__(self, f: IO[Any]): self._file: IO[Any] = f @@ -229,6 +237,7 @@ def __init__(self, full_config: Config, name: str, options: dict[str, str]): self.name: str = name self.name_a: str = options.pop("a") self.name_b: str = options.pop("b") + self.implicit = options.pop("implicit", None) self._partial_sync: str | None = options.pop("partial_sync", None) self.metadata = options.pop("metadata", None) or () @@ -247,6 +256,7 @@ def __init__(self, full_config: Config, name: str, options: dict[str, str]): ) else: _validate_collections_param(self.collections) + _validate_implicit_param(self.implicit) if options: raise ValueError("Unknown options: {}".format(", ".join(options))) diff --git a/vdirsyncer/cli/discover.py b/vdirsyncer/cli/discover.py index ebd4e1618..78556b043 100644 --- a/vdirsyncer/cli/discover.py +++ b/vdirsyncer/cli/discover.py @@ -93,6 +93,13 @@ async def collections_for_pair( connector=connector, ) + async def _handle_collection_not_found( + config, collection, e=None, implicit_create=False + ): + return await handle_collection_not_found( + config, collection, e=e, implicit_create=pair.implicit == "create" + ) + # We have to use a list here because the special None/null value would get # mangled to string (because JSON objects always have string keys). rv = await aiostream.stream.list( @@ -102,7 +109,7 @@ async def collections_for_pair( config_b=pair.config_b, get_a_discovered=a_discovered.get_self, get_b_discovered=b_discovered.get_self, - _handle_collection_not_found=handle_collection_not_found, + _handle_collection_not_found=_handle_collection_not_found, ) ) diff --git a/vdirsyncer/cli/utils.py b/vdirsyncer/cli/utils.py index 97f48edc2..fc593f5cd 100644 --- a/vdirsyncer/cli/utils.py +++ b/vdirsyncer/cli/utils.py @@ -286,7 +286,7 @@ async def storage_instance_from_config( except exceptions.CollectionNotFound as e: if create: config = await handle_collection_not_found( - config, config.get("collection", None), e=str(e) + config, config.get("collection", None), e=str(e), implicit_create=True ) return await storage_instance_from_config( config, @@ -342,7 +342,9 @@ def assert_permissions(path: str, wanted: int) -> None: os.chmod(path, wanted) -async def handle_collection_not_found(config, collection, e=None): +async def handle_collection_not_found( + config, collection, e=None, implicit_create=False +): storage_name = config.get("instance_name", None) cli_logger.warning( @@ -351,7 +353,7 @@ async def handle_collection_not_found(config, collection, e=None): ) ) - if click.confirm("Should vdirsyncer attempt to create it?"): + if implicit_create or click.confirm("Should vdirsyncer attempt to create it?"): storage_type = config["type"] cls, config = storage_class_from_config(config) config["collection"] = collection