1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600
|
.. currentmodule:: cloup.constraints
.. highlight:: none
Constraints
===========
Overview
--------
A :class:`Constraint` is essentially a validator for groups of parameters.
When unsatisfied, a constraint raises a :exc:`click.UsageError` with an
appropriate error message, which is handled and displayed by Click.
Each constraint also has an associated description (:meth:`Constraint.help`)
that can optionally be shown in the ``--help`` of a command.
You can easily override both the help description and the error message if you
want (see `Rephrasing constraints`_).
Constraints can be combined with logical operators (see `Defining new constraints`_)
and can also be applied conditionally (see `Conditional constraints`_).
Implemented constraints
-----------------------
Parametric constraints
~~~~~~~~~~~~~~~~~~~~~~
Parametric constraints are *subclasses* of ``Constraint`` and so they are
camel-cased;
.. autosummary::
RequireExactly
RequireAtLeast
AcceptAtMost
AcceptBetween
Non-parametric constraints
~~~~~~~~~~~~~~~~~~~~~~~~~~
Non-parametric constraints are *instances* of ``Constraint`` and so they are
snake-cased (``like_this``). Most of these are instances of parametric constraints
or (rephrased) combinations of them.
=========================== ============================================================
:data:`accept_none` Requires all parameters to be unset.
--------------------------- ------------------------------------------------------------
:data:`all_or_none` Satisfied if either all or none of the parameters are set.
--------------------------- ------------------------------------------------------------
:data:`mutually_exclusive` A rephrased version of ``AcceptAtMost(1)``.
--------------------------- ------------------------------------------------------------
:data:`require_all` Requires all parameters to be set.
--------------------------- ------------------------------------------------------------
:data:`require_any` Alias for ``RequireAtLeast(1)``.
--------------------------- ------------------------------------------------------------
:data:`require_one` Alias for ``RequireExactly(1)``.
=========================== ============================================================
When is a parameter considered "set"?
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Basically, Cloup considers a parameter to be "set" when its value differs from
the one assigned by Click when the parameter is not provided neither by the
CLI user nor by the developer.
.. list-table::
:header-rows: 1
:widths: 10 7 10 10
:align: center
* - Param type
- Click default
- It's set if
- Note
* - string
- ``None``
- ``value is not None``
- even if empty
* - number
- ``None``
- ``value is not None``
- even if zero
* - boolean non-flag
- ``None``
- ``value is not None``
- even if ``False``
* - boolean flag
- ``False``
- ``value is True``
-
* - tuple
- ``()``
- ``len(value) > 0``
-
In the future, this policy may become configurable at the context and parameter
level.
Conditional constraints
~~~~~~~~~~~~~~~~~~~~~~~
:class:`If` allows you to define conditional constraints::
If(condition, then, [else_])
- **condition** -- can be:
- a concrete instance of :class:`~conditions.Predicate`
- a parameter name; this is a shortcut for ``IsSet(param_name)``
- a list/tuple of parameter names; this is a shortcut for ``AllSet(*param_names)``.
- **then** -- the constraint checked when the condition is true.
- **else_** -- an optional constraint checked when the condition is false.
Available predicates can be imported from ``cloup.constraints`` and are:
.. autosummary::
IsSet
AllSet
AnySet
Equal
For example:
.. code-block:: python
from cloup.constraints import (
If, RequireAtLeast, require_all, accept_none,
IsSet, Equal
)
# If parameter with name "param" is set,
# then require all parameters, else forbid them all
If('param', then=require_all, else_=accept_none)
# Equivalent to:
If(IsSet('param'), then=require_all, else_=accept_none)
# If "arg" and "opt" are both set, then require exactly 1 param
If(['arg', 'opt'], then=RequireExactly(1))
# Another example... of course the else branch is optional
If(Equal('param', 'value'), then=RequireAtLeast(1))
Predicates have an associated ``description`` and can be composed with the
logical operators ``&`` (and), ``|`` (or) and ``~`` (not). For example:
.. code-block:: python
predicate = ~IsSet('foo') & Equal('bar', 'value')
# --foo is not set and --bar="value"
Applying constraints
--------------------
Constraints are well-integrated with option groups but decoupled from them:
you can apply them to any group of parameters, eventually including positional
arguments.
There are three ways to apply a constraint:
1. setting the parameter ``constraint`` of ``@option_group`` (or ``OptionGroup``)
2. using the ``@constraint`` decorator and specifying parameters by name
3. using the constraint as a decorator that takes parameter decorators as
arguments (similarly to ``@option_groups``, but supporting ``argument`` too);
this is just convenient *syntax sugar* on top of ``@constraint`` that can be
used in some circumstances.
As you'll see, Cloup handles slightly differently the constraints applied to
option groups, but only in relation to the ``--help`` output.
Usage with @option_group
~~~~~~~~~~~~~~~~~~~~~~~~
As you have probably seen in the :doc:`option-groups` chapter, you can easily
apply a constraint to an option group by setting the ``constraint`` argument of
``@option_group`` (or ``OptionGroup``):
.. code-block:: python
:emphasize-lines: 6
@option_group(
'Option group title',
option('-o', '--one', help='an option'),
option('-t', '--two', help='a second option'),
option('--three', help='a third option'),
constraint=RequireAtLeast(1),
)
This code produces the following help section with the constraint description
between square brackets on the right of the option group title::
Option group title: [at least 1 required]
-o, --one TEXT an option
-t, --two TEXT a second option
--three TEXT a third option
If the constraint description doesn't fit into the section heading line, it is
printed on the next line::
Option group title:
[this is a long description that doesn't fit into the title line]
-o, --one TEXT an option
-t, --two TEXT a second option
--three TEXT a third option
If the constraint is violated, the following error is shown::
Error: at least 1 of the following parameters must be set:
--one (-o)
--two (-t)
--three
You can customize both the help description and the error message of a constraint
using the method :meth:`Constraint.rephrased` (see `Rephrasing constraints`_).
If you simply want to hide the constraint description in the help, you can use
the method :meth:`Constraint.hidden`:
.. code-block:: python
@option_group(
...
constraint=RequireAtLeast(1).hidden(),
)
The ``@constraint`` decorator
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Using the :func:`cloup.constraint` decorator, you can apply a constraint to any
group of parameters (arguments and options) providing their **destination names**,
i.e. the names of the function arguments they are mapped to (by Click).
For example:
=============================================== ===================
Declaration Name
=============================================== ===================
``@option('-o')`` ``o``
``@option('-o', '--out-path')`` ``out_path``
``@option('-o', '--out-path', 'output_path')`` ``output_path``
=============================================== ===================
Here's a meaningless example just to show how to use the API:
.. code-block:: python
from cloup import argument, command, constraint, option
from cloup.constraints import If, RequireExactly, mutually_exclusive
@command('cmd', show_constraints=True)
@argument('arg', required=False)
@option('--one')
@option('--two')
@option('--three')
@option('--four')
@constraint(
mutually_exclusive, ['arg', 'one', 'two']
)
@constraint(
If('one', then=RequireExactly(1)), ['three', 'four']
)
def cmd(arg, one, two, three, four):
print('ciao')
.. _show-constraints:
If you set the ``command`` parameter ``show_constraints`` to ``True``,
the following section is shown at the bottom of the command help::
Constraints:
{ARG, --one, --two} mutually exclusive
{--three, --four} exactly 1 required if --one is set
Even in this case, you can still hide a specific constraint by using the method
:meth:`~Constraint.hidden`.
Note that ``show_constraint`` can also be set in the ``context_settings`` of
your root command. Of course, the context setting can be overridden by each
individual command.
.. _constraints-as-decorators:
Constraints as decorators
~~~~~~~~~~~~~~~~~~~~~~~~~
``@constraint`` is powerful but has some drawbacks:
- it requires to replicate (once again) the name of the constrained parameters;
- it doesn't visually group the involved parameters with nesting
(as ``@option_group`` does with options).
As an answer to these issues, Cloup introduced the possibility to use
constraints themselves as decorators, with an usage similar to that of
``@option_group``.
However, note that there are cases when ``@constraint`` is your only option.
This feature is just a layer of syntax sugar on top of ``@constraint``. The
following:
.. code-block:: python
@mutually_exclusive(
option('--one'),
option('--two'),
option('--three'),
)
is equivalent to:
.. code-block:: python
@option('--one')
@option('--two')
@option('--three')
@constraint(mutually_exclusive, ['one', 'two', 'three'])
.. admonition:: Syntax limitation in Python < 3.9
:name: attention-python-decorators
:class: attention
In Python < 3.9, the expression on the right of the operator ``@``
is required to be a "dotted name, optionally followed by a single call"
(see `PEP 614 <https://peps.python.org/pep-0614/#motivation>`_).
This means that you can't instantiate a parametric constraint on the right
of ``@``, because the resultant expressions would make two calls, e.g.:
.. code-block:: python
# This is a syntax error in Python < 3.9
@RequireExactly(2)( # 1st call to instantiate the constraint
... # 2nd call to apply the constraint
)
To work around this syntax limitation you can assign your constraint to a
variable before using it as a decorator:
.. code-block:: python
require_two = RequireExactly(2) # somewhere in the code
@require_two(
option('--one'),
option('--two'),
option('--three'),
)
or, in alternative, you can use the ``@constrained_params`` decorator
described below.
The ``@constrained_params`` decorator may turn useful to work around the just
described syntax limitation in Python < 3.9 or simply when your constraint is
long/complex enough that it'd be weird to use it as a decorator:
.. code-block:: python
@constrained_params(
RequireAtLeast(1),
option('--one'),
option('--two'),
option('--three'),
)
.. _constraint-inside-option-group:
You can use constraints as decorators even inside ``@option_group`` to constrain
one or multiple subgroups:
.. code-block:: python
:emphasize-lines: 3-6
@option_group(
"Number options",
RequireAtLeast(1)(
option('--one'),
option('--two')
),
option('--three')
)
# equivalent to:
@option_group(
"Number options",
option('--one'),
option('--two')
option('--three')
)
@constraint(RequireAtLeast(1), ['one', 'two'])
Note that the syntax limitation affecting Python < 3.9 described in the
:ref:`attention box <attention-python-decorators>` above does not apply in this case
since we are not using ``@`` here.
.. _rephrasing-constraints:
Rephrasing constraints
----------------------
You can override the help description and/or the error message of a constraint
using the :meth:`~Constraint.rephrased` method. It takes two arguments:
- **help** -- if provided, overrides the help description. It can be:
- a string
- a function ``(ctx: Context, constr: Constraint) -> str``
If you want to hide this constraint from the help, pass ``help=""`` or use
the method :meth:`~Constraint.hidden`.
- **error** -- if provided, overrides the error message. It can be:
- a string, eventually a ``format`` string whose fields are stored and
documented as attributes in :class:`ErrorFmt`.
- a function ``(err: ConstraintViolated) -> str``
where :exc:`ConstraintViolated` is an exception object that fully describes
the violation of a constraint, including fields like ``ctx``, ``constraint``
and ``params``.
An example from Cloup
~~~~~~~~~~~~~~~~~~~~~
Cloup itself makes use of rephrasing a lot for defining non-parametric constraints,
for example:
.. code-block:: python
mutually_exclusive = AcceptAtMost(1).rephrased(
help='mutually exclusive',
error=f'the following parameters are mutually exclusive:\n'
f'{ErrorFmt.param_list}'
)
Example: adding extra info to the original error
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Sometimes you just want to add extra info before or after the original error
message. In that case, you can either pass a function or using ``ErrorFmt.error``:
.. code-block:: python
# Using function (err: ConstraintViolated) -> str
mutually_exclusive.rephrased(
error=lambda err: f'{err}\n'
f'Use --renderer, the other options are deprecated.
)
# Using ErrorFmt.error
from cloup.constraint import ErrorFmt
mutually_exclusive.rephrased(
error=f'{ErrorFmt.error}\n'
f'Use --renderer, the other options are deprecated.
)
Defining new constraints
------------------------
The available constraints should cover 99% of use cases but if you need it, it's
very easy to define new ones. Here are your options:
- you can use the **logical operators** ``&`` and ``|`` to combine existing
constraints and then eventually:
- use the ``rephrased`` method described in the previous section
- or subclass :class:`~WrapperConstraint` if you want to define a new
parametric ``Constraint`` class wrapping the result
- just subclass ``Constraint``; look at existing implementations for guidance.
Example 1: logical operator + rephrasing
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
This is how Cloup defines ``all_or_none`` (this example may be out-of-date):
.. code-block:: python
all_or_none = (require_all | accept_none).rephrased(
help='provide all or none',
error=f'the following parameters must be provided all together '
f'(or none should be provided):\n'
f'{ErrorFmt.param_list}',
)
Example 2: defining a new parametric constraint
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
*Option 1 -- Just use a function.*
.. code-block:: python
def accept_between(min, max):
return (RequireAtLeast(min) & AcceptAtMost(max)).rephrased(
help=f'at least {min} required, at most {max} accepted'
)
>>> accept_between(1, 3)
Rephraser(help='at least 1 required, at most 3 accepted')
*Option 2 -- WrapperConstraint.* This is useful when you want to define a new
constraint type. ``WrapperConstraint`` delegates all methods to the wrapped
constraint so you can override only the methods you need to override.
.. code-block:: python
class AcceptBetween(WrapperConstraint):
def __init__(self, min: int, max: int):
# [...]
self._min = min
self._max = max
# whatever you pass as **kwargs is used in the __repr__
super().__init__(
RequireAtLeast(min) & AcceptAtMost(max),
min=min, max=max, # <= included in the __repr__
)
def help(self, ctx: Context) -> str:
return f'at least {self._min} required, ' \
f'at most {self._max} accepted'
>>> AcceptBetween(1, 3)
AcceptBetween(1, 3)
\*Validation protocol
---------------------
A constraint performs two types of checks and there's a method for each type:
- :meth:`~Constraint.check_consistency` – performs sanity checks meant to detect
mistakes of the developer; as such, they are performed *before* argument
parsing (when possible); for example, if you try to apply a
``mutually_exclusive`` constraint to an option group containing multiple
required options, this method will raise ``UnsatisfiableConstraint``
- :meth:`~Constraint.check_values` – performs user input validation and,
when unsatisfied, raises a ``ConstraintViolated`` error with an appropriate
message; ``ConstrainedViolated`` is a subclass of ``click.UsageError`` and,
as such, is handled by Click itself by showing the command usage and the
error message.
Using a constraint as a function is equivalent to call the method
:meth:`~Constraint.check`, which performs (by default) both kind of checks,
unless consistency checks are disabled (see below).
When you add constraints through ``@option_group``, ``OptionGroup`` and
``@constraint``, this is what happens:
- constraints are checked for consistency *before* parsing
- input is parsed and processed; all values are stored by Click in the ``Context``
object, precisely in ``ctx.params``
- constraints validate the parameter values.
In all cases, constraints applied to option groups are checked before those
added through ``@constraint``.
If you use a constraint inside a callback, of course, consistency checks can't
be performed before parsing. All checks are performed together after parsing.
Disabling consistency checks
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
You can safely skip this section since disabling consistency checks is a
micro-optimization likely to be completely irrelevant in practice.
Current consistency checks should not have any relevant impact on performance,
so they are enabled by default. Nonetheless, they are completely useless in
production, so I added the possibility to turn them off (globally) passing
``check_constraints_consistency=False`` as part of your ``context_settings``.
Just because I could.
To disable them only in production, you should set an environment variable in
your development machine, say ``PYTHON_ENV="dev"``; then you can put the
following code at the entry-point of your program:
.. code-block:: python
import os
from cloup import Context
SETTINGS = Context.setting(
check_constraints_consistency=(os.getenv('PYTHON_ENV') == 'dev')
# ... other settings ...
)
@group(context_settings=SETTINGS)
# ...
def main(...):
...
Have I already mentioned that this is probably not worth the effort?
\*Feature support
-----------------
.. note::
If you use command classes/decorators redefined by Cloup, you can skip
this section.
To support constraints, a ``Command`` must inherit from :class:`ConstraintMixin`.
It's worth noting that ``ConstraintMixin`` integrates with ``OptionGroupMixin``
but it **doesn't** require it to work.
To use the ``@constraint`` decorator, you must currently use ``@cloup.command``
as command decorator. Using ``@click.command(..., cls=cloup.Command)`` won't
work. This may change in the future though.
|