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
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 <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`
- ``pytest-httpserver`` and ``trustme`` are now 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.
.. _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

View file

@ -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"

View file

@ -96,6 +96,14 @@ def _validate_collections_param(collections):
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:
def __init__(self, f: IO[Any]):
self._file: IO[Any] = f
@ -230,6 +238,7 @@ class PairConfig:
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: str | tuple[()] = options.pop("metadata", ())
@ -248,6 +257,7 @@ class PairConfig:
)
else:
_validate_collections_param(self.collections)
_validate_implicit_param(self.implicit)
if options:
raise ValueError("Unknown options: {}".format(", ".join(options)))

View file

@ -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( # type: ignore[assignment]
@ -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,
)
)

View file

@ -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,
@ -341,7 +341,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(
@ -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"]
cls, config = storage_class_from_config(config)
config["collection"] = collection