Repository: datalad/datalad-next

Issue:
EnsureFromStdin(constraint)
We would benefit from a meta-constraint that wraps another constraint and reads items from `stdin`, passing each item through the payload constraint.

This would be a useful paradigm to factor out any input logic from the body of a command. This is pretty much providing a "batch-mode" for any command that had previously taken an iterable argument of some kind.

An `EnsureFromFile` could work in a similar fashion.

Discussion:
codecov[bot]: # [Codecov](https://codecov.io/gh/datalad/datalad-next/pull/116?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=datalad) Report
Base: **94.38**% // Head: **92.38**% // Decreases project coverage by **`-2.00%`** :warning:
> Coverage data is based on head [(`c264cb0`)](https://codecov.io/gh/datalad/datalad-next/pull/116?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=datalad) compared to base [(`f350ac2`)](https://codecov.io/gh/datalad/datalad-next/commit/f350ac2241f75cad39f38907c5afcafa3d2f4efe?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=datalad).
> Patch coverage: 98.88% of modified lines in pull request are covered.

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main     #116      +/-   ##
==========================================
- Coverage   94.38%   92.38%   -2.01%     
==========================================
  Files          38       45       +7     
  Lines        3955     5042    +1087     
==========================================
+ Hits         3733     4658     +925     
- Misses        222      384     +162     
```


| [Impacted Files](https://codecov.io/gh/datalad/datalad-next/pull/116?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=datalad) | Coverage Δ | |
|---|---|---|
| [datalad\_next/constraints/basic.py](https://codecov.io/gh/datalad/datalad-next/pull/116/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=datalad#diff-ZGF0YWxhZF9uZXh0L2NvbnN0cmFpbnRzL2Jhc2ljLnB5) | `96.73% <96.73%> (ø)` | |
| [datalad\_next/constraints/api.py](https://codecov.io/gh/datalad/datalad-next/pull/116/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=datalad#diff-ZGF0YWxhZF9uZXh0L2NvbnN0cmFpbnRzL2FwaS5weQ==) | `98.50% <98.50%> (ø)` | |
| [datalad\_next/constraints/\_\_init\_\_.py](https://codecov.io/gh/datalad/datalad-next/pull/116/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=datalad#diff-ZGF0YWxhZF9uZXh0L2NvbnN0cmFpbnRzL19faW5pdF9fLnB5) | `100.00% <100.00%> (ø)` | |
| [datalad\_next/constraints/git.py](https://codecov.io/gh/datalad/datalad-next/pull/116/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=datalad#diff-ZGF0YWxhZF9uZXh0L2NvbnN0cmFpbnRzL2dpdC5weQ==) | `100.00% <100.00%> (ø)` | |
| [datalad\_next/constraints/parameter.py](https://codecov.io/gh/datalad/datalad-next/pull/116/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=datalad#diff-ZGF0YWxhZF9uZXh0L2NvbnN0cmFpbnRzL3BhcmFtZXRlci5weQ==) | `100.00% <100.00%> (ø)` | |
| [datalad\_next/constraints/tests/test\_constraints.py](https://codecov.io/gh/datalad/datalad-next/pull/116/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=datalad#diff-ZGF0YWxhZF9uZXh0L2NvbnN0cmFpbnRzL3Rlc3RzL3Rlc3RfY29uc3RyYWludHMucHk=) | `100.00% <100.00%> (ø)` | |
| [datalad\_next/constraints/utils.py](https://codecov.io/gh/datalad/datalad-next/pull/116/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=datalad#diff-ZGF0YWxhZF9uZXh0L2NvbnN0cmFpbnRzL3V0aWxzLnB5) | `100.00% <100.00%> (ø)` | |
| [datalad\_next/\_version.py](https://codecov.io/gh/datalad/datalad-next/pull/116/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=datalad#diff-ZGF0YWxhZF9uZXh0L192ZXJzaW9uLnB5) | `45.39% <0.00%> (-54.61%)` | :arrow_down: |
| [datalad\_next/gitremote/datalad\_annex.py](https://codecov.io/gh/datalad/datalad-next/pull/116/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=datalad#diff-ZGF0YWxhZF9uZXh0L2dpdHJlbW90ZS9kYXRhbGFkX2FubmV4LnB5) | `89.89% <0.00%> (+0.27%)` | :arrow_up: |

Help us with your feedback. Take ten seconds to tell us [how you rate us](https://about.codecov.io/nps?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=datalad). Have a feature suggestion? [Share it here.](https://app.codecov.io/gh/feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=datalad)

</details>

[:umbrella: View full report at Codecov](https://codecov.io/gh/datalad/datalad-next/pull/116?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=datalad).   
:loudspeaker: Do you have feedback about the report comment? [Let us know in this issue](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=datalad).


Patch:
diff --git a/datalad_next/__init__.py b/datalad_next/__init__.py
index d89aa34..43aff50 100644
--- a/datalad_next/__init__.py
+++ b/datalad_next/__init__.py
@@ -45,7 +45,7 @@ import datalad_next.patches
 
 # register additional configuration items in datalad-core
 from datalad.support.extensions import register_config
-from datalad.support.constraints import EnsureBool
+from datalad_next.constraints import EnsureBool
 register_config(
     'datalad.credentials.repeat-secret-entry',
     'Require entering secrets twice for interactive specification?',
diff --git a/datalad_next/constraints/__init__.py b/datalad_next/constraints/__init__.py
new file mode 100644
index 0000000..1db4fa1
--- /dev/null
+++ b/datalad_next/constraints/__init__.py
@@ -0,0 +1,22 @@
+
+# expose constraints with direct applicability, but not
+# base and helper classes
+from .basic import (
+    EnsureBool,
+    EnsureCallable,
+    EnsureChoice,
+    EnsureFloat,
+    EnsureInt,
+    EnsureKeyChoice,
+    EnsureNone,
+    EnsurePath,
+    EnsureStr,
+    EnsureRange,
+    NoConstraint,
+)
+from .compound import (
+    EnsureIterableOf,
+    EnsureListOf,
+    EnsureTupleOf,
+    EnsureMapping,
+)
diff --git a/datalad_next/constraints/base.py b/datalad_next/constraints/base.py
new file mode 100644
index 0000000..86133ac
--- /dev/null
+++ b/datalad_next/constraints/base.py
@@ -0,0 +1,164 @@
+""""""
+__docformat__ = 'restructuredtext'
+
+from typing import (
+    TYPE_CHECKING,
+    TypeVar,
+)
+
+if TYPE_CHECKING:  # pragma: no cover
+    from datalad.distribution.dataset import Dataset
+
+ConstraintDerived = TypeVar('ConstraintDerived', bound='Constraint')
+DatasetDerived = TypeVar('DatasetDerived', bound='Dataset')
+
+
+class Constraint:
+    """Base class for input value conversion/validation.
+
+    These classes are also meant to be able to generate appropriate
+    documentation on an appropriate parameter value.
+    """
+
+    # TODO: __str__ and/or __repr__ for every one of them
+
+    def __repr__(self):
+        """Rudimentary repr to avoid default scary to the user Python repr"""
+        return "constraint:%s" % self.short_description()
+
+    def __and__(self, other):
+        return Constraints(self, other)
+
+    def __or__(self, other):
+        return AltConstraints(self, other)
+
+    def __call__(self, value):
+        # do any necessary checks or conversions, potentially catch exceptions
+        # and generate a meaningful error message
+        raise NotImplementedError("abstract class")
+
+    def long_description(self):
+        # return meaningful docs or None
+        # used as a comprehensive description in the parameter list
+        return self.short_description()
+
+    def short_description(self):
+        # return meaningful docs or None
+        # used as a condensed primer for the parameter lists
+        raise NotImplementedError("abstract class")
+
+    def for_dataset(self, dataset: DatasetDerived) -> ConstraintDerived:
+        """Return a constraint-variant for a specific dataset context
+
+        The default implementation returns the unmodified, identical
+        constraint. However, subclasses can implement different behaviors.
+        """
+        return self
+
+
+class _MultiConstraint(Constraint):
+    """Helper class to override the description methods to reported
+    multiple constraints
+    """
+    def _get_description(self, attr: str, operation: str) -> str:
+        cs = [
+            getattr(c, attr)()
+            for c in self.constraints
+            if hasattr(c, attr)
+        ]
+        cs = [c for c in cs if c is not None]
+        doc = f' {operation} '.join(cs)
+        if len(cs) > 1:
+            return f'({doc})'
+        else:
+            return doc
+
+
+class AltConstraints(_MultiConstraint):
+    """Logical OR for constraints.
+
+    An arbitrary number of constraints can be given. They are evaluated in the
+    order in which they were specified. The value returned by the first
+    constraint that does not raise an exception is the global return value.
+
+    Documentation is aggregated for all alternative constraints.
+    """
+    def __init__(self, *constraints):
+        """
+        Parameters
+        ----------
+        *constraints
+           Alternative constraints
+        """
+        super(AltConstraints, self).__init__()
+        # TODO Why is EnsureNone needed? Remove if possible
+        from .basic import EnsureNone
+        self.constraints = [
+            EnsureNone() if c is None else c for c in constraints
+        ]
+
+    def __or__(self, other):
+        if isinstance(other, AltConstraints):
+            self.constraints.extend(other.constraints)
+        else:
+            self.constraints.append(other)
+        return self
+
+    def __call__(self, value):
+        e_list = []
+        for c in self.constraints:
+            try:
+                return c(value)
+            except Exception as e:
+                e_list.append(e)
+        raise ValueError(
+            f"{value!r} violated all possible constraints {self.constraints}")
+
+    def long_description(self):
+        return self._get_description('long_description', 'or')
+
+    def short_description(self):
+        return self._get_description('short_description', 'or')
+
+
+class Constraints(_MultiConstraint):
+    """Logical AND for constraints.
+
+    An arbitrary number of constraints can be given. They are evaluated in the
+    order in which they were specified. The return value of each constraint is
+    passed an input into the next. The return value of the last constraint
+    is the global return value. No intermediate exceptions are caught.
+
+    Documentation is aggregated for all constraints.
+    """
+    def __init__(self, *constraints):
+        """
+        Parameters
+        ----------
+        *constraints
+           Constraints all of which must be satisfied
+        """
+        super(Constraints, self).__init__()
+        # TODO Why is EnsureNone needed? Remove if possible
+        from .basic import EnsureNone
+        self.constraints = [
+            EnsureNone() if c is None else c for c in constraints
+        ]
+
+    def __and__(self, other):
+        if isinstance(other, Constraints):
+            self.constraints.extend(other.constraints)
+        else:
+            self.constraints.append(other)
+        return self
+
+    def __call__(self, value):
+        for c in (self.constraints):
+            value = c(value)
+        return value
+
+    def long_description(self):
+        return self._get_description('long_description', 'and')
+
+    def short_description(self):
+        return self._get_description('short_description', 'and')
diff --git a/datalad_next/constraints/basic.py b/datalad_next/constraints/basic.py
new file mode 100644
index 0000000..bcef4f0
--- /dev/null
+++ b/datalad_next/constraints/basic.py
@@ -0,0 +1,414 @@
+# emacs: -*- mode: python; py-indent-offset: 4; tab-width: 4; indent-tabs-mode: nil -*-
+# ex: set sts=4 ts=4 sw=4 et:
+# ## ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ##
+#
+#   See COPYING file distributed along with the datalad package for the
+#   copyright and license terms.
+#
+# ## ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ##
+"""Helper for parameter validation, documentation and conversion"""
+
+__docformat__ = 'restructuredtext'
+
+from pathlib import Path
+import re
+
+from .base import Constraint
+from .utils import _type_str
+
+
+class NoConstraint(Constraint):
+    """A contraint that represents no constraints"""
+    def short_description(self):
+        return ''
+
+    def __call__(self, value):
+        return value
+
+
+class EnsureValue(Constraint):
+    """Ensure an input is a particular value"""
+    def __init__(self, value):
+        super().__init__()
+        self._target_value = value
+
+    def __call__(self, value):
+        if value == self._target_value:
+            return value
+        else:
+            raise ValueError(f"value must be {self._target_value!r}")
+
+    def short_description(self):
+        return f'{self._target_value!r}'
+
+    def long_description(self):
+        return f'value must be {self.short_description()}'
+
+
+class EnsureDType(Constraint):
+    """Ensure that an input (or several inputs) are of a particular data type.
+    """
+    # TODO extend to support numpy-like dtype specs, e.g. 'int64'
+    # in addition to functors
+    def __init__(self, dtype):
+        """
+        Parameters
+        ----------
+        dtype : functor
+        """
+        self._dtype = dtype
+
+    def __call__(self, value):
+        return self._dtype(value)
+
+    def short_description(self):
+        return _type_str(self._dtype)
+
+    def long_description(self):
+        return "value must be convertible to type '%s'" % self.short_description()
+
+
+class EnsureInt(EnsureDType):
+    """Ensure that an input (or several inputs) are of a data type 'int'.
+    """
+    def __init__(self):
+        """Initializes EnsureDType with int"""
+        EnsureDType.__init__(self, int)
+
+
+class EnsureFloat(EnsureDType):
+    """Ensure that an input (or several inputs) are of a data type 'float'.
+    """
+    def __init__(self):
+        """Initializes EnsureDType with float"""
+        EnsureDType.__init__(self, float)
+
+
+class EnsureBool(Constraint):
+    """Ensure that an input is a bool.
+
+    A couple of literal labels are supported, such as:
+    False: '0', 'no', 'off', 'disable', 'false'
+    True: '1', 'yes', 'on', 'enable', 'true'
+    """
+    def __call__(self, value):
+        if isinstance(value, bool):
+            return value
+        elif isinstance(value, (bytes, str)):
+            value = value.lower()
+            if value in ('0', 'no', 'off', 'disable', 'false'):
+                return False
+            elif value in ('1', 'yes', 'on', 'enable', 'true'):
+                return True
+        raise ValueError(
+            "value '{}' must be convertible to boolean".format(
+                value))
+
+    def long_description(self):
+        return 'value must be convertible to type bool'
+
+    def short_description(self):
+        return 'bool'
+
+
+class EnsureStr(Constraint):
+    """Ensure an input is a string of some min. length and matching a pattern
+
+    Pattern matching is optional and minimum length is zero (empty string is
+    OK).
+
+    No type conversion is performed.
+    """
+    def __init__(self, min_len: int = 0, match: str = None):
+        """
+        Parameters
+        ----------
+        min_len: int, optional
+           Minimal length for a string.
+        match:
+           Regular expression used to match any input value against.
+           Values not matching the expression will cause a
+           `ValueError` to be raised.
+        """
+        assert min_len >= 0
+        self._min_len = min_len
+        self._match = match
+        super().__init__()
+        if match is not None:
+            self._match = re.compile(match)
+
+    def __call__(self, value) -> str:
+        if not isinstance(value, (bytes, str)):
+            # do not perform a blind conversion ala str(), as almost
+            # anything can be converted and the result is most likely
+            # unintended
+            raise ValueError("%s is not a string" % repr(value))
+        if len(value) < self._min_len:
+            raise ValueError("%r is shorter than of minimal length %d"
+                             % (value, self._min_len))
+        if self._match:
+            if not self._match.match(value):
+                raise ValueError(
+                    f'{value} does not match {self._match.pattern}')
+        return value
+
+    def long_description(self):
+        return 'must be a string{}'.format(
+            f' and match {self._match.pattern}' if self._match else '',
+        )
+
+    def short_description(self):
+        return 'str{}'.format(
+            f'({self._match.pattern})' if self._match else '',
+        )
+
+
+# TODO possibly consolidate on EnsureStr from -gooey, which can take
+# a regex that could perform this. CON: documentation less clear.
+# But if custom documentation will be supported, it might get even
+# more clear nevertheless
+class EnsureStrPrefix(EnsureStr):
+    """Ensure an input is a string that starts with a given prefix.
+    """
+    def __init__(self, prefix):
+        """
+        Parameters
+        ----------
+        prefix : str
+           Mandatory prefix.
+        """
+        self._prefix = prefix
+        super().__init__()
+
+    def __call__(self, value):
+        super().__call__(value)
+        if not value.startswith(self._prefix):
+            raise ValueError("%r does not start with '%s'"
+                             % (value, self._prefix))
+        return value
+
+    def long_description(self):
+        return "value must start with '{}'".format(self._prefix)
+
+    def short_description(self):
+        return '{}...'.format(self._prefix)
+
+
+class EnsureNone(EnsureValue):
+    """Ensure an input is of value `None`"""
+    def __init__(self):
+        super().__init__(None)
+
+
+class EnsureCallable(Constraint):
+    """Ensure an input is of value `None`"""
+    def __call__(self, value):
+        if hasattr(value, '__call__'):
+            return value
+        else:
+            raise ValueError("value must be a callable")
+
+    def short_description(self):
+        return 'callable'
+
+    def long_description(self):
+        return 'value must be a callable'
+
+
+class EnsureChoice(Constraint):
+    """Ensure an input is element of a set of possible values"""
+
+    def __init__(self, *values):
+        """
+        Parameters
+        ----------
+        *values
+           Possible accepted values.
+        """
+        self._allowed = values
+        super(EnsureChoice, self).__init__()
+
+    def __call__(self, value):
+        if value not in self._allowed:
+            raise ValueError(f"value {value} is not one of {self._allowed}")
+        return value
+
+    def long_description(self):
+        return 'value must be one of [CMD: %s CMD][PY: %s PY]' % (
+            str(tuple(i for i in self._allowed if i is not None)),
+            str(self._allowed)
+        )
+
+    def short_description(self):
+        return '{%s}' % ', '.join([repr(c) for c in self._allowed])
+
+
+class EnsureKeyChoice(EnsureChoice):
+    """Ensure value under a key in an input is in a set of possible values"""
+
+    def __init__(self, key, values):
+        """
+        Parameters
+        ----------
+        key : str
+          The to-be-tested values are looked up under the given key in
+          a dict-like input object.
+        values : tuple
+           Possible accepted values.
+        """
+        self._key = key
+        super(EnsureKeyChoice, self).__init__(*values)
+
+    def __call__(self, value):
+        if self._key not in value:
+            raise ValueError("value not dict-like")
+        super(EnsureKeyChoice, self).__call__(value[self._key])
+        return value
+
+    def long_description(self):
+        return "value in '%s' must be one of %s" % (self._key, str(self._allowed),)
+
+    def short_description(self):
+        return '%s:{%s}' % (self._key, ', '.join([repr(c) for c in self._allowed]))
+
+
+class EnsureRange(Constraint):
+    """Ensure an input is within a particular range
+
+    No type checks are performed.
+    """
+    def __init__(self, min=None, max=None):
+        """
+        Parameters
+        ----------
+        min
+            Minimal value to be accepted in the range
+        max
+            Maximal value to be accepted in the range
+        """
+        self._min = min
+        self._max = max
+        if self._min == self._max == None:
+            raise ValueError('No range given, min == max == None')
+        super(EnsureRange, self).__init__()
+
+    def __call__(self, value):
+        if self._min is not None:
+            if value < self._min:
+                raise ValueError("value must be at least %s" % (self._min,))
+        if self._max is not None:
+            if value > self._max:
+                raise ValueError("value must be at most %s" % (self._max,))
+        return value
+
+    def long_description(self):
+        return self.short_description()
+
+    def short_description(self):
+        if self._max is None:
+            return f'not less than {self._min}'
+        elif self._min is None:
+            return f'not greater than {self._max}'
+        else:
+            # it is inclusive, but spelling it out would be wordy
+            return f'between {self._min} and {self._max}'
+
+
+class EnsurePath(Constraint):
+    """Ensures input is convertible to a (platform) path and returns a `Path`
+
+    Optionally, the path can be tested for existence and whether it is absolute
+    or relative.
+    """
+    def __init__(self,
+                 path_type: type = Path,
+                 is_format: str or None = None,
+                 lexists: bool or None = None,
+                 is_mode: callable = None,
+                 ref: Path = None,
+                 ref_is: str = 'parent-or-same-as'):
+        """
+        Parameters
+        ----------
+        path_type:
+          Specific pathlib type to convert the input to. The default is `Path`,
+          i.e. the platform's path type. Not all pathlib Path types can be
+          instantiated on all platforms, and not all checks are possible with
+          all path types.
+        is_format: {'absolute', 'relative'} or None
+          If not None, the path is tested whether it matches being relative or
+          absolute.
+        lexists:
+          If not None, the path is tested to confirmed exists or not. A symlink
+          need not point to an existing path to fullfil the "exists" condition.
+        is_mode:
+          If set, this callable will receive the path's `.lstat().st_mode`,
+          and an exception is raised, if the return value does not evaluate
+          to `True`. Typical callables for this feature are provided by the
+          `stat` module, e.g. `S_ISDIR()`
+        ref:
+          If set, defines a reference Path any given path is compared to. The
+          comparison operation is given by `ref_is`.
+        ref_is: {'parent-or-identical'}
+          Comparison operation to perform when `ref` is given.
+        """
+        super().__init__()
+        self._path_type = path_type
+        self._is_format = is_format
+        self._lexists = lexists
+        self._is_mode = is_mode
+        self._ref = ref
+        self._ref_is = ref_is
+
+    def __call__(self, value):
+        path = self._path_type(value)
+        mode = None
+        if self._lexists is not None or self._is_mode is not None:
+            try:
+                mode = path.lstat().st_mode
+            except FileNotFoundError:
+                # this is fine, handled below
+                pass
+        if self._lexists is not None:
+            if self._lexists and mode is None:
+                raise ValueError(f'{path} does not exist')
+            elif not self._lexists and mode is not None:
+                raise ValueError(f'{path} does (already) exist')
+        if self._is_format is not None:
+            is_abs = path.is_absolute()
+            if self._is_format == 'absolute' and not is_abs:
+                raise ValueError(f'{path} is not an absolute path')
+            elif self._is_format == 'relative' and is_abs:
+                raise ValueError(f'{path} is not a relative path')
+        if self._is_mode is not None:
+            if not self._is_mode(mode):
+                raise ValueError(f'{path} does not match desired mode')
+        if self._ref:
+            ok = True
+            if self._ref_is == 'parent-or-same-as':
+                ok = (path == self._ref or self._ref in path.parents)
+            elif self._ref_is == 'parent-of':
+                ok = self._ref in path.parents
+            else:
+                raise ValueError('Unknown `ref_is` operation label')
+
+            if not ok:
+                raise ValueError(
+                    f'{self._ref} is not {self._ref_is} {path}')
+        return path
+
+    def short_description(self):
+        return '{}{}path{}'.format(
+            'existing '
+            if self._lexists
+            else 'non-existing '
+            if self._lexists else '',
+            'absolute '
+            if self._is_format == 'absolute'
+            else 'relative'
+            if self._is_format == 'relative'
+            else '',
+            f' that is {self._ref_is} {self._ref}'
+            if self._ref
+            else '',
+        )
diff --git a/datalad_next/constraints/compound.py b/datalad_next/constraints/compound.py
new file mode 100644
index 0000000..86c9d2e
--- /dev/null
+++ b/datalad_next/constraints/compound.py
@@ -0,0 +1,234 @@
+"""Constraints that wrap or contain other constraints"""
+
+from pathlib import Path
+import sys
+from typing import (
+    Any,
+    Dict,
+    Generator,
+)
+
+from .base import (
+    Constraint,
+    DatasetDerived,
+)
+
+
+class EnsureIterableOf(Constraint):
+    """Ensure that an input is a list of a particular data type
+    """
+    # TODO support a delimiter to be able to take str-lists?
+    def __init__(self,
+                 iter_type: type,
+                 item_constraint: callable,
+                 min_len: int or None = None,
+                 max_len: int or None = None):
+        """
+        Parameters
+        ----------
+        iter_type:
+          Target type of iterable. Common types are `list`, or `tuple`,
+          but also generator type iterables are possible. Type constructor
+          must take an iterable with items as the only required positional
+          argument.
+        item_constraint:
+          Each incoming item will be mapped through this callable
+          before being passed to the iterable type constructor.
+        min_len:
+          If not None, the iterable will be verified to have this minimum
+          number of items. The iterable type must implement `__len__()`
+          for this check to be supported.
+        max_len:
+          If not None, the iterable will be verified to have this maximum
+          number of items. The iterable type must implement `__len__()`
+          for this check to be supported.
+        """
+        if min_len is not None and max_len is not None and min_len > max_len:
+            raise ValueError(
+                'Given minimum length exceeds given maximum length')
+        self._iter_type = iter_type
+        self._item_constraint = item_constraint
+        self._min_len = min_len
+        self._max_len = max_len
+        super().__init__()
+
+    @property
+    def item_constraint(self):
+        return self._item_constraint
+
+    def __call__(self, value):
+        iter = self._iter_type(
+            self._item_constraint(i) for i in value
+        )
+        if self._min_len is not None or self._max_len is not None:
+            # only do this if necessary, generators will not support
+            # __len__, for example
+            iter_len = len(iter)
+            if self._min_len is not None and iter_len < self._min_len:
+                raise ValueError(
+                    f'Length-{iter_len} iterable is shorter than '
+                    f'required minmum length {self._min_len}')
+            if self._max_len is not None and iter_len > self._max_len:
+                raise ValueError(
+                    f'Length-{iter_len} iterable is longer than '
+                    f'required maximum length {self._max_len}')
+        return iter
+
+    def short_description(self):
+        return f'{self._iter_type}({self._item_constraint})'
+
+
+class EnsureListOf(EnsureIterableOf):
+    def __init__(self,
+                 item_constraint: callable,
+                 min_len: int or None = None,
+                 max_len: int or None = None):
+        """
+        Parameters
+        ----------
+        item_constraint:
+          Each incoming item will be mapped through this callable
+          before being passed to the list constructor.
+        min_len:
+          If not None, the list will be verified to have this minimum
+          number of items.
+        max_len:
+          If not None, the list will be verified to have this maximum
+          number of items.
+        """
+        super().__init__(list, item_constraint,
+                         min_len=min_len, max_len=max_len)
+
+    def short_description(self):
+        return f'list({self._item_constraint})'
+
+
+class EnsureTupleOf(EnsureIterableOf):
+    def __init__(self,
+                 item_constraint: callable,
+                 min_len: int or None = None,
+                 max_len: int or None = None):
+        """
+        Parameters
+        ----------
+        item_constraint:
+          Each incoming item will be mapped through this callable
+          before being passed to the tuple constructor.
+        min_len:
+          If not None, the tuple will be verified to have this minimum
+          number of items.
+        max_len:
+          If not None, the tuple will be verified to have this maximum
+          number of items.
+        """
+        super().__init__(tuple, item_constraint,
+                         min_len=min_len, max_len=max_len)
+
+    def short_description(self):
+        return f'tuple({self._item_constraint})'
+
+
+class EnsureMapping(Constraint):
+    """Ensure a mapping of a key to a value of a specific nature"""
+
+    def __init__(self,
+                 key: Constraint,
+                 value: Constraint,
+                 delimiter: str = ':'):
+        """
+        Parameters
+        ----------
+        key:
+          Key constraint instance.
+        value:
+          Value constraint instance.
+        delimiter:
+          Delimiter to use for splitting a key from a value for a `str` input.
+        """
+        super().__init__()
+        self._key_constraint = key
+        self._value_constraint = value
+        self._delimiter = delimiter
+
+    def short_description(self):
+        return 'mapping of {} -> {}'.format(
+            self._key_constraint.short_description(),
+            self._value_constraint.short_description(),
+        )
+
+    def __call__(self, value) -> Dict:
+        # determine key and value from various kinds of input
+        if isinstance(value, str):
+            # will raise if it cannot split into two
+            key, val = value.split(sep=self._delimiter, maxsplit=1)
+        elif isinstance(value, dict):
+            if not len(value):
+                raise ValueError('dict does not contain a key')
+            elif len(value) > 1:
+                raise ValueError(f'{value} contains more than one key')
+            key, val = value.copy().popitem()
+        elif isinstance(value, (list, tuple)):
+            if not len(value) == 2:
+                raise ValueError('key/value sequence does not have length 2')
+            key, val = value
+        else:
+            raise ValueError(f'Unsupported data type for mapping: {value!r}')
+
+        key = self._key_constraint(key)
+        val = self._value_constraint(val)
+        return {key: val}
+
+    def for_dataset(self, dataset: DatasetDerived):
+        # tailor both constraints to the dataset and reuse delimiter
+        return EnsureMapping(
+            key=self._key_constraint.for_dataset(dataset),
+            value=self._value_constraint.for_dataset(dataset),
+            delimiter=self._delimiter,
+        )
+
+
+class EnsureGeneratorFromFileLike(Constraint):
+    """Ensure a constraint for each item read from a file-like.
+
+    A given value can either be a file-like (the outcome of `open()`,
+    or `StringIO`), or `-` as an alias of STDIN, or a path to an
+    existing file to be read from.
+    """
+
+    def __init__(self, item_constraint: callable):
+        """
+        Parameters
+        ----------
+        item_constraint:
+          Each incoming item will be mapped through this callable
+          before being yielded by the generator.
+        """
+        self._item_constraint = item_constraint
+        super().__init__()
+
+    def short_description(self):
+        return \
+            f'items of type "{self._item_constraint.short_description()}" ' \
+            'read from a file-like'
+
+    def __call__(self, value) -> Generator[Any, None, None]:
+        opened_file = False
+        if value == '-':
+            value = sys.stdin
+        elif isinstance(value, (str, Path)):
+            # we covered the '-' special case, so this must be a Path
+            path = Path(value) if not isinstance(value, Path) else value
+            if not path.is_file():
+                raise ValueError(f'{value} is not an existing file')
+            value = path.open()
+            opened_file = True
+        try:
+            for line in value:
+                yield self._item_constraint(
+                    # splitlines() removes the newline at the end of the string
+                    # that is left in by __iter__()
+                    line.splitlines()[0]
+                )
+        finally:
+            if opened_file:
+                value.close()
diff --git a/datalad_next/constraints/formats.py b/datalad_next/constraints/formats.py
new file mode 100644
index 0000000..62bec8a
--- /dev/null
+++ b/datalad_next/constraints/formats.py
@@ -0,0 +1,16 @@
+from json import loads
+
+from .base import Constraint
+
+
+class EnsureJSON(Constraint):
+    """Ensures that string is JSON formated and can be deserialized.
+    """
+    def __init__(self):
+        super().__init__()
+
+    def __call__(self, value):
+        return loads(value)
+
+    def short_description(self):
+        return 'JSON'
diff --git a/datalad_next/constraints/git.py b/datalad_next/constraints/git.py
new file mode 100644
index 0000000..460a42d
--- /dev/null
+++ b/datalad_next/constraints/git.py
@@ -0,0 +1,65 @@
+from .base import Constraint
+
+
+class EnsureGitRefName(Constraint):
+    """Ensures that a reference name is well formed
+
+    Validation is peformed by calling `git check-ref-format`.
+    """
+    def __init__(self,
+                 allow_onelevel: bool = True,
+                 normalize: bool = True,
+                 refspec_pattern: bool = False):
+        """
+        Parameters
+        ----------
+        allow_onelevel:
+          Flag whether one-level refnames are accepted, e.g. just 'main'
+          instead of 'refs/heads/main'.
+        normalize:
+          Flag whether a normalized refname is validated and return.
+          This includes removing any leading slash (/) characters and
+          collapsing runs of adjacent slashes between name components
+          into a single slash.
+        refspec_pattern:
+          Flag whether to interpret a value as a reference name pattern
+          for a refspec (allowed to contain a single '*').
+        """
+        super().__init__()
+        self._allow_onelevel = allow_onelevel
+        self._normalize = normalize
+        self._refspec_pattern = refspec_pattern
+
+    def __call__(self, value: str) -> str:
+        if not value:
+            # simple, do here
+            raise ValueError('refname must not be empty')
+
+        from datalad.runner import GitRunner, CommandError, StdOutCapture
+        runner = GitRunner()
+        cmd = ['git', 'check-ref-format']
+        cmd.append('--allow-onelevel'
+                   if self._allow_onelevel
+                   else '--no-allow-onelevel')
+        if self._refspec_pattern:
+            cmd.append('--refspec-pattern')
+        if self._normalize:
+            cmd.append('--normalize')
+
+        cmd.append(value)
+
+        try:
+            out = runner.run(cmd, protocol=StdOutCapture)
+        except CommandError as e:
+            raise ValueError(f'{value} is not a valid refname') from e
+
+        if self._normalize:
+            return out['stdout'].strip()
+        else:
+            return value
+
+    def short_description(self):
+        return '{}Git refname{}'.format(
+            '(single-level) ' if self._allow_onelevel else '',
+            ' or refspec pattern' if self._refspec_pattern else '',
+        )
diff --git a/datalad_next/constraints/parameter.py b/datalad_next/constraints/parameter.py
new file mode 100644
index 0000000..fc6ed6d
--- /dev/null
+++ b/datalad_next/constraints/parameter.py
@@ -0,0 +1,180 @@
+
+from typing import (
+    Any,
+    TYPE_CHECKING,
+    TypeVar,
+)
+
+from .base import ConstraintDerived
+from .basic import (
+    EnsureBool,
+    EnsureChoice,
+    EnsureFloat,
+    EnsureInt,
+    EnsureStr,
+    EnsureValue,
+    NoConstraint,
+)
+from .compound import (
+    EnsureIterableOf,
+    EnsureMapping,
+)
+
+if TYPE_CHECKING:  # pragma: no cover
+    from datalad.support.param import Parameter
+
+aEnsureParameterConstraint = TypeVar(
+    'aEnsureParameterConstraint',
+    bound='aEnsureParameterConstraint',
+)
+aParameter = TypeVar('aParameter', bound='Parameter')
+
+
+class EnsureParameterConstraint(EnsureMapping):
+    """Ensures a mapping from a Python parameter name to a value constraint
+    """
+    # valid parameter name for Python and CLI
+    valid_param_name_regex = r'[^0-9][a-z0-0_]+'
+
+    def __init__(self, constraint: ConstraintDerived):
+        super().__init__(
+            key=EnsureStr(
+                match=EnsureParameterConstraint.valid_param_name_regex),
+            value=constraint,
+            # make it look like dict(...)
+            delimiter='=',
+        )
+
+    @property
+    def parameter_constraint(self):
+        return self._value_constraint
+
+    @classmethod
+    def from_parameter(
+            cls,
+            spec: aParameter,
+            default: Any,
+            item_constraint: ConstraintDerived = None,
+            nargs: str or int = None) -> aEnsureParameterConstraint:
+        """
+        Parameters
+        ----------
+        spec: Parameter
+          Instance of a datalad-core Parameter. If not overwritten by values
+          given to the other arguments of this method, item constraints,
+          number of arguments and other argparse-specific information
+          is taken from this object and processed to built a comprehensive
+          constraint that handles all aspects of the specification in a
+          homogenous fashion via the Constraint interface.
+        default: Any
+          A parameter's default value. Any (intermediate) constraint is tested
+          whether it considers the default to be a valid value. If not,
+          the constraint is automatically expanded to also cover this
+          particular value.
+        item_constraint:
+          If given, it override any constraint declared in the Parameter
+          instance given to `spec`
+        nargs:
+          If given, it override any nargs setting declared in the Parameter
+          instance given to `spec`.
+        """
+        value_constraint = _get_comprehensive_constraint(
+            spec,
+            default,
+            item_constraint,
+            nargs,
+        )
+        return cls(value_constraint)
+
+
+# that mapping is NOT to be expanded!
+# it is a legacy leftover. It's usage triggers a DeprecationWarning
+_constraint_spec_map = {
+    'float': EnsureFloat(),
+    'int': EnsureInt(),
+    'bool': EnsureBool(),
+    'str': EnsureStr(),
+}
+
+
+def _get_comprehensive_constraint(
+        param_spec: aParameter,
+        default: Any,
+        item_constraint_override: ConstraintDerived = None,
+        nargs_override: str or int = None):
+    action = param_spec.cmd_kwargs.get('action')
+    # definitive per-item constraint, consider override
+    # otherwise fall back on Parameter.constraints
+    constraint = item_constraint_override or param_spec.constraints
+
+    if not (constraint is None or hasattr(constraint, '__call__')):
+        import warnings
+        warnings.warn("Literal constraint labels are no longer supported.",
+                      DeprecationWarning)
+        try:
+            return _constraint_spec_map[constraint]
+        except KeyError:
+            raise ValueError(
+                f"unsupported constraint specification '{constraint}'")
+
+    if not constraint:
+        if action in ('store_true', 'store_false'):
+            constraint = EnsureBool()
+        elif param_spec.cmd_kwargs.get('choices'):
+            constraint = EnsureChoice(*param_spec.cmd_kwargs.get('choices'))
+        else:
+            # always have one for simplicity
+            constraint = NoConstraint()
+
+    # we must addtionally consider the following nargs spec for
+    # a complete constraint specification
+    # (int, '*', '+'), plus action=
+    # - 'store_const' TODO
+    # - 'store_true' and 'store_false' TODO
+    # - 'append'
+    # - 'append_const' TODO
+    # - 'count' TODO
+    # - 'extend' TODO
+
+    # get the definitive argparse "nargs" value
+    nargs = nargs_override or param_spec.cmd_kwargs.get('nargs', None)
+    # try making a specific number explicit via dtype change
+    try:
+        nargs = int(nargs)
+    except (ValueError, TypeError):
+        pass
+
+    # TODO reconsider using `list`, with no length-check it could
+    # be a generator
+    if isinstance(nargs, int):
+        # we currently consider nargs=1 to be a request of a
+        # single item, not a forced single-item list
+        if nargs > 1:
+            # sequence of a particular length
+            constraint = EnsureIterableOf(
+                list, constraint, min_len=nargs, max_len=nargs)
+    elif nargs == '*':
+        # datalad expects things often/always to also work for a single item
+        constraint = EnsureIterableOf(list, constraint) | constraint
+    elif nargs == '+':
+        # sequence of at least 1 item, always a sequence,
+        # but again datalad expects things often/always to also work for
+        # a single item
+        constraint = EnsureIterableOf(
+            list, constraint, min_len=1) | constraint
+    # handling of `default` and `const` would be here
+    #elif nargs == '?'
+
+    if action == 'append':
+        # wrap into a(nother) sequence
+        # (think: list of 2-tuples, etc.
+        constraint = EnsureIterableOf(list, constraint)
+
+    # lastly try to validate the default, if that fails
+    # wrap into alternative
+    try:
+        constraint(default)
+    except Exception:
+        constraint = constraint | EnsureValue(default)
+
+    return constraint
diff --git a/datalad_next/constraints/utils.py b/datalad_next/constraints/utils.py
new file mode 100644
index 0000000..4e193a3
--- /dev/null
+++ b/datalad_next/constraints/utils.py
@@ -0,0 +1,31 @@
+# emacs: -*- mode: python; py-indent-offset: 4; tab-width: 4; indent-tabs-mode: nil -*-
+# ex: set sts=4 ts=4 sw=4 et:
+# ## ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ##
+#
+#   See COPYING file distributed along with the datalad package for the
+#   copyright and license terms.
+#
+# ## ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ##
+"""Helper for parameter validation, documentation and conversion"""
+
+__docformat__ = 'restructuredtext'
+
+import re
+
+
+def _strip_typerepr(s):
+    """Strip away <class '...'> and <type '...'> decorations for docstrings
+    """
+    return re.sub(r"<(class|type) '(\S+)'>", r'\2', s)
+
+
+def _type_str(t):
+    """Get string human-readable representation of a data type
+
+    If type (t) is given as a tuple, assume ability to choose any of the
+    listed types, so those types listing get joined with |
+    """
+    if isinstance(t, tuple):
+        s = ' or '.join(map(_type_str, t))
+        return ("(%s)" % s) if len(t) > 1 else s
+    return _strip_typerepr(str(t))
diff --git a/datalad_next/create_sibling_webdav.py b/datalad_next/create_sibling_webdav.py
index b36b34c..f207780 100644
--- a/datalad_next/create_sibling_webdav.py
+++ b/datalad_next/create_sibling_webdav.py
@@ -43,7 +43,7 @@ from datalad.interface.utils import (
 from datalad.log import log_progress
 from datalad.support.annexrepo import AnnexRepo
 from datalad.support.param import Parameter
-from datalad.support.constraints import (
+from datalad_next.constraints import (
     EnsureChoice,
     EnsureNone,
     EnsureStr,
diff --git a/datalad_next/credentials.py b/datalad_next/credentials.py
index 9a6b3a0..8c23cff 100644
--- a/datalad_next/credentials.py
+++ b/datalad_next/credentials.py
@@ -38,7 +38,7 @@ from datalad.interface.utils import (
     eval_results,
     generic_result_renderer,
 )
-from datalad.support.constraints import (
+from datalad_next.constraints import (
     EnsureChoice,
     EnsureNone,
     EnsureStr,
diff --git a/datalad_next/gitremote/datalad_annex.py b/datalad_next/gitremote/datalad_annex.py
index 100cd45..49eef2c 100755
--- a/datalad_next/gitremote/datalad_annex.py
+++ b/datalad_next/gitremote/datalad_annex.py
@@ -207,7 +207,7 @@ from datalad.support.annexrepo import AnnexRepo
 from datalad.support.exceptions import CapturedException
 from datalad.support.external_versions import external_versions
 from datalad.support.gitrepo import GitRepo
-from datalad.support.constraints import EnsureInt
+from datalad_next.constraints import EnsureInt
 from datalad.ui import ui
 from datalad.utils import (
     on_windows,
diff --git a/datalad_next/patches/__init__.py b/datalad_next/patches/__init__.py
index 420321f..0f10fe6 100644
--- a/datalad_next/patches/__init__.py
+++ b/datalad_next/patches/__init__.py
@@ -6,6 +6,7 @@ from . import (
     clone_ephemeral,
     configuration,
     create_sibling_ghlike,
+    interface_utils,
     push_to_export_remote,
     push_optimize,
     siblings,
diff --git a/datalad_next/patches/interface_utils.py b/datalad_next/patches/interface_utils.py
new file mode 100644
index 0000000..cd7a07f
--- /dev/null
+++ b/datalad_next/patches/interface_utils.py
@@ -0,0 +1,32 @@
+import logging
+from typing import Dict
+
+from datalad.interface import utils as mod_interface_utils
+from datalad.interface.utils import anInterface
+
+# use same logger as -core
+lgr = logging.getLogger('datalad.interface.utils')
+
+
+# This function interface is taken from
+# datalad-core@e94a49e3076b3c4cd340c8190e668a66f81a88ad
+def _validate_cmd_call(interface: anInterface, kwargs: Dict) -> None:
+    """Validate a parameterization of a command call
+
+    This is called by `_execute_command_()` before a command call, with
+    the respective Interface sub-type of the command, and all its
+    arguments in keyword argument dict style. This dict also includes
+    the default values for any parameter that was not explicitly included
+    in the command call.
+
+    This expected behavior is to raise an exception whenever an invalid
+    parameterization is encountered.
+    """
+    pass
+
+
+# TODO disabled for until a decision is made re usage of pydantic vs a
+# constraint-based approach
+## apply patch
+#lgr.debug('Apply datalad-next patch to interface.utils.py:_validate_cmd_call')
+#mod_interface_utils._validate_cmd_call = _validate_cmd_call
diff --git a/datalad_next/patches/push_to_export_remote.py b/datalad_next/patches/push_to_export_remote.py
index 432f209..d2a9c31 100644
--- a/datalad_next/patches/push_to_export_remote.py
+++ b/datalad_next/patches/push_to_export_remote.py
@@ -13,7 +13,7 @@ import datalad.core.distributed.push as push
 from datalad.distribution.dataset import Dataset
 from datalad.runner.exception import CommandError
 from datalad.support.annexrepo import AnnexRepo
-from datalad.support.constraints import EnsureChoice
+from datalad_next.constraints import EnsureChoice
 from datalad.support.exceptions import CapturedException
 from datalad.support.param import Parameter
 from datalad_next.credman import CredentialManager
diff --git a/datalad_next/tree.py b/datalad_next/tree.py
index 721cee4..bfa8498 100644
--- a/datalad_next/tree.py
+++ b/datalad_next/tree.py
@@ -38,7 +38,7 @@ from datalad.interface.results import (
 from datalad.interface.utils import eval_results
 
 from datalad.local.subdatasets import Subdatasets
-from datalad.support.constraints import (
+from datalad_next.constraints import (
     EnsureNone,
     EnsureStr,
     EnsureInt,


Test patch:
diff --git a/datalad_next/constraints/tests/__init__.py b/datalad_next/constraints/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/datalad_next/constraints/tests/test_base.py b/datalad_next/constraints/tests/test_base.py
new file mode 100644
index 0000000..56be147
--- /dev/null
+++ b/datalad_next/constraints/tests/test_base.py
@@ -0,0 +1,123 @@
+import pytest
+
+from ..base import (
+    Constraint,
+    Constraints,
+    AltConstraints,
+)
+from ..basic import (
+    EnsureInt,
+    EnsureFloat,
+    EnsureBool,
+    EnsureNone,
+    EnsureRange,
+)
+
+
+def test_base():
+    # there is no "standard" implementation
+    with pytest.raises(NotImplementedError):
+        Constraint()('whatever')
+    # no standard docs
+    with pytest.raises(NotImplementedError):
+        Constraint().short_description()
+    with pytest.raises(NotImplementedError):
+        Constraint().long_description()
+    # dataset context switching is by default a no-op
+    generic = Constraint()
+    assert id(generic) == id(generic.for_dataset('some'))
+
+
+def test_constraints():
+    # this should always work
+    c = Constraints(EnsureFloat())
+    assert c(7.0) == 7.0
+    c = Constraints(EnsureFloat(), EnsureRange(min=4.0))
+    assert c(7.0) == 7.0
+    # __and__ form
+    c = EnsureFloat() & EnsureRange(min=4.0)
+    assert c.short_description() == '(float and not less than 4.0)'
+    assert 'and not less than 4.0' in c.long_description()
+    assert c(7.0) == 7.0
+    with pytest.raises(ValueError):
+        c(3.9)
+    c = Constraints(EnsureFloat(), EnsureRange(min=4), EnsureRange(max=9))
+    assert c(7.0) == 7.0
+    with pytest.raises(ValueError):
+        c(3.9)
+    with pytest.raises(ValueError):
+        c(9.01)
+    # __and__ form
+    c = EnsureFloat() & EnsureRange(min=4) & EnsureRange(max=9)
+    assert c(7.0) == 7.0
+    with pytest.raises(ValueError):
+        c(3.99)
+    with pytest.raises(ValueError):
+        c(9.01)
+    # and reordering should not have any effect
+    c = Constraints(EnsureRange(max=4), EnsureRange(min=9), EnsureFloat())
+    with pytest.raises(ValueError):
+        c(3.99)
+    with pytest.raises(ValueError):
+        c(9.01)
+    # smoke test concat AND constraints
+    c = Constraints(EnsureRange(max=10), EnsureRange(min=5)) & \
+            Constraints(EnsureRange(max=6), EnsureRange(min=2))
+    assert c(6) == 6
+    with pytest.raises(ValueError):
+        c(4)
+
+
+def test_altconstraints():
+    # this should always work
+    c = AltConstraints(EnsureFloat())
+    # passes the docs through
+    assert c.short_description() == EnsureFloat().short_description()
+    assert c(7.0) == 7.0
+    c = AltConstraints(EnsureFloat(), EnsureNone())
+    # wraps docs in parenthesis to help appreciate the scope of the
+    # OR'ing
+    assert c.short_description().startswith(
+        f'({EnsureFloat().short_description()}')
+    assert c.short_description(), '(float or None)'
+    assert c(7.0) == 7.0
+    assert c(None) is None
+    # OR with an alternative just extends
+    c = c | EnsureInt()
+    assert c.short_description(), '(float or None or int)'
+    # OR with an alternative combo also extends
+    c = c | AltConstraints(EnsureBool(), EnsureInt())
+    # yes, no de-duplication
+    assert c.short_description(), '(float or None or int or bool or int)'
+    # spot check long_description, must have some number
+    assert len(c.long_description().split(' or ')) == 5
+    # __or__ form
+    c = EnsureFloat() | EnsureNone()
+    assert c(7.0) == 7.0
+    assert c(None) is None
+
+    # this should always fail
+    c = Constraints(EnsureRange(min=0, max=4), EnsureRange(min=9, max=11))
+    with pytest.raises(ValueError):
+        c(7.0)
+    c = EnsureRange(min=0, max=4) | EnsureRange(min=9, max=11)
+    assert c(3.0) == 3.0
+    assert c(9.0) == 9.0
+    with pytest.raises(ValueError):
+        c(7.0)
+    with pytest.raises(ValueError):
+        c(-1.0)
+
+
+def test_both():
+    # this should always work
+    c = AltConstraints(
+        Constraints(
+            EnsureFloat(),
+            EnsureRange(min=7.0, max=44.0)),
+        EnsureNone())
+    assert c(7.0) == 7.0
+    assert c(None) is None
+    # this should always fail
+    with pytest.raises(ValueError):
+        c(77.0)
diff --git a/datalad_next/constraints/tests/test_basic.py b/datalad_next/constraints/tests/test_basic.py
new file mode 100644
index 0000000..0d942a3
--- /dev/null
+++ b/datalad_next/constraints/tests/test_basic.py
@@ -0,0 +1,300 @@
+import pathlib
+import pytest
+
+from ..basic import (
+    EnsureInt,
+    EnsureFloat,
+    EnsureBool,
+    EnsureStr,
+    EnsureStrPrefix,
+    EnsureNone,
+    EnsureCallable,
+    EnsureChoice,
+    EnsureKeyChoice,
+    EnsureRange,
+    EnsurePath,
+    EnsureValue,
+    NoConstraint,
+)
+
+from ..utils import _type_str
+
+
+def test_noconstraint():
+    c = NoConstraint()
+    assert c(5) == 5
+    assert c.short_description() == ''
+
+
+def test_int():
+    c = EnsureInt()
+    # this should always work
+    assert c(7) == 7
+    assert c(7.0) == 7
+    assert c('7') == 7
+    # no automatic inspection of iterables, should use EnsureIterableOf
+    with pytest.raises(TypeError):
+        c([7, 3])
+    # this should always fail
+    with pytest.raises(ValueError):
+        c('fail')
+    # this will also fail
+    with pytest.raises(ValueError):
+        c('17.0')
+    assert c.short_description() == 'int'
+
+
+def test_float():
+    c = EnsureFloat()
+    # this should always work
+    assert c(7.0) == 7.0
+    assert c(7) == 7.0
+    assert c('7') == 7.0
+    # no automatic inspection of iterables, should use EnsureIterableOf
+    with pytest.raises(TypeError):
+        c([7.0, '3.0'])
+    # this should always fail
+    with pytest.raises(ValueError):
+        c('fail')
+
+
+def test_bool():
+    c = EnsureBool()
+    # this should always work
+    assert c(True) is True
+    assert c(False) is False
+    # all that resuls in True
+    assert c('True') is True
+    assert c('true') is True
+    assert c('1') is True
+    assert c('yes') is True
+    assert c('on') is True
+    assert c('enable') is True
+    # all that resuls in False
+    assert c('false') is False
+    assert c('False') is False
+    assert c('0') is False
+    assert c('no') is False
+    assert c('off') is False
+    assert c('disable') is False
+    # this should always fail
+    with pytest.raises(ValueError):
+        c(0)
+    with pytest.raises(ValueError):
+        c(1)
+
+
+def test_str():
+    c = EnsureStr()
+    # this should always work
+    assert c('hello') == 'hello'
+    assert c('7.0') == '7.0'
+    # this should always fail
+    with pytest.raises(ValueError):
+        c(['ab'])
+    with pytest.raises(ValueError):
+        c(['a', 'b'])
+    with pytest.raises(ValueError):
+        c(('a', 'b'))
+    # no automatic conversion attempted
+    with pytest.raises(ValueError):
+        c(7.0)
+    assert c.short_description() == 'str'
+
+
+def test_str_min_len():
+    c = EnsureStr(min_len=1)
+    assert c('hello') == 'hello'
+    assert c('h') == 'h'
+    with pytest.raises(ValueError):
+        c('')
+
+    c = EnsureStr(min_len=2)
+    assert c('hello') == 'hello'
+    with pytest.raises(ValueError):
+        c('h')
+
+
+def test_EnsureStr_match():
+    # alphanum plus _ and ., non-empty
+    pattern = '[a-zA-Z0-9-.]+'
+    constraint = EnsureStr(match=pattern)
+
+    # reports the pattern in the description
+    for m in (constraint.short_description, constraint.long_description):
+        assert pattern in m()
+
+    # must work
+    assert constraint('a0F-2.') == 'a0F-2.'
+
+    for v in ('', '123_abc'):
+        with pytest.raises(ValueError):
+            assert constraint('')
+
+
+def test_EnsureStrPrefix():
+    c = EnsureStrPrefix('some-')
+    c('some-mess') == 'some-mess'
+    with pytest.raises(ValueError):
+        c('mess')
+    assert c.short_description() == 'some-...'
+    assert c.long_description() == "value must start with 'some-'"
+
+
+def test_EnsureValue():
+    c = EnsureValue(5)
+    assert c.short_description() == '5'
+    # this should always work
+    assert c(5) == 5
+    # type mismatch
+    with pytest.raises(ValueError):
+        c('5')
+    # value mismatches
+    with pytest.raises(ValueError):
+        c('None')
+    with pytest.raises(ValueError):
+        c([])
+
+
+# special case of EnsureValue
+def test_none():
+    c = EnsureNone()
+    assert c.short_description() == 'None'
+    # this should always work
+    assert c(None) is None
+    # this should always fail
+    with pytest.raises(ValueError):
+        c('None')
+    with pytest.raises(ValueError):
+        c([])
+
+
+def test_callable():
+    c = EnsureCallable()
+    assert c.short_description() == 'callable'
+    assert c.long_description() == 'value must be a callable'
+    # this should always work
+    assert c(range) == range
+    with pytest.raises(ValueError):
+        c('range')
+
+
+def test_choice():
+    c = EnsureChoice('choice1', 'choice2', None)
+    descr = c.long_description()
+    for i in ('choice1', 'choice2', 'CMD', 'PY'):
+        assert i in descr
+    # short is a "set" or repr()s
+    assert c.short_description() == "{'choice1', 'choice2', None}"
+    # this should always work
+    assert c('choice1') == 'choice1'
+    assert c(None) is None
+    # this should always fail
+    with pytest.raises(ValueError):
+        c('fail')
+    with pytest.raises(ValueError):
+        c('None')
+
+
+def test_keychoice():
+    c = EnsureKeyChoice(key='some', values=('choice1', 'choice2', None))
+    descr = c.long_description()
+    for i in ('some', 'choice1', 'choice2'):
+        assert i in descr
+    assert c.short_description() == "some:{'choice1', 'choice2', None}"
+    assert c({'some': 'choice1'}) == {'some': 'choice1'}
+    assert c({'some': None}) == {'some': None}
+    assert c({'some': None, 'ign': 'ore'}) == {'some': None, 'ign': 'ore'}
+    with pytest.raises(ValueError):
+        c('fail')
+    with pytest.raises(ValueError):
+        c('None')
+    with pytest.raises(ValueError):
+        c({'nope': 'None'})
+    with pytest.raises(ValueError):
+        c({'some': 'None'})
+    with pytest.raises(ValueError):
+        c({'some': ('a', 'b')})
+
+
+def test_range():
+    with pytest.raises(ValueError):
+        EnsureRange(min=None, max=None)
+    c = EnsureRange(max=7)
+    assert c.short_description() == 'not greater than 7'
+    c = EnsureRange(min=3, max=7)
+    # this should always work
+    assert c(3.0) == 3.0
+
+    # this should always fail
+    with pytest.raises(ValueError):
+        c(2.9999999)
+    with pytest.raises(ValueError):
+        c(77)
+    with pytest.raises(TypeError):
+        c('fail')
+    with pytest.raises(TypeError):
+        c((3, 4))
+    # since no type checks are performed
+    with pytest.raises(TypeError):
+        c('7')
+
+    # Range doesn't have to be numeric
+    c = EnsureRange(min="e", max="qqq")
+    assert c('e') == 'e'
+    assert c('fa') == 'fa'
+    assert c('qq') == 'qq'
+    with pytest.raises(ValueError):
+        c('a')
+    with pytest.raises(ValueError):
+        c('qqqa')
+
+
+def test_type_str():
+    assert _type_str((str,)) == 'str'
+    assert _type_str(str) == 'str'
+
+
+def test_EnsurePath(tmp_path):
+    target = pathlib.Path(tmp_path)
+
+    assert EnsurePath()(tmp_path) == target
+    assert EnsurePath(lexists=True)(tmp_path) == target
+    with pytest.raises(ValueError):
+        EnsurePath(lexists=False)(tmp_path)
+    with pytest.raises(ValueError):
+        EnsurePath(lexists=True)(tmp_path / 'nothere')
+    assert EnsurePath(is_format='absolute')(tmp_path) == target
+    with pytest.raises(ValueError):
+        EnsurePath(is_format='relative')(tmp_path)
+    with pytest.raises(ValueError):
+        EnsurePath(is_format='absolute')(tmp_path.name)
+    from stat import S_ISDIR, S_ISREG
+    assert EnsurePath(is_mode=S_ISDIR)(tmp_path) == target
+    with pytest.raises(ValueError):
+        EnsurePath(is_mode=S_ISREG)(tmp_path)
+    # give particular path type
+    assert EnsurePath(path_type=pathlib.PurePath
+        )(tmp_path) == pathlib.PurePath(tmp_path)
+    # not everything is possible, this is known and OK
+    with pytest.raises(AttributeError):
+        EnsurePath(
+            path_type=pathlib.PurePath,
+            is_mode=S_ISREG,
+        )(tmp_path)
+    assert EnsurePath().short_description() == 'path'
+    assert EnsurePath(is_format='absolute').short_description() == 'absolute path'
+    # default comparison mode is parent-or-same-as
+    c = EnsurePath(ref=target)
+    assert c(target) == target
+    assert c(target / 'some') == target / 'some'
+    with pytest.raises(ValueError):
+        assert c(target.parent)
+    c = EnsurePath(ref=target, ref_is='parent-of')
+    assert c(target / 'some') == target / 'some'
+    with pytest.raises(ValueError):
+        assert c(target)
+    assert c.short_description() == f'path that is parent-of {target}'
+    c = EnsurePath(ref=target, ref_is='stupid')
+    with pytest.raises(ValueError):
+        c('doesnotmatter')
diff --git a/datalad_next/constraints/tests/test_compound.py b/datalad_next/constraints/tests/test_compound.py
new file mode 100644
index 0000000..7279e46
--- /dev/null
+++ b/datalad_next/constraints/tests/test_compound.py
@@ -0,0 +1,152 @@
+from inspect import isgenerator
+from io import StringIO
+import pytest
+from tempfile import NamedTemporaryFile
+from unittest.mock import patch
+
+from datalad.utils import on_windows
+
+from ..basic import (
+    EnsureInt,
+    EnsureBool,
+)
+from ..compound import (
+    EnsureIterableOf,
+    EnsureListOf,
+    EnsureTupleOf,
+    EnsureMapping,
+    EnsureGeneratorFromFileLike,
+)
+
+
+# imported from ancient test code in datalad-core,
+# main test is test_EnsureIterableOf
+def test_EnsureTupleOf():
+    c = EnsureTupleOf(str)
+    assert c(['a', 'b']) == ('a', 'b')
+    assert c(['a1', 'b2']) == ('a1', 'b2')
+    assert c.short_description() == "tuple(<class 'str'>)"
+
+
+# imported from ancient test code in datalad-core,
+# main test is test_EnsureIterableOf
+def test_EnsureListOf():
+    c = EnsureListOf(str)
+    assert c(['a', 'b']) == ['a', 'b']
+    assert c(['a1', 'b2']) == ['a1', 'b2']
+    assert c.short_description() == "list(<class 'str'>)"
+
+
+def test_EnsureIterableOf():
+    c = EnsureIterableOf(list, int)
+    assert c.short_description() == "<class 'list'>(<class 'int'>)"
+    assert c.item_constraint == int
+    # testing aspects that are not covered by test_EnsureListOf
+    tgt = [True, False, True]
+    assert EnsureIterableOf(list, bool)((1, 0, 1)) == tgt
+    assert EnsureIterableOf(list, bool, min_len=3, max_len=3)((1, 0, 1)) == tgt
+    with pytest.raises(ValueError):
+        # too many items
+        EnsureIterableOf(list, bool, max_len=2)((1, 0, 1))
+    with pytest.raises(ValueError):
+        # too few items
+        EnsureIterableOf(list, bool, min_len=4)((1, 0, 1))
+    with pytest.raises(ValueError):
+        # invalid specification min>max
+        EnsureIterableOf(list, bool, min_len=1, max_len=0)
+    with pytest.raises(TypeError):
+        # item_constraint fails
+        EnsureIterableOf(list, dict)([5.6, 3.2])
+    with pytest.raises(ValueError):
+        # item_constraint fails
+        EnsureIterableOf(list, EnsureBool())([5.6, 3.2])
+
+    seq = [3.3, 1, 2.6]
+
+    def _mygen():
+        for i in seq:
+            yield i
+
+    def _myiter(iter):
+        for i in iter:
+            yield i
+
+    # feeding a generator into EnsureIterableOf and getting one out
+    assert list(EnsureIterableOf(_myiter, int)(_mygen())) == [3, 1, 2]
+
+
+def test_EnsureMapping():
+    true_key = 5
+    true_value = False
+
+    constraint = EnsureMapping(EnsureInt(), EnsureBool(), delimiter='::')
+    # called without a mapping type
+    with pytest.raises(ValueError):
+        constraint(true_key)
+
+    assert 'mapping of int -> bool' in constraint.short_description()
+
+    # must all work
+    for v in ('5::no',
+              [5, 'false'],
+              ('5', False),
+              {'5': 'False'},
+    ):
+        d = constraint(v)
+        assert isinstance(d, dict)
+        assert len(d) == 1
+        k, v = d.popitem()
+        assert k == true_key
+        assert v == true_value
+
+    # must all fail
+    for v in ('5',
+              [],
+              tuple(),
+              {},
+              # additional value
+              [5, False, False],
+              {'5': 'False', '6': True}):
+        with pytest.raises(ValueError):
+            d = constraint(v)
+
+    # TODO test for_dataset() once we have a simple EnsurePathInDataset
+
+
+def test_EnsureGeneratorFromFileLike():
+    item_constraint = EnsureMapping(EnsureInt(), EnsureBool(), delimiter='::')
+    constraint = EnsureGeneratorFromFileLike(item_constraint)
+
+    assert 'items of type "mapping of int -> bool" read from a file-like' \
+        ==  constraint.short_description()
+
+    c = constraint(StringIO("5::yes\n1234::no\n"))
+    assert isgenerator(c)
+    assert list(c) == [{5: True}, {1234: False}]
+
+    # missing final newline is not a problem
+    c = constraint(StringIO("5::yes\n1234::no"))
+    assert list(c) == [{5: True}, {1234: False}]
+
+    # item constraint violation
+    c = constraint(StringIO("5::yes\n1234::BANG"))
+    with pytest.raises(ValueError) as e:
+        list(c)
+    assert 'be convertible to boolean' in str(e)
+
+    # read from STDIN
+    with patch("sys.stdin", StringIO("5::yes\n1234::no")):
+        assert list(constraint('-')) == [{5: True}, {1234: False}]
+
+    # read from file
+    if not on_windows:
+        # on windows the write-rewind-test logic is not possible
+        # (PermissionError) -- too lazy to implement a workaround
+        with NamedTemporaryFile('w+') as f:
+            f.write("5::yes\n1234::no")
+            f.seek(0)
+            assert list(constraint(f.name)) == [{5: True}, {1234: False}]
+
+    # invalid file
+    with pytest.raises(ValueError) as e:
+        list(constraint('pytestNOTHEREdatalad'))
diff --git a/datalad_next/constraints/tests/test_special_purpose.py b/datalad_next/constraints/tests/test_special_purpose.py
new file mode 100644
index 0000000..4eed5db
--- /dev/null
+++ b/datalad_next/constraints/tests/test_special_purpose.py
@@ -0,0 +1,163 @@
+from io import StringIO
+import pathlib
+import pytest
+
+from datalad.support.param import Parameter
+
+from ..basic import (
+    EnsureInt,
+    EnsureStr,
+    NoConstraint,
+)
+from ..compound import EnsureGeneratorFromFileLike
+from ..formats import EnsureJSON
+from ..git import (
+    EnsureGitRefName,
+)
+from ..parameter import EnsureParameterConstraint
+
+
+def test_EnsureGitRefName():
+    assert EnsureGitRefName().short_description() == '(single-level) Git refname'
+    # standard branch name must work
+    assert EnsureGitRefName()('main') == 'main'
+    # normalize is on by default
+    assert EnsureGitRefName()('/main') == 'main'
+    with pytest.raises(ValueError):
+        EnsureGitRefName(normalize=False)('/main')
+    assert EnsureGitRefName(normalize=False)('main') == 'main'
+    # no empty
+    with pytest.raises(ValueError):
+        EnsureGitRefName()('')
+    with pytest.raises(ValueError):
+        EnsureGitRefName()(None)
+    # be able to turn off onelevel
+    with pytest.raises(ValueError):
+        EnsureGitRefName(allow_onelevel=False)('main')
+    assert EnsureGitRefName(allow_onelevel=False)(
+        'refs/heads/main') == 'refs/heads/main'
+    # refspec pattern off by default
+    with pytest.raises(ValueError):
+        EnsureGitRefName()('refs/heads/*')
+    assert EnsureGitRefName(refspec_pattern=True)(
+        'refs/heads/*') == 'refs/heads/*'
+
+
+def test_EnsureParameterConstraint():
+    # most basic case, no value constraint
+    c = EnsureParameterConstraint(NoConstraint())
+    # invalid name
+    with pytest.raises(ValueError):
+        c({'4way': 123})
+    assert c('some=value') == dict(some='value')
+    # now some from a standard Parameter declaration
+    c = EnsureParameterConstraint.from_parameter(
+        Parameter(), 'whateverdefault')
+    assert c('some=value') == dict(some='value')
+
+    # want a bool
+    c = EnsureParameterConstraint.from_parameter(
+        Parameter(action="store_true"),
+        False)
+    assert c('some=off') == dict(some=False)
+    with pytest.raises(ValueError):
+        c('some=5')
+    c = EnsureParameterConstraint.from_parameter(
+        # argparse specific choice declaration without
+        # any constraint
+        Parameter(choices=['a', 'b']),
+        # but specifically use a default that is not a valid choice
+        None)
+    assert c('choice=a') == dict(choice='a')
+    # default is valid too
+    assert c({'choice': None}) == dict(choice=None)
+    # multi-item values
+    c = EnsureParameterConstraint.from_parameter(
+        Parameter(nargs=2),
+        (None, None))
+    assert c({'some': [3, 4]}) == dict(some=[3, 4])
+    with pytest.raises(TypeError):
+        c({'some': 3})
+    with pytest.raises(ValueError):
+        c({'some': [3, 4, 5]})
+    # one or more items
+    c = EnsureParameterConstraint.from_parameter(
+        Parameter(nargs='*'),
+        None)
+    # always prefers a list, no item type conversion by default
+    assert c('some=5') == dict(some=['5'])
+    assert c({'some': [5, 2]}) == dict(some=[5, 2])
+    # empty ok
+    assert c({'some': []}) == dict(some=[])
+    # at least one item
+    c = EnsureParameterConstraint.from_parameter(
+        Parameter(nargs='+', constraints=EnsureInt()),
+        None)
+    assert c('some=5') == dict(some=[5])
+    assert c({'some': [5, 2]}) == dict(some=[5, 2])
+    # empty not ok
+    with pytest.raises(ValueError):
+        c({'some': []})
+    # complex case of iterables of length 2
+    c = EnsureParameterConstraint.from_parameter(
+        Parameter(nargs=2, constraints=EnsureInt(), action='append'),
+        None)
+    # no iterable does not violate
+    assert c({'some': []}) == dict(some=[])
+    assert c({'some': [[3, 2]]}) == dict(some=[[3, 2]])
+    assert c({'some': [[3, 2], [5, 4]]}) == dict(some=[[3, 2], [5, 4]])
+    # length mismatch
+    with pytest.raises(ValueError):
+        c({'some': [[3, 2], [1]]})
+    # no iterable
+    with pytest.raises(ValueError):
+        c({'some': [3, [1, 2]]})
+    with pytest.raises(ValueError):
+        c({'some': 3})
+    # overwrite an item constraint and nargs
+    c = EnsureParameterConstraint.from_parameter(
+        Parameter(nargs=2, constraints=EnsureInt(), action='append'),
+        None,
+        item_constraint=EnsureStr(),
+        nargs=1)
+    assert c({'some': ['5']}) == dict(some=['5'])
+    # literal constraint label
+    # this is no longer supported, but still works: test until removed
+    with pytest.deprecated_call():
+        c = EnsureParameterConstraint.from_parameter(
+            Parameter(), 2, item_constraint='float')
+    assert c('some=3') == dict(some=3.0)
+    with pytest.raises(ValueError), \
+            pytest.deprecated_call():
+        EnsureParameterConstraint.from_parameter(
+            Parameter(), 2, item_constraint='unknown')
+
+
+nested_json = """\
+{"name": "Alexa", "wins": [["two pair", "4♠"], ["two pair", "9♠"]]}
+"""
+nested_json_decoded = {
+    "name": "Alexa",
+    "wins": [["two pair", "4♠"],
+             ["two pair", "9♠"]],
+}
+invalid_json = """\
+{"name": BOOM!}
+"""
+
+
+def test_EnsureJSONLines():
+    constraint = EnsureGeneratorFromFileLike(EnsureJSON())
+
+    assert 'items of type "JSON" read from a file-like' \
+        ==  constraint.short_description()
+
+    # typical is "object", but any valid JSON value type must work
+    assert list(constraint(StringIO("5"))) == [5]
+    # unicode must work
+    uc = "ΔЙקم๗あ"
+    assert list(constraint(StringIO(f'"{uc}"'))) == [uc]
+    assert list(constraint(StringIO(nested_json))) == [nested_json_decoded]
+
+    with pytest.raises(ValueError) as e:
+        list(constraint(StringIO(f'{nested_json}\n{invalid_json}')))