File: constraints.rst

package info (click to toggle)
python-cloup 3.0.8-1
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid
  • size: 936 kB
  • sloc: python: 5,371; makefile: 120
file content (600 lines) | stat: -rw-r--r-- 20,026 bytes parent folder | download | duplicates (2)
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.