File: _decorators.py

package info (click to toggle)
python-click-option-group 0.5.6-1.1
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid, trixie
  • size: 252 kB
  • sloc: python: 1,141; makefile: 16
file content (232 lines) | stat: -rw-r--r-- 8,144 bytes parent folder | download
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
# -*- coding: utf-8 -*-

from typing import (Callable, Optional, NamedTuple, List,
                    Tuple, Dict, Any, Type, TypeVar)

import collections
import warnings
import inspect

import click

from ._core import OptionGroup
from ._helpers import (
    get_callback_and_params,
    raise_mixing_decorators_error,
)

T = TypeVar('T')
F = TypeVar('F', bound=Callable)

Decorator = Callable[[F], F]


class OptionStackItem(NamedTuple):
    param_decls: Tuple[str, ...]
    attrs: Dict[str, Any]
    param_count: int


class _NotAttachedOption(click.Option):
    """The helper class to catch grouped options which were not attached to the group

    Raises TypeError if not attached options exist.
    """

    def __init__(self, param_decls=None, *, all_not_attached_options, **attrs):
        super().__init__(param_decls, expose_value=False, hidden=False, is_eager=True, **attrs)
        self._all_not_attached_options = all_not_attached_options

    def handle_parse_result(self, ctx, opts, args):
        options_error_hint = ''
        for option in reversed(self._all_not_attached_options[ctx.command.callback]):
            options_error_hint += f'  {option.get_error_hint(ctx)}\n'
        options_error_hint = options_error_hint[:-1]

        raise TypeError((
            f"Missing option group decorator in '{ctx.command.name}' command for the following grouped options:\n"
            f"{options_error_hint}\n"))


class _OptGroup:
    """A helper class to manage creating groups and group options via decorators

    The class provides two decorator-methods: `group`/`__call__` and `option`.
    These decorators should be used for adding grouped options. The class have
    single global instance `optgroup` that should be used in most cases.

    The example of usage::

        ...
        @optgroup('Group 1', help='option group 1')
        @optgroup.option('--foo')
        @optgroup.option('--bar')
        @optgroup.group('Group 2', help='option group 2')
        @optgroup.option('--spam')
        ...
    """

    def __init__(self) -> None:
        self._decorating_state: Dict[Callable, List[OptionStackItem]] = collections.defaultdict(list)
        self._not_attached_options: Dict[Callable, List[click.Option]] = collections.defaultdict(list)
        self._outer_frame_index = 1

    def __call__(self,
                 name: Optional[str] = None, *,
                 help: Optional[str] = None,
                 cls: Optional[Type[OptionGroup]] = None, **attrs):
        """Creates a new group and collects its options

        Creates the option group and registers all grouped options
        which were added by `option` decorator.

        :param name: Group name or None for default name
        :param help: Group help or None for empty help
        :param cls: Option group class that should be inherited from `OptionGroup` class
        :param attrs: Additional parameters of option group class
        """
        try:
            self._outer_frame_index = 2
            return self.group(name, help=help, cls=cls, **attrs)
        finally:
            self._outer_frame_index = 1

    def group(self,
              name: Optional[str] = None, *,
              help: Optional[str] = None,
              cls: Optional[Type[OptionGroup]] = None, **attrs):
        """The decorator creates a new group and collects its options

        Creates the option group and registers all grouped options
        which were added by `option` decorator.

        :param name: Group name or None for default name
        :param help: Group help or None for empty help
        :param cls: Option group class that should be inherited from `OptionGroup` class
        :param attrs: Additional parameters of option group class
        """

        if not cls:
            cls = OptionGroup
        else:
            if not issubclass(cls, OptionGroup):
                raise TypeError("'cls' must be a subclass of 'OptionGroup' class.")

        def decorator(func):
            callback, params = get_callback_and_params(func)

            if callback not in self._decorating_state:
                frame = inspect.getouterframes(inspect.currentframe())[self._outer_frame_index]
                lineno = frame.lineno

                with_name = f' "{name}"' if name else ''
                warnings.warn((f'The empty option group{with_name} was found (line {lineno}) '
                               f'for "{callback.__name__}". The group will not be added.'),
                              RuntimeWarning, stacklevel=2)
                return func

            option_stack = self._decorating_state.pop(callback)

            [params.remove(opt) for opt in self._not_attached_options.pop(callback)]
            self._check_mixing_decorators(callback, option_stack, self._filter_not_attached(params))

            attrs['help'] = help

            try:
                option_group = cls(name, **attrs)
            except TypeError as err:
                message = str(err).replace('__init__()', f"'{cls.__name__}' constructor")
                raise TypeError(message) from err

            for item in option_stack:
                func = option_group.option(*item.param_decls, **item.attrs)(func)

            return func

        return decorator

    def option(self, *param_decls, **attrs) -> Decorator:
        """The decorator adds a new option to the group

        The decorator is lazy. It adds option decls and attrs.
        All options will be registered by `group` decorator.

        :param param_decls: option declaration tuple
        :param attrs: additional option attributes and parameters
        """

        def decorator(func):
            callback, params = get_callback_and_params(func)

            option_stack = self._decorating_state[callback]
            params = self._filter_not_attached(params)

            self._check_mixing_decorators(callback, option_stack, params)
            self._add_not_attached_option(func, param_decls)
            option_stack.append(OptionStackItem(param_decls, attrs, len(params)))

            return func

        return decorator

    def help_option(self, *param_decls, **attrs) -> Decorator:
        """This decorator adds a help option to the group, which prints
        the command's help text and exits.
        """
        if not param_decls:
            param_decls = ('--help',)

        attrs.setdefault('is_flag', True)
        attrs.setdefault('is_eager', True)
        attrs.setdefault('expose_value', False)
        attrs.setdefault('help', 'Show this message and exit.')

        if 'callback' not in attrs:
            def callback(ctx, _, value):
                if not value or ctx.resilient_parsing:
                    return
                click.echo(ctx.get_help(), color=ctx.color)
                ctx.exit()

            attrs['callback'] = callback

        return self.option(*param_decls, **attrs)

    def _add_not_attached_option(self, func, param_decls) -> None:
        click.option(
            *param_decls,
            all_not_attached_options=self._not_attached_options,
            cls=_NotAttachedOption
        )(func)

        callback, params = get_callback_and_params(func)
        self._not_attached_options[callback].append(params[-1])

    @staticmethod
    def _filter_not_attached(options: List[T]) -> List[T]:
        return [opt for opt in options if not isinstance(opt, _NotAttachedOption)]

    @staticmethod
    def _check_mixing_decorators(callback, options_stack, params):
        if options_stack:
            last_state = options_stack[-1]

            if len(params) > last_state.param_count:
                raise_mixing_decorators_error(params[-1], callback)


optgroup = _OptGroup()
"""Provides decorators for creating option groups and adding grouped options

Decorators:
    - `group` is used for creating an option group
    - `option` is used for adding options to a group

Example::

    @optgroup.group('Group 1', help='option group 1')
    @optgroup.option('--foo')
    @optgroup.option('--bar')
    @optgroup.group('Group 2', help='option group 2')
    @optgroup.option('--spam')
"""