import abc
from contextlib import contextmanager
from typing import (
Callable, Iterable, Optional, Sequence, TypeVar, Union, overload
)
import click
from click import Context, Parameter
from cloup._util import check_arg, class_name, make_one_line_repr, make_repr, pluralize
from .exceptions import ConstraintViolated, UnsatisfiableConstraint
from .common import (
format_param_list,
get_param_label,
get_param_name,
get_params_whose_value_is_set,
get_required_params,
param_value_is_set,
)
Op = TypeVar('Op', bound='Operator')
HelpRephraser = Callable[[Context, 'Constraint'], str]
ErrorRephraser = Callable[[Context, 'Constraint', Sequence[Parameter]], str]
[docs]class Constraint(abc.ABC):
"""
A constraint that can be checked against an arbitrary collection of CLI
parameters with respect to a specific :class:`click.Context` (which
contains the values assigned to the parameters in ``ctx.params``).
"""
__check_consistency: bool = True
[docs] @classmethod
def must_check_consistency(cls) -> bool:
"""Returns True if consistency checks are enabled."""
return cls.__check_consistency
[docs] @classmethod
def toggle_consistency_checks(cls, value: bool):
"""Enables/disables consistency checks. Enabling means that:
- :meth:`check` will call :meth:`check_consistency`
- :class:`~cloup.ConstraintMixin` will call `check_consistency` on
constraints it is responsible for before parsing CLI arguments.
"""
cls.__check_consistency = value
[docs] @classmethod
@contextmanager
def consistency_checks_toggled(cls, value: bool):
value_to_restore = Constraint.__check_consistency
cls.__check_consistency = value
yield
cls.__check_consistency = value_to_restore
[docs] @abc.abstractmethod
def help(self, ctx: Context) -> str:
"""A description of the constraint. """
[docs] def check_consistency(self, params: Sequence[Parameter]) -> None:
"""
Performs some sanity checks that detect inconsistencies between this
constraints and the properties of the input parameters (e.g. required).
For example, a constraint that requires the parameters to be mutually
exclusive is not consistent with a group of parameters with multiple
required options.
These sanity checks are meant to catch developer's mistakes and don't
depend on the values assigned to the parameters; therefore:
- they can be performed before any parameter parsing
- they can be disabled in production (see :meth:`toggle_consistency_checks`)
:param params: list of :class:`click.Parameter` instances
:raises: :exc:`~cloup.constraints.errors.UnsatisfiableConstraint`
if the constraint cannot be satisfied independently from the values
provided by the user
"""
[docs] @abc.abstractmethod
def check_values(self, params: Sequence[Parameter], ctx: Context):
"""
Checks that the constraint is satisfied by the input parameters in the
given context, which (among other things) contains the values assigned
to the parameters in ``ctx.params``.
You probably don't want to call this method directly.
Use :meth:`check` instead.
:param params: list of :class:`click.Parameter` instances
:param ctx: :class:`click.Context`
:raises:
:exc:`~cloup.constraints.ConstraintViolated`
"""
@overload
def check(self, params: Sequence[Parameter], ctx: Optional[Context] = None) -> None:
... # pragma: no cover
@overload
def check(self, params: Iterable[str], ctx: Optional[Context] = None) -> None:
... # pragma: no cover
[docs] def check(self, params, ctx: Optional[Context] = None) -> None:
"""
Raises an exception if the constraint is not satisfied by the input
parameters in the given (or current) context.
This method calls both :meth:`check_consistency` (if enabled) and
:meth:`check_values`.
.. tip::
By default :meth:`check_consistency` is called since it shouldn't
have any performance impact. Nonetheless, you can disable it in
production passing ``False`` to :meth:`toggle_consistency_checks`.
:param params: an iterable of parameter names or a sequence of
:class:`click.Parameter`
:param ctx: a `Context`; if not provided, :func:`click.get_current_context`
is used
:raises:
:exc:`~cloup.constraints.ConstraintViolated`
:exc:`~cloup.constraints.UnsatisfiableConstraint`
"""
from ._support import ConstraintMixin
if not params:
raise ValueError("arg 'params' can't be empty")
ctx = click.get_current_context() if ctx is None else ctx
if not isinstance(ctx.command, ConstraintMixin): # this is needed for mypy
raise TypeError('constraints work only if the command inherits from '
'ConstraintMixin')
params_objects = (ctx.command.get_params_by_name(params)
if isinstance(params[0], str)
else params)
if self.must_check_consistency():
self.check_consistency(params_objects)
return self.check_values(params_objects, ctx)
[docs] def rephrased(
self,
help: Union[None, str, HelpRephraser] = None,
error: Union[None, str, ErrorRephraser] = None,
) -> 'Rephraser':
return Rephraser(self, help=help, error=error)
[docs] def hidden(self) -> 'Rephraser':
"""Hides this constraint from the command help."""
return Rephraser(self, help='')
[docs] def __call__(
self, param_names: Iterable[str], ctx: Optional[Context] = None
) -> None:
return self.check(param_names, ctx=ctx)
[docs] def __or__(self, other: 'Constraint') -> 'Or':
return Or(self, other)
[docs] def __and__(self, other: 'Constraint') -> 'And':
return And(self, other)
[docs] def __repr__(self):
return f'{class_name(self)}()'
[docs]class Operator(Constraint, abc.ABC):
"""Base class for all n-ary operators defined on constraints. """
HELP_SEP: str
"""Used as separator of all constraints' help strings."""
def __init__(self, *constraints: Constraint):
"""N-ary operator for constraints.
:param constraints: operands
"""
self.constraints = constraints
[docs] def help(self, ctx: Context) -> str:
return self.HELP_SEP.join(
'(%s)' % c.help(ctx) if isinstance(c, Operator) else c.help(ctx)
for c in self.constraints
)
[docs] def check_consistency(self, params: Sequence[Parameter]) -> None:
for c in self.constraints:
c.check_consistency(params)
[docs] def __repr__(self):
return make_repr(self, *self.constraints)
[docs]class And(Operator):
"""It's satisfied if all operands are satisfied."""
HELP_SEP = ' and '
[docs] def check_values(self, params: Sequence[Parameter], ctx: Context):
for c in self.constraints:
c.check_values(params, ctx)
[docs] def __and__(self, other) -> 'And':
if isinstance(other, And):
return And(*self.constraints, *other.constraints)
return And(*self.constraints, other)
[docs]class Or(Operator):
"""It's satisfied if at least one of the operands is satisfied."""
HELP_SEP = ' or '
[docs] def check_values(self, params: Sequence[Parameter], ctx: Context):
for c in self.constraints:
try:
return c.check_values(params, ctx)
except ConstraintViolated:
pass
raise ConstraintViolated.default(params, self.help(ctx), ctx=ctx)
[docs] def __or__(self, other) -> 'Or':
if isinstance(other, Or):
return Or(*self.constraints, *other.constraints)
return Or(*self.constraints, other)
[docs]class Rephraser(Constraint):
"""A Constraint decorator that can override the help and/or the error
message of the wrapped constraint.
This is useful also for defining new constraints.
See also :class:`WrapperConstraint`.
"""
def __init__(
self, constraint: Constraint,
help: Union[None, str, HelpRephraser] = None,
error: Union[None, str, ErrorRephraser] = None,
):
if help is None and error is None:
raise ValueError('at least one between [help] and [error] must not be None')
self._constraint = constraint
self._help = help
self._error = error
[docs] def help(self, ctx: Context) -> str:
if self._help is None:
return self._constraint.help(ctx)
elif isinstance(self._help, str):
return self._help
else:
return self._help(ctx, self._constraint)
def _get_rephrased_error(
self, ctx: Context, params: Sequence[Parameter]
) -> Optional[str]:
if self._error is None:
return None
elif isinstance(self._error, str):
return self._error.format(param_list=format_param_list(params))
else:
return self._error(ctx, self._constraint, params)
[docs] def check_consistency(self, params: Sequence[Parameter]) -> None:
try:
self._constraint.check_consistency(params)
except UnsatisfiableConstraint as exc:
raise UnsatisfiableConstraint(
self, params=params, reason=exc.reason)
[docs] def check_values(self, params: Sequence[Parameter], ctx: Context):
try:
return self._constraint.check_values(params, ctx)
except ConstraintViolated:
rephrased_error = self._get_rephrased_error(ctx, params)
if rephrased_error:
raise ConstraintViolated(rephrased_error, ctx=ctx)
raise
[docs] def __repr__(self):
return make_one_line_repr(self, help=self._help)
[docs]class WrapperConstraint(Constraint, metaclass=abc.ABCMeta):
"""Abstract class that wraps another constraint and delegates all methods
to it. Useful when you want to define a parametric constraint combining
other existing constraints minimizing the boilerplate.
This is an alternative to defining a function and using :class:`Rephraser`.
Feel free to do that in your code, but cloup will stick to the convention
that parametric constraints are defined as classes and written in
camel-case."""
def __init__(self, constraint: Constraint, **attrs):
"""
:param constraint: the constraint to wrap
:param attrs: these are just used to generate a ``__repr__`` method
"""
self._constraint = constraint
self._attrs = attrs
[docs] def help(self, ctx: Context) -> str:
return self._constraint.help(ctx)
[docs] def check_consistency(self, params: Sequence[Parameter]) -> None:
try:
self._constraint.check_consistency(params)
except UnsatisfiableConstraint as exc:
raise UnsatisfiableConstraint(self, params=params, reason=exc.reason)
[docs] def check_values(self, params: Sequence[Parameter], ctx: Context):
self._constraint.check_values(params, ctx)
[docs] def __repr__(self):
return make_repr(self, **self._attrs)
class _RequireAll(Constraint):
"""Satisfied if all parameters are set."""
def help(self, ctx: Context) -> str:
return 'all required'
def check_values(self, params: Sequence[Parameter], ctx: Context):
values = ctx.params
unset_params = [param for param in params
if not param_value_is_set(param, values[get_param_name(param)])]
if any(unset_params):
raise ConstraintViolated(
pluralize(
len(unset_params),
one=f"{get_param_label(unset_params[0])} is required",
many=f"the following parameters are required:\n"
f"{format_param_list(unset_params)}"),
ctx=ctx,
)
[docs]class RequireAtLeast(Constraint):
"""Satisfied if the number of set parameters is >= n."""
def __init__(self, n: int):
check_arg(n >= 0)
self._n = n
[docs] def help(self, ctx: Context) -> str:
return f'at least {self._n} required'
[docs] def check_consistency(self, params: Sequence[Parameter]) -> None:
n = self._n
if len(params) < n:
reason = (
f'the constraint requires a minimum of {n} parameters but '
f'it is applied on a group of only {len(params)} parameters!'
)
raise UnsatisfiableConstraint(self, params, reason)
[docs] def check_values(self, params: Sequence[Parameter], ctx: Context):
n = self._n
given_params = get_params_whose_value_is_set(params, ctx.params)
if len(given_params) < n:
raise ConstraintViolated(
f"at least {n} of the following parameters must be set:\n"
f"{format_param_list(params)}",
ctx=ctx
)
[docs] def __repr__(self):
return make_repr(self, self._n)
[docs]class AcceptAtMost(Constraint):
"""Satisfied if the number of set parameters is <= n."""
def __init__(self, n: int):
check_arg(n >= 0)
self._n = n
[docs] def help(self, ctx: Context) -> str:
return f'at most {self._n} accepted'
[docs] def check_consistency(self, params: Sequence[Parameter]) -> None:
num_required_opts = len(get_required_params(params))
if num_required_opts > self._n:
reason = f'{num_required_opts} of the parameters are required'
raise UnsatisfiableConstraint(self, params, reason)
[docs] def check_values(self, params: Sequence[Parameter], ctx: Context):
n = self._n
given_params = get_params_whose_value_is_set(params, ctx.params)
if len(given_params) > n:
raise ConstraintViolated(
f"no more than {n} of the following parameters can be set:\n"
f"{format_param_list(params)}",
ctx=ctx,
)
[docs] def __repr__(self):
return make_repr(self, self._n)
[docs]class RequireExactly(WrapperConstraint):
"""Requires an exact number of parameters to be set."""
def __init__(self, n: int):
check_arg(n > 0)
self._n = n
super().__init__(RequireAtLeast(n) & AcceptAtMost(n), n=n)
[docs] def help(self, ctx: Context) -> str:
return f'exactly {self._n} required'
[docs] def check_values(self, params: Sequence[Parameter], ctx: Context):
n = self._n
given_params = get_params_whose_value_is_set(params, ctx.params)
if len(given_params) != n:
reason = pluralize(
count=n,
zero='none of the following parameters must be set:\n',
many=f'exactly {n} of the following parameters must be set:\n'
) + format_param_list(params)
raise ConstraintViolated(reason, ctx=ctx)
[docs]class AcceptBetween(WrapperConstraint):
def __init__(self, min: int, max: int): # noqa
"""Satisfied if the number of set parameters is between
``min`` and ``max`` (included).
:param min: must be an integer >= 0
:param max: must be an integer > min
"""
check_arg(min >= 0, 'min must be non-negative')
if max is not None:
check_arg(min < max, 'must be: min < max.')
self._min = min
self._max = max
super().__init__(RequireAtLeast(min) & AcceptAtMost(max), min=min, max=max)
[docs] def help(self, ctx: Context) -> str:
return f'at least {self._min} required, at most {self._max} accepted'
require_all = _RequireAll()
"""Satisfied if all parameters are set."""
accept_none = AcceptAtMost(0).rephrased(
help='all forbidden',
error='the following parameters should not be provided:\n{param_list}'
)
"""Satisfied if none of the parameters is set. Useful only in conditional constraints."""
all_or_none = (require_all | accept_none).rephrased(
help='provide all or none',
error='the following parameters should be provided together (or none of '
'them should be provided):\n{param_list}',
)
"""Satisfied if either all or none of the parameters are set."""
mutually_exclusive = AcceptAtMost(1).rephrased(
help='mutually exclusive',
error='the following parameters are mutually exclusive:\n{param_list}'
)
"""Satisfied if at most one of the parameters is set."""