Source code for cloup.constraints._core

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)``."""