Source code for datalad_next.constraints.exceptions

"""Custom exceptions raised by ``Constraint`` implementations"""

from __future__ import annotations

from collections.abc import Mapping
from dataclasses import dataclass
from textwrap import indent
from types import MappingProxyType
from typing import (
    Any,
    Dict,
    Tuple,
)

# needed for imports in other pieced of the ``constraints`` module
from datalad_next.exceptions import NoDatasetFound


[docs] class ConstraintError(ValueError): # we derive from ValueError, because it provides the seemingly best fit # of any built-in exception. It is defined as: # # Raised when an operation or function receives an argument that has # the right type but an inappropriate value, and the situation is not # described by a more precise exception such as IndexError. # # In general a validation error can also occur because of a TypeError, but # ultimately what also matters here is an ability to coerce a given value # to a target type/value, but such an exception is not among the built-ins. # Moreover, many pieces of existing code do raise ValueError in practice, # and we aim to be widely applicable with this specialized class """Exception type raised by constraints when their conditions are violated A primary purpose of this class is to provide uniform means for communicating information on violated constraints. """ def __init__(self, constraint, value: Any, msg: str, ctx: Dict[str, Any] | None = None): """ Parameters ---------- constraint: Constraint Instance of the ``Constraint`` class that determined a violation. value: The value that is in violation of a constraint. msg: str A message describing the violation. If ``ctx`` is given too, the message can contain keyword placeholders in Python's ``format()`` syntax that will be applied on-access. ctx: dict, optional Mapping with context information on the violation. This information is used to interpolate a message, but may also contain additional key-value mappings. A recognized key is ``'__caused_by__'``, with a value of one exception (or a list of exceptions) that led to a ``ConstraintError`` being raised. """ # the msg/ctx setup is inspired by pydantic # we put `msg` in the `.args` container first to match where # `ValueError` would have it. Everything else goes after it. super().__init__(msg, constraint, value, ctx) @property def msg(self): """Obtain an (interpolated) message on the constraint violation The error message template can be interpolated with any information available in the error context dict (``ctx``). In addition to the information provided by the ``Constraint`` that raised the error, the following additional placeholders are provided: - ``__value__``: the value reported to have caused the error - ``__itemized_causes__``: an indented bullet list str with on item for each error in the ``caused_by`` report of the error. Message template can use any feature of the Python format mini language. For example ``{__value__!r}`` to get a ``repr()``-style representation of the offending value. """ msg_tmpl = self.args[0] # get interpolation values for message formatting # we need a copy, because we need to mutate the dict ctx = dict(self.context) # support a few standard placeholders # the verbatim value that caused the error: with !r and !s both # types of stringifications are accessible ctx['__value__'] = self.value if self.caused_by: ctx['__itemized_causes__'] = indent( '\n'.join(f'- {str(c)}' for c in self.caused_by), " ", ) return msg_tmpl.format(**ctx) @property def constraint(self): """Get the instance of the constraint that was violated""" return self.args[1] @property def caused_by(self) -> Tuple[Exception] | None: """Returns a tuple of any underlying exceptions that caused a violation """ cb = self.context.get('__caused_by__', None) if cb is None: return None elif isinstance(cb, Exception): return (cb,) else: return tuple(cb) @property def value(self): """Get the value that violated the constraint""" return self.args[2] @property def context(self) -> MappingProxyType: """Get a constraint violation's context This is a mapping of key/value-pairs matching the ``ctx`` constructor argument. """ return MappingProxyType(self.args[3] or {}) def __str__(self): return self.msg def __repr__(self): # rematch constructor arg-order, because we put `msg` first into # `.args` return '{0}({2!r}, {3!r}, {1!r}, {4!r})'.format( self.__class__.__name__, *self.args, )
class ConstraintErrors(ConstraintError): """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: Dict[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) -> MappingProxyType[Any, ConstraintError]: # read-only access return MappingProxyType(self.args[1]) def __repr__(self): return '{0}({{{1}}})'.format( self.__class__.__name__, ', '.join(f'{k!r}: {v!r}' for k, v in self.errors.items()), ) class ParameterContextErrors(Mapping): """Read-only convenience that wraps a ``ConstraintErrors`` error mapping """ # TODO extend this class with any query functionality that a command # API would want to use in order to get streamlined information on what # went wrong (in general, for a specific parameter, etc...) def __init__( self, errors: Dict[ParameterConstraintContext, ConstraintError], ): self._errors = errors def __repr__(self): return self._errors.__repr__() def __len__(self): return len(self._errors) def __iter__(self): return self._errors.__iter__() def __getitem__(self, key): return self._errors[key] def items(self): return self._errors.items() @property def messages(self): return [e.msg for e in self._errors.values()] @property def context_labels(self): return [e.label for e in self._errors.keys()] # TODO return all errors related to some parameter
[docs] @dataclass(frozen=True) class ParameterConstraintContext: """Representation of a parameter constraint context This type is used for the keys in the error map of. ``ParametrizationErrors``. 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 how the parameters were linked or combined. In the simple case of an error occurring in the context of a single parameter, the second component is superfluous. Otherwise, it can be thought of as an operation label, describing what aspect of the set of parameters is being relevant in a particular context. 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 ``ParameterConstraintContext`` and ``ConstraintError`` instances:: ParameterConstraintContext(('p1', 'p2'), 'inequality): ConstraintError(EnsureValue(True), False, <EnsureValue error>) ParameterConstraintContext(('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']) """ parameters: 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. """ # XXX 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.parameters), 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 from .parameter import NoValue return '{param}{descr}'.format( 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 '', )
class ParametrizationErrors(ConstraintErrors): """Exception type raised on violating parameter constraints This is a ``ConstraintErrors`` variant that uses parameter names (i.e, ``str`` labels) as context identifiers. In addition to individual parameter names an additional ``__all__`` identifier is recognized. It can be used to record a ``ConstraintError`` arising from high-order constraints, such as the violation of "mutually exclusive" requirements across more than one parameter. """ def __init__( self, exceptions: Dict[str, ConstraintError] | Dict[ParameterConstraintContext, ConstraintError]): super().__init__( {k if isinstance(k, ParameterConstraintContext) else ParameterConstraintContext((k,)): v for k, v in exceptions.items()} ) @property def errors(self) -> ParameterContextErrors: # read-only access return ParameterContextErrors(self.args[1]) 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.parameters[0]: c.value} ), msg=indent(str(c), ' '), ) for ctx, c in self.errors.items() ), )
[docs] class CommandParametrizationError(ParametrizationErrors): """Exception type raised on violating any command parameter constraints .. seealso:: :mod:`~datalad_next.constraints.EnsureCommandParameterization` """ def __str__(self): return self._render_violations_as_indented_text_list( 'command parameter')