Repository: datalad/datalad-next

Issue:
Allow for `Constraint` documentation override
Right now all constraints self-document. It would be better to be able to override or amend that documentation by a user.

Constraints are now more flexible than originally planned. That leads to more complex auto-descriptions that are not always needed or desirable.

Sometimes a user would simply know better.

Comments:

Patch:
diff --git a/datalad_next/constraints/__init__.py b/datalad_next/constraints/__init__.py
index e6e97e0..d910e28 100644
--- a/datalad_next/constraints/__init__.py
+++ b/datalad_next/constraints/__init__.py
@@ -41,6 +41,7 @@ from .compound import (
     EnsureTupleOf,
     EnsureMapping,
     EnsureGeneratorFromFileLike,
+    WithDescription,
 )
 # this is the key type, almost all consuming code will want to
 # have this for `except` clauses
diff --git a/datalad_next/constraints/base.py b/datalad_next/constraints/base.py
index 78cd888..f24be8b 100644
--- a/datalad_next/constraints/base.py
+++ b/datalad_next/constraints/base.py
@@ -62,12 +62,53 @@ class Constraint:
         # and generate a meaningful error message
         raise NotImplementedError("abstract class")
 
+    @property
+    def input_synopsis(self) -> str:
+        """Returns brief, single line summary of valid input for a constraint
+
+        This information is user-facing, and to be used in any place where
+        space is limited (tooltips, usage summaries, etc).
+
+        If possible, the synopsis should be written in a UI/API-agnostic
+        fashion. However, if this is impossible or leads to imprecisions or
+        confusion, it should focus on use within Python code and with Python
+        data types. Tailored documentation can be provided via the
+        ``WithDescription`` wrapper.
+        """
+        # return the legacy short description for now
+        return self.short_description()
+
+    @property
+    def input_description(self) -> str:
+        """Returns full description of valid input for a constraint
+
+        Like ``input_synopsis`` this information is user-facing. In contrast,
+        to the synopsis there is length/line limit. Nevertheless, the
+        information should be presented in a compact fashion that avoids
+        needless verbosity. If possible, a single paragraph is a good format.
+        If multiple paragraphs are necessary, they should be separated by
+        a single, empty line.
+
+        Rendering code may indent, or rewrap the text, so no line-by-line
+        formatting will be preserved.
+
+        If possible, the synopsis should be written in a UI/API-agnostic
+        fashion. However, if this is impossible or leads to imprecisions or
+        confusion, it should focus on use within Python code and with Python
+        data types. Tailored documentation can be provided via the
+        ``WithDescription`` wrapper.
+        """
+        # return the legacy short description for now
+        return self.long_description()
+
     def long_description(self):
+        """This method is deprecated. Use ``input_description`` instead"""
         # return meaningful docs or None
         # used as a comprehensive description in the parameter list
         return self.short_description()
 
     def short_description(self):
+        """This method is deprecated. Use ``input_synopsis`` instead"""
         # return meaningful docs or None
         # used as a condensed primer for the parameter lists
         raise NotImplementedError("abstract class")
diff --git a/datalad_next/constraints/compound.py b/datalad_next/constraints/compound.py
index 7ff5e10..1ee201e 100644
--- a/datalad_next/constraints/compound.py
+++ b/datalad_next/constraints/compound.py
@@ -369,3 +369,99 @@ class ConstraintWithPassthrough(Constraint):
 
     def short_description(self) -> str:
         return self._constraint.short_description()
