File: _option_groups.py

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 (388 lines) | stat: -rw-r--r-- 14,161 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
"""
Implements the "option groups" feature.
"""
from collections import defaultdict
from typing import (
    Any, Callable, Dict, Iterable, Iterator, List, Optional, Sequence, Tuple, overload,
)

import click
from click import Option, Parameter

import cloup
from cloup._params import option, make_arg_metavar
from cloup._util import first_bool, make_repr
from cloup.constraints import Constraint
from cloup.formatting import HelpSection, ensure_is_cloup_formatter
from cloup.typing import Decorator, F


class OptionGroup:
    def __init__(self, title: str,
                 help: Optional[str] = None,
                 constraint: Optional[Constraint] = None,
                 hidden: bool = False):
        """Contains the information of an option group and identifies it.
        Note that, as far as the clients of this library are concerned, an
        ``OptionGroups`` acts as a "marker" for options, not as a container for
        related options. When you call ``@optgroup.option(...)`` you are not
        adding an option to a container, you are just adding an option marked
        with this option group.

        .. versionadded:: 0.8.0
            The ``hidden`` parameter.
        """
        if not title:
            raise ValueError('name is a mandatory argument')  # pragma: no cover
        self.title = title
        self.help = help
        self._options: Sequence[click.Option] = []
        self.constraint = constraint
        self.hidden = hidden

    @property
    def options(self) -> Sequence[click.Option]:
        return self._options

    @options.setter
    def options(self, options: Iterable[click.Option]) -> None:
        self._options = opts = tuple(options)
        if self.hidden:
            for opt in opts:
                opt.hidden = True
        elif all(opt.hidden for opt in opts):
            self.hidden = True

    def get_help_records(self, ctx: click.Context) -> List[Tuple[str, str]]:
        if self.hidden:
            return []
        return [
            opt.get_help_record(ctx) for opt in self if not opt.hidden  # type: ignore
        ]  # get_help_record() should return None only if opt.hidden

    def option(self, *param_decls: str, **attrs: Any) -> Callable[[F], F]:
        """Refer to :func:`cloup.option`."""
        return option(*param_decls, group=self, **attrs)

    def __iter__(self) -> Iterator[click.Option]:
        return iter(self.options)

    def __getitem__(self, i: int) -> click.Option:
        return self.options[i]

    def __len__(self) -> int:
        return len(self.options)

    def __repr__(self) -> str:
        return make_repr(self, self.title, help=self.help, options=self.options)

    def __str__(self) -> str:
        return make_repr(
            self, self.title, options=[opt.name for opt in self.options])


def has_option_group(param: click.Parameter) -> bool:
    return getattr(param, 'group', None) is not None


def get_option_group_of(param: click.Option) -> Optional[OptionGroup]:
    return getattr(param, 'group', None)


