Repository: datalad/datalad-next

Issue:
Provide `EnsureRemoteName`
Many commands needs sibling names, existing or prospective.

Checking for syntax compliance is essentially

`EnsureGitRefName(allow_onelevel=True, refspec_pattern=False)`

for any given dataset, it can be turned into a `EnsureChoice()`, but it would also need some kind of `EnsureNotIn()` (for setting a non-existing remote name). So likely the best is to implement dataset interaction directly in `EnsureRemoteName`, and make it a subclass of `EnsureGitRefName`

Comments:

Patch:
diff --git a/datalad_next/commands/create_sibling_webdav.py b/datalad_next/commands/create_sibling_webdav.py
index 1b286ed..ba808a9 100644
--- a/datalad_next/commands/create_sibling_webdav.py
+++ b/datalad_next/commands/create_sibling_webdav.py
@@ -9,9 +9,6 @@
 """High-level interface for creating a combi-target on a WebDAV capable server
  """
 import logging
-from typing import (
-    Dict,
-)
 from unittest.mock import patch
 from urllib.parse import (
     quote as urlquote,
@@ -38,12 +35,19 @@ from datalad_next.constraints import (
     EnsureInt,
     EnsureParsedURL,
     EnsureRange,
+    EnsureRemoteName,
     EnsureStr,
 )
-from datalad_next.constraints.dataset import EnsureDataset
+from datalad_next.constraints.dataset import (
+    DatasetParameter,
+    EnsureDataset,
+)
+from datalad_next.constraints.exceptions import (
+    ConstraintError,
+    ParameterConstraintContext,
+)
 from datalad_next.utils import CredentialManager
 from datalad_next.utils import (
-    ParamDictator,
     get_specialremote_credential_properties,
     update_specialremote_credential,
     _yield_ds_w_matching_siblings,
@@ -56,37 +60,119 @@ lgr = logging.getLogger('datalad.distributed.create_sibling_webdav')
 
 
 class CreateSiblingWebDAVParamValidator(EnsureCommandParameterization):
-    def joint_validation(self, params: Dict, on_error: str) -> Dict:
-        p = ParamDictator(params)
-        if p.url.scheme == "http":
+    def __init__(self):
+        super().__init__(
+            param_constraints=dict(
+                url=EnsureParsedURL(
+                    required=['scheme', 'netloc'],
+                    forbidden=['query', 'fragment'],
+                    match='^(http|https)://',
+                ),
+                dataset=EnsureDataset(
+                    installed=True, purpose='create WebDAV sibling(s)'),
+                name=EnsureRemoteName(),
+                storage_name=EnsureRemoteName(),
+                mode=EnsureChoice(
+                    'annex', 'filetree', 'annex-only', 'filetree-only',
+                    'git-only',
+                ),
+                # TODO https://github.com/datalad/datalad-next/issues/131
+                credential=EnsureStr(),
+                existing=EnsureChoice('skip', 'error', 'reconfigure'),
+                recursive=EnsureBool(),
+                recursion_limit=EnsureInt() & EnsureRange(min=0),
+            ),
+            validate_defaults=('dataset',),
+            joint_constraints={
+                ParameterConstraintContext(('url',), 'url'):
+                self._validate_param_url,
+                ParameterConstraintContext(
+                    ('url', 'name'), 'default name'):
+                self._validate_default_name,
+                ParameterConstraintContext(
+                    ('mode', 'name', 'storage_name'), 'default storage name'):
+                self._validate_default_storage_name,
+                ParameterConstraintContext(
+                    ('mode', 'name', 'storage_name'), 'default storage name'):
+                self._validate_default_storage_name,
+                ParameterConstraintContext(
+                    ('existing', 'recursive', 'name', 'storage_name',
+                     'dataset', 'mode')):
+                self._validate_existing_names,
+            },
+        )
+
+    def _validate_param_url(self, url):
+        if url.scheme == "http":
             lgr.warning(
-                f"Using 'http:' ({p.url.geturl()!r}) means that WebDAV "
+                f"Using 'http:' ({url.geturl()!r}) means that WebDAV "
                 "credentials are sent unencrypted over network links. "
                 "Consider using 'https:'.")
 
-        if not params['name']:
+    def _validate_default_name(self, url, name):
+        if not name:
             # not using .netloc to avoid ports to show up in the name
-            params['name'] = p.url.hostname
+            return {'name': url.hostname}
 
-        if p.mode in ('annex-only', 'filetree-only') and p.storage_name:
+    def _validate_default_storage_name(self, mode, name, storage_name):
+        if mode in ('annex-only', 'filetree-only') and storage_name:
             lgr.warning(
                 "Sibling name will be used for storage sibling in "
                 "storage-sibling-only mode, but a storage sibling name "
                 "was provided"
             )
-        if p.mode == 'git-only' and p.storage_name:
+        if mode == 'git-only' and storage_name:
             lgr.warning(
                 "Storage sibling setup disabled, but a storage sibling name "
                 "was provided"
             )
-        if p.mode != 'git-only' and not p.storage_name:
-            p.storage_name = f"{p.name}-storage"
+        if mode != 'git-only' and not storage_name:
+            storage_name = f"{name}-storage"
 
-        if p.mode != 'git-only' and p.name == p.storage_name:
+        if mode != 'git-only' and name == storage_name:
             # leads to unresolvable, circular dependency with publish-depends
-            raise ValueError("sibling names must not be equal")
+            self.raise_for(
+                dict(mode=mode, name=name, storage_name=storage_name),
+                "sibling names must not be equal",
+            )
+        return dict(mode=mode, name=name, storage_name=storage_name)
+
+    def _validate_existing_names(
+            self, existing, recursive, name, storage_name, dataset,
+            mode):
+        if recursive:
+            # we don't do additional validation for recursive processing,
+            # this has to be done when things are running, because an
+            # upfront validation would require an expensive traversal
+            return
 
-        return params
+        if existing != 'error':
+            # nothing to check here
+            return
+
+        if not isinstance(dataset, DatasetParameter):
+            # we did not get a proper dataset parameter,
+            # hence cannot tailor to a dataset to check a remote
+            # name against
+            return
+
+        validator = EnsureRemoteName(known=False, dsarg=dataset)
+        try:
+            if mode != 'annex-only':
+                validator(name)
+            if mode != 'git-only':
+                validator(storage_name)
+        except ConstraintError as e:
+            self.raise_for(
+                dict(existing=existing,
+                     recursive=recursive,
+                     name=name,
+                     storage_name=storage_name,
+                     dataset=dataset,
+                     mode=mode),
+                e.msg,
+            )
+        return
 
 
 @build_doc
@@ -251,29 +337,7 @@ class CreateSiblingWebDAV(ValidatedInterface):
             """),
     )
 
-    _validators = dict(
-        url=EnsureParsedURL(
-            required=['scheme', 'netloc'],
-            forbidden=['query', 'fragment'],
-            match='^(http|https)://',
-        ),
-        dataset=EnsureDataset(
-            installed=True, purpose='create WebDAV sibling(s)'),
-        name=EnsureStr(),
-        storage_name=EnsureStr(),
-        mode=EnsureChoice(
-            'annex', 'filetree', 'annex-only', 'filetree-only', 'git-only'
-        ),
-        # TODO https://github.com/datalad/datalad-next/issues/131
-        credential=EnsureStr(),
-        existing=EnsureChoice('skip', 'error', 'reconfigure'),
-        recursive=EnsureBool(),
-        recursion_limit=EnsureInt() & EnsureRange(min=0),
-    )
-    _validator_ = CreateSiblingWebDAVParamValidator(
-        _validators,
-        validate_defaults=('dataset',),
-    )
+    _validator_ = CreateSiblingWebDAVParamValidator()
 
     @staticmethod
     @datasetmethod(name='create_sibling_webdav')
diff --git a/datalad_next/constraints/__init__.py b/datalad_next/constraints/__init__.py
index e6f0139..42fea3f 100644
--- a/datalad_next/constraints/__init__.py
+++ b/datalad_next/constraints/__init__.py
@@ -85,3 +85,8 @@ from .formats import (
     EnsureURL,
     EnsureParsedURL,
 )
+
+from .git import (
+    EnsureGitRefName,
+    EnsureRemoteName
+)
\ No newline at end of file
diff --git a/datalad_next/constraints/git.py b/datalad_next/constraints/git.py
index 25f8363..073c131 100644
--- a/datalad_next/constraints/git.py
+++ b/datalad_next/constraints/git.py
@@ -1,8 +1,12 @@
 """Constraints for Git-related concepts and parameters"""
+from __future__ import annotations
 
 import subprocess
 
-from .base import Constraint
+from .base import (
+    Constraint,
+    DatasetParameter,
+)
 
 
 class EnsureGitRefName(Constraint):
@@ -74,3 +78,101 @@ class EnsureGitRefName(Constraint):
             '(single-level) ' if self._allow_onelevel else '',
             ' or refspec pattern' if self._refspec_pattern else '',
         )
+
+
+class EnsureRemoteName(Constraint):
+    """Ensures a valid remote name, and optionally if such a remote is known
+    """
+    _label = 'remote'
+
+    def __init__(self,
+                 known: bool | None = None,
+                 dsarg: DatasetParameter | None = None):
+        """
+        Parameters
+        ----------
+        known: bool, optional
+           By default, a given value is only checked if it is a syntactically
+           correct remote name.
+           If ``True``, also checks that the given name corresponds to a
+           known remote in the dataset given by ``dsarg``. If ``False``,
+           checks that the given remote does not match any known remote
+           in that dataset.
+        dsarg: DatasetParameter, optional
+           Identifies a dataset for testing remote existence, if requested.
+        """
+        self._label = 'remote'
+        self._known = known
+        self._dsarg = dsarg
+
+    def __call__(self, value: str) -> str:
+        if not value:
+            # simple, do here
+            self.raise_for(
+                value,
+                f'missing {self._label} name',
+            )
+
+        if self._known is not None:
+            assert self._dsarg, \
+                f"Existence check for {self._label} requires dataset " \
+                "specification"
+
+        if self._known:
+            # we don't need to check much, only if a remote of this name
+            # already exists -- no need to check for syntax compliance
+            # again
+            if not any(
+                k.startswith(f"remote.{value}.")
+                for k in self._dsarg.ds.config.keys()
+            ):
+                self.raise_for(
+                    value,
+                    f'is not a known {self._label}',
+                )
+        else:
+            # whether or not the remote must not exist, or we would not care,
+            # in all cases we need to check for syntax compliance
+            EnsureGitRefName(
+                allow_onelevel=True,
+                refspec_pattern=False,
+            )(value)
+
+        if self._known is None:
+            # we only need to know that something was provided,
+            # no further check
+            return value
+
+        if self._known is False and any(
+            k.startswith(f"remote.{value}.")
+            for k in self._dsarg.ds.config.keys()
+        ):
+            self.raise_for(
+                value,
+                f'name conflicts with a known {self._label}',
+            )
+
+        return value
+
+    def short_description(self):
+        return f"Name of a{{desc}} {self._label}".format(
+            desc=' known' if self._known
+            else ' not-yet-known' if self._known is False else ''
+        )
+
+    def for_dataset(self, dataset: DatasetParameter) -> Constraint:
+        """Return an similarly parametrized variant that checks remote names
+        against a given dataset (argument)"""
+        return self.__class__(
+            known=self._known,
+            dsarg=dataset,
+        )
+
+
+class EnsureSiblingName(EnsureRemoteName):
+    """Identical to ``EnsureRemoteName``, but used the term "sibling"
+
+    Only error messages and documentation differ, with "remote" being
+    replaced with "sibling".
+    """
+    _label = 'sibling'


Test patch:
diff --git a/datalad_next/commands/tests/test_create_sibling_webdav.py b/datalad_next/commands/tests/test_create_sibling_webdav.py
index 5fda1fd..cd22232 100644
--- a/datalad_next/commands/tests/test_create_sibling_webdav.py
+++ b/datalad_next/commands/tests/test_create_sibling_webdav.py
@@ -74,6 +74,26 @@ def check_common_workflow(
             if declare_credential else None,
             mode=mode,
         )
+        # Ensure that remote name constraint check works
+        # second time should raise because the sibling exists already
+        with pytest.raises(ValueError) as e:
+            create_sibling_webdav(
+                url,
+                credential=webdav_credential['name']
+                if declare_credential else None,
+                mode=mode,
+                name='127.0.0.1',
+            )
+        with pytest.raises(ValueError) as e:
+            create_sibling_webdav(
+                url,
+                credential=webdav_credential['name']
+                if declare_credential else None,
+                mode=mode,
+                name='other',
+                storage_name='127.0.0.1-storage',
+            )
+
     assert_in_results(
         res,
         action='create_sibling_webdav.storage',
diff --git a/datalad_next/constraints/tests/test_special_purpose.py b/datalad_next/constraints/tests/test_special_purpose.py
index fb3d508..c69b3be 100644
--- a/datalad_next/constraints/tests/test_special_purpose.py
+++ b/datalad_next/constraints/tests/test_special_purpose.py
@@ -4,6 +4,7 @@ import pytest
 from datalad_next.commands import Parameter
 from datalad_next.utils import chpwd
 
+from ..base import DatasetParameter
 from ..basic import (
     EnsureInt,
     EnsureStr,
@@ -22,6 +23,7 @@ from ..formats import (
 )
 from ..git import (
     EnsureGitRefName,
+    EnsureRemoteName
 )
 from ..parameter_legacy import EnsureParameterConstraint
 
@@ -52,6 +54,36 @@ def test_EnsureGitRefName():
         'refs/heads/*') == 'refs/heads/*'
 
 
+def test_EnsureRemoteName(existing_dataset):
+    # empty sibling name must raise
+    with pytest.raises(ValueError):
+        EnsureRemoteName()('')
+    assert EnsureRemoteName().short_description() == 'Name of a remote'
+    assert EnsureRemoteName(
+        known=True).short_description() == 'Name of a known remote'
+    assert EnsureRemoteName(
+        known=False).short_description() == 'Name of a not-yet-known remote'
+    ds = existing_dataset
+    c = EnsureRemoteName(known=False)
+    tc = c.for_dataset(DatasetParameter(None, ds))
+    assert tc('newremotename') == 'newremotename'
+    # add a remote
+    ds._repo.add_remote('my-remote', 'here')
+    # check should fail when it shouldn't exist
+    with pytest.raises(ValueError):
+        tc('my-remote')
+    # should work when it should exist
+    c = EnsureRemoteName(known=True)
+    tc = c.for_dataset(DatasetParameter(None, ds))
+    assert tc('my-remote') == 'my-remote'
+    # but fail with non-existing remote
+    with pytest.raises(ValueError) as e:
+        tc('not-my-remote')
+    assert str(e.value) == "is not a known remote"
+    # return sibling name with no existence checks
+    assert EnsureRemoteName()('anything') == 'anything'
+
+
 def test_EnsureParameterConstraint():
     # most basic case, no value constraint
     c = EnsureParameterConstraint(NoConstraint())