summaryrefslogtreecommitdiffstats
path: root/lmi/scripts/common/command/endpoint.py
blob: c744e34f9aeab177e58283a3e65aa002902c218d (plain)
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
# Copyright (C) 2013-2014 Red Hat, Inc. All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
#
# 1. Redistributions of source code must retain the above copyright notice,
#    this list of conditions and the following disclaimer.
# 2. Redistributions in binary form must reproduce the above copyright notice,
#    this list of conditions and the following disclaimer in the documentation
#    and/or other materials provided with the distribution.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
#
# The views and conclusions contained in the software and documentation are
# those of the authors and should not be interpreted as representing official
# policies, either expressed or implied, of the FreeBSD Project.
#
# Authors: Michal Minar <miminar@redhat.com>
#
"""
Defines base command class for all endpoint commands. Those having no children.
"""
import abc
import inspect
import re
from docopt import docopt

from lmi.scripts.common import errors
from lmi.scripts.common import formatter
from lmi.scripts.common import get_logger
from lmi.scripts.common.formatter import command as fcmd
from lmi.scripts.common.command import base
from lmi.scripts.common.command import meta
from lmi.scripts.common.command import util

LOG = get_logger(__name__)

def opt_name_sanitize(opt_name):
    """
    Make a function parameter name out of option name. This replaces any
    character not suitable for python identificator with ``'_'`` and
    make the whole string lowercase.

    :param string opt_name: Option name.
    :returns: Modified option name.
    :rtype: string
    """
    return re.sub(r'[^a-zA-Z0-9]+', '_', opt_name).lower()

def options_dict2kwargs(options):
    """
    Convert option name from resulting ``docopt`` dictionary to a valid python
    identificator token used as function argument name.

    :param dictionary options: Dictionary returned by docopt call.
    :returns:  New dictionary with keys passable to function as argument
        names.
    :rtype: dictionary
    """
    # (new_name, value) for each pair in options dictionary
    kwargs = {}
    # (new_name, name)
    orig_names = {}
    for name, value in options.items():
        for (reg, func) in (
                (util.RE_OPT_BRACKET_ARGUMENT, lambda m: m.group('name')),
                (util.RE_OPT_UPPER_ARGUMENT,   lambda m: m.group('name')),
                (util.RE_OPT_SHORT_OPTION,     lambda m: m.group(0)),
                (util.RE_OPT_LONG_OPTION,      lambda m: m.group(0)),
                (util.RE_COMMAND_NAME,         lambda m: m.group(0))):
            match = reg.match(name)
            if match:
                new_name = func(match)
                break
        else:
            raise errors.LmiError(
                    'Failed to convert argument "%s" to function option.' %
                    name)
        if new_name == '--':
            continue    # ignore double dash
        new_name = opt_name_sanitize(new_name)
        if new_name in kwargs:
            raise errors.LmiError('Option clash for "%s" and "%s", which both'
                ' translate to "%s".' % (name, orig_names[new_name], new_name))
        kwargs[new_name] = value
        orig_names[new_name] = name
    return kwargs