# noinspection PyMethodMayBeStatic
class OptionGroupMixin:
    """Implements support for:

     - option groups
     - the "Positional arguments" help section; this section is shown only if
       at least one of your arguments has non-empty ``help``.

    .. important::
        In order to check the constraints defined on the option groups,
        a command must inherits from :class:`cloup.ConstraintMixin` too!

    .. versionadded:: 0.14.0
        added the "Positional arguments" help section.

    .. versionchanged:: 0.8.0
        this mixin now relies on ``cloup.HelpFormatter`` to align help sections.
        If a ``click.HelpFormatter`` is used with a ``TypeError`` is raised.

    .. versionchanged:: 0.8.0
        removed ``format_option_group``. Added ``get_default_option_group`` and
        ``make_option_group_help_section``.

    .. versionadded:: 0.5.0
    """

    def __init__(
        self, *args: Any, align_option_groups: Optional[bool] = None, **kwargs: Any
    ) -> None:
        """
        :param align_option_groups:
            whether to align the columns of all option groups' help sections.
            This is also available as a context setting having a lower priority
            than this attribute. Given that this setting should be consistent
            across all you commands, you should probably use the context
            setting only.
        :param args:
            positional arguments forwarded to the next class in the MRO
        :param kwargs:
            keyword arguments forwarded to the next class in the MRO
        """
        super().__init__(*args, **kwargs)

        self.align_option_groups = align_option_groups
        params = kwargs.get('params') or []
        arguments, option_groups, ungrouped_options = self._group_params(params)

        self.arguments = arguments

        self.option_groups = option_groups
        """List of all option groups, except the "default option group"."""

        self.ungrouped_options = ungrouped_options
        """List of options not explicitly assigned to an user-defined option group.
        These options will be included in the "default option group".
        **Note:** this list does not include options added automatically by Click
        based on context settings, like the ``--help`` option; use the
        :meth:`get_ungrouped_options` method if you need the real full list
        (which needs a ``Context`` object)."""

    @staticmethod
    def _group_params(
        params: List[Parameter]
    ) -> Tuple[List[click.Argument], List[OptionGroup], List[Option]]:

        options_by_group: Dict[OptionGroup, List[click.Option]] = defaultdict(list)
        arguments: List[click.Argument] = []
        ungrouped_options: List[click.Option] = []
        for param in params:
            if isinstance(param, click.Argument):
                arguments.append(param)
            elif isinstance(param, click.Option):
                grp = get_option_group_of(param)
                if grp is None:
                    ungrouped_options.append(param)
                else:
                    options_by_group[grp].append(param)

        option_groups = list(options_by_group.keys())
        for group, options in options_by_group.items():
            group.options = options

        return arguments, option_groups, ungrouped_options

    def get_ungrouped_options(self, ctx: click.Context) -> Sequence[click.Option]:
        """Return options not explicitly assigned to an option group
        (eventually including the ``--help`` option), i.e. options that will be
        part of the "default option group"."""
        help_option = ctx.command.get_help_option(ctx)
        if help_option is not None:
            return self.ungrouped_options + [help_option]
        else:
            return self.ungrouped_options

    def get_argument_help_record(
        self, arg: click.Argument, ctx: click.Context
    ) -> Tuple[str, str]:
        if isinstance(arg, cloup.Argument):
            return arg.get_help_record(ctx)
        return make_arg_metavar(arg, ctx), ""

    def get_arguments_help_section(self, ctx: click.Context) -> Optional[HelpSection]:
        args_with_help = (arg for arg in self.arguments if getattr(arg, "help", None))
        if not any(args_with_help):
            return None
        return HelpSection(
            heading="Positional arguments",
            definitions=[
                self.get_argument_help_record(arg, ctx) for arg in self.arguments
            ],
        )

    def make_option_group_help_section(
        self, group: OptionGroup, ctx: click.Context
    ) -> HelpSection:
        """Return a ``HelpSection`` for an ``OptionGroup``, i.e. an object containing
        the title, the optional description and the options' definitions for
        this option group.

        .. versionadded:: 0.8.0
        """
        return HelpSection(
            heading=group.title,
            definitions=group.get_help_records(ctx),
            help=group.help,
            constraint=group.constraint.help(ctx) if group.constraint else None
        )

    def must_align_option_groups(
        self, ctx: Optional[click.Context], default: bool = True
    ) -> bool:
        """
        Return ``True`` if the help sections of all options groups should have
        their columns aligned.

        .. versionadded:: 0.8.0
        """
        return first_bool(
            self.align_option_groups,
            getattr(ctx, 'align_option_groups', None),
            default,
        )

    def get_default_option_group(
        self, ctx: click.Context, is_the_only_visible_option_group: bool = False
    ) -> OptionGroup:
        """
        Return an ``OptionGroup`` instance for the options not explicitly
        assigned to an option group, eventually including the ``--help`` option.

        .. versionadded:: 0.8.0
        """
        default_group = OptionGroup(
            "Options" if is_the_only_visible_option_group else "Other options")
        default_group.options = self.get_ungrouped_options(ctx)
        return default_group

    def format_params(
        self, ctx: click.Context, formatter: click.HelpFormatter
    ) -> None:
        formatter = ensure_is_cloup_formatter(formatter)

        visible_sections = []

        # Positional arguments
        positional_arguments_section = self.get_arguments_help_section(ctx)
        if positional_arguments_section:
            visible_sections.append(positional_arguments_section)

        # Option groups
        option_group_sections = [
            self.make_option_group_help_section(group, ctx)
            for group in self.option_groups
            if not group.hidden
        ]
        default_group = self.get_default_option_group(
            ctx, is_the_only_visible_option_group=not option_group_sections
        )
        if not default_group.hidden:
            option_group_sections.append(
                self.make_option_group_help_section(default_group, ctx))

        visible_sections += option_group_sections

        formatter.write_many_sections(
            visible_sections,
            aligned=self.must_align_option_groups(ctx),
        )


