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.
This commit is contained in:
Sami Samhuri 2025-06-13 10:22:21 -07:00 committed by Hugo
parent ac9919d865
commit cbb4e314f6
6 changed files with 96 additions and 4 deletions

View file

@ -73,6 +73,10 @@ Version 0.19.0
- Add a new ``showconfig`` status. This prints *some* configuration values as - 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 JSON. This is intended to be used by external tools and helpers that interact
with ``vdirsyncer``, and considered experimental. with ``vdirsyncer``, and considered experimental.
- Add ``implicit`` option to the :ref:`pair section <pair_config>`. 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` - Update TLS-related tests that were failing due to weak MDs. :gh:`903`
- ``pytest-httpserver`` and ``trustme`` are now required for tests. - ``pytest-httpserver`` and ``trustme`` are now required for tests.
- ``pytest-localserver`` is no longer required for tests. - ``pytest-localserver`` is no longer required for tests.

View file

@ -128,6 +128,16 @@ Pair Section
The ``conflict_resolution`` parameter applies for these properties too. 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_config:
Storage Section Storage Section

View file

@ -222,3 +222,62 @@ def test_validate_collections_param():
x([["c", None, "b"]]) x([["c", None, "b"]])
x([["c", "a", None]]) x([["c", "a", None]])
x([["c", None, 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"

View file

@ -96,6 +96,14 @@ def _validate_collections_param(collections):
raise ValueError(f"`collections` parameter, position {i}: {e!s}") raise ValueError(f"`collections` parameter, position {i}: {e!s}")
def _validate_implicit_param(implicit):
if implicit is None:
return
if implicit != "create":
raise ValueError("`implicit` parameter must be 'create' or absent.")
class _ConfigReader: class _ConfigReader:
def __init__(self, f: IO[Any]): def __init__(self, f: IO[Any]):
self._file: IO[Any] = f self._file: IO[Any] = f
@ -230,6 +238,7 @@ class PairConfig:
self.name: str = name self.name: str = name
self.name_a: str = options.pop("a") self.name_a: str = options.pop("a")
self.name_b: str = options.pop("b") self.name_b: str = options.pop("b")
self.implicit = options.pop("implicit", None)
self._partial_sync: str | None = options.pop("partial_sync", None) self._partial_sync: str | None = options.pop("partial_sync", None)
self.metadata: str | tuple[()] = options.pop("metadata", ()) self.metadata: str | tuple[()] = options.pop("metadata", ())
@ -248,6 +257,7 @@ class PairConfig:
) )
else: else:
_validate_collections_param(self.collections) _validate_collections_param(self.collections)
_validate_implicit_param(self.implicit)
if options: if options:
raise ValueError("Unknown options: {}".format(", ".join(options))) raise ValueError("Unknown options: {}".format(", ".join(options)))

View file

@ -93,6 +93,13 @@ async def collections_for_pair(
connector=connector, 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 # 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). # mangled to string (because JSON objects always have string keys).
rv = await aiostream.stream.list( # type: ignore[assignment] rv = await aiostream.stream.list( # type: ignore[assignment]
@ -102,7 +109,7 @@ async def collections_for_pair(
config_b=pair.config_b, config_b=pair.config_b,
get_a_discovered=a_discovered.get_self, get_a_discovered=a_discovered.get_self,
get_b_discovered=b_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,
) )
) )

View file

@ -286,7 +286,7 @@ async def storage_instance_from_config(
except exceptions.CollectionNotFound as e: except exceptions.CollectionNotFound as e:
if create: if create:
config = await handle_collection_not_found( 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( return await storage_instance_from_config(
config, config,
@ -341,7 +341,9 @@ def assert_permissions(path: str, wanted: int) -> None:
os.chmod(path, wanted) 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) storage_name = config.get("instance_name", None)
cli_logger.warning( cli_logger.warning(
@ -350,7 +352,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"] storage_type = config["type"]
cls, config = storage_class_from_config(config) cls, config = storage_class_from_config(config)
config["collection"] = collection config["collection"] = collection