1import re
2import copy
3
4# https://stackoverflow.com/questions/5121931/in-python-how-can-you-load-yaml-mappings-as-ordereddicts
5
6# stdlib
7import os
8from inspect import isclass
9from lxml import etree
10import json
11
12# local
13from jnpr.junos.exception import RpcError
14from jnpr.junos.utils.start_shell import StartShell
15from jnpr.junos.factory.state_machine import StateMachine
16from jnpr.junos.factory.to_json import TableJSONEncoder
17
18from jinja2 import Template
19
20HAS_NTC_TEMPLATE = False
21try:
22    from ntc_templates import parse as ntc_parse
23
24    HAS_NTC_TEMPLATE = True
25except:
26    pass
27
28import logging
29
30logger = logging.getLogger("jnpr.junos.factory.cmdtable")
31
32
33class CMDTable(object):
34    def __init__(self, dev=None, raw=None, path=None, template_dir=None):
35        """
36        :dev: Device instance
37        :raw: string blob of the command output
38        :path: file path to XML, to be used rather than :dev:
39        :template_dir: To look for textfsm templates in this folder first
40        """
41        self._dev = dev
42        self.xml = None
43        self.view = None
44        self.ITEM_FILTER = "name"
45        self._key_list = []
46        self._path = path
47        self._parser = None
48        self.output = None
49        self.data = raw
50        self.template_dir = template_dir
51
52    # -------------------------------------------------------------------------
53    # PUBLIC METHODS
54    # -------------------------------------------------------------------------
55
56    def get(self, *vargs, **kvargs):
57        """
58        Retrieve the XML (string blob under <output> tag of table data from the
59        Device instance and returns back the Table instance - for call-chaining
60        purposes.
61
62        If the Table was created with a :path: rather than a Device,
63        then this method will load the string blob from that file.  In this
64        case, the \*vargs, and \**kvargs are not used.
65
66        ALIAS: __call__
67
68        :vargs:
69          [0] is the table :arg_key: value.  This is used so that
70          the caller can retrieve just one item from the table without
71          having to know the Junos RPC argument.
72
73        :kvargs:
74          these are the name/value pairs relating to the specific Junos
75          command attached to the table.  For example, if the command
76          is 'show ithrottle id {{ id }}', there is a parameter 'id'.
77          Any valid command argument can be passed to :kvargs: to further filter
78          the results of the :get(): operation.  neato!
79
80        NOTES:
81          If you need to create a 'stub' for unit-testing
82          purposes, you want to create a subclass of your table and
83          overload this methods.
84        """
85        self._clearkeys()
86
87        if self._path and self.data:
88            raise AttributeError("path and data are mutually exclusive")
89        if self._path is not None:
90            # for loading from local file-path
91            with open(self._path, "r") as fp:
92                self.data = fp.read().strip()
93            if self.data.startswith("<output>") and self.data.endswith("</output>"):
94                self.data = etree.fromstring(self.data).text
95
96        if "target" in kvargs:
97            self.TARGET = kvargs["target"]
98
99        if "key" in kvargs:
100            self.KEY = kvargs["key"]
101
102        if "key_items" in kvargs:
103            self.KEY_ITEMS = kvargs["key_items"]
104
105        if "filters" in kvargs:
106            self.VIEW.FILTERS = (
107                [kvargs["filters"]]
108                if isinstance(kvargs["filters"], str)
109                else kvargs["filters"]
110            )
111
112        cmd_args = self.CMD_ARGS.copy()
113        if "args" in kvargs and isinstance(kvargs["args"], dict):
114            cmd_args.update(kvargs["args"])
115
116        if len(cmd_args) > 0:
117            self.GET_CMD = Template(self.GET_CMD).render(**cmd_args)
118
119        if self.data is None:
120            # execute the Junos RPC to retrieve the table
121            if hasattr(self, "TARGET"):
122                if self.TARGET is None:
123                    raise ValueError('"target" value not provided')
124                rpc_args = {
125                    "target": self.TARGET,
126                    "command": self.GET_CMD,
127                    "timeout": "0",
128                }
129                try:
130                    self.xml = getattr(self.RPC, "request_pfe_execute")(**rpc_args)
131                    self.data = self.xml.text
132                    ver_info = self._dev.facts.get("version_info")
133                    if ver_info and ver_info.major[0] <= 15:
134                        # Junos <=15.x output has output something like below
135                        #
136                        # <rpc-reply>
137                        # <output>
138                        # SENT: Ukern command: show memory
139                        # GOT:
140                        # GOT: ID      Base      Total(b)       Free(b)     Used(b)
141                        # GOT: --  --------     ---------     ---------   ---------
142                        # GOT:  0  44e72078    1882774284    1689527364   193246920
143                        # GOT:  1  b51ffb88      67108860      57651900     9456960
144                        # GOT:  2  bcdfffe0      52428784      52428784           0
145                        # GOT:  3  b91ffb88      62914556      62914556           0
146                        # LOCAL: End of file
147                        # </output>
148                        # </rpc-reply>
149                        # hence need to do cleanup
150                        self.data = self.data.replace("GOT: ", "")
151                except RpcError:
152                    with StartShell(self.D) as ss:
153                        ret = ss.run(
154                            'cprod -A %s -c "%s"' % (self.TARGET, self.GET_CMD)
155                        )
156                        if ret[0]:
157                            self.data = ret[1]
158            else:
159                self.xml = self.RPC.cli(self.GET_CMD)
160                self.data = self.xml.text
161
162        if self.USE_TEXTFSM:
163            if HAS_NTC_TEMPLATE:
164                self.output = self._parse_textfsm(
165                    platform=self.PLATFORM, command=self.GET_CMD, raw=self.data
166                )
167            else:
168                raise ImportError(
169                    "ntc_template is missing. Need to be installed explicitly."
170                )
171        else:
172            # state machine
173            sm = StateMachine(self)
174            self.output = sm.parse(self.data.splitlines())
175
176        # returning self for call-chaining purposes, yo!
177        return self
178
179    # -------------------------------------------------------------------------
180    # PROPERTIES
181    # -------------------------------------------------------------------------
182
183    @property
184    def D(self):
185        """ the Device instance """
186        return self._dev
187
188    @property
189    def CLI(self):
190        """ the Device.cli instance """
191        return self.D.cli
192
193    @property
194    def RPC(self):
195        """ the Device.rpc instance """
196        return self.D.rpc
197
198    @property
199    def view(self):
200        """ returns the current view assigned to this table """
201        return self._view
202
203    @view.setter
204    def view(self, cls):
205        """ assigns a new view to the table """
206        if cls is None:
207            self._view = None
208            return
209
210        if not isclass(cls):
211            raise ValueError("Must be given RunstatView class")
212
213        self._view = cls
214
215    @property
216    def hostname(self):
217        return self.D.hostname
218
219    @property
220    def key_list(self):
221        """ the list of keys, as property for caching """
222        return self._key_list
223
224    # -------------------------------------------------------------------------
225    # PRIVATE METHODS
226    # -------------------------------------------------------------------------
227
228    def _assert_data(self):
229        if self.data is None:
230            raise RuntimeError("Table is empty, use get()")
231
232    def _clearkeys(self):
233        self._key_list = []
234
235    # -------------------------------------------------------------------------
236    # PUBLIC METHODS
237    # -------------------------------------------------------------------------
238
239    # ------------------------------------------------------------------------
240    # keys
241    # ------------------------------------------------------------------------
242
243    def _keys(self):
244        """ return a list of data item keys from the data string """
245
246        return self.output.keys()
247
248    def keys(self):
249        # if the key_list has been cached, then use it
250        if len(self.key_list):
251            return self.key_list
252
253        # otherwise, build the list of keys into the cache
254        self._key_list = self._keys()
255        return self._key_list
256
257    # ------------------------------------------------------------------------
258    # values
259    # ------------------------------------------------------------------------
260
261    def values(self):
262        """ returns list of table entry items() """
263
264        self._assert_data()
265        return self.output.values()
266
267    # ------------------------------------------------------------------------
268    # items
269    # ------------------------------------------------------------------------
270
271    def items(self):
272        """ returns list of tuple(name,values) for each table entry """
273        return self.output.items()
274
275    def to_json(self):
276        """
277        :returns: JSON encoded string of entire Table contents
278        """
279        return json.dumps(self, cls=TableJSONEncoder)
280
281    # -------------------------------------------------------------------------
282    # OVERLOADS
283    # -------------------------------------------------------------------------
284
285    __call__ = get
286
287    def __repr__(self):
288        cls_name = self.__class__.__name__
289        source = self.D.hostname if self.D is not None else self._path
290
291        if self.data is None:
292            return "%s:%s - Table empty" % (cls_name, source)
293        else:
294            n_items = len(self.keys())
295            return "%s:%s: %s items" % (cls_name, source, n_items)
296
297    def __len__(self):
298        self._assert_data()
299        return len(self.keys())
300
301    def __iter__(self):
302        """ iterate over each time in the table """
303        self._assert_data()
304
305        for key, value in self.output.items():
306            yield key, value
307
308    def __getitem__(self, value):
309        """
310        returns a table item. If a table view is set (should be by default)
311        then the item will be converted to the view upon return.  if there is
312        no table view, then the XML object will be returned.
313
314        :value:
315          for <string>, this will perform a select based on key-name
316          for <tuple>, this will perform a select based on compsite key-name
317          for <int>, this will perform a select based by position, like <list>
318            [0] is the first item
319            [-1] is the last item
320          when it is a <slice> then this will return a <list> of View widgets
321        """
322        self._assert_data()
323        return self.output[value]
324
325    def __contains__(self, key):
326        """ membership for use with 'in' """
327        return bool(key in self.keys())
328
329    # ------------------------------------------------------------------------
330    # textfsm
331    # ------------------------------------------------------------------------
332
333    def _parse_textfsm(self, platform=None, command=None, raw=None):
334        """
335        textfsm returns list of dict, make it JSON/dict
336
337        :param platform: vendor platform, for ex cisco_xr
338        :param command: cli command to be parsed
339        :param raw: string blob output from the cli command execution
340        :return: dict of parsed data.
341        """
342        attrs = dict(Command=command, Platform=platform)
343
344        template = None
345        template_dir = None
346        if self.template_dir is not None:
347            # we dont need index file for lookup
348            index = None
349            template_path = os.path.join(
350                self.template_dir,
351                "{}_{}.textfsm".format(platform, "_".join(command.split())),
352            )
353            if not os.path.exists(template_path):
354                msg = "Template file %s missing" % template_path
355                logger.error(msg)
356                raise FileNotFoundError(msg)
357            else:
358                template = template_path
359                template_dir = self.template_dir
360        if template_dir is None:
361            index = "index"
362            template_dir = ntc_parse._get_template_dir()
363
364        cli_table = ntc_parse.clitable.CliTable(index, template_dir)
365        try:
366            cli_table.ParseCmd(raw, attrs, template)
367        except ntc_parse.clitable.CliTableError as ex:
368            logger.error(
369                'Unable to parse command "%s" on platform %s' % (command, platform)
370            )
371            raise ex
372        return self._filter_output(cli_table)
373
374    def _filter_output(self, cli_table):
375        """
376        textfsm return list of list, covert it into more consumable format
377
378        :param cli_table: CLiTable object from textfsm
379        :return: dict of key, fields and its values, list of dict when key is None
380        """
381        self._set_key(cli_table)
382
383        fields = self.VIEW.FIELDS if self.VIEW is not None else {}
384        reverse_fields = {val: key for key, val in fields.items()}
385        if self.KEY is None:
386            cli_table_size = cli_table.size
387            if cli_table_size > 1:
388                raise KeyError(
389                    "Key is Mandatory for parsed o/p of %s " "length" % cli_table_size
390                )
391            elif cli_table_size == 1:
392                temp_dict = self._parse_row(cli_table[1], cli_table, reverse_fields)
393                logger.debug("For Null Key, data returned: {}".format(temp_dict))
394                return temp_dict
395        output = {}
396        for row in cli_table:
397            temp_dict = self._parse_row(row, cli_table, reverse_fields)
398            logger.debug("data at index {} is {}".format(row.row, temp_dict))
399            if isinstance(self.KEY, list):
400                key_list = []
401                for key in self.KEY:
402                    if key not in fields:
403                        key_list.append(temp_dict.pop(key))
404                    else:
405                        key_list.append(temp_dict[key])
406                output[tuple(key_list)] = temp_dict
407            elif self.KEY in temp_dict:
408                if self.KEY not in fields:
409                    output[temp_dict.pop(self.KEY)] = temp_dict
410                else:
411                    output[temp_dict[self.KEY]] = temp_dict
412            else:
413                logger.debug("Key {} not present in {}".format(self.KEY, temp_dict))
414        return output
415
416    def _parse_row(self, row, cli_table, reverse_fields):
417        temp_dict = {}
418        for index, element in enumerate(row):
419            key = cli_table.header[index]
420            if self.KEY and key in self.KEY:
421                temp_dict[key] = element
422            if reverse_fields:
423                if key in reverse_fields:
424                    temp_dict[reverse_fields[key]] = element
425            else:
426                temp_dict[key] = element
427        return temp_dict
428
429    def _set_key(self, cli_table):
430        """
431        Preference to user provided key, then template and at last default
432        Checks and update if we need KEY from template file
433
434        :param cli_table: CLiTable object from textfsm
435        :return:
436        """
437        if self.KEY == "name" and len(cli_table._keys) > 0:
438            template_keys = list(cli_table._keys)
439            self.KEY = template_keys[0] if len(template_keys) == 1 else template_keys
440        logger.debug("KEY being used: {}".format(self.KEY))
441