1from collections import namedtuple
2import json
3import logging
4import pprint
5import re
6import six
7
8(STATUS_OK, STATUS_ERROR) = range(2)
9
10CommandsResponse = namedtuple('CommandsResponse', ['status', 'value'])
11
12LOG = logging.getLogger('bgpspeaker.operator.command')
13
14
15def default_help_formatter(quick_helps):
16    """Apply default formatting for help messages
17
18        :param quick_helps: list of tuples containing help info
19     """
20    ret = ''
21    for line in quick_helps:
22        cmd_path, param_hlp, cmd_hlp = line
23        ret += ' '.join(cmd_path) + ' '
24        if param_hlp:
25            ret += param_hlp + ' '
26        ret += '- ' + cmd_hlp + '\n'
27    return ret
28
29
30class Command(object):
31    """Command class is used as a node in tree of commands.
32
33    Each command can do some action or have some sub-commands, just like in IOS
34    Command with it's sub-commands form tree.
35    Each command can have one or more parameters. Parameters have to be
36    distinguishable from sub-commands.
37        One can inject dependency into command Cmd(api=my_object).
38    This dependency will be injected to every sub-command. And can be used
39    to interact with model/data etc.
40        Example of path in command tree `show count all`.
41    """
42
43    help_msg = ''
44    param_help_msg = ''
45    command = ''
46    cli_resp_line_template = '{0}: {1}\n'
47
48    def __init__(self, api=None, parent=None,
49                 help_formatter=default_help_formatter,
50                 resp_formatter_name='cli'):
51        """:param api: object which is saved as self.api
52                 and re-injected to every sub-command. You can use it to
53                 manipulate your model from inside Commands'
54           :param parent: parent command instance.
55           :param help_formatter: function used to format
56                output of '?'command. Is re-injected to every
57                sub-command as well.
58           :param resp_formatter_name: used to select function to format
59                output of _action. cli_resp_formatter and json_resp_formatter
60                are defined by default, but you can define your own formatters.
61                If you use custom formatter(not cli nor json) remember to
62                implement it for every sub-command.
63        """
64
65        self.resp_formatter_name = resp_formatter_name
66
67        if hasattr(self, resp_formatter_name + '_resp_formatter'):
68            self.resp_formatter = \
69                getattr(self, resp_formatter_name + '_resp_formatter')
70        else:
71            self.resp_formatter = self.cli_resp_formatter
72
73        self.api = api
74        self.parent_cmd = parent
75        self.help_formatter = help_formatter
76        if not hasattr(self, 'subcommands'):
77            self.subcommands = {}
78
79    def __call__(self, params):
80        """You run command by calling it.
81
82        :param params: As params you give list of subcommand names
83            and params to final subcommand. Kind of like in
84            cisco ios cli, ie. show int eth1 / 1, where show is command,
85            int subcommand and eth1 / 1 is param for subcommand.
86        :return: returns tuple of CommandsResponse and class of
87            sub - command on which _action was called. (last sub - command)
88            CommandsResponse.status is action status,
89            and CommandsResponse.value is formatted response.
90        """
91        if len(params) == 0:
92            return self._action_wrapper([])
93
94        first_param = params[0]
95
96        if first_param == '?':
97            return self.question_mark()
98
99        if first_param in self.subcommands:
100            return self._instantiate_subcommand(first_param)(params[1:])
101
102        return self._action_wrapper(params)
103
104    @classmethod
105    def cli_resp_formatter(cls, resp):
106        """Override this method to provide custom formatting of cli response.
107        """
108        if not resp.value:
109            return ''
110
111        if resp.status == STATUS_OK:
112
113            if type(resp.value) in (str, bool, int, float, six.text_type):
114                return str(resp.value)
115
116            ret = ''
117            val = resp.value
118            if not isinstance(val, list):
119                val = [val]
120            for line in val:
121                for k, v in line.items():
122                    if isinstance(v, dict):
123                        ret += cls.cli_resp_line_template.format(
124                            k, '\n' + pprint.pformat(v)
125                        )
126                    else:
127                        ret += cls.cli_resp_line_template.format(k, v)
128            return ret
129        else:
130            return "Error: {0}".format(resp.value)
131
132    @classmethod
133    def json_resp_formatter(cls, resp):
134        """Override this method to provide custom formatting of json response.
135        """
136        return json.dumps(resp.value)
137
138    @classmethod
139    def dict_resp_formatter(cls, resp):
140        return resp.value
141
142    def _action_wrapper(self, params):
143        filter_params = []
144        if '|' in params:
145            ind = params.index('|')
146            new_params = params[:ind]
147            filter_params = params[ind:]
148            params = new_params
149
150        action_resp = self.action(params)
151        if len(filter_params) > 1:
152            # we don't pass '|' around so filter_params[1:]
153            action_resp = self.filter_resp(action_resp, filter_params[1:])
154        action_resp = CommandsResponse(
155            action_resp.status,
156            self.resp_formatter(action_resp)
157        )
158        return action_resp, self.__class__
159
160    def action(self, params):
161        """Override this method to define what command should do.
162
163        :param params: list of text parameters applied to this command.
164        :return: returns CommandsResponse instance.
165                 CommandsResponse.status can be STATUS_OK or STATUS_ERROR
166                 CommandsResponse.value should be dict or str
167        """
168        return CommandsResponse(STATUS_ERROR, 'Not implemented')
169
170    def filter_resp(self, action_resp, filter_params):
171        """Filter response of action. Used to make printed results more
172        specific
173
174        :param action_resp: named tuple (CommandsResponse)
175            containing response from action.
176        :param filter_params: params used after '|' specific for given filter
177        :return: filtered response.
178        """
179        if action_resp.status == STATUS_OK:
180            try:
181                return CommandsResponse(
182                    STATUS_OK,
183                    TextFilter.filter(action_resp.value, filter_params)
184                )
185            except FilterError as e:
186                return CommandsResponse(STATUS_ERROR, str(e))
187        else:
188            return action_resp
189
190    def question_mark(self):
191        """Shows help for this command and it's sub-commands.
192        """
193        ret = []
194        if self.param_help_msg or len(self.subcommands) == 0:
195            ret.append(self._quick_help())
196
197        if len(self.subcommands) > 0:
198            for k, _ in sorted(self.subcommands.items()):
199                command_path, param_help, cmd_help = \
200                    self._instantiate_subcommand(k)._quick_help(nested=True)
201                if command_path or param_help or cmd_help:
202                    ret.append((command_path, param_help, cmd_help))
203
204        return (
205            CommandsResponse(STATUS_OK, self.help_formatter(ret)),
206            self.__class__
207        )
208
209    def _quick_help(self, nested=False):
210        """:param nested: True if help is requested directly for this command
211                    and False when help is requested for a list of possible
212                    completions.
213        """
214        if nested:
215            return self.command_path(), None, self.help_msg
216        else:
217            return self.command_path(), self.param_help_msg, self.help_msg
218
219    def command_path(self):
220        if self.parent_cmd:
221            return self.parent_cmd.command_path() + [self.command]
222        else:
223            return [self.command]
224
225    def _instantiate_subcommand(self, key):
226        return self.subcommands[key](
227            api=self.api,
228            parent=self,
229            help_formatter=self.help_formatter,
230            resp_formatter_name=self.resp_formatter_name
231        )
232
233
234class TextFilter(object):
235
236    @classmethod
237    def filter(cls, action_resp_value, filter_params):
238        try:
239            action, expected_value = filter_params
240        except ValueError:
241            raise FilterError('Wrong number of filter parameters')
242        if action == 'regexp':
243
244            if isinstance(action_resp_value, list):
245                resp = list(action_resp_value)
246                iterator = enumerate(action_resp_value)
247            else:
248                resp = dict(action_resp_value)
249                iterator = iter(action_resp_value.items())
250
251            remove = []
252
253            for key, value in iterator:
254                if not re.search(expected_value, str(value)):
255                    remove.append(key)
256
257            if isinstance(resp, list):
258                resp = [resp[key] for key, value in enumerate(resp)
259                        if key not in remove]
260            else:
261                resp = dict([(key, value)
262                             for key, value in resp.items()
263                             if key not in remove])
264
265            return resp
266        else:
267            raise FilterError('Unknown filter')
268
269
270class FilterError(Exception):
271    pass
272