Source code for datalad_core.commands.exceptions

from __future__ import annotations

from dataclasses import dataclass
from textwrap import indent
from types import MappingProxyType
from typing import (
    TYPE_CHECKING,
    Any,
)

if TYPE_CHECKING:
    from collections.abc import Mapping

from datalad_core.constraints import ConstraintError
from datalad_core.consts import UnsetValue


# TODO: Consider moving that to datalad_core for reuse.
# In here it is only an internal base class for ParamErrors
class ConstraintErrors(ConstraintError):  # noqa: N818
    """Exception representing context-specific ConstraintError instances

    This class enables the association of a context in which any particular
    constraint was violated. This is done by passing a mapping, of a context
    identifier (e.g., a label) to the particular ``ConstraintError`` that
    occurred in this context, to the constructor.

    This is a generic implementation with no requirements regarding the
    nature of the context identifiers (expect for being hashable). See
    ``CommandParametrizationError`` for a specialization.
    """

    def __init__(self, exceptions: Mapping[Any, ConstraintError]):
        super().__init__(
            # this is the main payload, the base class expects a Constraint
            # but only stores it
            constraint=exceptions,
            # all values are already on record in the respective exceptions
            # no need to pass again
            value=None,
            # no support for a dedicated message here (yet?), pass an empty
            # string to match assumptions
            msg='',
            # and no context
            ctx=None,
        )

    @property
    def errors(self) -> Mapping[Any, ConstraintError]:
        # read-only access
        return MappingProxyType(self.args[1])


[docs] @dataclass(frozen=True) class ParamConstraintContext: """Representation of a parameter constraint context This type is used for the keys in the error map of. ``ParamErrors``. Its purpose is to clearly identify which parameter combination (and its nature) led to a `ConstraintError`. An error context comprises to components: 1) the names of the parameters that were considered, and 2) a description of the particular aspect of the parameter values is the focus of the constraint. In the simple case of an error occurring in the context of a single parameter, the second component is superfluous. Example: A command has two parameters `p1` and `p2`. The may also have respective individual constraints, but importantly they 1) must not have identical values, and 2) their sum must be larger than 3. If the command is called with ``cmd(p1=1, p2=1)``, both conditions are violated. The reporting may be implemented using the following ``ParamConstraintContext`` and ``ConstraintError`` instances:: ParamConstraintContext(('p1', 'p2'), 'inequality): ConstraintError(EnsureValue(True), False, <EnsureValue error>) ParamConstraintContext(('p1', 'p2'), 'sum): ConstraintError(EnsureRange(min=3), False, <EnsureRange error>) where the ``ConstraintError`` instances are generated by standard ``Constraint`` implementation. For the second error, this could look like:: EnsureRange(min=3)(params['p1'] + params['p2']) """ param_names: tuple[str, ...] description: str | None = None def __str__(self): return f'Context[{self.label}]' @property def label(self) -> str: """A concise summary of the context This label will be a compact as possible. """ # TODO: this could be __str__ but its intended usage for rendering # a text description of all errors would seemingly forbid adding # type information -- which OTOH seems to be desirable for __str__ return '{param}{descr}'.format( param=', '.join(self.param_names), descr=f' ({self.description})' if self.description else '', )
[docs] def get_label_with_parameter_values(self, values: dict) -> str: """Like ``.label`` but each parameter will also state a value""" # TODO: truncate the values after repr() to ensure a somewhat compact # output return '{param}{descr}'.format( param=', '.join( f'{p}=<no value>' if isinstance(values[p], UnsetValue) else f'{p}={values[p]!r}' for p in self.param_names ), descr=f' ({self.description})' if self.description else '', )
[docs] class ParamErrors(ConstraintErrors): """Exception for command parameter constraint violations This is a ``ConstraintErrors`` variant that uses :class:`ParamConstraintContext` (i.e, parameter names) as context identifiers. The main purpose of this class is to enable structured reporting of individual errors via its :attr:`errors` property. . There is also a generic :meth:`__str__` implementation that can render an itemization of errors with some context information. >>> pe = ParamErrors( ... { ... ParamConstraintContext(('p1', 'p2'), 'range'): ConstraintError( ... '<constraint instance irrelevant here>', ... {'p1': 4, 'p2': 3}, ... 'p1 must not be larger than p2', ... ), ... } ... ) >>> print(pe) 1 parameter constraint violation p1=4, p2=3 (range) p1 must not be larger than p2 Key components of an error message are available via dedicated convenience accessors: >>> pe.context_labels ('p1, p2 (range)',) >>> pe.messages ('p1 must not be larger than p2',) """ def __init__( self, exceptions: Mapping[ParamConstraintContext, ConstraintError], ): """ """ super().__init__(exceptions) @property def messages(self) -> tuple[str]: return tuple(e.msg for e in self.errors.values()) @property def context_labels(self): """Some""" return tuple(e.label for e in self.errors) def __str__(self): return self._render_violations_as_indented_text_list('parameter') def _render_violations_as_indented_text_list(self, violation_subject): violations = len(self.errors) return '{ne} {vs}constraint violation{p}\n{el}'.format( ne=violations, vs=f'{violation_subject} ' if violation_subject else '', p='s' if violations > 1 else '', el='\n'.join( '{ctx}\n{msg}'.format( ctx=ctx.get_label_with_parameter_values( c.value if isinstance(c.value, dict) else {ctx.param_names[0]: c.value} ), msg=indent(str(c), ' '), ) for ctx, c in self.errors.items() ), )