@overload
def option_group(
    title: str,
    help: str,
    *options: Decorator,
    constraint: Optional[Constraint] = None,
    hidden: bool = False,
) -> Callable[[F], F]:
    ...


@overload
def option_group(
    title: str,
    *options: Decorator,
    help: Optional[str] = None,
    constraint: Optional[Constraint] = None,
    hidden: bool = False,
) -> Callable[[F], F]:
    ...


# noinspection PyIncorrectDocstring
def option_group(title: str, *args: Any, **kwargs: Any) -> Callable[[F], F]:
    """
    Return a decorator that annotates a function with an option group.

    The ``help`` argument is an optional description and can be provided either
    as keyword argument or as 2nd positional argument after the ``name`` of
    the group::

        # help as keyword argument
        @option_group(name, *options, help=None, ...)

        # help as 2nd positional argument
        @option_group(name, help, *options, ...)

    .. versionchanged:: 0.9.0
        in order to support the decorator :func:`cloup.constrained_params`,
        ``@option_group`` now allows each input decorators to add multiple
        options.

    :param title:
        title of the help section describing the option group.
    :param help:
        an optional description shown below the name; can be provided as keyword
        argument or 2nd positional argument.
    :param options:
        an arbitrary number of decorators like ``click.option``, which attach
        one or multiple options to the decorated command function.
    :param constraint:
        an optional instance of :class:`~cloup.constraints.Constraint`
        (see :doc:`Constraints </pages/constraints>` for more info);
        a description of the constraint will be shown between squared brackets
        aside the option group title (or below it if too long).
    :param hidden:
        if ``True``, the option group and all its options are hidden from the help page
        (all contained options will have their ``hidden`` attribute set to ``True``).
    """
    if args and isinstance(args[0], str):
        return _option_group(title, options=args[1:], help=args[0], **kwargs)
    else:
        return _option_group(title, options=args, **kwargs)


def _option_group(
    title: str,
    options: Sequence[Callable[[F], F]],
    help: Optional[str] = None,
    constraint: Optional[Constraint] = None,
    hidden: bool = False,
) -> Callable[[F], F]:
    if not isinstance(title, str):
        raise TypeError(
            'the first argument of `@option_group` must be its title, a string; '
            'you probably forgot it'
        )

    if not options:
        raise ValueError('you must provide at least one option')

    def decorator(f: F) -> F:
        opt_group = OptionGroup(title, help=help, constraint=constraint, hidden=hidden)
        if not hasattr(f, '__click_params__'):
            f.__click_params__ = []  # type: ignore
        cli_params = f.__click_params__  # type: ignore
        for add_option in reversed(options):
            prev_len = len(cli_params)
            add_option(f)
            added_options = cli_params[prev_len:]
            for new_option in added_options:
                if not isinstance(new_option, Option):
                    raise TypeError(
                        "only parameter of type `Option` can be added to option groups")
                existing_group = get_option_group_of(new_option)
                if existing_group is not None:
                    raise ValueError(
                        f'Option "{new_option}" was first assigned to group '
                        f'"{existing_group}" and then passed as argument to '
                        f'`@option_group({title!r}, ...)`'
                    )
                new_option.group = opt_group  # type: ignore
                if hidden:
                    new_option.hidden = True
        return f

    return decorator