1055 lines
30 KiB
Python
1055 lines
30 KiB
Python
#!/usr/bin/python2
|
|
#
|
|
# Copyright 2010 Google Inc. All Rights Reserved.
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
# implied. See the License for the specific language governing
|
|
# permissions and limitations under the License.
|
|
|
|
"""Template based text parser.
|
|
|
|
This module implements a parser, intended to be used for converting
|
|
human readable text, such as command output from a router CLI, into
|
|
a list of records, containing values extracted from the input text.
|
|
|
|
A simple template language is used to describe a state machine to
|
|
parse a specific type of text input, returning a record of values
|
|
for each input entity.
|
|
|
|
Import it to ~/file/client/common_lib/cros/.
|
|
"""
|
|
from __future__ import absolute_import
|
|
from __future__ import division
|
|
from __future__ import print_function
|
|
|
|
__version__ = '0.3.2'
|
|
|
|
import getopt
|
|
import inspect
|
|
import re
|
|
import string
|
|
import sys
|
|
|
|
|
|
class Error(Exception):
|
|
"""Base class for errors."""
|
|
|
|
|
|
class Usage(Exception):
|
|
"""Error in command line execution."""
|
|
|
|
|
|
class TextFSMError(Error):
|
|
"""Error in the FSM state execution."""
|
|
|
|
|
|
class TextFSMTemplateError(Error):
|
|
"""Errors while parsing templates."""
|
|
|
|
|
|
# The below exceptions are internal state change triggers
|
|
# and not used as Errors.
|
|
class FSMAction(Exception):
|
|
"""Base class for actions raised with the FSM."""
|
|
|
|
|
|
class SkipRecord(FSMAction):
|
|
"""Indicate a record is to be skipped."""
|
|
|
|
|
|
class SkipValue(FSMAction):
|
|
"""Indicate a value is to be skipped."""
|
|
|
|
|
|
class TextFSMOptions(object):
|
|
"""Class containing all valid TextFSMValue options.
|
|
|
|
Each nested class here represents a TextFSM option. The format
|
|
is "option<name>".
|
|
Each class may override any of the methods inside the OptionBase class.
|
|
|
|
A user of this module can extend options by subclassing
|
|
TextFSMOptionsBase, adding the new option class(es), then passing
|
|
that new class to the TextFSM constructor with the 'option_class'
|
|
argument.
|
|
"""
|
|
|
|
class OptionBase(object):
|
|
"""Factory methods for option class.
|
|
|
|
Attributes:
|
|
value: A TextFSMValue, the parent Value.
|
|
"""
|
|
|
|
def __init__(self, value):
|
|
self.value = value
|
|
|
|
@property
|
|
def name(self):
|
|
return self.__class__.__name__.replace('option', '')
|
|
|
|
def OnCreateOptions(self):
|
|
"""Called after all options have been parsed for a Value."""
|
|
|
|
def OnClearVar(self):
|
|
"""Called when value has been cleared."""
|
|
|
|
def OnClearAllVar(self):
|
|
"""Called when a value has clearalled."""
|
|
|
|
def OnAssignVar(self):
|
|
"""Called when a matched value is being assigned."""
|
|
|
|
def OnGetValue(self):
|
|
"""Called when the value name is being requested."""
|
|
|
|
def OnSaveRecord(self):
|
|
"""Called just prior to a record being committed."""
|
|
|
|
@classmethod
|
|
def ValidOptions(cls):
|
|
"""Returns a list of valid option names."""
|
|
valid_options = []
|
|
for obj_name in dir(cls):
|
|
obj = getattr(cls, obj_name)
|
|
if inspect.isclass(obj) and issubclass(obj, cls.OptionBase):
|
|
valid_options.append(obj_name)
|
|
return valid_options
|
|
|
|
@classmethod
|
|
def GetOption(cls, name):
|
|
"""Returns the class of the requested option name."""
|
|
return getattr(cls, name)
|
|
|
|
class Required(OptionBase):
|
|
"""The Value must be non-empty for the row to be recorded."""
|
|
|
|
def OnSaveRecord(self):
|
|
if not self.value.value:
|
|
raise SkipRecord
|
|
|
|
class Filldown(OptionBase):
|
|
"""Value defaults to the previous line's value."""
|
|
|
|
def OnCreateOptions(self):
|
|
self._myvar = None
|
|
|
|
def OnAssignVar(self):
|
|
self._myvar = self.value.value
|
|
|
|
def OnClearVar(self):
|
|
self.value.value = self._myvar
|
|
|
|
def OnClearAllVar(self):
|
|
self._myvar = None
|
|
|
|
class Fillup(OptionBase):
|
|
"""Like Filldown, but upwards until it finds a non-empty entry."""
|
|
|
|
def OnAssignVar(self):
|
|
# If value is set, copy up the results table, until we
|
|
# see a set item.
|
|
if self.value.value:
|
|
# Get index of relevant result column.
|
|
value_idx = self.value.fsm.values.index(self.value)
|
|
# Go up the list from the end until we see a filled value.
|
|
# pylint: disable=protected-access
|
|
for result in reversed(self.value.fsm._result):
|
|
if result[value_idx]:
|
|
# Stop when a record has this column already.
|
|
break
|
|
# Otherwise set the column value.
|
|
result[value_idx] = self.value.value
|
|
|
|
class Key(OptionBase):
|
|
"""Value constitutes part of the Key of the record."""
|
|
|
|
class List(OptionBase):
|
|
"""Value takes the form of a list."""
|
|
|
|
def OnCreateOptions(self):
|
|
self.OnClearAllVar()
|
|
|
|
def OnAssignVar(self):
|
|
self._value.append(self.value.value)
|
|
|
|
def OnClearVar(self):
|
|
if 'Filldown' not in self.value.OptionNames():
|
|
self._value = []
|
|
|
|
def OnClearAllVar(self):
|
|
self._value = []
|
|
|
|
def OnSaveRecord(self):
|
|
self.value.value = list(self._value)
|
|
|
|
|
|
class TextFSMValue(object):
|
|
"""A TextFSM value.
|
|
|
|
A value has syntax like:
|
|
|
|
'Value Filldown,Required helloworld (.*)'
|
|
|
|
Where 'Value' is a keyword.
|
|
'Filldown' and 'Required' are options.
|
|
'helloworld' is the value name.
|
|
'(.*) is the regular expression to match in the input data.
|
|
|
|
Attributes:
|
|
max_name_len: (int), maximum character length os a variable name.
|
|
name: (str), Name of the value.
|
|
options: (list), A list of current Value Options.
|
|
regex: (str), Regex which the value is matched on.
|
|
template: (str), regexp with named groups added.
|
|
fsm: A TextFSMBase(), the containing FSM.
|
|
value: (str), the current value.
|
|
"""
|
|
# The class which contains valid options.
|
|
|
|
def __init__(self, fsm=None, max_name_len=48, options_class=None):
|
|
"""Initialise a new TextFSMValue."""
|
|
self.max_name_len = max_name_len
|
|
self.name = None
|
|
self.options = []
|
|
self.regex = None
|
|
self.value = None
|
|
self.fsm = fsm
|
|
self._options_cls = options_class
|
|
|
|
def AssignVar(self, value):
|
|
"""Assign a value to this Value."""
|
|
self.value = value
|
|
# Call OnAssignVar on options.
|
|
_ = [option.OnAssignVar() for option in self.options]
|
|
|
|
def ClearVar(self):
|
|
"""Clear this Value."""
|
|
self.value = None
|
|
# Call OnClearVar on options.
|
|
_ = [option.OnClearVar() for option in self.options]
|
|
|
|
def ClearAllVar(self):
|
|
"""Clear this Value."""
|
|
self.value = None
|
|
# Call OnClearAllVar on options.
|
|
_ = [option.OnClearAllVar() for option in self.options]
|
|
|
|
def Header(self):
|
|
"""Fetch the header name of this Value."""
|
|
# Call OnGetValue on options.
|
|
_ = [option.OnGetValue() for option in self.options]
|
|
return self.name
|
|
|
|
def OptionNames(self):
|
|
"""Returns a list of option names for this Value."""
|
|
return [option.name for option in self.options]
|
|
|
|
def Parse(self, value):
|
|
"""Parse a 'Value' declaration.
|
|
|
|
Args:
|
|
value: String line from a template file, must begin with 'Value '.
|
|
|
|
Raises:
|
|
TextFSMTemplateError: Value declaration contains an error.
|
|
|
|
"""
|
|
|
|
value_line = value.split(' ')
|
|
if len(value_line) < 3:
|
|
raise TextFSMTemplateError('Expect at least 3 tokens on line.')
|
|
|
|
if not value_line[2].startswith('('):
|
|
# Options are present
|
|
options = value_line[1]
|
|
for option in options.split(','):
|
|
self._AddOption(option)
|
|
# Call option OnCreateOptions callbacks
|
|
_ = [option.OnCreateOptions() for option in self.options]
|
|
|
|
self.name = value_line[2]
|
|
self.regex = ' '.join(value_line[3:])
|
|
else:
|
|
# There were no valid options, so there are no options.
|
|
# Treat this argument as the name.
|
|
self.name = value_line[1]
|
|
self.regex = ' '.join(value_line[2:])
|
|
|
|
if len(self.name) > self.max_name_len:
|
|
raise TextFSMTemplateError(
|
|
"Invalid Value name '%s' or name too long." % self.name)
|
|
|
|
if (not re.match(r'^\(.*\)$', self.regex) or
|
|
self.regex.count('(') != self.regex.count(')')):
|
|
raise TextFSMTemplateError(
|
|
"Value '%s' must be contained within a '()' pair." % self.regex)
|
|
|
|
self.template = re.sub(r'^\(', '(?P<%s>' % self.name, self.regex)
|
|
|
|
def _AddOption(self, name):
|
|
"""Add an option to this Value.
|
|
|
|
Args:
|
|
name: (str), the name of the Option to add.
|
|
|
|
Raises:
|
|
TextFSMTemplateError: If option is already present or
|
|
the option does not exist.
|
|
"""
|
|
|
|
# Check for duplicate option declaration
|
|
if name in [option.name for option in self.options]:
|
|
raise TextFSMTemplateError('Duplicate option "%s"' % name)
|
|
|
|
# Create the option object
|
|
try:
|
|
option = self._options_cls.GetOption(name)(self)
|
|
except AttributeError:
|
|
raise TextFSMTemplateError('Unknown option "%s"' % name)
|
|
|
|
self.options.append(option)
|
|
|
|
def OnSaveRecord(self):
|
|
"""Called just prior to a record being committed."""
|
|
_ = [option.OnSaveRecord() for option in self.options]
|
|
|
|
def __str__(self):
|
|
"""Prints out the FSM Value, mimic the input file."""
|
|
|
|
if self.options:
|
|
return 'Value %s %s %s' % (
|
|
','.join(self.OptionNames()),
|
|
self.name,
|
|
self.regex)
|
|
else:
|
|
return 'Value %s %s' % (self.name, self.regex)
|
|
|
|
|
|
class CopyableRegexObject(object):
|
|
"""Like a re.RegexObject, but can be copied."""
|
|
# pylint: disable=C6409
|
|
|
|
def __init__(self, pattern):
|
|
self.pattern = pattern
|
|
self.regex = re.compile(pattern)
|
|
|
|
def match(self, *args, **kwargs):
|
|
return self.regex.match(*args, **kwargs)
|
|
|
|
def sub(self, *args, **kwargs):
|
|
return self.regex.sub(*args, **kwargs)
|
|
|
|
def __copy__(self):
|
|
return CopyableRegexObject(self.pattern)
|
|
|
|
def __deepcopy__(self, unused_memo):
|
|
return self.__copy__()
|
|
|
|
|
|
class TextFSMRule(object):
|
|
"""A rule in each FSM state.
|
|
|
|
A value has syntax like:
|
|
|
|
^<regexp> -> Next.Record State2
|
|
|
|
Where '<regexp>' is a regular expression.
|
|
'Next' is a Line operator.
|
|
'Record' is a Record operator.
|
|
'State2' is the next State.
|
|
|
|
Attributes:
|
|
match: Regex to match this rule.
|
|
regex: match after template substitution.
|
|
line_op: Operator on input line on match.
|
|
record_op: Operator on output record on match.
|
|
new_state: Label to jump to on action
|
|
regex_obj: Compiled regex for which the rule matches.
|
|
line_num: Integer row number of Value.
|
|
"""
|
|
# Implicit default is '(regexp) -> Next.NoRecord'
|
|
MATCH_ACTION = re.compile(r'(?P<match>.*)(\s->(?P<action>.*))')
|
|
|
|
# The structure to the right of the '->'.
|
|
LINE_OP = ('Continue', 'Next', 'Error')
|
|
RECORD_OP = ('Clear', 'Clearall', 'Record', 'NoRecord')
|
|
|
|
# Line operators.
|
|
LINE_OP_RE = '(?P<ln_op>%s)' % '|'.join(LINE_OP)
|
|
# Record operators.
|
|
RECORD_OP_RE = '(?P<rec_op>%s)' % '|'.join(RECORD_OP)
|
|
# Line operator with optional record operator.
|
|
OPERATOR_RE = r'(%s(\.%s)?)' % (LINE_OP_RE, RECORD_OP_RE)
|
|
# New State or 'Error' string.
|
|
NEWSTATE_RE = r'(?P<new_state>\w+|\".*\")'
|
|
|
|
# Compound operator (line and record) with optional new state.
|
|
ACTION_RE = re.compile(r'\s+%s(\s+%s)?$' % (OPERATOR_RE, NEWSTATE_RE))
|
|
# Record operator with optional new state.
|
|
ACTION2_RE = re.compile(r'\s+%s(\s+%s)?$' % (RECORD_OP_RE, NEWSTATE_RE))
|
|
# Default operators with optional new state.
|
|
ACTION3_RE = re.compile(r'(\s+%s)?$' % (NEWSTATE_RE))
|
|
|
|
def __init__(self, line, line_num=-1, var_map=None):
|
|
"""Initialise a new rule object.
|
|
|
|
Args:
|
|
line: (str), a template rule line to parse.
|
|
line_num: (int), Optional line reference included in error reporting.
|
|
var_map: Map for template (${var}) substitutions.
|
|
|
|
Raises:
|
|
TextFSMTemplateError: If 'line' is not a valid format for a Value entry.
|
|
"""
|
|
self.match = ''
|
|
self.regex = ''
|
|
self.regex_obj = None
|
|
self.line_op = '' # Equivalent to 'Next'.
|
|
self.record_op = '' # Equivalent to 'NoRecord'.
|
|
self.new_state = '' # Equivalent to current state.
|
|
self.line_num = line_num
|
|
|
|
line = line.strip()
|
|
if not line:
|
|
raise TextFSMTemplateError('Null data in FSMRule. Line: %s'
|
|
% self.line_num)
|
|
|
|
# Is there '->' action present.
|
|
match_action = self.MATCH_ACTION.match(line)
|
|
if match_action:
|
|
self.match = match_action.group('match')
|
|
else:
|
|
self.match = line
|
|
|
|
# Replace ${varname} entries.
|
|
self.regex = self.match
|
|
if var_map:
|
|
try:
|
|
self.regex = string.Template(self.match).substitute(var_map)
|
|
except (ValueError, KeyError):
|
|
raise TextFSMTemplateError(
|
|
"Duplicate or invalid variable substitution: '%s'. Line: %s." %
|
|
(self.match, self.line_num))
|
|
|
|
try:
|
|
# Work around a regression in Python 2.6 that makes RE Objects uncopyable.
|
|
self.regex_obj = CopyableRegexObject(self.regex)
|
|
except re.error:
|
|
raise TextFSMTemplateError(
|
|
"Invalid regular expression: '%s'. Line: %s." %
|
|
(self.regex, self.line_num))
|
|
|
|
# No '->' present, so done.
|
|
if not match_action:
|
|
return
|
|
|
|
# Attempt to match line.record operation.
|
|
action_re = self.ACTION_RE.match(match_action.group('action'))
|
|
if not action_re:
|
|
# Attempt to match record operation.
|
|
action_re = self.ACTION2_RE.match(match_action.group('action'))
|
|
if not action_re:
|
|
# Math implicit defaults with an optional new state.
|
|
action_re = self.ACTION3_RE.match(match_action.group('action'))
|
|
if not action_re:
|
|
# Last attempt, match an optional new state only.
|
|
raise TextFSMTemplateError("Badly formatted rule '%s'. Line: %s." %
|
|
(line, self.line_num))
|
|
|
|
# We have an Line operator.
|
|
if 'ln_op' in action_re.groupdict() and action_re.group('ln_op'):
|
|
self.line_op = action_re.group('ln_op')
|
|
|
|
# We have a record operator.
|
|
if 'rec_op' in action_re.groupdict() and action_re.group('rec_op'):
|
|
self.record_op = action_re.group('rec_op')
|
|
|
|
# A new state was specified.
|
|
if 'new_state' in action_re.groupdict() and action_re.group('new_state'):
|
|
self.new_state = action_re.group('new_state')
|
|
|
|
# Only 'Next' (or implicit 'Next') line operator can have a new_state.
|
|
# But we allow error to have one as a warning message so we are left
|
|
# checking that Continue does not.
|
|
if self.line_op == 'Continue' and self.new_state:
|
|
raise TextFSMTemplateError(
|
|
"Action '%s' with new state %s specified. Line: %s."
|
|
% (self.line_op, self.new_state, self.line_num))
|
|
|
|
# Check that an error message is present only with the 'Error' operator.
|
|
if self.line_op != 'Error' and self.new_state:
|
|
if not re.match(r'\w+', self.new_state):
|
|
raise TextFSMTemplateError(
|
|
'Alphanumeric characters only in state names. Line: %s.'
|
|
% (self.line_num))
|
|
|
|
def __str__(self):
|
|
"""Prints out the FSM Rule, mimic the input file."""
|
|
|
|
operation = ''
|
|
if self.line_op and self.record_op:
|
|
operation = '.'
|
|
|
|
operation = '%s%s%s' % (self.line_op, operation, self.record_op)
|
|
|
|
if operation and self.new_state:
|
|
new_state = ' ' + self.new_state
|
|
else:
|
|
new_state = self.new_state
|
|
|
|
# Print with implicit defaults.
|
|
if not (operation or new_state):
|
|
return ' %s' % self.match
|
|
|
|
# Non defaults.
|
|
return ' %s -> %s%s' % (self.match, operation, new_state)
|
|
|
|
|
|
class TextFSM(object):
|
|
"""Parses template and creates Finite State Machine (FSM).
|
|
|
|
Attributes:
|
|
states: (str), Dictionary of FSMState objects.
|
|
values: (str), List of FSMVariables.
|
|
value_map: (map), For substituting values for names in the expressions.
|
|
header: Ordered list of values.
|
|
state_list: Ordered list of valid states.
|
|
"""
|
|
# Variable and State name length.
|
|
MAX_NAME_LEN = 48
|
|
comment_regex = re.compile(r'^\s*#')
|
|
state_name_re = re.compile(r'^(\w+)$')
|
|
_DEFAULT_OPTIONS = TextFSMOptions
|
|
|
|
def __init__(self, template, options_class=_DEFAULT_OPTIONS):
|
|
"""Initialises and also parses the template file."""
|
|
|
|
self._options_cls = options_class
|
|
self.states = {}
|
|
# Track order of state definitions.
|
|
self.state_list = []
|
|
self.values = []
|
|
self.value_map = {}
|
|
# Track where we are for error reporting.
|
|
self._line_num = 0
|
|
# Run FSM in this state
|
|
self._cur_state = None
|
|
# Name of the current state.
|
|
self._cur_state_name = None
|
|
|
|
# Read and parse FSM definition.
|
|
# Restore the file pointer once done.
|
|
try:
|
|
self._Parse(template)
|
|
finally:
|
|
template.seek(0)
|
|
|
|
# Initialise starting data.
|
|
self.Reset()
|
|
|
|
def __str__(self):
|
|
"""Returns the FSM template, mimic the input file."""
|
|
|
|
result = '\n'.join([str(value) for value in self.values])
|
|
result += '\n'
|
|
|
|
for state in self.state_list:
|
|
result += '\n%s\n' % state
|
|
state_rules = '\n'.join([str(rule) for rule in self.states[state]])
|
|
if state_rules:
|
|
result += state_rules + '\n'
|
|
|
|
return result
|
|
|
|
def Reset(self):
|
|
"""Preserves FSM but resets starting state and current record."""
|
|
|
|
# Current state is Start state.
|
|
self._cur_state = self.states['Start']
|
|
self._cur_state_name = 'Start'
|
|
|
|
# Clear table of results and current record.
|
|
self._result = []
|
|
self._ClearAllRecord()
|
|
|
|
@property
|
|
def header(self):
|
|
"""Returns header."""
|
|
return self._GetHeader()
|
|
|
|
def _GetHeader(self):
|
|
"""Returns header."""
|
|
header = []
|
|
for value in self.values:
|
|
try:
|
|
header.append(value.Header())
|
|
except SkipValue:
|
|
continue
|
|
return header
|
|
|
|
def _GetValue(self, name):
|
|
"""Returns the TextFSMValue object natching the requested name."""
|
|
for value in self.values:
|
|
if value.name == name:
|
|
return value
|
|
|
|
def _AppendRecord(self):
|
|
"""Adds current record to result if well formed."""
|
|
|
|
# If no Values then don't output.
|
|
if not self.values:
|
|
return
|
|
|
|
cur_record = []
|
|
for value in self.values:
|
|
try:
|
|
value.OnSaveRecord()
|
|
except SkipRecord:
|
|
self._ClearRecord()
|
|
return
|
|
except SkipValue:
|
|
continue
|
|
|
|
# Build current record into a list.
|
|
cur_record.append(value.value)
|
|
|
|
# If no Values in template or whole record is empty then don't output.
|
|
if len(cur_record) == (cur_record.count(None) + cur_record.count([])):
|
|
return
|
|
|
|
# Replace any 'None' entries with null string ''.
|
|
while None in cur_record:
|
|
cur_record[cur_record.index(None)] = ''
|
|
|
|
self._result.append(cur_record)
|
|
self._ClearRecord()
|
|
|
|
def _Parse(self, template):
|
|
"""Parses template file for FSM structure.
|
|
|
|
Args:
|
|
template: Valid template file.
|
|
|
|
Raises:
|
|
TextFSMTemplateError: If template file syntax is invalid.
|
|
"""
|
|
|
|
if not template:
|
|
raise TextFSMTemplateError('Null template.')
|
|
|
|
# Parse header with Variables.
|
|
self._ParseFSMVariables(template)
|
|
|
|
# Parse States.
|
|
while self._ParseFSMState(template):
|
|
pass
|
|
|
|
# Validate destination states.
|
|
self._ValidateFSM()
|
|
|
|
def _ParseFSMVariables(self, template):
|
|
"""Extracts Variables from start of template file.
|
|
|
|
Values are expected as a contiguous block at the head of the file.
|
|
These will be line separated from the State definitions that follow.
|
|
|
|
Args:
|
|
template: Valid template file, with Value definitions at the top.
|
|
|
|
Raises:
|
|
TextFSMTemplateError: If syntax or semantic errors are found.
|
|
"""
|
|
|
|
self.values = []
|
|
|
|
for line in template:
|
|
self._line_num += 1
|
|
line = line.rstrip()
|
|
|
|
# Blank line signifies end of Value definitions.
|
|
if not line:
|
|
return
|
|
|
|
# Skip commented lines.
|
|
if self.comment_regex.match(line):
|
|
continue
|
|
|
|
if line.startswith('Value '):
|
|
try:
|
|
value = TextFSMValue(
|
|
fsm=self, max_name_len=self.MAX_NAME_LEN,
|
|
options_class=self._options_cls)
|
|
value.Parse(line)
|
|
except TextFSMTemplateError as error:
|
|
raise TextFSMTemplateError('%s Line %s.' % (error, self._line_num))
|
|
|
|
if value.name in self.header:
|
|
raise TextFSMTemplateError(
|
|
"Duplicate declarations for Value '%s'. Line: %s."
|
|
% (value.name, self._line_num))
|
|
|
|
try:
|
|
self._ValidateOptions(value)
|
|
except TextFSMTemplateError as error:
|
|
raise TextFSMTemplateError('%s Line %s.' % (error, self._line_num))
|
|
|
|
self.values.append(value)
|
|
self.value_map[value.name] = value.template
|
|
# The line has text but without the 'Value ' prefix.
|
|
elif not self.values:
|
|
raise TextFSMTemplateError('No Value definitions found.')
|
|
else:
|
|
raise TextFSMTemplateError(
|
|
'Expected blank line after last Value entry. Line: %s.'
|
|
% (self._line_num))
|
|
|
|
def _ValidateOptions(self, value):
|
|
"""Checks that combination of Options is valid."""
|
|
# Always passes in base class.
|
|
pass
|
|
|
|
def _ParseFSMState(self, template):
|
|
"""Extracts State and associated Rules from body of template file.
|
|
|
|
After the Value definitions the remainder of the template is
|
|
state definitions. The routine is expected to be called iteratively
|
|
until no more states remain - indicated by returning None.
|
|
|
|
The routine checks that the state names are a well formed string, do
|
|
not clash with reserved names and are unique.
|
|
|
|
Args:
|
|
template: Valid template file after Value definitions
|
|
have already been read.
|
|
|
|
Returns:
|
|
Name of the state parsed from file. None otherwise.
|
|
|
|
Raises:
|
|
TextFSMTemplateError: If any state definitions are invalid.
|
|
"""
|
|
|
|
if not template:
|
|
return
|
|
|
|
state_name = ''
|
|
# Strip off extra white space lines (including comments).
|
|
for line in template:
|
|
self._line_num += 1
|
|
line = line.rstrip()
|
|
|
|
# First line is state definition
|
|
if line and not self.comment_regex.match(line):
|
|
# Ensure statename has valid syntax and is not a reserved word.
|
|
if (not self.state_name_re.match(line) or
|
|
len(line) > self.MAX_NAME_LEN or
|
|
line in TextFSMRule.LINE_OP or
|
|
line in TextFSMRule.RECORD_OP):
|
|
raise TextFSMTemplateError("Invalid state name: '%s'. Line: %s"
|
|
% (line, self._line_num))
|
|
|
|
state_name = line
|
|
if state_name in self.states:
|
|
raise TextFSMTemplateError("Duplicate state name: '%s'. Line: %s"
|
|
% (line, self._line_num))
|
|
self.states[state_name] = []
|
|
self.state_list.append(state_name)
|
|
break
|
|
|
|
# Parse each rule in the state.
|
|
for line in template:
|
|
self._line_num += 1
|
|
line = line.rstrip()
|
|
|
|
# Finish rules processing on blank line.
|
|
if not line:
|
|
break
|
|
|
|
if self.comment_regex.match(line):
|
|
continue
|
|
|
|
# A rule within a state, starts with whitespace
|
|
if not (line.startswith(' ^') or line.startswith('\t^')):
|
|
raise TextFSMTemplateError(
|
|
"Missing white space or carat ('^') before rule. Line: %s" %
|
|
self._line_num)
|
|
|
|
self.states[state_name].append(
|
|
TextFSMRule(line, self._line_num, self.value_map))
|
|
|
|
return state_name
|
|
|
|
def _ValidateFSM(self):
|
|
"""Checks state names and destinations for validity.
|
|
|
|
Each destination state must exist, be a valid name and
|
|
not be a reserved name.
|
|
There must be a 'Start' state and if 'EOF' or 'End' states are specified,
|
|
they must be empty.
|
|
|
|
Returns:
|
|
True if FSM is valid.
|
|
|
|
Raises:
|
|
TextFSMTemplateError: If any state definitions are invalid.
|
|
"""
|
|
|
|
# Must have 'Start' state.
|
|
if 'Start' not in self.states:
|
|
raise TextFSMTemplateError("Missing state 'Start'.")
|
|
|
|
# 'End/EOF' state (if specified) must be empty.
|
|
if self.states.get('End'):
|
|
raise TextFSMTemplateError("Non-Empty 'End' state.")
|
|
|
|
if self.states.get('EOF'):
|
|
raise TextFSMTemplateError("Non-Empty 'EOF' state.")
|
|
|
|
# Remove 'End' state.
|
|
if 'End' in self.states:
|
|
del self.states['End']
|
|
self.state_list.remove('End')
|
|
|
|
# Ensure jump states are all valid.
|
|
for state in self.states:
|
|
for rule in self.states[state]:
|
|
if rule.line_op == 'Error':
|
|
continue
|
|
|
|
if not rule.new_state or rule.new_state in ('End', 'EOF'):
|
|
continue
|
|
|
|
if rule.new_state not in self.states:
|
|
raise TextFSMTemplateError(
|
|
"State '%s' not found, referenced in state '%s'" %
|
|
(rule.new_state, state))
|
|
|
|
return True
|
|
|
|
def ParseText(self, text, eof=True):
|
|
"""Passes CLI output through FSM and returns list of tuples.
|
|
|
|
First tuple is the header, every subsequent tuple is a row.
|
|
|
|
Args:
|
|
text: (str), Text to parse with embedded newlines.
|
|
eof: (boolean), Set to False if we are parsing only part of the file.
|
|
Suppresses triggering EOF state.
|
|
|
|
Raises:
|
|
TextFSMError: An error occurred within the FSM.
|
|
|
|
Returns:
|
|
List of Lists.
|
|
"""
|
|
|
|
lines = []
|
|
if text:
|
|
lines = text.splitlines()
|
|
|
|
for line in lines:
|
|
self._CheckLine(line)
|
|
if self._cur_state_name in ('End', 'EOF'):
|
|
break
|
|
|
|
if self._cur_state_name != 'End' and 'EOF' not in self.states and eof:
|
|
# Implicit EOF performs Next.Record operation.
|
|
# Suppressed if Null EOF state is instantiated.
|
|
self._AppendRecord()
|
|
|
|
return self._result
|
|
|
|
def _CheckLine(self, line):
|
|
"""Passes the line through each rule until a match is made.
|
|
|
|
Args:
|
|
line: A string, the current input line.
|
|
"""
|
|
for rule in self._cur_state:
|
|
matched = self._CheckRule(rule, line)
|
|
if matched:
|
|
for value in matched.groupdict():
|
|
self._AssignVar(matched, value)
|
|
|
|
if self._Operations(rule):
|
|
# Not a Continue so check for state transition.
|
|
if rule.new_state:
|
|
if rule.new_state not in ('End', 'EOF'):
|
|
self._cur_state = self.states[rule.new_state]
|
|
self._cur_state_name = rule.new_state
|
|
break
|
|
|
|
def _CheckRule(self, rule, line):
|
|
"""Check a line against the given rule.
|
|
|
|
This is a separate method so that it can be overridden by
|
|
a debugging tool.
|
|
|
|
Args:
|
|
rule: A TextFSMRule(), the rule to check.
|
|
line: A str, the line to check.
|
|
|
|
Returns:
|
|
A regex match object.
|
|
"""
|
|
return rule.regex_obj.match(line)
|
|
|
|
def _AssignVar(self, matched, value):
|
|
"""Assigns variable into current record from a matched rule.
|
|
|
|
If a record entry is a list then append, otherwise values are replaced.
|
|
|
|
Args:
|
|
matched: (regexp.match) Named group for each matched value.
|
|
value: (str) The matched value.
|
|
"""
|
|
self._GetValue(value).AssignVar(matched.group(value))
|
|
|
|
def _Operations(self, rule):
|
|
"""Operators on the data record.
|
|
|
|
Operators come in two parts and are a '.' separated pair:
|
|
|
|
Operators that effect the input line or the current state (line_op).
|
|
'Next' Get next input line and restart parsing (default).
|
|
'Continue' Keep current input line and continue resume parsing.
|
|
'Error' Unrecoverable input discard result and raise Error.
|
|
|
|
Operators that affect the record being built for output (record_op).
|
|
'NoRecord' Does nothing (default)
|
|
'Record' Adds the current record to the result.
|
|
'Clear' Clears non-Filldown data from the record.
|
|
'Clearall' Clears all data from the record.
|
|
|
|
Args:
|
|
rule: FSMRule object.
|
|
|
|
Returns:
|
|
True if state machine should restart state with new line.
|
|
|
|
Raises:
|
|
TextFSMError: If Error state is encountered.
|
|
"""
|
|
# First process the Record operators.
|
|
if rule.record_op == 'Record':
|
|
self._AppendRecord()
|
|
|
|
elif rule.record_op == 'Clear':
|
|
# Clear record.
|
|
self._ClearRecord()
|
|
|
|
elif rule.record_op == 'Clearall':
|
|
# Clear all record entries.
|
|
self._ClearAllRecord()
|
|
|
|
# Lastly process line operators.
|
|
if rule.line_op == 'Error':
|
|
if rule.new_state:
|
|
raise TextFSMError('Error: %s. Line: %s.'
|
|
% (rule.new_state, rule.line_num))
|
|
|
|
raise TextFSMError('State Error raised. Line: %s.'
|
|
% (rule.line_num))
|
|
|
|
elif rule.line_op == 'Continue':
|
|
# Continue with current line without returning to the start of the state.
|
|
return False
|
|
|
|
# Back to start of current state with a new line.
|
|
return True
|
|
|
|
def _ClearRecord(self):
|
|
"""Remove non 'Filldown' record entries."""
|
|
_ = [value.ClearVar() for value in self.values]
|
|
|
|
def _ClearAllRecord(self):
|
|
"""Remove all record entries."""
|
|
_ = [value.ClearAllVar() for value in self.values]
|
|
|
|
def GetValuesByAttrib(self, attribute):
|
|
"""Returns the list of values that have a particular attribute."""
|
|
|
|
if attribute not in self._options_cls.ValidOptions():
|
|
raise ValueError("'%s': Not a valid attribute." % attribute)
|
|
|
|
result = []
|
|
for value in self.values:
|
|
if attribute in value.OptionNames():
|
|
result.append(value.name)
|
|
|
|
return result
|
|
|
|
|
|
def main(argv=None):
|
|
"""Validate text parsed with FSM or validate an FSM via command line."""
|
|
|
|
if argv is None:
|
|
argv = sys.argv
|
|
|
|
try:
|
|
opts, args = getopt.getopt(argv[1:], 'h', ['help'])
|
|
except getopt.error as msg:
|
|
raise Usage(msg)
|
|
|
|
for opt, _ in opts:
|
|
if opt in ('-h', '--help'):
|
|
print(__doc__)
|
|
print(help_msg)
|
|
return 0
|
|
|
|
if not args or len(args) > 4:
|
|
raise Usage('Invalid arguments.')
|
|
|
|
# If we have an argument, parse content of file and display as a template.
|
|
# Template displayed will match input template, minus any comment lines.
|
|
with open(args[0], 'r') as template:
|
|
fsm = TextFSM(template)
|
|
print('FSM Template:\n%s\n' % fsm)
|
|
|
|
if len(args) > 1:
|
|
# Second argument is file with example cli input.
|
|
# Prints parsed tabular result.
|
|
with open(args[1], 'r') as f:
|
|
cli_input = f.read()
|
|
|
|
table = fsm.ParseText(cli_input)
|
|
print('FSM Table:')
|
|
result = str(fsm.header) + '\n'
|
|
for line in table:
|
|
result += str(line) + '\n'
|
|
print(result, end='')
|
|
|
|
if len(args) > 2:
|
|
# Compare tabular result with data in third file argument.
|
|
# Exit value indicates if processed data matched expected result.
|
|
with open(args[2], 'r') as f:
|
|
ref_table = f.read()
|
|
|
|
if ref_table != result:
|
|
print('Data mis-match!')
|
|
return 1
|
|
else:
|
|
print('Data match!')
|
|
|
|
|
|
if __name__ == '__main__':
|
|
help_msg = '%s [--help] template [input_file [output_file]]\n' % sys.argv[0]
|
|
try:
|
|
sys.exit(main())
|
|
except Usage as err:
|
|
print(err, file=sys.stderr)
|
|
print('For help use --help', file=sys.stderr)
|
|
sys.exit(2)
|
|
except (IOError, TextFSMError, TextFSMTemplateError) as err:
|
|
print(err, file=sys.stderr)
|
|
sys.exit(2)
|