class LmiEndPointCommand(base.LmiBaseCommand):
    """
    Base class for any leaf command.

    List of additional recognized properties:

        ``CALLABLE`` : ``tuple``
            Associated function. Will be wrapped in
            :py:meth:`LmiEndPointCommand.execute` method and will be accessible
            directly as a ``cmd.execute.dest`` property. It may be specified
            either as a string in form ``"<module_name>:<callable>"`` or as a
            reference to callable itself.
        ``ARG_ARRAY_SUFFIX`` : ``str``
            String appended to every option parsed by ``docopt`` having list as
            an associated value. It defaults to empty string. This modification
            is applied before calling
            :py:meth:`LmiEndPointCommand.verify_options` and
            :py:meth:`LmiEndPointCommand.transform_options`.
        ``FORMATTER`` : callable
            Default formatter factory for instances of given command. This
            factory accepts an output stream as the only parameter and returns
            an instance of :py:class:`~lmi.scripts.common.formatter.Formatter`.

    Using metaclass:
        :py:class:`.meta.EndPointCommandMetaClass`.
    """
    __metaclass__ = meta.EndPointCommandMetaClass

    def __init__(self, *args, **kwargs):
        super(LmiEndPointCommand, self).__init__(*args, **kwargs)
        self._formatter = None
        # saved options dictionary after call to transform_options()
        self._options = None

    @abc.abstractmethod
    def execute(self, *args, **kwargs):
        """
        Subclasses must override this method to pass given arguments to
        command library function. This function shall be specified in
        ``CALLABLE`` property.
        """
        raise NotImplementedError("execute method must be overriden"
                " in subclass")

    def formatter_factory(self):
        """
        Subclasses shall override this method to provide default formatter
        factory for printing output.

        :returns: Subclass of basic formatter.
        """
        return formatter.Formatter

    @classmethod
    def dest_pos_args_count(cls):
        """
        Number of positional arguments the associated function takes from
        command. These arguments are created by the command alone -- they do
        not belong to options in usage string. Function can take additional
        positional arguments that need to be covered by usage string.

        :rtype: integer
        """
        dest = getattr(cls.execute, "dest", cls.execute)
        abstract = dest == cls.execute and util.is_abstract_method(
                cls, 'execute', True)
        # if the destination function is not yet defined (abstract is True)
        # let's assume it's not a method => 0 positional arguments needed
        return 1 if not abstract and inspect.ismethod(dest) else 0

    def run_with_args(self, args, kwargs):
        """
        Process end-point arguments and exit.

        :param list args: Positional arguments to pass to associated
            function in command library.
        :param dictionary kwargs: Keyword arguments as a dictionary.
        :returns: Exit code of application.
        :rtype: integer
        """
        return self.execute(*args, **kwargs)

    @property
    def formatter(self):
        """
        Return instance of default formatter.

        :rtype: :py:class:`~lmi.scripts.common.formatter.Formatter`
        """
        if self._formatter is None:
            opts = self.format_options
            factory = self.formatter_factory()
            argspec = inspect.getargspec(
                    factory.__init__ if type(factory) is type else factory)
            if not argspec.keywords:
                kwargs = {k: v for k, v in opts.items() if k in argspec.args}
            self._formatter = factory(self.app.stdout, **kwargs)
        return self._formatter

    def _make_end_point_args(self, options):
        """
        Creates a pair of positional and keyword arguments for a call to
        associated function from command line options. All keyword
        options not expected by target function are removed.

        :param dictionary options: Output of ``docopt`` parser.
        :returns: Positional and keyword arguments as a pair.
        :rtype: tuple
        """
        # if execute method does not have a *dest* attribute, then it's
        # itself a destination
        dest = getattr(self.execute, "dest", self.execute)
        argspec = inspect.getargspec(dest)
        kwargs = options_dict2kwargs(options)
        # number of positional arguments not covered by usage string
        pos_args_count = self.dest_pos_args_count()
        to_remove = []
        # if associated function takes keyword arguments in a single
        # dictionary (kwargs), we can pass all options
        if argspec.keywords is None:
            # otherwise we need to remove any unhandled
            for opt_name in kwargs:
                if opt_name not in argspec.args[pos_args_count:]:
                    if opt_name not in self.cmd_name_parts:
                        LOG().debug('Option "%s" not handled in function "%s",'
                            ' ignoring.', opt_name, self.cmd_name)
                    to_remove.append(opt_name)
        for opt_name in to_remove:
            # remove options unhandled by function
            del kwargs[opt_name]
        args = []
        for arg_name in argspec.args[pos_args_count:]:
            if arg_name not in kwargs:
                raise errors.LmiCommandError(
                    self.__module__, self.__class__.__name__,
                    'registered command "%s" expects option "%s", which'
                    ' is not covered in usage string'
                    % (self.cmd_name, arg_name))
            args.append(kwargs.pop(arg_name))
        return args, kwargs

    def _preprocess_options(self, options):
        """
        This method may be overriden by
        :py:class:`~.meta.EndPointCommandMetaClass`
        as a result of processing ``ARG_ARRAY_SUFFIX`` and other properties
        modifying names of parsed options.

        This should not be overriden in command class's body.

        :param dictionary options: The result of ``docopt`` parser invocation
            which can be modified by this method.
        """
        pass

    def _parse_args(self, args):
        """
        Run ``docopt`` command line parser on given list of arguments.
        Removes all unrelated commands from created dictionary of options.

        :param list args: List of command line arguments just after the
            current command.
        :returns: Dictionary with parsed options. Please refer to
            docopt_ documentation for more informations.
        :rtype: dictionary

        .. _docopt: http://docopt.org/
        """
        full_args = self.get_cmd_name_parts(for_docopt=True) + args
        options = docopt(self.get_usage(), full_args, help=False)
        self._preprocess_options(options)

        # remove all command names from options
        cmd = self.parent
        while cmd is not None and not cmd.has_own_usage():
            cmd = cmd.parent
        if cmd is not None:
            for scn in cmd.child_commands():
                try:
                    del options[scn]
                except KeyError:
                    LOG().warn('Usage string of "%s.%s" command does not'
                            ' contain registered command "%s" command.',
                            cmd.__module__, cmd.__class__.__name__, scn)
        # remove also the root command name from options
        if cmd is not None and cmd.cmd_name in options:
            del options[cmd.cmd_name]
        return options

    def verify_options(self, options):
        """
        This method can be overriden in subclasses to check, whether the
        options given on command line are valid. If any flaw is discovered, an
        :py:exc:`~lmi.scripts.common.errors.LmiInvalidOptions` exception shall
        be raised. Any returned value is ignored.

        .. note::
            This is run before :py:meth:`transform_options()` method.

        :param dictionary options: Dictionary as returned by ``docopt`` parser.
        """
        pass

    def transform_options(self, options):
        """
        This method can be overriden in subclasses if options shall be somehow
        modified before passing them associated function.

        .. note::
            Run after :py:meth:`verify_options()` method.

        :param dictionary options: Dictionary as returned by ``docopt`` parser.
        """
        pass

    def produce_output(self, data):
        """
        This method can be use to render and print results with default
        formatter.

        :param data: Is an object expected by the
            :py:meth:`~lmi.scripts.common.formatter.Formatter.produce_output`
            method of formatter.
        """
        self.formatter.produce_output(data)

    def run(self, args):
        """
        Create options dictionary from input arguments, verify them,
        transform them, make positional and keyword arguments out of them and
        pass them to ``process_session()``.

        :param list args: List of command arguments.
        :returns: Exit code of application.
        :rtype: integer
        """
        options = self._parse_args(args)
        self.verify_options(options)
        self.transform_options(options)
        self._options = options.copy()
        args, kwargs = self._make_end_point_args(options)
        return self.run_with_args(args, kwargs)

    def _print_errors(self, error_list, new_line=True):
        """
        Print list of errors.

        :param list errors: Errors to print. Each error is a ``tuple``: ::

                (hostname, [error, error])

            Where ``error`` may be a test description or an instance of
            exception.
        :param new_line: Whether to print the new line before new error
            table is printed.
        """
        fmt = formatter.ErrorFormatter(self.app.stderr)
        if new_line:
            fmt.out.write('\n')
        if error_list:
            new_table_cmd = fcmd.NewTableCommand("There %s %d error%s" %
                ( 'were' if len(error_list) > 1 else 'was'
                , len(error_list)
                , 's' if len(error_list) > 1 else ''))
            fmt.produce_output((new_table_cmd, ))
        for hostname, host_errors in error_list:
            fmt.produce_output((fcmd.NewHostCommand(hostname), ))
            fmt.produce_output(host_errors)