Repository: datalad/datalad-next

Issue:
Insufficient arguments not handled well in `EnsureCommandParameterization`
Not given all mandatory arguments via the Python API results in a plain

```
CommandParametrizationError: 0 command parameter constraint violation
```

while the CLI (where argparse is intercepting this immediately) provides a usage synopsis and a clear error message like

```
error: the following arguments are required: TYPE, LOCATION
```

Discussion:

Patch:
diff --git a/datalad_next/constraints/exceptions.py b/datalad_next/constraints/exceptions.py
index a3e4d9a..2955c50 100644
--- a/datalad_next/constraints/exceptions.py
+++ b/datalad_next/constraints/exceptions.py
@@ -273,8 +273,14 @@ class ParameterConstraintContext:
         """Like ``.label`` but each parameter will also state a value"""
         # TODO truncate the values after repr() to ensure a somewhat compact
         # output
+        from .parameter import NoValue
         return '{param}{descr}'.format(
-            param=", ".join(f'{p}={values[p]!r}' for p in self.parameters),
+            param=", ".join(
+                f'{p}=<no value>'
+                if isinstance(values[p], NoValue)
+                else f'{p}={values[p]!r}'
+                for p in self.parameters
+            ),
             descr=f" ({self.description})" if self.description else '',
         )
 
diff --git a/datalad_next/constraints/parameter.py b/datalad_next/constraints/parameter.py
index 6c40d39..1ae892e 100644
--- a/datalad_next/constraints/parameter.py
+++ b/datalad_next/constraints/parameter.py
@@ -243,6 +243,7 @@ class EnsureCommandParameterization(Constraint):
         self,
         kwargs,
         at_default=None,
+        required=None,
         on_error='raise-early',
     ) -> Dict:
         """
@@ -256,6 +257,8 @@ class EnsureCommandParameterization(Constraint):
           match their respective defaults. This is used for deciding whether
           or not to process them with an associated value constraint (see the
           ``validate_defaults`` constructor argument).
+        required: set or None
+          Set of parameter names that are known to be required.
         on_error: {'raise-early', 'raise-at-end'}
           Flag how to handle constraint violation. By default, validation is
           stopped at the first error and an exception is raised. When an
@@ -273,6 +276,18 @@ class EnsureCommandParameterization(Constraint):
         """
         assert on_error in ('raise-early', 'raise-at-end')
 
+        exceptions = {}
+        missing_args = tuple(a for a in (required or []) if a not in kwargs)
+        if missing_args:
+            exceptions[ParameterConstraintContext(missing_args)] = \
+                ConstraintError(
+                    self,
+                    dict(zip(missing_args, [NoValue()] * len(missing_args))),
+                    'missing required arguments',
+                )
+            if on_error == 'raise-early':
+                raise CommandParametrizationError(exceptions)
+
         # validators to work with. make a copy of the dict to be able to tailor
         # them for this run only
         # TODO copy likely not needed
@@ -290,7 +305,6 @@ class EnsureCommandParameterization(Constraint):
         # strip all args provider args that have not been provided
         ds_provider_params.intersection_update(kwargs)
 
-        exceptions = {}
         validated = {}
         # process all parameters. starts with those that are needed as
         # dependencies for others.
diff --git a/datalad_next/patches/interface_utils.py b/datalad_next/patches/interface_utils.py
index fa16d65..4bc5179 100644
--- a/datalad_next/patches/interface_utils.py
+++ b/datalad_next/patches/interface_utils.py
@@ -50,12 +50,14 @@ def get_allargs_as_kwargs(call, args, kwargs):
 
     Returns
     -------
-    (dict, set)
+    (dict, set, set)
       The first return value is a mapping of argument names to their respective
       values.
       The second return value in the tuple is a set of argument names for
       which the effective value is identical to the default declared in the
       signature of the callable.
+      The third value is a set with names of all mandatory arguments, whether
+      or not they are included in the returned mapping.
     """
     from datalad_next.utils import getargspec
     argspec = getargspec(call, include_kwonlyargs=True)
@@ -83,7 +85,14 @@ def get_allargs_as_kwargs(call, args, kwargs):
     # API commands support more kwargs than what is discoverable
     # from their signature...
     #assert (nargs == len(kwargs_))
-    return kwargs_, at_default
+    return (
+        # argument name/value mapping
+        kwargs_,
+        # names of arguments that are at their default
+        at_default,
+        # names of mandatory arguments (set for uniformity)
+        set(argspec.args),
+    )
 
 
 # This function interface is taken from
@@ -116,7 +125,7 @@ def _execute_command_(
     # for result filters and validation
     # we need to produce a dict with argname/argvalue pairs for all args
     # incl. defaults and args given as positionals
-    allkwargs, at_default = get_allargs_as_kwargs(
+    allkwargs, at_default, required_args = get_allargs_as_kwargs(
         cmd,
         cmd_args,
         {**cmd_kwargs, **exec_kwargs},
@@ -133,6 +142,7 @@ def _execute_command_(
         lgr.debug('Command parameter validation for %s', interface)
         validator_kwargs = dict(
             at_default=at_default,
+            required=required_args or None,
         )
         # make immediate vs exhaustive parameter validation
         # configurable


Test patch:
diff --git a/datalad_next/constraints/tests/test_cmdarg_validation.py b/datalad_next/constraints/tests/test_cmdarg_validation.py
index b551fb6..d06111c 100644
--- a/datalad_next/constraints/tests/test_cmdarg_validation.py
+++ b/datalad_next/constraints/tests/test_cmdarg_validation.py
@@ -262,6 +262,15 @@ def test_cmd_with_validation():
             return_type='item-or-list', result_renderer='disabled',
         )
 
+    # no call with a required argument missing
+    with pytest.raises(ValueError) as e:
+        CmdWithValidation.__call__()
+    exc_rendering = str(e.value)
+    # must label the issue correctly
+    assert 'missing required argument' in exc_rendering
+    # must identify the missing argument
+    assert 'spec=<no value>' in exc_rendering
+
 
 #
 # test dataset tailoring