import abc
from typing import (
Any, Callable, Optional, Sequence, TypeVar, Union, cast, overload,
)
import click
from cloup._util import (
FrozenSpace, check_arg, class_name,
first_bool, make_one_line_repr, make_repr, pluralize, reindent,
)
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,
)
from .exceptions import ConstraintViolated, UnsatisfiableConstraint
from ..typing import Decorator, F
Op = TypeVar('Op', bound='Operator')
HelpRephraser = Callable[[click.Context, 'Constraint'], str]
ErrorRephraser = Callable[[ConstraintViolated], 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``).
.. versionchanged:: 0.9.0
calling a constraint, previously equivalent to :meth:`~Constraint.check`,
is now equivalent to calling :func:`cloup.constrained_params` with this
constraint as first argument.
"""
[docs] @staticmethod
def must_check_consistency(ctx: click.Context) -> bool:
"""Return ``True`` if consistency checks are enabled.
.. versionchanged:: 0.9.0
this method now a static method and takes a ``click.Context`` in input.
"""
return first_bool(
getattr(ctx, 'check_constraints_consistency', True),
True,
)
[docs] def __getattr__(self, attr: str) -> Any:
removed_attrs = ('toggle_consistency_checks', 'consistency_checks_toggled')
if attr in removed_attrs:
raise AttributeError(
f'attribute `{attr}` was removed in v0.9. You can now enable/disable '
f'consistency checks using the `click.Context` parameter '
f'`check_constraints_consistency`. '
f'Pass it as part of your `context_settings`.'
)
else:
raise AttributeError(attr)
[docs] @abc.abstractmethod
def help(self, ctx: click.Context) -> str:
"""A description of the constraint. """
[docs] def check_consistency(self, params: Sequence[click.Parameter]) -> None:
"""
Perform some sanity checks that detect inconsistencies between these
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 (setting
``check_constraints_consistency=False`` in ``context_settings``)
: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[click.Parameter], ctx: click.Context) -> None:
"""
Check 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[click.Parameter], ctx: Optional[click.Context] = None
) -> None:
...
@overload
def check(self, params: Sequence[str], ctx: Optional[click.Context] = None) -> None:
...
[docs] def check(
self, params: Union[Sequence[click.Parameter], Sequence[str]],
ctx: Optional[click.Context] = None
) -> None:
"""
Raise 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 ``check_constraints_consistency=False`` as part
of your ``context_settings``.
:param params: an iterable of parameter names or a sequence of
:class:`click.Parameter`
:param ctx: a `click.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("argument `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`')
if isinstance(params[0], str):
param_names = cast(Sequence[str], params)
params_objects = ctx.command.get_params_by_name(param_names)
else:
params_objects = cast(Sequence[click.Parameter], params)
if Constraint.must_check_consistency(ctx):
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':
"""
Override the help string and/or the error message of this constraint
wrapping it with a :class:`Rephraser`.
:param help:
if provided, overrides the help string of this constraint. It can be
a string or a function ``(ctx: click.Context, constr: Constraint) -> str``.
If you want to hide this constraint from the help, pass ``help=""``.
:param error:
if provided, overrides the error message of this constraint.
It can be:
- a string, eventually a ``format`` string supporting the replacement
fields described in :class:`ErrorFmt`.
- or a function ``(err: ConstraintViolated) -> str``; note that
a :class:`ConstraintViolated` error has fields for ``ctx``,
``constraint`` and ``params``, so it's a complete description
of what happened.
"""
return Rephraser(self, help=help, error=error)
[docs] def hidden(self) -> 'Rephraser':
"""Hide this constraint from the command help."""
return Rephraser(self, help='')
[docs] def __call__(self, *param_adders: Decorator) -> Callable[[F], F]:
"""Equivalent to calling :func:`cloup.constrained_params` with this
constraint as first argument.
.. versionchanged:: 0.9.0
this method, previously equivalent to :meth:`~Constraint.check`, is
now equivalent to calling :func:`cloup.constrained_params` with this
constraint as first argument.
"""
from ._support import constrained_params
# TODO: remove this check in the future
if not callable(param_adders[0]):
from cloup import __version__
raise TypeError(reindent(f"""\n
since Cloup v0.9, calling a constraint has a completely different
semantics and takes parameter decorators as arguments, see:
https://cloup.readthedocs.io/en/v{__version__}/pages/constraints.html#constraints-as-decorators
To check a constraint imperatively, you can use the check() method.
""", 4))
return constrained_params(self, *param_adders)
[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) -> str:
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: click.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[click.Parameter]) -> None:
for c in self.constraints:
c.check_consistency(params)
[docs] def __repr__(self) -> str:
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[click.Parameter], ctx: click.Context) -> None:
for c in self.constraints:
c.check_values(params, ctx)
[docs] def __and__(self, other: Constraint) -> '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[click.Parameter], ctx: click.Context) -> None:
for c in self.constraints:
try:
c.check_values(params, ctx)
return
except ConstraintViolated:
pass
raise ConstraintViolated.default(
self.help(ctx), ctx=ctx, constraint=self, params=params
)
[docs] def __or__(self, other: Constraint) -> 'Or':
if isinstance(other, Or):
return Or(*self.constraints, *other.constraints)
return Or(*self.constraints, other)
[docs]class ErrorFmt(FrozenSpace):
""":class:`Rephraser` allows you to pass a ``format`` string as ``error``
argument; this class contains the "replacement fields" supported by such
format string. You can use them as following::
mutually_exclusive.rephrased(
error=f"{ErrorFmt.error}\\n"
f"Some extra information here."
)
"""
error = '{error}'
"""Replaced by the original error message. Useful if all you want is to
append or prepend some extra info to the original error message."""
param_list = '{param_list}'
"""Replaced by a 2-space indented list of the constrained parameters."""
[docs]class Rephraser(Constraint):
"""A constraint decorator that can override the help and/or the error
message of the wrapped constraint.
You'll rarely (if ever) use this class directly. In most cases, you'll use
the method :meth:`Constraint.rephrased`. Refer to it for more info.
.. seealso::
- :meth:`Constraint.rephrased` -- wraps a constraint with a ``Rephraser``.
- :class:`WrapperConstraint` -- alternative to ``Rephraser``.
- :class:`ErrorFmt` -- describes the keyword you can use in an error
format string.
"""
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('`help` and `error` cannot both be `None`')
self.constraint = constraint
self._help = help
self._error = error
[docs] def help(self, ctx: click.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, err: ConstraintViolated) -> Optional[str]:
if self._error is None:
return None
elif isinstance(self._error, str):
return self._error.format(
error=str(err),
param_list=format_param_list(err.params),
)
else:
return self._error(err)
[docs] def check_consistency(self, params: Sequence[click.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[click.Parameter], ctx: click.Context) -> None:
try:
self.constraint.check_values(params, ctx)
except ConstraintViolated as err:
rephrased_error = self._get_rephrased_error(err)
if rephrased_error:
raise ConstraintViolated(
rephrased_error, ctx=ctx, constraint=self, params=params)
raise
[docs] def __repr__(self) -> str:
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: Any):
"""
: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: click.Context) -> str:
return self._constraint.help(ctx)
[docs] def check_consistency(self, params: Sequence[click.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[click.Parameter], ctx: click.Context) -> None:
self._constraint.check_values(params, ctx)
[docs] def __repr__(self) -> str:
return make_repr(self, **self._attrs)
class _RequireAll(Constraint):
"""Satisfied if all parameters are set."""
def help(self, ctx: click.Context) -> str:
return 'all required'
def check_values(self, params: Sequence[click.Parameter], ctx: click.Context) -> None:
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,
constraint=self,
params=params,
)
[docs]class RequireAtLeast(Constraint):
"""Satisfied if the number of set parameters is >= n."""
def __init__(self, n: int):
check_arg(n >= 0)
self.min_num_params = n
[docs] def help(self, ctx: click.Context) -> str:
return f'at least {self.min_num_params} required'
[docs] def check_consistency(self, params: Sequence[click.Parameter]) -> None:
n = self.min_num_params
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[click.Parameter], ctx: click.Context) -> None:
n = self.min_num_params
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, constraint=self, params=params,
)
[docs] def __repr__(self) -> str:
return make_repr(self, self.min_num_params)
[docs]class AcceptAtMost(Constraint):
"""Satisfied if the number of set parameters is <= n."""
def __init__(self, n: int):
check_arg(n >= 0)
self.max_num_params = n
[docs] def help(self, ctx: click.Context) -> str:
return f'at most {self.max_num_params} accepted'
[docs] def check_consistency(self, params: Sequence[click.Parameter]) -> None:
num_required_params = len(get_required_params(params))
if num_required_params > self.max_num_params:
reason = f'{num_required_params} of the parameters are required'
raise UnsatisfiableConstraint(self, params, reason)
[docs] def check_values(self, params: Sequence[click.Parameter], ctx: click.Context) -> None:
n = self.max_num_params
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, constraint=self, params=params,
)
[docs] def __repr__(self) -> str:
return make_repr(self, self.max_num_params)
[docs]class RequireExactly(WrapperConstraint):
"""Requires an exact number of parameters to be set."""
def __init__(self, n: int):
check_arg(n > 0)
# Defined as a wrapper to reuse check_consistency() of the wrapped constraint.
super().__init__(RequireAtLeast(n) & AcceptAtMost(n))
self.num_params = n
[docs] def help(self, ctx: click.Context) -> str:
return f'exactly {self.num_params} required'
[docs] def check_values(self, params: Sequence[click.Parameter], ctx: click.Context) -> None:
n = self.num_params
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, constraint=self, params=params)
[docs] def __repr__(self) -> str:
return make_repr(self, self.num_params)
[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.')
super().__init__(RequireAtLeast(min) & AcceptAtMost(max), min=min, max=max)
self.min_num_params = min
self.max_num_params = max
[docs] def help(self, ctx: click.Context) -> str:
return f'at least {self.min_num_params} required, ' \
f'at most {self.max_num_params} accepted'
require_all = _RequireAll()
"""Satisfied if all parameters are set."""
accept_none = AcceptAtMost(0).rephrased(
help='all forbidden',
error=f'the following parameters should not be provided:\n'
f'{ErrorFmt.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=f'the following parameters should be provided together (or none of '
f'them should be provided):\n'
f'{ErrorFmt.param_list}',
)
"""Satisfied if either all or none of the parameters are set."""
mutually_exclusive = AcceptAtMost(1).rephrased(
help='mutually exclusive',
error=f'the following parameters are mutually exclusive:\n'
f'{ErrorFmt.param_list}'
)
"""Satisfied if at most one of the parameters is set."""
require_any = RequireAtLeast(1)
"""Alias for ``RequireAtLeast(1)``."""
require_one = RequireExactly(1)
"""Alias for ``RequireExactly(1)``."""