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()
),
)