+
+
+class WithDescription(Constraint):
+    """Contraint that wraps another constraint and replaces its description
+
+    Whenever a constraint's self-description does not fit an application
+    context, it can be wrapped with this class. The given synopsis and
+    description of valid inputs replaces those of the wrapped constraint.
+    """
+    def __init__(self,
+                 constraint: Constraint,
+                 *,
+                 input_synopsis: str | None = None,
+                 input_description: str | None = None,
+                 input_synopsis_for_ds: str | None = None,
+                 input_description_for_ds: str | None = None,
+    ):
+        """
+        Parameters
+        ----------
+        constraint: Constraint
+          Any ``Constraint`` subclass instance that will be used to validate
+          values.
+        input_synopsis: optional
+          If given, text to be returned as the constraint's ``input_synopsis``.
+          Otherwise the wrapped constraint's ``input_synopsis`` is returned.
+        input_description: optional
+          If given, text to be returned as the constraint's
+          ``input_description``. Otherwise the wrapped constraint's
+          ``input_description`` is returned.
+        input_synopsis_for_ds: optional
+          If either this or ``input_description_for_ds`` are given, the
+          result of tailoring a constraint for a particular dataset
+          (``for_dataset()``) will also be wrapped with this custom
+          synopsis.
+        input_description_for_ds: optional
+          If either this or ``input_synopsis_for_ds`` are given, the
+          result of tailoring a constraint for a particular dataset
+          (``for_dataset()``) will also be wrapped with this custom
+          description.
+        """
+        super().__init__()
+        self._constraint = constraint
+        self._synopsis = input_synopsis
+        self._description = input_description
+        self._synopsis_for_ds = input_synopsis_for_ds
+        self._description_for_ds = input_description_for_ds
+
+    @property
+    def constraint(self) -> Constraint:
+        """Returns the wrapped constraint instance"""
+        return self._constraint
+
+    def __call__(self, value) -> Any:
+        return self._constraint(value)
+
+    def __str__(self) -> str:
+        return \
+            f'<{self._constraint.__class__.__name__} with custom description>'
+
+    def __repr__(self) -> str:
+        return f'{self.__class__.__name__}' \
+               f'({self._constraint!r}, ' \
+               f'input_synopsis={self._synopsis!r}, ' \
+               f'input_description={self._description!r}, ' \
+               f'input_synopsis_for_ds={self._synopsis_for_ds!r}, ' \
+               f'input_description_for_ds={self._description_for_ds!r})'
+
+    def for_dataset(self, dataset: DatasetParameter) -> Constraint:
+        """Wrap the wrapped constraint again after tailoring it for the dataset
+        """
+        if self._synopsis_for_ds is not None \
+                or self._description_for_ds is not None:
+            # we also want to wrap the tailored constraint
+            return self.__class__(
+                self._constraint.for_dataset(dataset),
+                input_synopsis=self._synopsis_for_ds,
+                input_description=self._description_for_ds,
+            )
+        else:
+            return self._constraint.for_dataset(dataset)
+
+    @property
+    def input_synopsis(self):
+        return self._synopsis or self.constraint.input_synopsis
+
+    @property
+    def input_description(self):
+        return self._description or self.constraint.input_description
+
+    # legacy compatibility
+    def long_description(self) -> str:
+        return self.input_description
+
+    def short_description(self) -> str:
+        return self.input_synopsis


Test patch:
diff --git a/datalad_next/constraints/tests/test_compound.py b/datalad_next/constraints/tests/test_compound.py
index 351f404..ca81b1f 100644
--- a/datalad_next/constraints/tests/test_compound.py
+++ b/datalad_next/constraints/tests/test_compound.py
@@ -22,6 +22,7 @@ from ..compound import (
     EnsureTupleOf,
     EnsureMapping,
     EnsureGeneratorFromFileLike,
+    WithDescription,
 )
 
 
@@ -210,3 +211,49 @@ def test_ConstraintWithPassthrough(tmp_path):
     cwp_ds = cwp.for_dataset(ds)
     assert cwp_ds.passthrough == cwp.passthrough
     assert cwp.constraint == wrapped.for_dataset(ds)
+
+
+def test_WithDescription(tmp_path):
+    wrapped = EnsureInt()
+    # confirm starting point
+    assert wrapped.input_synopsis == 'int'
+    assert wrapped.input_description \
+        == "value must be convertible to type 'int'"
+    # we are actually not replacing anything
+    c = WithDescription(wrapped)
+    assert c.input_synopsis == wrapped.input_synopsis
+    assert c.input_description == wrapped.input_description
+    # with no dataset docs, the wrapping is removed on tailoring
+    ds = Dataset(tmp_path)
+    assert isinstance(
+        c.for_dataset(DatasetParameter(None, ds)),
+        EnsureInt)
+    # check all replacements are working
+    c = WithDescription(
+        wrapped,
+        input_synopsis='mysynopsis',
+        input_description='mydescription',
+        input_synopsis_for_ds='dssynopsis',
+        input_description_for_ds='dsdescription',
+    )
+    # function is maintained
+    assert c('5') is 5
+    assert str(c) == '<EnsureInt with custom description>'
+    assert repr(c) == \
+        "WithDescription(EnsureInt(), " \
+        "input_synopsis='mysynopsis', " \
+        "input_description='mydescription', " \
+        "input_synopsis_for_ds='dssynopsis', " \
+        "input_description_for_ds='dsdescription')"
+    assert c.constraint is wrapped
+    assert c.input_synopsis == 'mysynopsis'
+    assert c.input_description == 'mydescription'
+    # description propagates through tailoring
+    cds = c.for_dataset(DatasetParameter(None, ds))
+    assert isinstance(cds, WithDescription)
+    assert cds.input_synopsis == 'dssynopsis'
+    assert cds.input_description == 'dsdescription'
+
+    # legacy functionality
+    c.short_description() == c.input_synopsis
+    c.long_description() == c.input_description